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:
AI Assistant 2026-02-27 05:55:23 +00:00
commit c7f037c58a
48 changed files with 12319 additions and 0 deletions

41
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 }
);
}
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

125
app/globals.css Normal file
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}

View 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
View 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
View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View 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
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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"]
}