feat: Complete OpenClaw Dashboard with all features
- Dashboard Overview with real-time status display - Live Log Viewer (scrollable, filterable) - Config Editor with JSON syntax highlighting - Model Switcher for provider management - Provider Manager for API key configuration - Quick Actions for common tasks - API Routes: status, logs, config, actions, models, providers Tech Stack: - Next.js 14 (App Router) - TypeScript - Tailwind CSS - shadcn/ui components - CodeMirror for JSON editing - Docker support with docker-compose
This commit is contained in:
commit
c7f037c58a
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@ -0,0 +1,52 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Install dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++ sqlite
|
||||
|
||||
# Install better-sqlite3
|
||||
WORKDIR /app
|
||||
RUN npm install better-sqlite3 --build-from-source --no-save
|
||||
|
||||
# Dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Builder
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npx next build
|
||||
|
||||
# Runner
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Install better-sqlite3
|
||||
RUN npm install better-sqlite3 --build-from-source --no-save
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
181
README.md
Normal file
181
README.md
Normal file
@ -0,0 +1,181 @@
|
||||
# OpenClaw Dashboard 🦞
|
||||
|
||||
Self-hosted Web-UI zur Verwaltung von OpenClaw- und OpenCode-Instanzen.
|
||||
|
||||
## 🚀 Live Deployment
|
||||
|
||||
**URL:** https://ai-center.tail7b642c.ts.net/
|
||||
**Auth:** `admin / openclaw`
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Dashboard-Übersicht:** Live-Status aller Instanzen (Gateway, Modelle, System)
|
||||
- **Config-Editor:** openclaw.json direkt bearbeiten mit Syntax-Highlighting
|
||||
- **Log-Viewer:** Live-Logs des Gateway-Prozesses mit Auto-Refresh
|
||||
- **Provider-Management:** API-Keys hinzufügen und verwalten
|
||||
- **Model-Switcher:** Aktives Model wechseln mit allen verfügbaren Modellen
|
||||
- **Quick Actions:** Gateway starten/stoppen/neustarten
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Frontend:** Next.js 14 + React + Tailwind CSS + shadcn/ui
|
||||
- **Backend:** Next.js API Routes
|
||||
- **Auth:** Basic Auth (Middleware)
|
||||
- **Deployment:** Node.js + Tailscale Funnel (HTTPS)
|
||||
- **Design:** Dark Mode mit Apple/Vercel-Ästhetik
|
||||
|
||||
## 📦 Setup & Deployment
|
||||
|
||||
### 1. Dependencies installieren
|
||||
|
||||
```bash
|
||||
cd /mnt/pve/Main-HDD/user-data/projects/openclaw-dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Environment konfigurieren
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env to change credentials
|
||||
```
|
||||
|
||||
### 3. Server starten (Development)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Server starten (Production)
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
PORT=3001 npm start
|
||||
```
|
||||
|
||||
### 5. HTTPS via Tailscale Funnel
|
||||
|
||||
```bash
|
||||
# Starte den Server
|
||||
PORT=3001 npm start &
|
||||
|
||||
# Tailscale Funnel aktivieren (HTTPS)
|
||||
tailscale funnel --bg 3001
|
||||
|
||||
# Funnel deaktivieren
|
||||
tailscale funnel --https=443 off
|
||||
```
|
||||
|
||||
## 🔐 Authentifizierung
|
||||
|
||||
Standard-Credentials:
|
||||
- **Username:** `admin`
|
||||
- **Password:** `openclaw`
|
||||
|
||||
Ändern in `.env`:
|
||||
```
|
||||
AUTH_USERNAME=your_username
|
||||
AUTH_PASSWORD=your_password
|
||||
```
|
||||
|
||||
## 📁 Projektstruktur
|
||||
|
||||
```
|
||||
openclaw-dashboard/
|
||||
├── app/
|
||||
│ ├── api/ # API Routes
|
||||
│ │ ├── status/ # Gateway Status
|
||||
│ │ ├── config/ # Config Editor
|
||||
│ │ ├── logs/ # Log Viewer
|
||||
│ │ ├── providers/ # API Key Management
|
||||
│ │ ├── models/ # Model Switcher
|
||||
│ │ └── actions/ # Gateway Actions
|
||||
│ ├── layout.tsx # Root Layout
|
||||
│ ├── page.tsx # Main Page
|
||||
│ └── globals.css # Tailwind Styles
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui Components
|
||||
│ ├── DashboardOverview.tsx
|
||||
│ ├── ConfigEditor.tsx
|
||||
│ ├── LogViewer.tsx
|
||||
│ ├── ProviderManager.tsx
|
||||
│ ├── ModelSwitcher.tsx
|
||||
│ └── QuickActions.tsx
|
||||
├── lib/
|
||||
│ ├── db.ts # SQLite Database
|
||||
│ ├── openclaw.ts # OpenClaw API Wrapper
|
||||
│ └── utils.ts # Utilities
|
||||
├── middleware.ts # Basic Auth Middleware
|
||||
├── next.config.ts # Next.js Config
|
||||
├── Dockerfile # Docker Build
|
||||
└── docker-compose.yml # Docker Compose
|
||||
```
|
||||
|
||||
## 🌐 API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/status` | GET | System status & Gateway status |
|
||||
| `/api/config` | GET/POST | Get/update openclaw.json |
|
||||
| `/api/logs` | GET | Get Gateway logs |
|
||||
| `/api/providers` | GET/POST | Get/update provider API keys |
|
||||
| `/api/models` | GET/POST | Get available models / switch model |
|
||||
| `/api/actions` | POST | Execute gateway actions (start/stop/restart) |
|
||||
|
||||
## 🎨 Design
|
||||
|
||||
- **Colors:** Dark (#0a0a0a) + Orange (#f97316) + Cyan (#06b6d4)
|
||||
- **Theme:** Dark Mode only
|
||||
- **Responsive:** Desktop + Mobile
|
||||
- **Animations:** Framer Motion (subtil)
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Server startet nicht
|
||||
```bash
|
||||
# Check logs
|
||||
cat /tmp/dashboard.log
|
||||
|
||||
# Check port
|
||||
lsof -i :3001
|
||||
```
|
||||
|
||||
### Tailscale Funnel nicht erreichbar
|
||||
```bash
|
||||
# Check Tailscale status
|
||||
tailscale status
|
||||
|
||||
# Check Funnel status
|
||||
tailscale funnel status
|
||||
|
||||
# Reset Funnel
|
||||
tailscale funnel reset
|
||||
tailscale funnel --bg 3001
|
||||
```
|
||||
|
||||
### Auth funktioniert nicht
|
||||
```bash
|
||||
# Prüfe .env Variablen
|
||||
cat .env | grep AUTH
|
||||
|
||||
# Middleware neu kompilieren
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## 📝 TODO (Future Features)
|
||||
|
||||
- [ ] OpenCode Instanzen Integration
|
||||
- [ ] Multi-Instance Support
|
||||
- [ ] Real-time WebSocket Updates
|
||||
- [ ] Advanced Analytics Dashboard
|
||||
- [ ] Backup & Restore Configuration
|
||||
- [ ] Custom Alerts & Notifications
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for OpenClaw by Yannick**
|
||||
35
app/api/actions/route.ts
Normal file
35
app/api/actions/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { startGateway, stopGateway, restartGateway } from '@/lib/openclaw';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { action } = await request.json();
|
||||
|
||||
if (!action || !['start', 'stop', 'restart'].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Must be start, stop, or restart' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let result;
|
||||
switch (action) {
|
||||
case 'start':
|
||||
result = await startGateway();
|
||||
break;
|
||||
case 'stop':
|
||||
result = await stopGateway();
|
||||
break;
|
||||
case 'restart':
|
||||
result = await restartGateway();
|
||||
break;
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/api/config/route.ts
Normal file
40
app/api/config/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getOpenClawConfig, applyConfig } from '@/lib/openclaw';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = await getOpenClawConfig();
|
||||
// Mask API keys for security
|
||||
const maskedConfig = JSON.parse(JSON.stringify(config));
|
||||
if (maskedConfig.auth?.profiles) {
|
||||
for (const profile of Object.values(maskedConfig.auth.profiles) as any) {
|
||||
if (profile.apiKey) {
|
||||
profile.apiKey = profile.apiKey.slice(0, 8) + '***masked***';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maskedConfig.tools?.web?.search?.apiKey) {
|
||||
maskedConfig.tools.web.search.apiKey =
|
||||
maskedConfig.tools.web.search.apiKey.slice(0, 8) + '***masked***';
|
||||
}
|
||||
return NextResponse.json(maskedConfig);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const config = await request.json();
|
||||
const result = await applyConfig(config);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
17
app/api/logs/route.ts
Normal file
17
app/api/logs/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getGatewayLogs } from '@/lib/openclaw';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const lines = parseInt(searchParams.get('lines') || '100', 10);
|
||||
|
||||
const logs = await getGatewayLogs(lines);
|
||||
return NextResponse.json({ logs });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/api/models/route.ts
Normal file
39
app/api/models/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAvailableModels, getActiveModel, setActiveModel } from '@/lib/openclaw';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [models, activeModel] = await Promise.all([
|
||||
getAvailableModels(),
|
||||
getActiveModel(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ models, activeModel });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { modelId } = await request.json();
|
||||
|
||||
if (!modelId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'modelId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await setActiveModel(modelId);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
app/api/providers/route.ts
Normal file
35
app/api/providers/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getProviders, setProviderApiKey } from '@/lib/openclaw';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const providers = await getProviders();
|
||||
return NextResponse.json({ providers });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { providerId, apiKey } = await request.json();
|
||||
|
||||
if (!providerId || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'providerId and apiKey are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await setProviderApiKey(providerId, apiKey);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
14
app/api/status/route.ts
Normal file
14
app/api/status/route.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getGatewayStatus, getActiveModel, getSystemStatus } from '@/lib/openclaw';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const status = await getSystemStatus();
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
125
app/globals.css
Normal file
125
app/globals.css
Normal file
@ -0,0 +1,125 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
22
app/layout.tsx
Normal file
22
app/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OpenClaw Dashboard',
|
||||
description: 'Self-hosted AI infrastructure management dashboard',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
99
app/page.tsx
Normal file
99
app/page.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DashboardOverview } from '@/components/DashboardOverview';
|
||||
import { ConfigEditor } from '@/components/ConfigEditor';
|
||||
import { LogViewer } from '@/components/LogViewer';
|
||||
import { ProviderManager } from '@/components/ProviderManager';
|
||||
import { ModelSwitcher } from '@/components/ModelSwitcher';
|
||||
import { QuickActions } from '@/components/QuickActions';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Activity, Settings, Terminal, Key, Cpu, Zap } from 'lucide-react';
|
||||
|
||||
type TabValue = 'dashboard' | 'config' | 'logs' | 'providers' | 'models' | 'actions';
|
||||
|
||||
export default function Home() {
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('dashboard');
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950 text-zinc-100">
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Header */}
|
||||
<header className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-orange-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
OpenClaw Dashboard
|
||||
</h1>
|
||||
<p className="text-zinc-400 mt-1">Manage your AI infrastructure</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-zinc-500">Status</div>
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-6 bg-zinc-900/50 border border-zinc-800">
|
||||
<TabsTrigger value="dashboard" className="data-[state=active]:bg-orange-500/20 data-[state=active]:text-orange-400">
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="data-[state=active]:bg-orange-500/20 data-[state=active]:text-orange-400">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="data-[state=active]:bg-orange-500/20 data-[state=active]:text-orange-400">
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Logs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="providers" className="data-[state=active]:bg-orange-500/20 data-[state=active]:text-orange-400">
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Providers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="data-[state=active]:bg-orange-500/20 data-[state=active]:text-orange-400">
|
||||
<Cpu className="w-4 h-4 mr-2" />
|
||||
Models
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="actions" className="data-[state=active]:bg-orange-500/20 data-[state=active]:text-orange-400">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Actions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="mt-6">
|
||||
<DashboardOverview />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" className="mt-6">
|
||||
<ConfigEditor />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="mt-6">
|
||||
<LogViewer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="providers" className="mt-6">
|
||||
<ProviderManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="mt-6">
|
||||
<ModelSwitcher />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="mt-6">
|
||||
<QuickActions />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 text-center text-zinc-600 text-sm">
|
||||
<p>OpenClaw Dashboard • Self-hosted AI Management</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
components.json
Normal file
23
components.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
224
components/ConfigEditor.tsx
Normal file
224
components/ConfigEditor.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Save, Download, Upload, RefreshCw, Check, X } from 'lucide-react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
|
||||
export function ConfigEditor() {
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const data = await response.json();
|
||||
setConfig(data);
|
||||
setEditValue(JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to load config' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
// Validate JSON
|
||||
const parsed = JSON.parse(editValue);
|
||||
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: result.message || 'Config saved successfully!' });
|
||||
setConfig(parsed);
|
||||
setEditing(false);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.message || 'Failed to save config' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Invalid JSON format' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(JSON.stringify(config, null, 2));
|
||||
setEditing(false);
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'openclaw-config.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
JSON.parse(content); // Validate
|
||||
setEditValue(content);
|
||||
setEditing(true);
|
||||
setMessage(null);
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Invalid JSON file' });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-orange-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Button
|
||||
onClick={() => setEditing(true)}
|
||||
disabled={editing}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
Edit Config
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={fetchConfig}
|
||||
disabled={loading}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Reload
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => document.getElementById('config-upload')?.click()}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<input
|
||||
id="config-upload"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<Alert variant={message.type === 'error' ? 'destructive' : 'default'} className={
|
||||
message.type === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-400' : ''
|
||||
}>
|
||||
{message.type === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<Card className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle>openclaw.json</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg overflow-hidden border border-zinc-700">
|
||||
<CodeMirror
|
||||
value={editValue}
|
||||
height="600px"
|
||||
theme={oneDark}
|
||||
extensions={[json()]}
|
||||
editable={editing}
|
||||
onChange={setEditValue}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save & Apply
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-sm text-zinc-500">
|
||||
<p>⚠️ Warning: Editing the config directly restarts the Gateway to apply changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
components/DashboardOverview.tsx
Normal file
221
components/DashboardOverview.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Activity, Cpu, Clock, Zap } from 'lucide-react';
|
||||
|
||||
interface SystemStatus {
|
||||
gateway: {
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
port?: number;
|
||||
uptime?: number;
|
||||
mode?: string;
|
||||
};
|
||||
activeModel: string | null;
|
||||
instanceCount: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export function DashboardOverview() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 5000); // Refresh every 5s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchStatus();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-orange-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="bg-zinc-900/50 border-zinc-800 hover:border-zinc-700 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Gateway Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-3 h-3 rounded-full ${status?.gateway.running ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-2xl font-bold">
|
||||
{status?.gateway.running ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{status?.gateway.port && (
|
||||
<p className="text-sm text-zinc-500 mt-1">Port: {status.gateway.port}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-zinc-900/50 border-zinc-800 hover:border-zinc-700 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
Active Model
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-semibold truncate" title={status?.activeModel || 'None'}>
|
||||
{status?.activeModel || 'None'}
|
||||
</p>
|
||||
<Badge variant="outline" className="mt-2 border-zinc-700">
|
||||
{status?.activeModel ? 'Configured' : 'Not set'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-zinc-900/50 border-zinc-800 hover:border-zinc-700 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
Instances
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="text-2xl font-bold">{status?.instanceCount || 0}</span>
|
||||
<p className="text-sm text-zinc-500 mt-1">Active instances</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-zinc-900/50 border-zinc-800 hover:border-zinc-700 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
System Uptime
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="text-2xl font-bold">
|
||||
{status?.uptime ? formatUptime(status.uptime) : '--'}
|
||||
</span>
|
||||
<p className="text-sm text-zinc-500 mt-1">Since restart</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Gateway Details */}
|
||||
<Card className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
Gateway Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400">Status</p>
|
||||
<p className="font-semibold">{status?.gateway.running ? 'Running' : 'Stopped'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400">Mode</p>
|
||||
<p className="font-semibold">{status?.gateway.mode || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400">PID</p>
|
||||
<p className="font-semibold">{status?.gateway.pid || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400">Port</p>
|
||||
<p className="font-semibold">{status?.gateway.port || 'N/A'}</p>
|
||||
</div>
|
||||
{status?.gateway.uptime && (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400">Gateway Uptime</p>
|
||||
<p className="font-semibold">{formatUptime(status.gateway.uptime)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions Preview */}
|
||||
<Card className="bg-gradient-to-r from-orange-500/10 to-cyan-500/10 border-orange-500/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Access</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
onClick={() => document.querySelector('[value="config"]')?.dispatchEvent(new MouseEvent('click'))}
|
||||
>
|
||||
Edit Config
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
onClick={() => document.querySelector('[value="logs"]')?.dispatchEvent(new MouseEvent('click'))}
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
onClick={() => document.querySelector('[value="models"]')?.dispatchEvent(new MouseEvent('click'))}
|
||||
>
|
||||
Switch Model
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
components/LogViewer.tsx
Normal file
166
components/LogViewer.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RefreshCw, Download, Search, Pause, Play } from 'lucide-react';
|
||||
|
||||
interface LogViewerProps {}
|
||||
|
||||
export function LogViewer() {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const filteredLogsRef = useRef<string[]>([]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/logs?lines=200');
|
||||
const data = await response.json();
|
||||
setLogs(data.logs || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
let interval: NodeJS.Timeout;
|
||||
if (autoRefresh) {
|
||||
interval = setInterval(fetchLogs, 3000); // Refresh every 3s
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const toggleAutoRefresh = () => {
|
||||
setAutoRefresh(!autoRefresh);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const content = logs.join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `openclaw-logs-${new Date().toISOString()}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Filter logs
|
||||
const filteredLogs = filter
|
||||
? logs.filter((log) =>
|
||||
log.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
: logs;
|
||||
|
||||
filteredLogsRef.current = filteredLogs;
|
||||
|
||||
// Parse log level for styling
|
||||
const getLogStyle = (log: string) => {
|
||||
if (log.toLowerCase().includes('error') || log.toLowerCase().includes('failed'))
|
||||
return 'text-red-400';
|
||||
if (log.toLowerCase().includes('warn'))
|
||||
return 'text-yellow-400';
|
||||
if (log.toLowerCase().includes('info'))
|
||||
return 'text-blue-400';
|
||||
return 'text-zinc-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? 'default' : 'outline'}
|
||||
onClick={toggleAutoRefresh}
|
||||
className={autoRefresh ? 'bg-orange-500 hover:bg-orange-600' : 'border-zinc-700 hover:bg-zinc-800'}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
Auto-refresh On
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Auto-refresh Off
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Filter logs..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="pl-10 bg-zinc-900/50 border-zinc-700 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Display */}
|
||||
<Card className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Gateway Logs</span>
|
||||
<span className="text-sm font-normal text-zinc-400">
|
||||
{filteredLogs.length} entries
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[600px] overflow-auto rounded-lg bg-black/50 p-4 font-mono text-xs">
|
||||
{loading && logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-orange-400" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-zinc-500">
|
||||
{filter ? 'No logs match your filter' : 'No logs available'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredLogs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`whitespace-pre-wrap break-words ${getLogStyle(log)}`}
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-sm text-zinc-500">
|
||||
<p>ℹ️ Logs refresh automatically every 3 seconds when auto-refresh is enabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
components/ModelSwitcher.tsx
Normal file
230
components/ModelSwitcher.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { RefreshCw, Check, X, Cpu, Search } from 'lucide-react';
|
||||
|
||||
interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function ModelSwitcher() {
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [activeModel, setActiveModelState] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [switching, setSwitching] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/models');
|
||||
const data = await response.json();
|
||||
setModels(data.models || []);
|
||||
setActiveModelState(data.activeModel);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to load models' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSwitchModel = async (modelId: string) => {
|
||||
if (switching) return;
|
||||
|
||||
setSwitching(modelId);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ modelId }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: result.message });
|
||||
setActiveModelState(modelId);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.message || 'Failed to switch model' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to switch model' });
|
||||
} finally {
|
||||
setSwitching(null);
|
||||
}
|
||||
};
|
||||
|
||||
const groupedModels = models.reduce((acc, model) => {
|
||||
const provider = model.id.split('/')[0];
|
||||
if (!acc[provider]) {
|
||||
acc[provider] = [];
|
||||
}
|
||||
acc[provider].push(model);
|
||||
return acc;
|
||||
}, {} as Record<string, Model[]>);
|
||||
|
||||
const filteredGroups = filter
|
||||
? Object.fromEntries(
|
||||
Object.entries(groupedModels).filter(([_, models]) =>
|
||||
models.some((m) =>
|
||||
m.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
m.id.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
)
|
||||
)
|
||||
: groupedModels;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
{activeModel && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<Cpu className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm text-green-400">Active: {activeModel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-w-[200px] max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search models..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="pl-10 bg-zinc-900/50 border-zinc-700 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<Alert variant={message.type === 'error' ? 'destructive' : 'default'} className={
|
||||
message.type === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-400' : ''
|
||||
}>
|
||||
{message.type === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Models List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-orange-400" />
|
||||
</div>
|
||||
) : Object.keys(filteredGroups).length === 0 ? (
|
||||
<Card className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardContent className="py-12 text-center text-zinc-500">
|
||||
<Cpu className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>{filter ? 'No models match your search' : 'No models found'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(filteredGroups).map(([provider, providerModels]) => (
|
||||
<Card key={provider} className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="capitalize text-lg">
|
||||
{provider}
|
||||
<Badge variant="outline" className="ml-2 border-zinc-700">
|
||||
{providerModels.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{providerModels
|
||||
.filter((model) =>
|
||||
!filter ||
|
||||
model.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
model.id.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`p-4 rounded-lg border transition-all ${
|
||||
activeModel === model.id
|
||||
? 'bg-orange-500/10 border-orange-500/50'
|
||||
: 'bg-zinc-800/50 border-zinc-700 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate" title={model.name}>
|
||||
{model.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate mt-1" title={model.id}>
|
||||
{model.id}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={activeModel === model.id ? 'default' : 'outline'}
|
||||
className={
|
||||
activeModel === model.id
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'border-zinc-700'
|
||||
}
|
||||
>
|
||||
{activeModel === model.id ? 'Active' : 'Available'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleSwitchModel(model.id)}
|
||||
disabled={switching === model.id || activeModel === model.id}
|
||||
variant={activeModel === model.id ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
className="mt-3 w-full"
|
||||
>
|
||||
{switching === model.id ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Switching...
|
||||
</>
|
||||
) : activeModel === model.id ? (
|
||||
'Active'
|
||||
) : (
|
||||
'Switch to this model'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-zinc-500">
|
||||
<p>ℹ️ Switching models restarts the Gateway to apply changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
components/ProviderManager.tsx
Normal file
271
components/ProviderManager.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Key, Plus, RefreshCw, Check, X, Edit, Trash2 } from 'lucide-react';
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hasKey: boolean;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export function ProviderManager() {
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const fetchProviders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/providers');
|
||||
const data = await response.json();
|
||||
setProviders(data.providers || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch providers:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to load providers' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
const handleEdit = (provider: Provider) => {
|
||||
setEditingProvider(provider);
|
||||
setApiKey(provider.key || '');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditingProvider(null);
|
||||
setApiKey('');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingProvider) {
|
||||
setMessage({ type: 'error', text: 'Please select a provider to edit' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/providers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
providerId: editingProvider.id,
|
||||
apiKey,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: result.message });
|
||||
setDialogOpen(false);
|
||||
fetchProviders();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.message || 'Failed to update API key' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to update API key' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groupedProviders = providers.reduce((acc, provider) => {
|
||||
if (!acc[provider.type]) {
|
||||
acc[provider.type] = [];
|
||||
}
|
||||
acc[provider.type].push(provider);
|
||||
return acc;
|
||||
}, {} as Record<string, Provider[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header Actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={fetchProviders}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={handleAddNew} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<Alert variant={message.type === 'error' ? 'destructive' : 'default'} className={
|
||||
message.type === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-400' : ''
|
||||
}>
|
||||
{message.type === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Providers List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-orange-400" />
|
||||
</div>
|
||||
) : Object.keys(groupedProviders).length === 0 ? (
|
||||
<Card className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardContent className="py-12 text-center text-zinc-500">
|
||||
<Key className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No providers found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedProviders).map(([type, typeProviders]) => (
|
||||
<Card key={type} className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="capitalize text-lg">
|
||||
{type}
|
||||
<Badge variant="outline" className="ml-2 border-zinc-700">
|
||||
{typeProviders.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{typeProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="w-4 h-4 text-zinc-400" />
|
||||
<div>
|
||||
<p className="font-medium">{provider.name}</p>
|
||||
<p className="text-xs text-zinc-500">{provider.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={provider.hasKey ? 'default' : 'secondary'} className={
|
||||
provider.hasKey ? 'bg-green-500/20 text-green-400 hover:bg-green-500/30' : ''
|
||||
}>
|
||||
{provider.hasKey ? 'Configured' : 'No Key'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(provider)}
|
||||
className="hover:bg-zinc-700"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit/Add Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProvider ? `Edit ${editingProvider.name}` : 'Add Provider'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{editingProvider ? (
|
||||
<>
|
||||
<div>
|
||||
<Label>Provider ID</Label>
|
||||
<Input value={editingProvider.id} disabled className="bg-zinc-800 border-zinc-700" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Provider Name</Label>
|
||||
<Input value={editingProvider.name} disabled className="bg-zinc-800 border-zinc-700" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
To add a new provider, please first add it via the OpenClaw CLI configuration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{editingProvider && (
|
||||
<div>
|
||||
<Label htmlFor="api-key">API Key</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Enter API key..."
|
||||
className="bg-zinc-800 border-zinc-700 focus:border-orange-500"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
The key will be masked after saving.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{editingProvider && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
components/QuickActions.tsx
Normal file
162
components/QuickActions.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Power, RefreshCw, Check, X, Play, Square, RotateCcw, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export function QuickActions() {
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
|
||||
setLoading(action);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/actions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: result.message });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.message || 'Action failed' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to execute action' });
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
id: 'start' as const,
|
||||
title: 'Start Gateway',
|
||||
description: 'Start the OpenClaw Gateway daemon',
|
||||
icon: Play,
|
||||
variant: 'default' as const,
|
||||
color: 'bg-green-600 hover:bg-green-700',
|
||||
},
|
||||
{
|
||||
id: 'stop' as const,
|
||||
title: 'Stop Gateway',
|
||||
description: 'Stop the OpenClaw Gateway daemon',
|
||||
icon: Square,
|
||||
variant: 'outline' as const,
|
||||
color: 'border-red-600 text-red-400 hover:bg-red-600/10',
|
||||
},
|
||||
{
|
||||
id: 'restart' as const,
|
||||
title: 'Restart Gateway',
|
||||
description: 'Restart the OpenClaw Gateway daemon',
|
||||
icon: RotateCcw,
|
||||
variant: 'outline' as const,
|
||||
color: 'border-orange-600 text-orange-400 hover:bg-orange-600/10',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Warning */}
|
||||
<Alert className="bg-yellow-500/10 border-yellow-500/20 text-yellow-400">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These actions affect the entire Gateway. Be careful when stopping or restarting.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<Alert variant={message.type === 'error' ? 'destructive' : 'default'} className={
|
||||
message.type === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-400' : ''
|
||||
}>
|
||||
{message.type === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
const isLoading = loading === action.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={action.id}
|
||||
className="bg-zinc-900/50 border-zinc-800 hover:border-zinc-700 transition-all hover:scale-[1.02]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5" />
|
||||
{action.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-zinc-400 mb-4">
|
||||
{action.description}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => handleAction(action.id)}
|
||||
disabled={isLoading}
|
||||
className={`w-full ${action.id === 'start' ? action.color : ''} ${
|
||||
action.id !== 'start'
|
||||
? 'border-zinc-700 hover:bg-zinc-800'
|
||||
: ''
|
||||
}`}
|
||||
variant={action.variant}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
{action.id.charAt(0).toUpperCase() + action.id.slice(1)}ing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{action.id === 'start' && <Power className="w-4 h-4 mr-2" />}
|
||||
{action.id === 'stop' && <Square className="w-4 h-4 mr-2" />}
|
||||
{action.id === 'restart' && <RotateCcw className="w-4 h-4 mr-2" />}
|
||||
{action.title}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<Card className="bg-zinc-900/50 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Quick Actions Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-zinc-400">
|
||||
<div>
|
||||
<strong className="text-zinc-300">Start Gateway:</strong> Launches the OpenClaw
|
||||
Gateway daemon if it's not running.
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-zinc-300">Stop Gateway:</strong> Gracefully stops the Gateway.
|
||||
All active sessions will be terminated.
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-zinc-300">Restart Gateway:</strong> Stops and immediately
|
||||
starts the Gateway again. Useful for applying config changes.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
components/ui/alert.tsx
Normal file
66
components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
48
components/ui/badge.tsx
Normal file
48
components/ui/badge.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
components/ui/button.tsx
Normal file
64
components/ui/button.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
158
components/ui/dialog.tsx
Normal file
158
components/ui/dialog.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
190
components/ui/select.tsx
Normal file
190
components/ui/select.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
35
components/ui/switch.tsx
Normal file
35
components/ui/switch.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
91
components/ui/tabs.tsx
Normal file
91
components/ui/tabs.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@ -0,0 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
openclaw-dashboard:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: openclaw-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# Persist SQLite database
|
||||
- ./data:/app/data
|
||||
# Mount OpenClaw config (read-only)
|
||||
- /home/ai/.openclaw:/home/ai/.openclaw:ro
|
||||
# Mount socket for openclaw CLI access
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
network_mode: host
|
||||
# For Tailscale, comment out network_mode: host and use:
|
||||
# networks:
|
||||
# - tailscale
|
||||
|
||||
networks:
|
||||
tailscale:
|
||||
external: true
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
228
lib/db.ts
Normal file
228
lib/db.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const DB_PATH = path.join(DATA_DIR, 'openclaw.db');
|
||||
|
||||
const db = new Database(DB_PATH, {
|
||||
verbose: process.env.NODE_ENV === 'development' ? console.log : undefined,
|
||||
});
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Initialize tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
endpoint TEXT,
|
||||
last_check INTEGER,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_instance ON logs(instance_id);
|
||||
`);
|
||||
|
||||
export interface Instance {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'openclaw' | 'opencode' | 'gateway';
|
||||
status: 'online' | 'offline' | 'unknown' | 'starting' | 'stopping';
|
||||
endpoint?: string;
|
||||
last_check?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
instance_id: string;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_type: string;
|
||||
config: Record<string, any>;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// Instance CRUD
|
||||
export const dbGetInstances = (): Instance[] => {
|
||||
const stmt = db.prepare('SELECT * FROM instances ORDER BY name');
|
||||
const rows = stmt.all() as any[];
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
endpoint: row.endpoint,
|
||||
last_check: row.last_check,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
})) as Instance[];
|
||||
};
|
||||
|
||||
export const dbGetInstance = (id: string): Instance | undefined => {
|
||||
const stmt = db.prepare('SELECT * FROM instances WHERE id = ?');
|
||||
const row = stmt.get(id) as any;
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
...row,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const dbUpsertInstance = (instance: Instance): void => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO instances (id, name, type, status, endpoint, last_check, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
status = excluded.status,
|
||||
endpoint = excluded.endpoint,
|
||||
last_check = excluded.last_check,
|
||||
metadata = excluded.metadata
|
||||
`);
|
||||
stmt.run(
|
||||
instance.id,
|
||||
instance.name,
|
||||
instance.type,
|
||||
instance.status,
|
||||
instance.endpoint || null,
|
||||
instance.last_check || Date.now(),
|
||||
instance.metadata ? JSON.stringify(instance.metadata) : null
|
||||
);
|
||||
};
|
||||
|
||||
// Log CRUD
|
||||
export const dbInsertLog = (log: Omit<LogEntry, 'id'>): void => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO logs (instance_id, level, message, timestamp, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(log.instance_id, log.level, log.message, log.timestamp, log.source || null);
|
||||
};
|
||||
|
||||
export const dbGetLogs = (instanceId: string, limit: number = 100, offset: number = 0): LogEntry[] => {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM logs
|
||||
WHERE instance_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
return stmt.all(instanceId, limit, offset) as LogEntry[];
|
||||
};
|
||||
|
||||
export const dbGetRecentLogs = (limit: number = 50): LogEntry[] => {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM logs
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as LogEntry[];
|
||||
};
|
||||
|
||||
export const dbDeleteOldLogs = (before: number = Date.now() - 7 * 24 * 60 * 60 * 1000): number => {
|
||||
const stmt = db.prepare('DELETE FROM logs WHERE timestamp < ?');
|
||||
const info = stmt.run(before);
|
||||
return info.changes;
|
||||
};
|
||||
|
||||
// Settings CRUD
|
||||
export const dbGetSetting = (key: string): string | undefined => {
|
||||
const stmt = db.prepare('SELECT value FROM settings WHERE key = ?');
|
||||
const row = stmt.get(key) as any;
|
||||
return row?.value;
|
||||
};
|
||||
|
||||
export const dbSetSetting = (key: string, value: string): void => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
stmt.run(key, value, Date.now());
|
||||
};
|
||||
|
||||
// Provider CRUD
|
||||
export const dbGetProviders = (): Provider[] => {
|
||||
const stmt = db.prepare('SELECT * FROM providers ORDER BY name');
|
||||
const rows = stmt.all() as any[];
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
provider_type: row.provider_type,
|
||||
config: JSON.parse(row.config),
|
||||
enabled: Boolean(row.enabled),
|
||||
created_at: row.created_at,
|
||||
})) as Provider[];
|
||||
};
|
||||
|
||||
export const dbUpsertProvider = (provider: Omit<Provider, 'created_at'> & { created_at?: number }): void => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO providers (id, name, provider_type, config, enabled, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
provider_type = excluded.provider_type,
|
||||
config = excluded.config,
|
||||
enabled = excluded.enabled
|
||||
`);
|
||||
stmt.run(
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.provider_type,
|
||||
JSON.stringify(provider.config),
|
||||
provider.enabled ? 1 : 0,
|
||||
provider.created_at || Date.now()
|
||||
);
|
||||
};
|
||||
|
||||
export const dbDeleteProvider = (id: string): void => {
|
||||
const stmt = db.prepare('DELETE FROM providers WHERE id = ?');
|
||||
stmt.run(id);
|
||||
};
|
||||
|
||||
export default db;
|
||||
387
lib/openclaw.ts
Normal file
387
lib/openclaw.ts
Normal file
@ -0,0 +1,387 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = '/home/ai/.openclaw/openclaw.json';
|
||||
const OPENCLAW_BIN = 'openclaw';
|
||||
|
||||
export interface OpenClawConfig {
|
||||
meta?: {
|
||||
lastTouchedVersion?: string;
|
||||
lastTouchedAt?: string;
|
||||
};
|
||||
auth?: {
|
||||
profiles?: Record<string, any>;
|
||||
};
|
||||
models?: {
|
||||
mode?: string;
|
||||
providers?: Record<string, any>;
|
||||
defaults?: {
|
||||
model?: {
|
||||
primary?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
gateway?: {
|
||||
port?: number;
|
||||
mode?: string;
|
||||
bind?: string;
|
||||
controlUi?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
auth?: {
|
||||
token?: string;
|
||||
mode?: string;
|
||||
};
|
||||
};
|
||||
plugins?: {
|
||||
entries?: Record<string, any>;
|
||||
};
|
||||
tools?: {
|
||||
web?: {
|
||||
search?: {
|
||||
apiKey?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
port?: number;
|
||||
uptime?: number;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface InstanceStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'openclaw' | 'opencode' | 'gateway';
|
||||
status: 'online' | 'offline' | 'unknown' | 'starting' | 'stopping';
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
lastCheck: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OpenClaw configuration
|
||||
*/
|
||||
export async function getOpenClawConfig(): Promise<OpenClawConfig> {
|
||||
try {
|
||||
const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read OpenClaw config: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the OpenClaw configuration
|
||||
*/
|
||||
export async function saveOpenClawConfig(config: OpenClawConfig): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(
|
||||
OPENCLAW_CONFIG_PATH,
|
||||
JSON.stringify(config, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save OpenClaw config: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway status
|
||||
*/
|
||||
export async function getGatewayStatus(): Promise<GatewayStatus> {
|
||||
try {
|
||||
const { stdout } = await execAsync('ps aux | grep "[o]penclaw gateway"');
|
||||
const running = stdout.trim().length > 0;
|
||||
|
||||
if (!running) {
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
// Try to get more details from the running process
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('openclaw gateway status --json', {
|
||||
timeout: 5000,
|
||||
});
|
||||
const statusData = JSON.parse(statusOutput);
|
||||
|
||||
return {
|
||||
running: true,
|
||||
pid: statusData.pid,
|
||||
port: statusData.port,
|
||||
uptime: statusData.uptime,
|
||||
mode: statusData.mode,
|
||||
};
|
||||
} catch {
|
||||
return { running: true };
|
||||
}
|
||||
} catch {
|
||||
return { running: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Gateway
|
||||
*/
|
||||
export async function startGateway(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await execAsync('openclaw gateway start', { timeout: 10000 });
|
||||
return { success: true, message: 'Gateway started successfully' };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to start Gateway: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Gateway
|
||||
*/
|
||||
export async function stopGateway(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await execAsync('openclaw gateway stop', { timeout: 10000 });
|
||||
return { success: true, message: 'Gateway stopped successfully' };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to stop Gateway: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the Gateway
|
||||
*/
|
||||
export async function restartGateway(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await execAsync('openclaw gateway restart', { timeout: 15000 });
|
||||
return { success: true, message: 'Gateway restarted successfully' };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to restart Gateway: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent logs from Gateway
|
||||
*/
|
||||
export async function getGatewayLogs(lines: number = 100): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`journalctl -u openclaw -n ${lines} --no-pager --output=cat`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
return stdout.trim().split('\n');
|
||||
} catch {
|
||||
// Fallback: Try to read from common log locations
|
||||
const logPaths = [
|
||||
'/var/log/openclaw/gateway.log',
|
||||
'/root/.openclaw/logs/gateway.log',
|
||||
'/tmp/openclaw-gateway.log',
|
||||
];
|
||||
|
||||
for (const logPath of logPaths) {
|
||||
try {
|
||||
const content = await fs.readFile(logPath, 'utf-8');
|
||||
return content.trim().split('\n').slice(-lines);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active model
|
||||
*/
|
||||
export async function getActiveModel(): Promise<string | null> {
|
||||
try {
|
||||
const config = await getOpenClawConfig();
|
||||
return config.models?.defaults?.model?.primary || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active model
|
||||
*/
|
||||
export async function setActiveModel(modelId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const config = await getOpenClawConfig();
|
||||
if (!config.models) config.models = {};
|
||||
if (!config.models.defaults) config.models.defaults = {};
|
||||
if (!config.models.defaults.model) config.models.defaults.model = {};
|
||||
|
||||
config.models.defaults.model.primary = modelId;
|
||||
|
||||
await saveOpenClawConfig(config);
|
||||
|
||||
// Restart gateway to apply changes
|
||||
await restartGateway();
|
||||
|
||||
return { success: true, message: `Model set to ${modelId}` };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to set model: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models
|
||||
*/
|
||||
export async function getAvailableModels(): Promise<Array<{ id: string; name: string }>> {
|
||||
try {
|
||||
const config = await getOpenClawConfig();
|
||||
const models: Array<{ id: string; name: string }> = [];
|
||||
|
||||
// Collect models from all providers
|
||||
if (config.models?.providers) {
|
||||
for (const [providerName, providerData] of Object.entries(config.models.providers)) {
|
||||
if (providerData && typeof providerData === 'object' && 'models' in providerData) {
|
||||
const providerModels = (providerData as any).models || [];
|
||||
for (const model of providerModels) {
|
||||
models.push({
|
||||
id: `${providerName}/${model.id}`,
|
||||
name: model.name || model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all providers with their API keys
|
||||
*/
|
||||
export async function getProviders(): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hasKey: boolean;
|
||||
key?: string;
|
||||
}>> {
|
||||
try {
|
||||
const config = await getOpenClawConfig();
|
||||
const providers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hasKey: boolean;
|
||||
key?: string;
|
||||
}> = [];
|
||||
|
||||
if (config.auth?.profiles) {
|
||||
for (const [id, profile] of Object.entries(config.auth.profiles)) {
|
||||
const providerData = profile as any;
|
||||
providers.push({
|
||||
id,
|
||||
name: providerData.email || id,
|
||||
type: providerData.provider || id.split(':')[0],
|
||||
hasKey: !!providerData.apiKey,
|
||||
key: providerData.apiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return providers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a provider's API key
|
||||
*/
|
||||
export async function setProviderApiKey(
|
||||
providerId: string,
|
||||
apiKey: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const config = await getOpenClawConfig();
|
||||
if (!config.auth) config.auth = {};
|
||||
if (!config.auth.profiles) config.auth.profiles = {};
|
||||
|
||||
if (!config.auth.profiles[providerId]) {
|
||||
// Create new provider profile
|
||||
const [providerName, ...rest] = providerId.split(':');
|
||||
config.auth.profiles[providerId] = {
|
||||
provider: providerName,
|
||||
mode: 'api_key',
|
||||
apiKey,
|
||||
};
|
||||
} else {
|
||||
config.auth.profiles[providerId].apiKey = apiKey;
|
||||
}
|
||||
|
||||
await saveOpenClawConfig(config);
|
||||
|
||||
return { success: true, message: `API key updated for ${providerId}` };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to update API key: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system status summary
|
||||
*/
|
||||
export async function getSystemStatus(): Promise<{
|
||||
gateway: GatewayStatus;
|
||||
activeModel: string | null;
|
||||
instanceCount: number;
|
||||
uptime: number;
|
||||
}> {
|
||||
const [gatewayStatus, activeModel] = await Promise.all([
|
||||
getGatewayStatus(),
|
||||
getActiveModel(),
|
||||
]);
|
||||
|
||||
// Get system uptime
|
||||
const uptime = process.uptime();
|
||||
|
||||
return {
|
||||
gateway: gatewayStatus,
|
||||
activeModel,
|
||||
instanceCount: 1, // Will be expanded for multiple instances
|
||||
uptime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply config changes and restart gateway
|
||||
*/
|
||||
export async function applyConfig(config: OpenClawConfig): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
// Validate config (basic)
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Invalid configuration');
|
||||
}
|
||||
|
||||
await saveOpenClawConfig(config);
|
||||
|
||||
// Restart gateway to apply changes
|
||||
const restartResult = await restartGateway();
|
||||
|
||||
if (!restartResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Config saved but failed to restart Gateway: ${restartResult.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, message: 'Configuration applied and Gateway restarted' };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to apply config: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
61
middleware.ts
Normal file
61
middleware.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
// Basic Auth configuration
|
||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || 'admin';
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'openclaw';
|
||||
|
||||
// Skip auth for public routes (if any)
|
||||
const publicRoutes = ['/api/status']; // Allow status API without auth for health checks
|
||||
|
||||
function basicAuth(req: NextRequest) {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [type, credentials] = authHeader.split(' ');
|
||||
|
||||
if (type !== 'Basic') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(credentials, 'base64').toString('utf-8');
|
||||
const [username, password] = decoded.split(':');
|
||||
|
||||
return username === AUTH_USERNAME && password === AUTH_PASSWORD;
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
// Skip auth for public routes
|
||||
if (publicRoutes.some(route => req.nextUrl.pathname.startsWith(route))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!basicAuth(req)) {
|
||||
return new NextResponse(
|
||||
'Authentication required',
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Basic realm="OpenClaw Dashboard"',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
14
next.config.ts
Normal file
14
next.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb',
|
||||
},
|
||||
},
|
||||
// Allow mounting OpenClaw config from host
|
||||
serverExternalPackages: ['better-sqlite3'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8410
package-lock.json
generated
Normal file
8410
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
Normal file
49
package.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "openclaw-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"docker:build": "docker-compose build",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:logs": "docker-compose logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user