feat(web): port electric dashboard UI from source repo
This commit is contained in:
parent
a6102f8dd6
commit
b248d40abc
35
run_dashboard.sh
Executable file
35
run_dashboard.sh
Executable file
@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Quick setup script to run ZeroClaw web dashboard
|
||||
|
||||
echo "🦀 ZeroClaw Web Dashboard Setup"
|
||||
echo "================================"
|
||||
|
||||
# Check if web assets are built
|
||||
if [ ! -d "web/dist" ]; then
|
||||
echo "📦 Building web assets first..."
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
echo "✅ Web assets built!"
|
||||
fi
|
||||
|
||||
# Build the project
|
||||
echo "🔨 Building ZeroClaw binary with embedded web dashboard..."
|
||||
cargo build --release
|
||||
|
||||
# Check if build was successful
|
||||
if [ -f "target/release/zeroclaw" ]; then
|
||||
echo "✅ Build successful! Web dashboard is embedded in the binary."
|
||||
echo ""
|
||||
echo "🚀 Starting ZeroClaw Gateway..."
|
||||
echo "📱 Dashboard URL: http://127.0.0.1:3000/"
|
||||
echo "🔧 API Endpoint: http://127.0.0.1:3000/api/"
|
||||
echo "⏹️ Press Ctrl+C to stop the gateway"
|
||||
echo ""
|
||||
|
||||
# Start the gateway
|
||||
./target/release/zeroclaw gateway --open-dashboard
|
||||
else
|
||||
echo "❌ Build failed! Please check the error messages above."
|
||||
exit 1
|
||||
fi
|
||||
1
web/dist/assets/index-DEhGL4Jw.css
vendored
1
web/dist/assets/index-DEhGL4Jw.css
vendored
File diff suppressed because one or more lines are too long
295
web/dist/assets/index-Dam-egf7.js
vendored
295
web/dist/assets/index-Dam-egf7.js
vendored
File diff suppressed because one or more lines are too long
8
web/dist/index.html
vendored
8
web/dist/index.html
vendored
@ -3,10 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws: wss: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>ZeroClaw</title>
|
||||
<script type="module" crossorigin src="/_app/assets/index-Dam-egf7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_app/assets/index-DEhGL4Jw.css">
|
||||
<script type="module" crossorigin src="/_app/assets/index-CW_YEDAa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_app/assets/index-Bud_uSBD.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
97
web/e2e/dashboard.mobile.spec.ts
Normal file
97
web/e2e/dashboard.mobile.spec.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Dashboard mobile smoke', () => {
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
|
||||
test('renders mock dashboard and supports mobile navigation and collapsible cards', async ({ page }) => {
|
||||
await page.route('**/health', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
paired: true,
|
||||
require_pairing: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.2',
|
||||
temperature: 0.4,
|
||||
uptime_seconds: 68420,
|
||||
gateway_port: 42617,
|
||||
locale: 'en-US',
|
||||
memory_backend: 'sqlite',
|
||||
paired: true,
|
||||
channels: {
|
||||
telegram: true,
|
||||
discord: false,
|
||||
whatsapp: true,
|
||||
github: true,
|
||||
},
|
||||
health: {
|
||||
uptime_seconds: 68420,
|
||||
updated_at: '2026-03-02T19:34:29.678544+00:00',
|
||||
pid: 4242,
|
||||
components: {
|
||||
gateway: {
|
||||
status: 'ok',
|
||||
updated_at: '2026-03-02T19:34:29.678544+00:00',
|
||||
last_ok: '2026-03-02T19:34:29.678544+00:00',
|
||||
last_error: null,
|
||||
restart_count: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/cost', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
cost: {
|
||||
session_cost_usd: 0.0842,
|
||||
daily_cost_usd: 1.3026,
|
||||
monthly_cost_usd: 14.9875,
|
||||
total_tokens: 182342,
|
||||
request_count: 426,
|
||||
by_model: {
|
||||
'gpt-5.2': {
|
||||
model: 'gpt-5.2',
|
||||
cost_usd: 11.4635,
|
||||
total_tokens: 141332,
|
||||
request_count: 292,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Electric Runtime Dashboard')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Open navigation' }).click();
|
||||
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
const closeButtons = page.getByRole('button', { name: 'Close navigation' });
|
||||
await closeButtons.first().click();
|
||||
|
||||
const costPulseButton = page.getByRole('button', { name: /Cost Pulse/i });
|
||||
await expect(costPulseButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await costPulseButton.click();
|
||||
await expect(costPulseButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws: wss: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>ZeroClaw</title>
|
||||
</head>
|
||||
|
||||
8
web/netlify.toml
Normal file
8
web/netlify.toml
Normal file
@ -0,0 +1,8 @@
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
1652
web/package-lock.json
generated
1652
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,22 +7,36 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:mobile-smoke": "node ./scripts/mobile-smoke-runner.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@uiw/react-codemirror": "^4.25.5",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react-router-dom": "^7.1.1",
|
||||
"smol-toml": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.1.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
31
web/package.nix
Normal file
31
web/package.nix
Normal file
@ -0,0 +1,31 @@
|
||||
{ buildNpmPackage, lib }:
|
||||
buildNpmPackage {
|
||||
pname = "zeroclaw-web";
|
||||
version = "0.1.0";
|
||||
|
||||
src =
|
||||
let
|
||||
fs = lib.fileset;
|
||||
in
|
||||
fs.toSource {
|
||||
root = ./.;
|
||||
fileset = fs.unions [
|
||||
./src
|
||||
./index.html
|
||||
./package.json
|
||||
./package-lock.json
|
||||
./tsconfig.json
|
||||
./tsconfig.app.json
|
||||
./tsconfig.node.json
|
||||
./vite.config.ts
|
||||
];
|
||||
};
|
||||
|
||||
npmDepsHash = "sha256-H3extDaq4DgNYTUcw57gqwVWc3aPCWjIJEVYRMzdFdM=";
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
cp -r dist $out
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
15
web/playwright.config.mjs
Normal file
15
web/playwright.config.mjs
Normal file
@ -0,0 +1,15 @@
|
||||
export default {
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
headless: true,
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
|
||||
port: 4173,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
};
|
||||
BIN
web/public/logo.png
Normal file
BIN
web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
56
web/scripts/mobile-smoke-runner.mjs
Normal file
56
web/scripts/mobile-smoke-runner.mjs
Normal file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
function localBin(name) {
|
||||
const suffix = isWin ? '.cmd' : '';
|
||||
return path.join(rootDir, 'node_modules', '.bin', `${name}${suffix}`);
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
function hasPlaywright() {
|
||||
try {
|
||||
require.resolve('@playwright/test', { paths: [rootDir] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const vitestArgs = [
|
||||
'run',
|
||||
'src/pages/Dashboard.test.tsx',
|
||||
'src/components/layout/Sidebar.test.tsx',
|
||||
];
|
||||
|
||||
if (hasPlaywright()) {
|
||||
console.log('[mobile-smoke] Playwright detected. Running browser + fallback smoke tests.');
|
||||
const playwrightStatus = run(localBin('playwright'), ['test', 'e2e/dashboard.mobile.spec.ts']);
|
||||
if (playwrightStatus !== 0) {
|
||||
process.exit(playwrightStatus);
|
||||
}
|
||||
process.exit(run(localBin('vitest'), vitestArgs));
|
||||
}
|
||||
|
||||
console.log('[mobile-smoke] @playwright/test not vendored. Running Vitest mobile smoke fallback.');
|
||||
process.exit(run(localBin('vitest'), vitestArgs));
|
||||
@ -7,22 +7,25 @@ import Tools from './pages/Tools';
|
||||
import Cron from './pages/Cron';
|
||||
import Integrations from './pages/Integrations';
|
||||
import Memory from './pages/Memory';
|
||||
import Devices from './pages/Devices';
|
||||
import Config from './pages/Config';
|
||||
import Cost from './pages/Cost';
|
||||
import Logs from './pages/Logs';
|
||||
import Doctor from './pages/Doctor';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import { setLocale, type Locale } from './lib/i18n';
|
||||
import { coerceLocale, setLocale, type Locale } from './lib/i18n';
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'zeroclaw:locale';
|
||||
|
||||
// Locale context
|
||||
interface LocaleContextType {
|
||||
locale: string;
|
||||
setAppLocale: (locale: string) => void;
|
||||
locale: Locale;
|
||||
setAppLocale: (locale: Locale) => void;
|
||||
}
|
||||
|
||||
export const LocaleContext = createContext<LocaleContextType>({
|
||||
locale: 'tr',
|
||||
setAppLocale: () => {},
|
||||
locale: 'en',
|
||||
setAppLocale: (_locale: Locale) => {},
|
||||
});
|
||||
|
||||
export const useLocaleContext = () => useContext(LocaleContext);
|
||||
@ -47,11 +50,11 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="bg-gray-900 rounded-xl p-8 w-full max-w-md border border-gray-800">
|
||||
<div className="pairing-shell min-h-screen flex items-center justify-center px-4">
|
||||
<div className="pairing-card w-full max-w-md rounded-2xl p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">ZeroClaw</h1>
|
||||
<p className="text-gray-400">Enter the pairing code from your terminal</p>
|
||||
<h1 className="mb-2 text-2xl font-semibold tracking-[0.16em] pairing-brand">ZEROCLAW</h1>
|
||||
<p className="text-sm text-[#9bb8e8]">Enter the one-time pairing code from your terminal</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
@ -59,17 +62,17 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="6-digit code"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4"
|
||||
className="w-full rounded-xl border border-[#29509c] bg-[#071228]/90 px-4 py-3 text-center text-2xl tracking-[0.35em] text-white focus:border-[#4f83ff] focus:outline-none mb-4"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm mb-4 text-center">{error}</p>
|
||||
<p className="mb-4 text-center text-sm text-rose-300">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length < 6}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
|
||||
className="electric-button w-full rounded-xl py-3 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Pairing...' : 'Pair'}
|
||||
</button>
|
||||
@ -81,11 +84,28 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, loading, pair, logout } = useAuth();
|
||||
const [locale, setLocaleState] = useState('tr');
|
||||
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const setAppLocale = (newLocale: string) => {
|
||||
const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (saved) {
|
||||
return coerceLocale(saved);
|
||||
}
|
||||
|
||||
return coerceLocale(window.navigator.language);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocale(locale);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
const setAppLocale = (newLocale: Locale) => {
|
||||
setLocaleState(newLocale);
|
||||
setLocale(newLocale as Locale);
|
||||
};
|
||||
|
||||
// Listen for 401 events to force logout
|
||||
@ -99,8 +119,11 @@ function AppContent() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<p className="text-gray-400">Connecting...</p>
|
||||
<div className="pairing-shell min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="electric-loader h-10 w-10 rounded-full" />
|
||||
<p className="text-[#a7c4f3]">Connecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -119,6 +142,7 @@ function AppContent() {
|
||||
<Route path="/cron" element={<Cron />} />
|
||||
<Route path="/integrations" element={<Integrations />} />
|
||||
<Route path="/memory" element={<Memory />} />
|
||||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/cost" element={<Cost />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
|
||||
121
web/src/components/config/ConfigFormEditor.tsx
Normal file
121
web/src/components/config/ConfigFormEditor.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { CONFIG_SECTIONS } from './configSections';
|
||||
import ConfigSection from './ConfigSection';
|
||||
import type { FieldDef } from './types';
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'security', label: 'Security' },
|
||||
{ key: 'channels', label: 'Channels' },
|
||||
{ key: 'runtime', label: 'Runtime' },
|
||||
{ key: 'tools', label: 'Tools' },
|
||||
{ key: 'memory', label: 'Memory' },
|
||||
{ key: 'network', label: 'Network' },
|
||||
{ key: 'advanced', label: 'Advanced' },
|
||||
] as const;
|
||||
|
||||
interface Props {
|
||||
getFieldValue: (sectionPath: string, fieldKey: string) => unknown;
|
||||
setFieldValue: (sectionPath: string, fieldKey: string, value: unknown) => void;
|
||||
isFieldMasked: (sectionPath: string, fieldKey: string) => boolean;
|
||||
}
|
||||
|
||||
export default function ConfigFormEditor({
|
||||
getFieldValue,
|
||||
setFieldValue,
|
||||
isFieldMasked,
|
||||
}: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
|
||||
const isSearching = search.trim().length > 0;
|
||||
|
||||
const filteredSections = useMemo(() => {
|
||||
if (isSearching) {
|
||||
const q = search.toLowerCase();
|
||||
return CONFIG_SECTIONS.map((section) => {
|
||||
const titleMatch = section.title.toLowerCase().includes(q);
|
||||
const descMatch = section.description?.toLowerCase().includes(q);
|
||||
|
||||
if (titleMatch || descMatch) {
|
||||
return { section, fields: undefined };
|
||||
}
|
||||
|
||||
const matchingFields = section.fields.filter(
|
||||
(f: FieldDef) =>
|
||||
f.label.toLowerCase().includes(q) ||
|
||||
f.key.toLowerCase().includes(q) ||
|
||||
f.description?.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
if (matchingFields.length > 0) {
|
||||
return { section, fields: matchingFields };
|
||||
}
|
||||
|
||||
return null;
|
||||
}).filter(Boolean) as { section: (typeof CONFIG_SECTIONS)[0]; fields: FieldDef[] | undefined }[];
|
||||
}
|
||||
|
||||
// Category filter
|
||||
const sections = activeCategory === 'all'
|
||||
? CONFIG_SECTIONS
|
||||
: CONFIG_SECTIONS.filter((s) => s.category === activeCategory);
|
||||
|
||||
return sections.map((s) => ({ section: s, fields: undefined }));
|
||||
}, [search, isSearching, activeCategory]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search config fields..."
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category pills — hidden during search */}
|
||||
{!isSearching && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORY_ORDER.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveCategory(key)}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:bg-gray-800 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{filteredSections.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">
|
||||
No matching config fields found.
|
||||
</div>
|
||||
) : (
|
||||
filteredSections.map(({ section, fields }) => (
|
||||
<ConfigSection
|
||||
key={section.path || '_root'}
|
||||
section={fields ? { ...section, defaultCollapsed: false } : section}
|
||||
getFieldValue={getFieldValue}
|
||||
setFieldValue={setFieldValue}
|
||||
isFieldMasked={isFieldMasked}
|
||||
visibleFields={fields}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
web/src/components/config/ConfigRawEditor.tsx
Normal file
45
web/src/components/config/ConfigRawEditor.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
|
||||
interface Props {
|
||||
rawToml: string;
|
||||
onChange: (raw: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const tomlLanguage = StreamLanguage.define(toml);
|
||||
|
||||
export default function ConfigRawEditor({ rawToml, onChange, disabled }: Props) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
|
||||
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
|
||||
TOML Configuration
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{rawToml.split('\n').length} lines
|
||||
</span>
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={rawToml}
|
||||
onChange={onChange}
|
||||
theme={oneDark}
|
||||
readOnly={Boolean(disabled)}
|
||||
editable={!disabled}
|
||||
height="500px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightActiveLine: false,
|
||||
}}
|
||||
extensions={[tomlLanguage, EditorView.lineWrapping]}
|
||||
className="text-sm [&_.cm-scroller]:font-mono [&_.cm-scroller]:leading-6 [&_.cm-content]:py-4 [&_.cm-content]:px-0 [&_.cm-gutters]:border-r [&_.cm-gutters]:border-gray-800 [&_.cm-gutters]:bg-gray-950 [&_.cm-editor]:bg-gray-950 [&_.cm-editor]:focus:outline-none [&_.cm-focused]:ring-2 [&_.cm-focused]:ring-blue-500/70 [&_.cm-focused]:ring-inset"
|
||||
aria-label="Raw TOML configuration editor with syntax highlighting"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
web/src/components/config/ConfigSection.tsx
Normal file
131
web/src/components/config/ConfigSection.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import type { SectionDef, FieldDef } from './types';
|
||||
import TextField from './fields/TextField';
|
||||
import NumberField from './fields/NumberField';
|
||||
import ToggleField from './fields/ToggleField';
|
||||
import SelectField from './fields/SelectField';
|
||||
import TagListField from './fields/TagListField';
|
||||
|
||||
interface Props {
|
||||
section: SectionDef;
|
||||
getFieldValue: (sectionPath: string, fieldKey: string) => unknown;
|
||||
setFieldValue: (sectionPath: string, fieldKey: string, value: unknown) => void;
|
||||
isFieldMasked: (sectionPath: string, fieldKey: string) => boolean;
|
||||
visibleFields?: FieldDef[];
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: FieldDef,
|
||||
value: unknown,
|
||||
onChange: (v: unknown) => void,
|
||||
isMasked: boolean,
|
||||
) {
|
||||
const props = { field, value, onChange, isMasked };
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'password':
|
||||
return <TextField {...props} />;
|
||||
case 'number':
|
||||
return <NumberField {...props} />;
|
||||
case 'toggle':
|
||||
return <ToggleField {...props} />;
|
||||
case 'select':
|
||||
return <SelectField {...props} />;
|
||||
case 'tag-list':
|
||||
return <TagListField {...props} />;
|
||||
default:
|
||||
return <TextField {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfigSection({
|
||||
section,
|
||||
getFieldValue,
|
||||
setFieldValue,
|
||||
isFieldMasked,
|
||||
visibleFields,
|
||||
}: Props) {
|
||||
const [collapsed, setCollapsed] = useState(section.defaultCollapsed ?? false);
|
||||
const sectionPanelId = useMemo(
|
||||
() =>
|
||||
`config-section-${(section.path || 'root').replace(/[^a-zA-Z0-9_-]/g, '-')}`,
|
||||
[section.path],
|
||||
);
|
||||
const Icon = section.icon;
|
||||
const fields = visibleFields ?? section.fields;
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(section.defaultCollapsed ?? false);
|
||||
}, [section.path, section.defaultCollapsed]);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={sectionPanelId}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition-colors rounded-t-xl"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
<Icon className="h-4 w-4 text-blue-400 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-white">{section.title}</span>
|
||||
{section.description && (
|
||||
<span className="text-xs text-gray-500 hidden sm:inline">
|
||||
— {section.description}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-gray-600">
|
||||
{fields.length} {fields.length === 1 ? 'field' : 'fields'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div
|
||||
id={sectionPanelId}
|
||||
className="border-t border-gray-800 px-4 py-4 grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4"
|
||||
>
|
||||
{fields.map((field) => {
|
||||
const value = getFieldValue(section.path, field.key);
|
||||
const masked = isFieldMasked(section.path, field.key);
|
||||
const spanFull = field.type === 'tag-list';
|
||||
|
||||
return (
|
||||
<div key={field.key} className={`flex flex-col${spanFull ? ' sm:col-span-2' : ''}`}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-1.5">
|
||||
<span>{field.label}</span>
|
||||
{field.sensitive && (
|
||||
<span className="text-[10px] text-yellow-400 bg-yellow-900/30 border border-yellow-800/50 px-1.5 py-0.5 rounded">
|
||||
sensitive
|
||||
</span>
|
||||
)}
|
||||
{masked && (
|
||||
<span className="text-[10px] text-blue-400 bg-blue-900/30 border border-blue-800/50 px-1.5 py-0.5 rounded">
|
||||
masked
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{field.description && field.type !== 'text' && field.type !== 'password' && field.type !== 'number' && (
|
||||
<p className="text-xs text-gray-500 mb-1.5">{field.description}</p>
|
||||
)}
|
||||
<div className="mt-auto">
|
||||
{renderField(
|
||||
field,
|
||||
value,
|
||||
(v) => setFieldValue(section.path, field.key, v),
|
||||
masked,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1098
web/src/components/config/configSections.ts
Normal file
1098
web/src/components/config/configSections.ts
Normal file
File diff suppressed because it is too large
Load Diff
41
web/src/components/config/fields/NumberField.tsx
Normal file
41
web/src/components/config/fields/NumberField.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import type { FieldProps } from '../types';
|
||||
|
||||
export default function NumberField({ field, value, onChange }: FieldProps) {
|
||||
const numValue = value === undefined || value === null || value === '' ? '' : Number(value);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={numValue}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!isNaN(n)) {
|
||||
onChange(n);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (field.step !== undefined && field.step < 1) {
|
||||
return;
|
||||
}
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!isNaN(n)) {
|
||||
onChange(Math.floor(n));
|
||||
}
|
||||
}}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step ?? 1}
|
||||
placeholder={field.description ?? ''}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
web/src/components/config/fields/SelectField.tsx
Normal file
20
web/src/components/config/fields/SelectField.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type { FieldProps } from '../types';
|
||||
|
||||
export default function SelectField({ field, value, onChange }: FieldProps) {
|
||||
const strValue = (value as string) ?? '';
|
||||
|
||||
return (
|
||||
<select
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
60
web/src/components/config/fields/TagListField.tsx
Normal file
60
web/src/components/config/fields/TagListField.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { FieldProps } from '../types';
|
||||
|
||||
export default function TagListField({ field, value, onChange }: FieldProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const tags: string[] = Array.isArray(value) ? value : [];
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
onChange([...tags, trimmed]);
|
||||
}
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
onChange(tags.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTag(input);
|
||||
} else if (e.key === 'Backspace' && input === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{tags.map((tag, i) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 bg-gray-700 text-gray-200 rounded-full px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(i)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => { if (input.trim()) addTag(input); }}
|
||||
placeholder={field.tagPlaceholder ?? 'Type and press Enter to add'}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
web/src/components/config/fields/TextField.tsx
Normal file
39
web/src/components/config/fields/TextField.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, EyeOff, Lock } from 'lucide-react';
|
||||
import type { FieldProps } from '../types';
|
||||
|
||||
export default function TextField({ field, value, onChange, isMasked }: FieldProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPassword = field.type === 'password';
|
||||
const strValue = isMasked ? '' : ((value as string) ?? '');
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={isPassword && !showPassword ? 'password' : 'text'}
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={isMasked ? 'Configured (masked)' : field.description ?? ''}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 pr-16"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{isMasked && (
|
||||
<Lock className="h-3.5 w-3.5 text-yellow-500" />
|
||||
)}
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
web/src/components/config/fields/ToggleField.tsx
Normal file
27
web/src/components/config/fields/ToggleField.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import type { FieldProps } from '../types';
|
||||
|
||||
export default function ToggleField({ field, value, onChange }: FieldProps) {
|
||||
const isOn = Boolean(value);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
aria-label={field.label}
|
||||
onClick={() => onChange(!isOn)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isOn ? 'bg-blue-600' : 'bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isOn ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-400">{isOn ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
web/src/components/config/types.ts
Normal file
40
web/src/components/config/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'password'
|
||||
| 'number'
|
||||
| 'toggle'
|
||||
| 'select'
|
||||
| 'tag-list';
|
||||
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
description?: string;
|
||||
sensitive?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: { value: string; label: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
tagPlaceholder?: string;
|
||||
}
|
||||
|
||||
export interface SectionDef {
|
||||
path: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: LucideIcon;
|
||||
fields: FieldDef[];
|
||||
defaultCollapsed?: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface FieldProps {
|
||||
field: FieldDef;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
isMasked: boolean;
|
||||
}
|
||||
307
web/src/components/config/useConfigForm.ts
Normal file
307
web/src/components/config/useConfigForm.ts
Normal file
@ -0,0 +1,307 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
import { getConfig, putConfig } from '@/lib/api';
|
||||
|
||||
const MASKED = '***MASKED***';
|
||||
|
||||
type ParsedConfig = Record<string, unknown>;
|
||||
|
||||
function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/** Recursively scan for MASKED strings and collect their dotted paths. */
|
||||
function scanMasked(obj: unknown, prefix: string, out: Set<string>) {
|
||||
if (obj === null || obj === undefined) return;
|
||||
if (typeof obj === 'string' && obj === MASKED) {
|
||||
out.add(prefix);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((item, i) => {
|
||||
scanMasked(item, `${prefix}.${i}`, out);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
scanMasked(v, prefix ? `${prefix}.${k}` : k, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate into an object by dotted path segments, returning the value. */
|
||||
function getNestedValue(obj: unknown, segments: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const seg of segments) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Set a value in an object by dotted path segments, creating intermediates. */
|
||||
function setNestedValue(obj: Record<string, unknown>, segments: string[], value: unknown) {
|
||||
if (segments.length === 0) return;
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg: string = segments[i]!;
|
||||
if (current[seg] === undefined || current[seg] === null || typeof current[seg] !== 'object') {
|
||||
current[seg] = {};
|
||||
}
|
||||
current = current[seg] as Record<string, unknown>;
|
||||
}
|
||||
const lastSeg: string = segments[segments.length - 1]!;
|
||||
if (value === undefined || value === '') {
|
||||
delete current[lastSeg];
|
||||
} else {
|
||||
current[lastSeg] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export type EditorMode = 'form' | 'raw';
|
||||
|
||||
export interface ConfigFormState {
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
mode: EditorMode;
|
||||
rawToml: string;
|
||||
parsed: ParsedConfig;
|
||||
maskedPaths: Set<string>;
|
||||
dirtyPaths: Set<string>;
|
||||
setMode: (mode: EditorMode) => boolean;
|
||||
getFieldValue: (sectionPath: string, fieldKey: string) => unknown;
|
||||
setFieldValue: (sectionPath: string, fieldKey: string, value: unknown) => void;
|
||||
isFieldMasked: (sectionPath: string, fieldKey: string) => boolean;
|
||||
isFieldDirty: (sectionPath: string, fieldKey: string) => boolean;
|
||||
setRawToml: (raw: string) => void;
|
||||
save: () => Promise<void>;
|
||||
reload: () => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export function useConfigForm(): ConfigFormState {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [mode, setModeState] = useState<EditorMode>('form');
|
||||
const [rawToml, setRawTomlState] = useState('');
|
||||
const [parsed, setParsed] = useState<ParsedConfig>({});
|
||||
const maskedPathsRef = useRef<Set<string>>(new Set());
|
||||
const dirtyPathsRef = useRef<Set<string>>(new Set());
|
||||
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getConfig();
|
||||
const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
setRawTomlState(raw);
|
||||
|
||||
try {
|
||||
const obj = parse(raw) as ParsedConfig;
|
||||
setParsed(obj);
|
||||
const masked = new Set<string>();
|
||||
scanMasked(obj, '', masked);
|
||||
maskedPathsRef.current = masked;
|
||||
} catch {
|
||||
// If TOML parse fails, start in raw mode
|
||||
setParsed({});
|
||||
maskedPathsRef.current = new Set();
|
||||
setModeState('raw');
|
||||
}
|
||||
|
||||
dirtyPathsRef.current = new Set();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load once on mount.
|
||||
const hasLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasLoaded.current) {
|
||||
hasLoaded.current = true;
|
||||
void loadConfig();
|
||||
}
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fieldPath = (sectionPath: string, fieldKey: string) =>
|
||||
sectionPath ? `${sectionPath}.${fieldKey}` : fieldKey;
|
||||
|
||||
const fieldSegments = (sectionPath: string, fieldKey: string) => {
|
||||
const full = fieldPath(sectionPath, fieldKey);
|
||||
return full.split('.').filter(Boolean);
|
||||
};
|
||||
|
||||
const getFieldValue = useCallback(
|
||||
(sectionPath: string, fieldKey: string): unknown => {
|
||||
const segments = fieldSegments(sectionPath, fieldKey);
|
||||
return getNestedValue(parsed, segments);
|
||||
},
|
||||
[parsed],
|
||||
);
|
||||
|
||||
const setFieldValue = useCallback(
|
||||
(sectionPath: string, fieldKey: string, value: unknown) => {
|
||||
const fp = fieldPath(sectionPath, fieldKey);
|
||||
const segments = fieldSegments(sectionPath, fieldKey);
|
||||
|
||||
setParsed((prev) => {
|
||||
const next = deepClone(prev);
|
||||
setNestedValue(next, segments, value);
|
||||
return next;
|
||||
});
|
||||
|
||||
dirtyPathsRef.current.add(fp);
|
||||
forceRender((n) => n + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const isFieldMasked = useCallback(
|
||||
(sectionPath: string, fieldKey: string): boolean => {
|
||||
const fp = fieldPath(sectionPath, fieldKey);
|
||||
return maskedPathsRef.current.has(fp) && !dirtyPathsRef.current.has(fp);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const isFieldDirty = useCallback(
|
||||
(sectionPath: string, fieldKey: string): boolean => {
|
||||
const fp = fieldPath(sectionPath, fieldKey);
|
||||
return dirtyPathsRef.current.has(fp);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const syncFormToRaw = useCallback((): string => {
|
||||
try {
|
||||
const toml = stringify(parsed);
|
||||
return toml;
|
||||
} catch {
|
||||
return rawToml;
|
||||
}
|
||||
}, [parsed, rawToml]);
|
||||
|
||||
const syncRawToForm = useCallback(
|
||||
(raw: string): boolean => {
|
||||
try {
|
||||
const obj = parse(raw) as ParsedConfig;
|
||||
setParsed(obj);
|
||||
// Re-scan masked paths from fresh parse, preserving dirty overrides
|
||||
const masked = new Set<string>();
|
||||
scanMasked(obj, '', masked);
|
||||
maskedPathsRef.current = masked;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setMode = useCallback(
|
||||
(newMode: EditorMode): boolean => {
|
||||
if (newMode === mode) return true;
|
||||
|
||||
if (newMode === 'raw') {
|
||||
// form → raw: serialize parsed to TOML
|
||||
const toml = syncFormToRaw();
|
||||
setRawTomlState(toml);
|
||||
setModeState('raw');
|
||||
return true;
|
||||
} else {
|
||||
// raw → form: parse TOML
|
||||
if (syncRawToForm(rawToml)) {
|
||||
setModeState('form');
|
||||
return true;
|
||||
} else {
|
||||
setError('Invalid TOML syntax. Fix errors before switching to Form view.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[mode, syncFormToRaw, syncRawToForm, rawToml],
|
||||
);
|
||||
|
||||
const setRawToml = useCallback((raw: string) => {
|
||||
setRawTomlState(raw);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
}
|
||||
|
||||
try {
|
||||
let toml: string;
|
||||
if (mode === 'form') {
|
||||
toml = syncFormToRaw();
|
||||
} else {
|
||||
toml = rawToml;
|
||||
}
|
||||
await putConfig(toml);
|
||||
setSuccess('Configuration saved successfully.');
|
||||
|
||||
// Auto-dismiss success after 4 seconds
|
||||
successTimeoutRef.current = setTimeout(() => setSuccess(null), 4000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [mode, syncFormToRaw, rawToml]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
await loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
successTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
success,
|
||||
mode,
|
||||
rawToml,
|
||||
parsed,
|
||||
maskedPaths: maskedPathsRef.current,
|
||||
dirtyPaths: dirtyPathsRef.current,
|
||||
setMode,
|
||||
getFieldValue,
|
||||
setFieldValue,
|
||||
isFieldMasked,
|
||||
isFieldDirty,
|
||||
setRawToml,
|
||||
save,
|
||||
reload,
|
||||
clearMessages,
|
||||
};
|
||||
}
|
||||
40
web/src/components/integrations/ChatChannelsGuide.test.tsx
Normal file
40
web/src/components/integrations/ChatChannelsGuide.test.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ChatChannelsGuide from './ChatChannelsGuide';
|
||||
import { CHAT_CHANNEL_SUPPORT } from '@/lib/chatChannels';
|
||||
|
||||
describe('ChatChannelsGuide', () => {
|
||||
it('renders the supported channel matrix and key notes', () => {
|
||||
render(<ChatChannelsGuide />);
|
||||
|
||||
expect(screen.getByText('Supported Chat Channels')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`${CHAT_CHANNEL_SUPPORT.length} channels listed`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('BlueBubbles')).toBeInTheDocument();
|
||||
expect(screen.getByText('WhatsApp')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zalo Personal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Channel Notes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports collapsing and expanding the section', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChatChannelsGuide />);
|
||||
|
||||
const toggle = screen.getByRole('button', {
|
||||
name: /Supported Chat Channels/i,
|
||||
});
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('BlueBubbles')).toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('BlueBubbles')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('BlueBubbles')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
104
web/src/components/integrations/ChatChannelsGuide.tsx
Normal file
104
web/src/components/integrations/ChatChannelsGuide.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, MessageCircleMore, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
CHAT_CHANNEL_NOTES,
|
||||
CHAT_CHANNEL_SUPPORT,
|
||||
type ChatChannelSupportLevel,
|
||||
} from '@/lib/chatChannels';
|
||||
|
||||
const SUPPORT_LEVEL_CLASSES: Record<ChatChannelSupportLevel, string> = {
|
||||
'Built-in': 'border-[#2f63c8] bg-[#0a265f]/70 text-[#acd0ff]',
|
||||
Plugin: 'border-[#2f5ea0] bg-[#071a41]/80 text-[#8eb8f4]',
|
||||
Legacy: 'border-[#5f6080] bg-[#141731]/80 text-[#c2c5e8]',
|
||||
};
|
||||
|
||||
export default function ChatChannelsGuide() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<section className="electric-card motion-rise">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
aria-expanded={isOpen}
|
||||
className="group flex w-full items-center justify-between gap-4 rounded-xl px-4 py-4 text-left md:px-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="electric-icon h-10 w-10 rounded-xl">
|
||||
<MessageCircleMore className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
Supported Chat Channels
|
||||
</h2>
|
||||
<p className="text-xs uppercase tracking-[0.13em] text-[#7ea5eb]">
|
||||
{CHAT_CHANNEL_SUPPORT.length} channels listed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={[
|
||||
'h-5 w-5 text-[#7ea5eb] transition-transform duration-300',
|
||||
isOpen ? 'rotate-180' : 'rotate-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-[#18356f] px-4 pb-5 pt-4 md:px-5">
|
||||
<div className="rounded-xl border border-[#1e3a78] bg-[#07142f]/85 p-3 md:p-4">
|
||||
<p className="text-sm leading-relaxed text-[#c8dcff]">
|
||||
ZeroClaw can talk to you on the chat apps you already use through
|
||||
Gateway. Text is supported across all channels; media and reactions
|
||||
vary by channel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{CHAT_CHANNEL_SUPPORT.map((channel) => (
|
||||
<article
|
||||
key={channel.id}
|
||||
className="rounded-xl border border-[#1f3d76] bg-[#060f25]/85 p-3 shadow-[0_0_22px_-15px_rgba(80,176,255,0.9)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{channel.name}</h3>
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
SUPPORT_LEVEL_CLASSES[channel.supportLevel],
|
||||
].join(' ')}
|
||||
>
|
||||
{channel.supportLevel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-[#97baee]">
|
||||
{channel.summary}
|
||||
</p>
|
||||
{channel.details && (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-[#7ca6de]">
|
||||
{channel.details}
|
||||
</p>
|
||||
)}
|
||||
{channel.recommended && (
|
||||
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-[#cfe3ff]">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Recommended
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-[#1b3770] bg-[#061129]/85 p-3 md:p-4">
|
||||
<h3 className="text-sm font-semibold text-white">Channel Notes</h3>
|
||||
<ul className="mt-2 space-y-1.5 text-xs leading-relaxed text-[#9bbce8]">
|
||||
{CHAT_CHANNEL_NOTES.map((note) => (
|
||||
<li key={note}>• {note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { LogOut, Menu } from 'lucide-react';
|
||||
import { t, LANGUAGE_BUTTON_LABELS, LANGUAGE_SWITCH_ORDER } from '@/lib/i18n';
|
||||
import { useLocaleContext } from '@/App';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
@ -11,13 +11,20 @@ const routeTitles: Record<string, string> = {
|
||||
'/cron': 'nav.cron',
|
||||
'/integrations': 'nav.integrations',
|
||||
'/memory': 'nav.memory',
|
||||
'/devices': 'nav.devices',
|
||||
'/config': 'nav.config',
|
||||
'/cost': 'nav.cost',
|
||||
'/logs': 'nav.logs',
|
||||
'/doctor': 'nav.doctor',
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
const languageSummary = 'English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export default function Header({ onToggleSidebar }: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const { logout } = useAuth();
|
||||
const { locale, setAppLocale } = useLocaleContext();
|
||||
@ -26,33 +33,53 @@ export default function Header() {
|
||||
const pageTitle = t(titleKey);
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setAppLocale(locale === 'en' ? 'tr' : 'en');
|
||||
const currentIndex = LANGUAGE_SWITCH_ORDER.indexOf(locale);
|
||||
const nextLocale =
|
||||
LANGUAGE_SWITCH_ORDER[(currentIndex + 1) % LANGUAGE_SWITCH_ORDER.length] ?? 'en';
|
||||
setAppLocale(nextLocale);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-6">
|
||||
{/* Page title */}
|
||||
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
|
||||
<header className="glass-header relative flex min-h-[4.5rem] flex-wrap items-center justify-between gap-2 rounded-2xl border border-[#1a3670] px-4 py-3 sm:px-5 sm:py-3.5 md:flex-nowrap md:px-8 md:py-4">
|
||||
<div className="absolute inset-0 pointer-events-none opacity-70 bg-[radial-gradient(circle_at_15%_30%,rgba(41,148,255,0.22),transparent_45%),radial-gradient(circle_at_85%_75%,rgba(0,209,255,0.14),transparent_40%)]" />
|
||||
|
||||
{/* Right-side controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Language switcher */}
|
||||
<div className="relative flex min-w-0 items-center gap-2.5 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Open navigation"
|
||||
className="rounded-lg border border-[#294a8f] bg-[#081637]/70 p-1.5 text-[#9ec2ff] transition hover:border-[#4f83ff] hover:text-white md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold tracking-wide text-white sm:text-lg">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<p className="hidden text-[10px] uppercase tracking-[0.16em] text-[#7ea5eb] sm:block">
|
||||
ZeroClaw dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full items-center justify-end gap-1.5 sm:gap-2 md:w-auto md:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLanguage}
|
||||
className="px-3 py-1 rounded-md text-sm font-medium border border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
title={`🌐 Languages: ${languageSummary}`}
|
||||
className="rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1 text-xs font-medium text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white sm:px-3 sm:text-sm"
|
||||
>
|
||||
{locale === 'en' ? 'EN' : 'TR'}
|
||||
{LANGUAGE_BUTTON_LABELS[locale] ?? 'EN'}
|
||||
</button>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
className="flex items-center gap-1 rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1.5 text-xs text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white sm:gap-1.5 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>{t('auth.logout')}</span>
|
||||
<span className="hidden sm:inline">{t('auth.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -1,19 +1,47 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'zeroclaw:sidebar-collapsed';
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
|
||||
});
|
||||
|
||||
const toggleSidebarCollapsed = () => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white">
|
||||
{/* Fixed sidebar */}
|
||||
<Sidebar />
|
||||
<div className="app-shell min-h-screen text-white">
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onToggleCollapse={toggleSidebarCollapsed}
|
||||
/>
|
||||
|
||||
{/* Main area offset by sidebar width (240px / w-60) */}
|
||||
<div className="ml-60 flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<div
|
||||
className={[
|
||||
'flex min-h-screen flex-col transition-[margin-left] duration-300 ease-out',
|
||||
sidebarCollapsed ? 'md:ml-[6.25rem]' : 'md:ml-[17.5rem]',
|
||||
].join(' ')}
|
||||
>
|
||||
<Header onToggleSidebar={() => setSidebarOpen((open) => !open)} />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<main className="flex-1 overflow-y-auto px-4 pb-8 pt-5 md:px-8 md:pt-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
87
web/src/components/layout/Sidebar.test.tsx
Normal file
87
web/src/components/layout/Sidebar.test.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
function renderSidebar({
|
||||
isOpen = false,
|
||||
isCollapsed = false,
|
||||
onClose = vi.fn(),
|
||||
onToggleCollapse = vi.fn(),
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onClose?: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
} = {}) {
|
||||
const view = render(
|
||||
<MemoryRouter>
|
||||
<Sidebar
|
||||
isOpen={isOpen}
|
||||
isCollapsed={isCollapsed}
|
||||
onClose={onClose}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
return { ...view, onClose, onToggleCollapse };
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('toggles open/close state and invokes close handlers for mobile controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
const closed = renderSidebar({ isOpen: false });
|
||||
const closedButtons = closed.getAllByRole('button', {
|
||||
name: /Close navigation/i,
|
||||
});
|
||||
expect(closedButtons.length).toBeGreaterThan(0);
|
||||
const closedOverlay = closedButtons[0];
|
||||
if (!closedOverlay) {
|
||||
throw new Error('Expected sidebar overlay button');
|
||||
}
|
||||
expect(closedOverlay).toHaveClass('pointer-events-none');
|
||||
closed.unmount();
|
||||
|
||||
const opened = renderSidebar({ isOpen: true });
|
||||
const openedCloseButtons = opened.getAllByRole('button', {
|
||||
name: /Close navigation/i,
|
||||
});
|
||||
expect(openedCloseButtons.length).toBeGreaterThanOrEqual(2);
|
||||
const openedOverlay = openedCloseButtons[0];
|
||||
const mobileCloseButton = openedCloseButtons[1];
|
||||
if (!openedOverlay || !mobileCloseButton) {
|
||||
throw new Error('Expected sidebar overlay and close buttons');
|
||||
}
|
||||
|
||||
expect(openedOverlay).toHaveClass('opacity-100');
|
||||
|
||||
await user.click(openedOverlay);
|
||||
await user.click(mobileCloseButton);
|
||||
expect(opened.onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('supports collapsed mode controls and closes on navigation click', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const view = renderSidebar({ isOpen: true, isCollapsed: true });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
const collapseToggle = screen.getByRole('button', {
|
||||
name: /Expand navigation/i,
|
||||
});
|
||||
fireEvent.click(collapseToggle);
|
||||
expect(view.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dashboardLink = screen.getByRole('link', { name: 'Dashboard' });
|
||||
expect(dashboardLink).toHaveAttribute('title', 'Dashboard');
|
||||
fireEvent.click(dashboardLink);
|
||||
expect(view.onClose).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,18 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
Clock,
|
||||
Puzzle,
|
||||
Brain,
|
||||
Smartphone,
|
||||
Settings,
|
||||
DollarSign,
|
||||
Activity,
|
||||
Stethoscope,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
const COLLAPSE_BUTTON_DELAY_MS = 1000;
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
|
||||
{ to: '/agent', icon: MessageSquare, labelKey: 'nav.agent' },
|
||||
@ -20,46 +26,134 @@ const navItems = [
|
||||
{ to: '/cron', icon: Clock, labelKey: 'nav.cron' },
|
||||
{ to: '/integrations', icon: Puzzle, labelKey: 'nav.integrations' },
|
||||
{ to: '/memory', icon: Brain, labelKey: 'nav.memory' },
|
||||
{ to: '/devices', icon: Smartphone, labelKey: 'nav.devices' },
|
||||
{ to: '/config', icon: Settings, labelKey: 'nav.config' },
|
||||
{ to: '/cost', icon: DollarSign, labelKey: 'nav.cost' },
|
||||
{ to: '/logs', icon: Activity, labelKey: 'nav.logs' },
|
||||
{ to: '/doctor', icon: Stethoscope, labelKey: 'nav.doctor' },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed top-0 left-0 h-screen w-60 bg-gray-900 flex flex-col border-r border-gray-800">
|
||||
{/* Logo / Title */}
|
||||
<div className="flex items-center gap-2 px-5 py-5 border-b border-gray-800">
|
||||
<div className="h-8 w-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
ZC
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white tracking-wide">
|
||||
ZeroClaw
|
||||
</span>
|
||||
</div>
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
isCollapsed: boolean;
|
||||
onClose: () => void;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
|
||||
{navItems.map(({ to, icon: Icon, labelKey }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
export default function Sidebar({
|
||||
isOpen,
|
||||
isCollapsed,
|
||||
onClose,
|
||||
onToggleCollapse,
|
||||
}: SidebarProps) {
|
||||
const [showCollapseButton, setShowCollapseButton] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setShowCollapseButton(true), COLLAPSE_BUTTON_DELAY_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={onClose}
|
||||
className={[
|
||||
'fixed inset-0 z-30 bg-black/50 transition-opacity md:hidden',
|
||||
isOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
<aside
|
||||
className={[
|
||||
'fixed left-0 top-0 z-40 flex h-screen w-[86vw] max-w-[17.5rem] flex-col border-r border-[#1e2f5d] bg-[#050b1a]/95 backdrop-blur-xl',
|
||||
'shadow-[0_0_50px_-25px_rgba(8,121,255,0.7)]',
|
||||
'transform transition-[width,transform] duration-300 ease-out',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
isCollapsed ? 'md:w-[6.25rem]' : 'md:w-[17.5rem]',
|
||||
'md:translate-x-0',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="relative flex items-center justify-between border-b border-[#1a2d5e] px-4 py-4">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<img
|
||||
src="/_app/logo.png"
|
||||
alt="ZeroClaw"
|
||||
className="h-9 w-9 shrink-0 rounded-xl object-contain"
|
||||
/>
|
||||
<span className="text-lg font-semibold tracking-[0.1em] text-white">
|
||||
ZeroClaw
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showCollapseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={isCollapsed ? 'Expand navigation' : 'Collapse navigation'}
|
||||
className="hidden rounded-lg border border-[#2c4e97] bg-[#0a1b3f]/60 p-1.5 text-[#8bb9ff] transition hover:border-[#4f83ff] hover:text-white md:block"
|
||||
>
|
||||
<ChevronsLeftRightEllipsis className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close navigation"
|
||||
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-800 hover:text-white md:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
|
||||
{navItems.map(({ to, icon: Icon, labelKey }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
onClick={onClose}
|
||||
title={isCollapsed ? t(labelKey) : undefined}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'group flex items-center gap-3 overflow-hidden rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-300',
|
||||
isActive
|
||||
? 'border border-[#3a6de0] bg-[#0b2f80]/55 text-white shadow-[0_0_30px_-16px_rgba(72,140,255,0.95)]'
|
||||
: 'border border-transparent text-[#9bb7eb] hover:border-[#294a8d] hover:bg-[#07132f] hover:text-white',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span
|
||||
className={[
|
||||
'whitespace-nowrap transition-[opacity,transform,width] duration-300',
|
||||
isCollapsed ? 'w-0 -translate-x-3 opacity-0 md:invisible' : 'w-auto opacity-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'mx-3 mb-4 rounded-xl border border-[#1b3670] bg-[#071328]/80 px-3 py-3 text-xs text-[#89a9df] transition-all duration-300',
|
||||
isCollapsed ? 'md:px-1.5 md:text-center' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={isCollapsed ? 'hidden md:block' : ''}>Gateway + Dashboard</p>
|
||||
<p className={isCollapsed ? 'text-[10px] uppercase tracking-widest' : 'mt-1 text-[#5f84cc]'}>
|
||||
{isCollapsed ? 'UI' : 'Runtime Mode'}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
setToken as writeToken,
|
||||
clearToken as removeToken,
|
||||
isAuthenticated as checkAuth,
|
||||
TOKEN_STORAGE_KEY,
|
||||
} from '../lib/auth';
|
||||
import { pair as apiPair, getPublicHealth } from '../lib/api';
|
||||
|
||||
@ -69,10 +70,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep state in sync if localStorage is changed in another tab
|
||||
// Keep state in sync if token storage is changed from another browser context.
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === 'zeroclaw_token') {
|
||||
if (e.key === TOKEN_STORAGE_KEY) {
|
||||
const t = readToken();
|
||||
setTokenState(t);
|
||||
setAuthenticated(t !== null && t.length > 0);
|
||||
|
||||
@ -1,89 +1,600 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* ZeroClaw Dark Theme
|
||||
* Dark-mode by default with gray cards and blue/green accents.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--color-bg-primary: #0a0a0f;
|
||||
--color-bg-secondary: #12121a;
|
||||
--color-bg-card: #1a1a2e;
|
||||
--color-bg-card-hover: #22223a;
|
||||
--color-bg-input: #14141f;
|
||||
|
||||
--color-border-default: #2a2a3e;
|
||||
--color-border-subtle: #1e1e30;
|
||||
|
||||
--color-accent-blue: #3b82f6;
|
||||
--color-accent-blue-hover: #2563eb;
|
||||
--color-accent-green: #10b981;
|
||||
--color-accent-green-hover: #059669;
|
||||
|
||||
--color-text-primary: #e2e8f0;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
|
||||
--color-status-success: #10b981;
|
||||
--color-status-warning: #f59e0b;
|
||||
--color-status-error: #ef4444;
|
||||
--color-status-info: #3b82f6;
|
||||
--color-electric-50: #eaf3ff;
|
||||
--color-electric-100: #d8e8ff;
|
||||
--color-electric-300: #87b8ff;
|
||||
--color-electric-500: #2f8fff;
|
||||
--color-electric-700: #0f57dd;
|
||||
--color-electric-900: #031126;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
color: #edf4ff;
|
||||
background: #020813;
|
||||
font-family:
|
||||
"Inter",
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Sora",
|
||||
"Manrope",
|
||||
"Avenir Next",
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: geometricPrecision;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(47, 143, 255, 0.22), transparent 35%),
|
||||
radial-gradient(circle at 92% 14%, rgba(0, 209, 255, 0.16), transparent 32%),
|
||||
linear-gradient(175deg, #020816 0%, #03091b 46%, #040e24 100%);
|
||||
}
|
||||
|
||||
.app-shell::before,
|
||||
.app-shell::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.app-shell::before {
|
||||
background-image:
|
||||
linear-gradient(rgba(76, 118, 194, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(76, 118, 194, 0.1) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
mask-image: radial-gradient(circle at 50% 36%, black 22%, transparent 80%);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.app-shell::after {
|
||||
background:
|
||||
radial-gradient(circle at 16% 86%, rgba(42, 128, 255, 0.34), transparent 43%),
|
||||
radial-gradient(circle at 84% 22%, rgba(0, 212, 255, 0.2), transparent 38%),
|
||||
radial-gradient(circle at 52% 122%, rgba(40, 118, 255, 0.3), transparent 56%);
|
||||
filter: blur(4px);
|
||||
animation: appGlowDrift 28s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.glass-header {
|
||||
position: relative;
|
||||
backdrop-filter: blur(16px);
|
||||
background: linear-gradient(160deg, rgba(6, 19, 45, 0.85), rgba(5, 14, 33, 0.9));
|
||||
box-shadow:
|
||||
0 18px 32px -28px rgba(68, 145, 255, 0.95),
|
||||
inset 0 1px 0 rgba(140, 183, 255, 0.14);
|
||||
}
|
||||
|
||||
.glass-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(105deg, transparent 10%, rgba(79, 155, 255, 0.24), transparent 70%);
|
||||
transform: translateX(-70%);
|
||||
animation: topGlowSweep 7s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid #21438c;
|
||||
padding: 1.15rem 1.2rem;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, rgba(56, 143, 255, 0.24), transparent 40%),
|
||||
linear-gradient(146deg, rgba(8, 26, 64, 0.95), rgba(4, 13, 34, 0.92));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(130, 174, 255, 0.16),
|
||||
0 22px 50px -38px rgba(64, 145, 255, 0.94);
|
||||
}
|
||||
|
||||
.hero-panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(118deg, transparent, rgba(128, 184, 255, 0.12), transparent 70%);
|
||||
transform: translateX(-62%);
|
||||
animation: heroSweep 5.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 19rem;
|
||||
height: 19rem;
|
||||
right: -6rem;
|
||||
top: -10rem;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at center, rgba(63, 167, 255, 0.42), transparent 70%);
|
||||
filter: blur(12px);
|
||||
animation: heroGlowPulse 4.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #2d58ac;
|
||||
background: rgba(6, 29, 78, 0.68);
|
||||
color: #c2d9ff;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
box-shadow: 0 0 22px -16px rgba(83, 153, 255, 0.95);
|
||||
}
|
||||
|
||||
.electric-brand-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(circle at 22% 18%, rgba(94, 200, 255, 0.22), rgba(23, 119, 255, 0.14) 52%, rgba(10, 72, 181, 0.18) 100%),
|
||||
url("/logo/background.png") center / cover no-repeat;
|
||||
background-blend-mode: screen;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24),
|
||||
0 10px 25px -12px rgba(41, 130, 255, 0.95);
|
||||
}
|
||||
|
||||
.electric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #1a3670;
|
||||
background:
|
||||
linear-gradient(165deg, rgba(8, 24, 60, 0.95), rgba(4, 14, 34, 0.96));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(129, 174, 255, 0.12),
|
||||
0 25px 45px -36px rgba(47, 140, 255, 0.95),
|
||||
0 0 0 1px rgba(63, 141, 255, 0.2),
|
||||
0 0 26px -17px rgba(76, 176, 255, 0.82);
|
||||
}
|
||||
|
||||
.electric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20%;
|
||||
right: -20%;
|
||||
bottom: -65%;
|
||||
height: 72%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(59, 148, 255, 0.25), transparent 72%);
|
||||
filter: blur(16px);
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
animation: cardGlowPulse 5.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.electric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9bc3ff;
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(123, 198, 255, 0.38), rgba(29, 92, 214, 0.32) 66%, rgba(12, 44, 102, 0.48) 100%);
|
||||
border: 1px solid rgba(86, 143, 255, 0.45);
|
||||
}
|
||||
|
||||
.metric-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #244991;
|
||||
background: rgba(6, 22, 54, 0.74);
|
||||
color: #91b8fb;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.3rem 0.55rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: #ffffff;
|
||||
font-size: clamp(1.15rem, 1.8vw, 1.45rem);
|
||||
font-weight: 620;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
color: #89aee8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.metric-pill {
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid #1d3c77;
|
||||
background: rgba(5, 17, 44, 0.86);
|
||||
padding: 0.6rem 0.72rem;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(62, 137, 255, 0.16),
|
||||
0 16px 30px -24px rgba(47, 140, 255, 0.86),
|
||||
0 0 18px -15px rgba(73, 176, 255, 0.78);
|
||||
}
|
||||
|
||||
.metric-pill span {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #85a9e1;
|
||||
}
|
||||
|
||||
.metric-pill strong {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.93rem;
|
||||
color: #f5f9ff;
|
||||
}
|
||||
|
||||
.electric-progress {
|
||||
background:
|
||||
linear-gradient(90deg, #1f76ff 0%, #2f97ff 60%, #48cdff 100%);
|
||||
box-shadow: 0 0 18px -7px rgba(62, 166, 255, 0.95);
|
||||
}
|
||||
|
||||
.pairing-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
min-height: 100dvh;
|
||||
background:
|
||||
radial-gradient(circle at 20% 5%, rgba(64, 141, 255, 0.24), transparent 35%),
|
||||
radial-gradient(circle at 75% 92%, rgba(0, 193, 255, 0.13), transparent 35%),
|
||||
linear-gradient(155deg, #020816 0%, #030c20 58%, #030915 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pairing-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background:
|
||||
radial-gradient(circle at 10% 20%, rgba(84, 173, 255, 0.25), transparent 60%),
|
||||
radial-gradient(circle at 85% 80%, rgba(0, 204, 255, 0.22), transparent 60%);
|
||||
filter: blur(12px);
|
||||
opacity: 0.7;
|
||||
animation: pairingSpotlightSweep 18s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.pairing-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid #2956a8;
|
||||
background:
|
||||
linear-gradient(155deg, rgba(9, 27, 68, 0.9), rgba(4, 15, 35, 0.94));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(146, 190, 255, 0.16),
|
||||
0 30px 60px -44px rgba(47, 141, 255, 0.98),
|
||||
0 0 0 1px rgba(67, 150, 255, 0.2),
|
||||
0 0 28px -18px rgba(76, 184, 255, 0.82);
|
||||
}
|
||||
|
||||
.pairing-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, transparent 10%, rgba(102, 186, 255, 0.5), transparent 80%);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.0;
|
||||
transform: translateX(-65%);
|
||||
animation: pairingCardSweep 7.5s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pairing-brand {
|
||||
background-image: linear-gradient(120deg, #5bc0ff 0%, #f9e775 28%, #5bc0ff 56%, #f9e775 100%);
|
||||
background-size: 260% 260%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow:
|
||||
0 0 14px rgba(96, 189, 255, 0.8),
|
||||
0 0 32px rgba(46, 138, 255, 0.9),
|
||||
0 0 42px rgba(252, 238, 147, 0.85);
|
||||
letter-spacing: 0.18em;
|
||||
animation: pairingElectricCharge 5.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-xl"][class*="border"],
|
||||
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-lg"][class*="border"],
|
||||
:is(div, section, article)[class*="bg-gray-950"][class*="rounded-lg"][class*="border"] {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(67, 144, 255, 0.14),
|
||||
0 22px 40px -32px rgba(45, 134, 255, 0.86),
|
||||
0 0 22px -16px rgba(73, 180, 255, 0.75);
|
||||
}
|
||||
|
||||
.electric-button {
|
||||
border: 1px solid #4a89ff;
|
||||
background: linear-gradient(126deg, #125bdf 0%, #1f88ff 55%, #17b4ff 100%);
|
||||
box-shadow: 0 18px 30px -20px rgba(47, 141, 255, 0.9);
|
||||
transition: transform 180ms ease, filter 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.electric-button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 20px 34px -19px rgba(56, 154, 255, 0.95);
|
||||
}
|
||||
|
||||
.electric-loader {
|
||||
border: 3px solid rgba(89, 146, 255, 0.22);
|
||||
border-top-color: #51abff;
|
||||
box-shadow: 0 0 20px -12px rgba(66, 157, 255, 1);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.motion-rise {
|
||||
animation: riseIn 580ms ease both;
|
||||
}
|
||||
|
||||
.motion-delay-1 {
|
||||
animation-delay: 70ms;
|
||||
}
|
||||
|
||||
.motion-delay-2 {
|
||||
animation-delay: 130ms;
|
||||
}
|
||||
|
||||
.motion-delay-3 {
|
||||
animation-delay: 190ms;
|
||||
}
|
||||
|
||||
.motion-delay-4 {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #244787 #081126;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
background: #081126;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
background: #244787;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
background: #3160b6;
|
||||
}
|
||||
|
||||
/* Card utility */
|
||||
.card {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background-color: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
/* Focus ring utility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-accent-blue);
|
||||
outline: 2px solid #4ea4ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes riseIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroSweep {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-68%);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
60% {
|
||||
transform: translateX(58%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroGlowPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardGlowPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
transform: translateY(0) scale(0.96);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
transform: translateY(-2%) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pairingElectricCharge {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
text-shadow:
|
||||
0 0 14px rgba(86, 177, 255, 0.5),
|
||||
0 0 32px rgba(36, 124, 255, 0.7),
|
||||
0 0 38px rgba(252, 238, 147, 0.6);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
35% {
|
||||
background-position: 80% 50%;
|
||||
text-shadow:
|
||||
0 0 26px rgba(138, 218, 255, 1),
|
||||
0 0 52px rgba(56, 176, 255, 1),
|
||||
0 0 60px rgba(252, 238, 147, 1);
|
||||
transform: translateY(-1px) scale(1.06);
|
||||
}
|
||||
60% {
|
||||
background-position: 50% 50%;
|
||||
text-shadow:
|
||||
0 0 18px rgba(86, 177, 255, 0.7),
|
||||
0 0 36px rgba(36, 124, 255, 0.8),
|
||||
0 0 44px rgba(252, 238, 147, 0.7);
|
||||
transform: translateY(0) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pairingSpotlightSweep {
|
||||
0% {
|
||||
transform: translate3d(-12%, 8%, 0) scale(1);
|
||||
opacity: 0.45;
|
||||
}
|
||||
30% {
|
||||
transform: translate3d(10%, -4%, 0) scale(1.06);
|
||||
opacity: 0.7;
|
||||
}
|
||||
55% {
|
||||
transform: translate3d(16%, 10%, 0) scale(1.1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
80% {
|
||||
transform: translate3d(-8%, -6%, 0) scale(1.04);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(-12%, 8%, 0) scale(1);
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pairingCardSweep {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-70%);
|
||||
opacity: 0;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
transform: translateX(55%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes topGlowSweep {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-78%);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
58% {
|
||||
transform: translateX(58%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appGlowDrift {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate3d(-3%, 1.8%, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.5;
|
||||
transform: translate3d(2.6%, -1.2%, 0) scale(1.04);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.56;
|
||||
transform: translate3d(4.4%, -3.4%, 0) scale(1.09);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.44;
|
||||
transform: translate3d(-1.8%, -2.1%, 0) scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.34;
|
||||
transform: translate3d(-3.6%, 2.6%, 0) scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-panel {
|
||||
padding: 0.95rem 0.95rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 0.28rem 0.52rem;
|
||||
font-size: 0.61rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.electric-card {
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-panel::after,
|
||||
.hero-panel::before,
|
||||
.glass-header::after,
|
||||
.electric-card::after,
|
||||
.app-shell::after,
|
||||
.pairing-shell::after,
|
||||
.pairing-card::before,
|
||||
.motion-rise,
|
||||
.electric-loader {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.electric-button {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@ import type {
|
||||
ToolSpec,
|
||||
CronJob,
|
||||
Integration,
|
||||
IntegrationSettingsPayload,
|
||||
DiagResult,
|
||||
MemoryEntry,
|
||||
PairedDevice,
|
||||
CostSummary,
|
||||
CliTool,
|
||||
HealthSnapshot,
|
||||
@ -184,6 +186,23 @@ export function getIntegrations(): Promise<Integration[]> {
|
||||
);
|
||||
}
|
||||
|
||||
export function getIntegrationSettings(): Promise<IntegrationSettingsPayload> {
|
||||
return apiFetch<IntegrationSettingsPayload>('/api/integrations/settings');
|
||||
}
|
||||
|
||||
export function putIntegrationCredentials(
|
||||
integrationId: string,
|
||||
body: { revision?: string; fields: Record<string, string> },
|
||||
): Promise<{ status: string; revision: string; unchanged?: boolean }> {
|
||||
return apiFetch<{ status: string; revision: string; unchanged?: boolean }>(
|
||||
`/api/integrations/${encodeURIComponent(integrationId)}/credentials`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doctor / Diagnostics
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -229,6 +248,22 @@ export function deleteMemory(key: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paired Devices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getPairedDevices(): Promise<PairedDevice[]> {
|
||||
return apiFetch<PairedDevice[] | { devices: PairedDevice[] }>('/api/pairing/devices').then(
|
||||
(data) => unwrapField(data, 'devices'),
|
||||
);
|
||||
}
|
||||
|
||||
export function revokePairedDevice(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/pairing/devices/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,36 +1,84 @@
|
||||
const TOKEN_KEY = 'zeroclaw_token';
|
||||
export const TOKEN_STORAGE_KEY = 'zeroclaw_token';
|
||||
let inMemoryToken: string | null = null;
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
// sessionStorage may be unavailable in some browser privacy modes
|
||||
}
|
||||
}
|
||||
|
||||
function removeStorage(key: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegacyLocalStorageToken(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the stored authentication token.
|
||||
*/
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
if (inMemoryToken && inMemoryToken.length > 0) {
|
||||
return inMemoryToken;
|
||||
}
|
||||
|
||||
const sessionToken = readStorage(TOKEN_STORAGE_KEY);
|
||||
if (sessionToken && sessionToken.length > 0) {
|
||||
inMemoryToken = sessionToken;
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
// One-time migration from older localStorage-backed sessions.
|
||||
try {
|
||||
const legacy = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
if (legacy && legacy.length > 0) {
|
||||
inMemoryToken = legacy;
|
||||
writeStorage(TOKEN_STORAGE_KEY, legacy);
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
return legacy;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an authentication token.
|
||||
*/
|
||||
export function setToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g. in some private browsing modes)
|
||||
}
|
||||
inMemoryToken = token;
|
||||
writeStorage(TOKEN_STORAGE_KEY, token);
|
||||
clearLegacyLocalStorageToken(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the stored authentication token.
|
||||
*/
|
||||
export function clearToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
inMemoryToken = null;
|
||||
removeStorage(TOKEN_STORAGE_KEY);
|
||||
clearLegacyLocalStorageToken(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
171
web/src/lib/chatChannels.ts
Normal file
171
web/src/lib/chatChannels.ts
Normal file
@ -0,0 +1,171 @@
|
||||
export type ChatChannelSupportLevel = 'Built-in' | 'Plugin' | 'Legacy';
|
||||
|
||||
export interface ChatChannelSupport {
|
||||
id: string;
|
||||
name: string;
|
||||
supportLevel: ChatChannelSupportLevel;
|
||||
summary: string;
|
||||
details?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export const CHAT_CHANNEL_SUPPORT: ChatChannelSupport[] = [
|
||||
{
|
||||
id: 'bluebubbles',
|
||||
name: 'BlueBubbles',
|
||||
supportLevel: 'Built-in',
|
||||
recommended: true,
|
||||
summary: 'Recommended for iMessage with BlueBubbles macOS server REST API.',
|
||||
details:
|
||||
'Supports edit, unsend, effects, reactions, and group management. Edit is currently broken on macOS 26 Tahoe.',
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Discord Bot API + Gateway for servers, channels, and direct messages.',
|
||||
},
|
||||
{
|
||||
id: 'feishu',
|
||||
name: 'Feishu',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Feishu/Lark bot integration over WebSocket.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'google-chat',
|
||||
name: 'Google Chat',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Google Chat app integration via HTTP webhook.',
|
||||
},
|
||||
{
|
||||
id: 'imessage-legacy',
|
||||
name: 'iMessage (legacy)',
|
||||
supportLevel: 'Legacy',
|
||||
summary: 'Legacy macOS integration via imsg CLI.',
|
||||
details: 'Deprecated path for new setups; BlueBubbles is recommended.',
|
||||
},
|
||||
{
|
||||
id: 'irc',
|
||||
name: 'IRC',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Classic IRC channels and DMs with pairing and allowlist controls.',
|
||||
},
|
||||
{
|
||||
id: 'line',
|
||||
name: 'LINE',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'LINE Messaging API bot integration.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'matrix',
|
||||
name: 'Matrix',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Matrix protocol integration for rooms and direct messaging.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'mattermost',
|
||||
name: 'Mattermost',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Bot API + WebSocket for channels, groups, and DMs.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'microsoft-teams',
|
||||
name: 'Microsoft Teams',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Enterprise support track for Teams environments.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'nextcloud-talk',
|
||||
name: 'Nextcloud Talk',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Self-hosted chat via Nextcloud Talk integration.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'nostr',
|
||||
name: 'Nostr',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Decentralized encrypted DMs via NIP-04 and modern NIP flows.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'signal',
|
||||
name: 'Signal',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Privacy-focused messaging through signal-cli.',
|
||||
},
|
||||
{
|
||||
id: 'synology-chat',
|
||||
name: 'Synology Chat',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Synology NAS Chat via outgoing and incoming webhooks.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Slack workspace apps powered by Bolt SDK.',
|
||||
},
|
||||
{
|
||||
id: 'telegram',
|
||||
name: 'Telegram',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Bot API integration via grammY with strong group support.',
|
||||
},
|
||||
{
|
||||
id: 'tlon',
|
||||
name: 'Tlon',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Urbit-based messenger integration path.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'twitch',
|
||||
name: 'Twitch',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Twitch chat support over IRC connection.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'webchat',
|
||||
name: 'WebChat',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Gateway WebChat UI over WebSocket for browser-based sessions.',
|
||||
},
|
||||
{
|
||||
id: 'whatsapp',
|
||||
name: 'WhatsApp',
|
||||
supportLevel: 'Built-in',
|
||||
summary: 'Baileys-backed integration with QR pairing flow.',
|
||||
},
|
||||
{
|
||||
id: 'zalo',
|
||||
name: 'Zalo',
|
||||
supportLevel: 'Plugin',
|
||||
summary: "Zalo Bot API for Vietnam's popular messenger ecosystem.",
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
{
|
||||
id: 'zalo-personal',
|
||||
name: 'Zalo Personal',
|
||||
supportLevel: 'Plugin',
|
||||
summary: 'Personal account integration with QR login.',
|
||||
details: 'Plugin track, installed separately.',
|
||||
},
|
||||
];
|
||||
|
||||
export const CHAT_CHANNEL_NOTES: string[] = [
|
||||
'Channels can run simultaneously; configure multiple and ZeroClaw routes per chat.',
|
||||
'Fastest initial setup is usually Telegram with a simple bot token.',
|
||||
'WhatsApp requires local state on disk for persistent sessions.',
|
||||
'Group behavior varies by channel. See docs/channels-reference.md for policy details.',
|
||||
'DM pairing and allowlists are enforced for safety. See docs/security/README.md.',
|
||||
'Troubleshooting lives in docs/troubleshooting.md under channel guidance.',
|
||||
'Model providers are documented separately in docs/providers-reference.md.',
|
||||
];
|
||||
33
web/src/lib/i18n.test.ts
Normal file
33
web/src/lib/i18n.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceLocale, LANGUAGE_SWITCH_ORDER } from './i18n';
|
||||
|
||||
describe('i18n locale support', () => {
|
||||
it('normalizes locale hints for the supported language set', () => {
|
||||
expect(coerceLocale('en-US')).toBe('en');
|
||||
expect(coerceLocale('zh')).toBe('zh-CN');
|
||||
expect(coerceLocale('zh-HK')).toBe('zh-CN');
|
||||
expect(coerceLocale('ja-JP')).toBe('ja');
|
||||
expect(coerceLocale('ru-RU')).toBe('ru');
|
||||
expect(coerceLocale('fr-CA')).toBe('fr');
|
||||
expect(coerceLocale('vi-VN')).toBe('vi');
|
||||
expect(coerceLocale('el-GR')).toBe('el');
|
||||
});
|
||||
|
||||
it('falls back to English for unknown locales', () => {
|
||||
expect(coerceLocale('es-ES')).toBe('en');
|
||||
expect(coerceLocale('pt-BR')).toBe('en');
|
||||
expect(coerceLocale(undefined)).toBe('en');
|
||||
});
|
||||
|
||||
it('uses the expected language switch order', () => {
|
||||
expect(LANGUAGE_SWITCH_ORDER).toEqual([
|
||||
'en',
|
||||
'zh-CN',
|
||||
'ja',
|
||||
'ru',
|
||||
'fr',
|
||||
'vi',
|
||||
'el',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -5,7 +5,39 @@ import { getStatus } from './api';
|
||||
// Translation dictionaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Locale = 'en' | 'tr';
|
||||
export type Locale = 'en' | 'tr' | 'zh-CN' | 'ja' | 'ru' | 'fr' | 'vi' | 'el';
|
||||
|
||||
export const LANGUAGE_SWITCH_ORDER: ReadonlyArray<Locale> = [
|
||||
'en',
|
||||
'zh-CN',
|
||||
'ja',
|
||||
'ru',
|
||||
'fr',
|
||||
'vi',
|
||||
'el',
|
||||
];
|
||||
|
||||
export const LANGUAGE_BUTTON_LABELS: Record<Locale, string> = {
|
||||
en: 'EN',
|
||||
tr: 'TR',
|
||||
'zh-CN': '简体',
|
||||
ja: '日本語',
|
||||
ru: 'РУ',
|
||||
fr: 'FR',
|
||||
vi: 'VI',
|
||||
el: 'ΕΛ',
|
||||
};
|
||||
|
||||
const KNOWN_LOCALES: ReadonlyArray<Locale> = [
|
||||
'en',
|
||||
'tr',
|
||||
'zh-CN',
|
||||
'ja',
|
||||
'ru',
|
||||
'fr',
|
||||
'vi',
|
||||
'el',
|
||||
];
|
||||
|
||||
const translations: Record<Locale, Record<string, string>> = {
|
||||
en: {
|
||||
@ -16,6 +48,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'nav.cron': 'Scheduled Jobs',
|
||||
'nav.integrations': 'Integrations',
|
||||
'nav.memory': 'Memory',
|
||||
'nav.devices': 'Devices',
|
||||
'nav.config': 'Configuration',
|
||||
'nav.cost': 'Cost Tracker',
|
||||
'nav.logs': 'Logs',
|
||||
@ -199,6 +232,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'nav.cron': 'Zamanlanmis Gorevler',
|
||||
'nav.integrations': 'Entegrasyonlar',
|
||||
'nav.memory': 'Hafiza',
|
||||
'nav.devices': 'Cihazlar',
|
||||
'nav.config': 'Yapilandirma',
|
||||
'nav.cost': 'Maliyet Takibi',
|
||||
'nav.logs': 'Kayitlar',
|
||||
@ -373,6 +407,195 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'health.uptime': 'Calisma Suresi',
|
||||
'health.updated_at': 'Son Guncelleme',
|
||||
},
|
||||
|
||||
'zh-CN': {
|
||||
// Navigation
|
||||
'nav.dashboard': '仪表盘',
|
||||
'nav.agent': '智能体',
|
||||
'nav.tools': '工具',
|
||||
'nav.cron': '定时任务',
|
||||
'nav.integrations': '集成',
|
||||
'nav.memory': '记忆',
|
||||
'nav.devices': '设备',
|
||||
'nav.config': '配置',
|
||||
'nav.cost': '成本追踪',
|
||||
'nav.logs': '日志',
|
||||
'nav.doctor': '诊断',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': '仪表盘',
|
||||
'dashboard.provider': '提供商',
|
||||
'dashboard.model': '模型',
|
||||
'dashboard.uptime': '运行时长',
|
||||
'dashboard.temperature': '温度',
|
||||
'dashboard.gateway_port': '网关端口',
|
||||
'dashboard.locale': '语言区域',
|
||||
'dashboard.memory_backend': '记忆后端',
|
||||
'dashboard.paired': '已配对',
|
||||
'dashboard.channels': '渠道',
|
||||
'dashboard.health': '健康状态',
|
||||
'dashboard.status': '状态',
|
||||
'dashboard.overview': '总览',
|
||||
'dashboard.system_info': '系统信息',
|
||||
'dashboard.quick_actions': '快捷操作',
|
||||
|
||||
// Agent / Chat
|
||||
'agent.title': '智能体聊天',
|
||||
'agent.send': '发送',
|
||||
'agent.placeholder': '输入消息...',
|
||||
'agent.connecting': '连接中...',
|
||||
'agent.connected': '已连接',
|
||||
'agent.disconnected': '已断开连接',
|
||||
'agent.reconnecting': '重连中...',
|
||||
'agent.thinking': '思考中...',
|
||||
'agent.tool_call': '工具调用',
|
||||
'agent.tool_result': '工具结果',
|
||||
|
||||
// Tools
|
||||
'tools.title': '可用工具',
|
||||
'tools.name': '名称',
|
||||
'tools.description': '描述',
|
||||
'tools.parameters': '参数',
|
||||
'tools.search': '搜索工具...',
|
||||
'tools.empty': '暂无可用工具。',
|
||||
'tools.count': '工具总数',
|
||||
|
||||
// Cron
|
||||
'cron.title': '定时任务',
|
||||
'cron.add': '添加任务',
|
||||
'cron.delete': '删除',
|
||||
'cron.enable': '启用',
|
||||
'cron.disable': '禁用',
|
||||
'cron.name': '名称',
|
||||
'cron.command': '命令',
|
||||
'cron.schedule': '计划',
|
||||
'cron.next_run': '下次运行',
|
||||
'cron.last_run': '上次运行',
|
||||
'cron.last_status': '上次状态',
|
||||
'cron.enabled': '已启用',
|
||||
'cron.empty': '暂无定时任务。',
|
||||
'cron.confirm_delete': '确定要删除此任务吗?',
|
||||
|
||||
// Integrations
|
||||
'integrations.title': '集成',
|
||||
'integrations.available': '可用',
|
||||
'integrations.active': '已激活',
|
||||
'integrations.coming_soon': '即将推出',
|
||||
'integrations.category': '分类',
|
||||
'integrations.status': '状态',
|
||||
'integrations.search': '搜索集成...',
|
||||
'integrations.empty': '未找到集成。',
|
||||
'integrations.activate': '激活',
|
||||
'integrations.deactivate': '停用',
|
||||
|
||||
// Memory
|
||||
'memory.title': '记忆存储',
|
||||
'memory.search': '搜索记忆...',
|
||||
'memory.add': '存储记忆',
|
||||
'memory.delete': '删除',
|
||||
'memory.key': '键',
|
||||
'memory.content': '内容',
|
||||
'memory.category': '分类',
|
||||
'memory.timestamp': '时间戳',
|
||||
'memory.session': '会话',
|
||||
'memory.score': '评分',
|
||||
'memory.empty': '未找到记忆条目。',
|
||||
'memory.confirm_delete': '确定要删除此记忆条目吗?',
|
||||
'memory.all_categories': '全部分类',
|
||||
|
||||
// Config
|
||||
'config.title': '配置',
|
||||
'config.save': '保存',
|
||||
'config.reset': '重置',
|
||||
'config.saved': '配置保存成功。',
|
||||
'config.error': '配置保存失败。',
|
||||
'config.loading': '配置加载中...',
|
||||
'config.editor_placeholder': 'TOML 配置...',
|
||||
|
||||
// Cost
|
||||
'cost.title': '成本追踪',
|
||||
'cost.session': '会话成本',
|
||||
'cost.daily': '每日成本',
|
||||
'cost.monthly': '每月成本',
|
||||
'cost.total_tokens': 'Token 总数',
|
||||
'cost.request_count': '请求数',
|
||||
'cost.by_model': '按模型统计成本',
|
||||
'cost.model': '模型',
|
||||
'cost.tokens': 'Token',
|
||||
'cost.requests': '请求',
|
||||
'cost.usd': '成本(USD)',
|
||||
|
||||
// Logs
|
||||
'logs.title': '实时日志',
|
||||
'logs.clear': '清空',
|
||||
'logs.pause': '暂停',
|
||||
'logs.resume': '继续',
|
||||
'logs.filter': '筛选日志...',
|
||||
'logs.empty': '暂无日志条目。',
|
||||
'logs.connected': '已连接到事件流。',
|
||||
'logs.disconnected': '与事件流断开连接。',
|
||||
|
||||
// Doctor
|
||||
'doctor.title': '系统诊断',
|
||||
'doctor.run': '运行诊断',
|
||||
'doctor.running': '正在运行诊断...',
|
||||
'doctor.ok': '正常',
|
||||
'doctor.warn': '警告',
|
||||
'doctor.error': '错误',
|
||||
'doctor.severity': '严重级别',
|
||||
'doctor.category': '分类',
|
||||
'doctor.message': '消息',
|
||||
'doctor.empty': '尚未运行诊断。',
|
||||
'doctor.summary': '诊断摘要',
|
||||
|
||||
// Auth / Pairing
|
||||
'auth.pair': '设备配对',
|
||||
'auth.pairing_code': '配对码',
|
||||
'auth.pair_button': '配对',
|
||||
'auth.logout': '退出登录',
|
||||
'auth.pairing_success': '配对成功!',
|
||||
'auth.pairing_failed': '配对失败,请重试。',
|
||||
'auth.enter_code': '输入配对码以连接到智能体。',
|
||||
|
||||
// Common
|
||||
'common.loading': '加载中...',
|
||||
'common.error': '发生错误。',
|
||||
'common.retry': '重试',
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确认',
|
||||
'common.save': '保存',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.close': '关闭',
|
||||
'common.yes': '是',
|
||||
'common.no': '否',
|
||||
'common.search': '搜索...',
|
||||
'common.no_data': '暂无数据。',
|
||||
'common.refresh': '刷新',
|
||||
'common.back': '返回',
|
||||
'common.actions': '操作',
|
||||
'common.name': '名称',
|
||||
'common.description': '描述',
|
||||
'common.status': '状态',
|
||||
'common.created': '创建时间',
|
||||
'common.updated': '更新时间',
|
||||
|
||||
// Health
|
||||
'health.title': '系统健康',
|
||||
'health.component': '组件',
|
||||
'health.status': '状态',
|
||||
'health.last_ok': '最近正常',
|
||||
'health.last_error': '最近错误',
|
||||
'health.restart_count': '重启次数',
|
||||
'health.pid': '进程 ID',
|
||||
'health.uptime': '运行时长',
|
||||
'health.updated_at': '最后更新',
|
||||
},
|
||||
ja: {},
|
||||
ru: {},
|
||||
fr: {},
|
||||
vi: {},
|
||||
el: {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -413,6 +636,21 @@ export function tLocale(key: string, locale: Locale): string {
|
||||
// React hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function coerceLocale(locale: string | undefined): Locale {
|
||||
if (!locale) return 'en';
|
||||
if (KNOWN_LOCALES.includes(locale as Locale)) return locale as Locale;
|
||||
|
||||
const lowered = locale.toLowerCase();
|
||||
if (lowered.startsWith('tr')) return 'tr';
|
||||
if (lowered === 'zh' || lowered.startsWith('zh-')) return 'zh-CN';
|
||||
if (lowered === 'ja' || lowered.startsWith('ja-')) return 'ja';
|
||||
if (lowered === 'ru' || lowered.startsWith('ru-')) return 'ru';
|
||||
if (lowered === 'fr' || lowered.startsWith('fr-')) return 'fr';
|
||||
if (lowered === 'vi' || lowered.startsWith('vi-')) return 'vi';
|
||||
if (lowered === 'el' || lowered.startsWith('el-')) return 'el';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that fetches the locale from /api/status on mount and keeps the
|
||||
* i18n module in sync. Returns the current locale and a `t` helper bound to it.
|
||||
@ -426,9 +664,7 @@ export function useLocale(): { locale: Locale; t: (key: string) => string } {
|
||||
getStatus()
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
const detected = status.locale?.toLowerCase().startsWith('tr')
|
||||
? 'tr'
|
||||
: 'en';
|
||||
const detected = coerceLocale(status.locale);
|
||||
setLocale(detected);
|
||||
setLocaleState(detected);
|
||||
})
|
||||
|
||||
@ -52,6 +52,7 @@ export class SSEClient {
|
||||
connect(): void {
|
||||
this.intentionallyClosed = false;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
this.controller = new AbortController();
|
||||
|
||||
const token = getToken();
|
||||
|
||||
9
web/src/lib/wasm.test.ts
Normal file
9
web/src/lib/wasm.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isWasmSupported } from './wasm';
|
||||
|
||||
describe('isWasmSupported', () => {
|
||||
it('returns a boolean without throwing', () => {
|
||||
expect(() => isWasmSupported()).not.toThrow();
|
||||
expect(typeof isWasmSupported()).toBe('boolean');
|
||||
});
|
||||
});
|
||||
31
web/src/lib/wasm.ts
Normal file
31
web/src/lib/wasm.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Canonical tiny WASM module header for capability checks.
|
||||
const wasmProbeBytes = Uint8Array.of(
|
||||
0x00,
|
||||
0x61,
|
||||
0x73,
|
||||
0x6d,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
);
|
||||
|
||||
export function isWasmSupported(): boolean {
|
||||
try {
|
||||
if (typeof WebAssembly !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (typeof WebAssembly.Module !== 'function') {
|
||||
return false;
|
||||
}
|
||||
if (typeof WebAssembly.Instance !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const module = new WebAssembly.Module(wasmProbeBytes);
|
||||
const instance = new WebAssembly.Instance(module);
|
||||
return instance instanceof WebAssembly.Instance;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ export interface WebSocketClientOptions {
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const WS_SESSION_STORAGE_KEY = 'zeroclaw.ws.session_id';
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
@ -35,6 +36,7 @@ export class WebSocketClient {
|
||||
private readonly reconnectDelay: number;
|
||||
private readonly maxReconnectDelay: number;
|
||||
private readonly autoReconnect: boolean;
|
||||
private readonly sessionId: string;
|
||||
|
||||
constructor(options: WebSocketClientOptions = {}) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@ -44,6 +46,7 @@ export class WebSocketClient {
|
||||
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
|
||||
this.autoReconnect = options.autoReconnect ?? true;
|
||||
this.currentDelay = this.reconnectDelay;
|
||||
this.sessionId = this.resolveSessionId();
|
||||
}
|
||||
|
||||
/** Open the WebSocket connection. */
|
||||
@ -52,9 +55,13 @@ export class WebSocketClient {
|
||||
this.clearReconnectTimer();
|
||||
|
||||
const token = getToken();
|
||||
const url = `${this.baseUrl}/ws/chat${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const url = `${this.baseUrl}/ws/chat?session_id=${encodeURIComponent(this.sessionId)}`;
|
||||
const protocols = ['zeroclaw.v1'];
|
||||
if (token) {
|
||||
protocols.push(`bearer.${token}`);
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws = new WebSocket(url, protocols);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.currentDelay = this.reconnectDelay;
|
||||
@ -122,4 +129,17 @@ export class WebSocketClient {
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSessionId(): string {
|
||||
const existing = window.localStorage.getItem(WS_SESSION_STORAGE_KEY);
|
||||
if (existing && /^[A-Za-z0-9_-]{1,128}$/.test(existing)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const generated =
|
||||
globalThis.crypto?.randomUUID?.().replace(/-/g, '_') ??
|
||||
`sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
window.localStorage.setItem(WS_SESSION_STORAGE_KEY, generated);
|
||||
return generated;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,20 @@ interface ChatMessage {
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let fallbackMessageIdCounter = 0;
|
||||
const EMPTY_DONE_FALLBACK =
|
||||
'Tool execution completed, but no final response text was returned.';
|
||||
|
||||
function makeMessageId(): string {
|
||||
const uuid = globalThis.crypto?.randomUUID?.();
|
||||
if (uuid) return uuid;
|
||||
|
||||
fallbackMessageIdCounter += 1;
|
||||
return `msg_${Date.now().toString(36)}_${fallbackMessageIdCounter.toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export default function AgentChat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
@ -40,6 +54,22 @@ export default function AgentChat() {
|
||||
|
||||
ws.onMessage = (msg: WsMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'history': {
|
||||
const restored: ChatMessage[] = (msg.messages ?? [])
|
||||
.filter((entry) => entry.content?.trim())
|
||||
.map((entry): ChatMessage => ({
|
||||
id: makeMessageId(),
|
||||
role: entry.role === 'user' ? 'user' : 'agent',
|
||||
content: entry.content.trim(),
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
|
||||
setMessages(restored);
|
||||
setTyping(false);
|
||||
pendingContentRef.current = '';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chunk':
|
||||
setTyping(true);
|
||||
pendingContentRef.current += msg.content ?? '';
|
||||
@ -47,18 +77,19 @@ export default function AgentChat() {
|
||||
|
||||
case 'message':
|
||||
case 'done': {
|
||||
const content = msg.full_response ?? msg.content ?? pendingContentRef.current;
|
||||
if (content) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'agent',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
const content = (msg.full_response ?? msg.content ?? pendingContentRef.current ?? '').trim();
|
||||
const finalContent = content || EMPTY_DONE_FALLBACK;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: makeMessageId(),
|
||||
role: 'agent',
|
||||
content: finalContent,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
pendingContentRef.current = '';
|
||||
setTyping(false);
|
||||
break;
|
||||
@ -68,7 +99,7 @@ export default function AgentChat() {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
id: makeMessageId(),
|
||||
role: 'agent',
|
||||
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
|
||||
timestamp: new Date(),
|
||||
@ -80,7 +111,7 @@ export default function AgentChat() {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
id: makeMessageId(),
|
||||
role: 'agent',
|
||||
content: `[Tool Result] ${msg.output ?? ''}`,
|
||||
timestamp: new Date(),
|
||||
@ -92,7 +123,7 @@ export default function AgentChat() {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
id: makeMessageId(),
|
||||
role: 'agent',
|
||||
content: `[Error] ${msg.message ?? 'Unknown error'}`,
|
||||
timestamp: new Date(),
|
||||
@ -123,7 +154,7 @@ export default function AgentChat() {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
id: makeMessageId(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: new Date(),
|
||||
@ -150,7 +181,7 @@ export default function AgentChat() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<div className="flex min-h-[28rem] flex-col h-[calc(100dvh-8.5rem)]">
|
||||
{/* Connection status bar */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-900/30 border-b border-red-700 flex items-center gap-2 text-sm text-red-300">
|
||||
@ -228,7 +259,7 @@ export default function AgentChat() {
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-gray-800 bg-gray-900 p-4">
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
||||
<div className="flex items-center gap-3 max-w-4xl mx-auto">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
|
||||
@ -1,50 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
FileText,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { getConfig, putConfig } from '@/lib/api';
|
||||
import { useConfigForm, type EditorMode } from '@/components/config/useConfigForm';
|
||||
import ConfigFormEditor from '@/components/config/ConfigFormEditor';
|
||||
import ConfigRawEditor from '@/components/config/ConfigRawEditor';
|
||||
|
||||
function ModeTab({
|
||||
mode,
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
mode: EditorMode;
|
||||
active: boolean;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'
|
||||
}`}
|
||||
aria-pressed={active}
|
||||
data-mode={mode}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Config() {
|
||||
const [config, setConfig] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getConfig()
|
||||
.then((data) => {
|
||||
// The API may return either a raw string or a JSON string
|
||||
setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await putConfig(config);
|
||||
setSuccess('Configuration saved successfully.');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-dismiss success after 4 seconds
|
||||
useEffect(() => {
|
||||
if (!success) return;
|
||||
const timer = setTimeout(() => setSuccess(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [success]);
|
||||
const {
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
success,
|
||||
mode,
|
||||
rawToml,
|
||||
setMode,
|
||||
getFieldValue,
|
||||
setFieldValue,
|
||||
isFieldMasked,
|
||||
setRawToml,
|
||||
save,
|
||||
} = useConfigForm();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -62,14 +73,34 @@ export default function Config() {
|
||||
<Settings className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">Configuration</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex items-center gap-1 bg-gray-900 border border-gray-800 rounded-lg p-0.5">
|
||||
<ModeTab
|
||||
mode="form"
|
||||
active={mode === 'form'}
|
||||
icon={SlidersHorizontal}
|
||||
label="Form"
|
||||
onClick={() => setMode('form')}
|
||||
/>
|
||||
<ModeTab
|
||||
mode="raw"
|
||||
active={mode === 'raw'}
|
||||
icon={FileText}
|
||||
label="Raw"
|
||||
onClick={() => setMode('raw')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sensitive fields note */}
|
||||
@ -80,8 +111,9 @@ export default function Config() {
|
||||
Sensitive fields are masked
|
||||
</p>
|
||||
<p className="text-sm text-yellow-400/70 mt-0.5">
|
||||
API keys, tokens, and passwords are hidden for security. To update a
|
||||
masked field, replace the entire masked value with your new value.
|
||||
{mode === 'form'
|
||||
? 'Masked fields show "Configured (masked)" as a placeholder. Leave them untouched to preserve existing values, or enter a new value to update.'
|
||||
: 'API keys, tokens, and passwords are hidden for security. To update a masked field, replace the entire masked value with your new value.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -102,24 +134,20 @@ export default function Config() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Editor */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
|
||||
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
|
||||
TOML Configuration
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{config.split('\n').length} lines
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={config}
|
||||
onChange={(e) => setConfig(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[500px] bg-gray-950 text-gray-200 font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||
style={{ tabSize: 4 }}
|
||||
{/* Editor */}
|
||||
{mode === 'form' ? (
|
||||
<ConfigFormEditor
|
||||
getFieldValue={getFieldValue}
|
||||
setFieldValue={setFieldValue}
|
||||
isFieldMasked={isFieldMasked}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ConfigRawEditor
|
||||
rawToml={rawToml}
|
||||
onChange={setRawToml}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
web/src/pages/Dashboard.test.tsx
Normal file
95
web/src/pages/Dashboard.test.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Dashboard from './Dashboard';
|
||||
import { getCost, getStatus } from '@/lib/api';
|
||||
import type { CostSummary, StatusResponse } from '@/types/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getStatus: vi.fn(),
|
||||
getCost: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetStatus = vi.mocked(getStatus);
|
||||
const mockedGetCost = vi.mocked(getCost);
|
||||
|
||||
const statusFixture: StatusResponse = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.2',
|
||||
temperature: 0.4,
|
||||
uptime_seconds: 68420,
|
||||
gateway_port: 42617,
|
||||
locale: 'en-US',
|
||||
memory_backend: 'sqlite',
|
||||
paired: true,
|
||||
channels: {
|
||||
telegram: true,
|
||||
discord: false,
|
||||
whatsapp: true,
|
||||
},
|
||||
health: {
|
||||
uptime_seconds: 68420,
|
||||
updated_at: '2026-03-02T19:34:29.678544+00:00',
|
||||
pid: 4242,
|
||||
components: {
|
||||
gateway: {
|
||||
status: 'ok',
|
||||
updated_at: '2026-03-02T19:34:29.678544+00:00',
|
||||
last_ok: '2026-03-02T19:34:29.678544+00:00',
|
||||
last_error: null,
|
||||
restart_count: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const costFixture: CostSummary = {
|
||||
session_cost_usd: 0.0842,
|
||||
daily_cost_usd: 1.3026,
|
||||
monthly_cost_usd: 14.9875,
|
||||
total_tokens: 182342,
|
||||
request_count: 426,
|
||||
by_model: {
|
||||
'gpt-5.2': {
|
||||
model: 'gpt-5.2',
|
||||
cost_usd: 11.4635,
|
||||
total_tokens: 141332,
|
||||
request_count: 292,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
it('renders with API data and supports collapsing every dashboard section', async () => {
|
||||
mockedGetStatus.mockResolvedValue(statusFixture);
|
||||
mockedGetCost.mockResolvedValue(costFixture);
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
expect(await screen.findByText('Electric Runtime Dashboard')).toBeInTheDocument();
|
||||
expect(await screen.findByText('openai')).toBeInTheDocument();
|
||||
|
||||
const sectionButtons = [
|
||||
screen.getByRole('button', { name: /Cost Pulse/i }),
|
||||
screen.getByRole('button', { name: /Channel Activity/i }),
|
||||
screen.getByRole('button', { name: /Component Health/i }),
|
||||
];
|
||||
|
||||
for (const sectionButton of sectionButtons) {
|
||||
expect(sectionButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await userEvent.click(sectionButton);
|
||||
await waitFor(() => {
|
||||
expect(sectionButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
await userEvent.click(sectionButton);
|
||||
await waitFor(() => {
|
||||
expect(sectionButton).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,15 +1,37 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ComponentType, ReactNode, SVGProps } from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
Clock,
|
||||
Globe,
|
||||
Database,
|
||||
Activity,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Cpu,
|
||||
Database,
|
||||
DollarSign,
|
||||
Globe2,
|
||||
Radio,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import type { StatusResponse, CostSummary } from '@/types/api';
|
||||
import { getStatus, getCost } from '@/lib/api';
|
||||
import type { CostSummary, StatusResponse } from '@/types/api';
|
||||
import { getCost, getStatus } from '@/lib/api';
|
||||
|
||||
type DashboardSectionKey = 'cost' | 'channels' | 'health';
|
||||
|
||||
interface DashboardSectionState {
|
||||
cost: boolean;
|
||||
channels: boolean;
|
||||
health: boolean;
|
||||
}
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
sectionKey: DashboardSectionKey;
|
||||
openState: DashboardSectionState;
|
||||
onToggle: (section: DashboardSectionKey) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
@ -28,13 +50,13 @@ function healthColor(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'healthy':
|
||||
return 'bg-green-500';
|
||||
return 'bg-emerald-400';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'bg-yellow-500';
|
||||
return 'bg-amber-400';
|
||||
default:
|
||||
return 'bg-red-500';
|
||||
return 'bg-rose-500';
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,44 +64,105 @@ function healthBorder(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'healthy':
|
||||
return 'border-green-500/30';
|
||||
return 'border-emerald-500/30';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'border-yellow-500/30';
|
||||
return 'border-amber-400/30';
|
||||
default:
|
||||
return 'border-red-500/30';
|
||||
return 'border-rose-500/35';
|
||||
}
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
sectionKey,
|
||||
openState,
|
||||
onToggle,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
const isOpen = openState[sectionKey];
|
||||
|
||||
return (
|
||||
<section className="electric-card motion-rise">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(sectionKey)}
|
||||
aria-expanded={isOpen}
|
||||
className="group flex w-full items-center justify-between gap-4 rounded-xl px-4 py-4 text-left md:px-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="electric-icon h-10 w-10 rounded-xl">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{title}</h2>
|
||||
<p className="text-xs uppercase tracking-[0.13em] text-[#7ea5eb]">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={[
|
||||
'h-5 w-5 text-[#7ea5eb] transition-transform duration-300',
|
||||
isOpen ? 'rotate-180' : 'rotate-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={[
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-300 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="min-h-0 border-t border-[#18356f] px-4 pb-4 pt-4 md:px-5">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [cost, setCost] = useState<CostSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sectionsOpen, setSectionsOpen] = useState<DashboardSectionState>({
|
||||
cost: true,
|
||||
channels: true,
|
||||
health: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getStatus(), getCost()])
|
||||
.then(([s, c]) => {
|
||||
setStatus(s);
|
||||
setCost(c);
|
||||
.then(([statusPayload, costPayload]) => {
|
||||
setStatus(statusPayload);
|
||||
setCost(costPayload);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : 'Unknown dashboard load error';
|
||||
setError(message);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSection = (section: DashboardSectionKey) => {
|
||||
setSectionsOpen((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
|
||||
Failed to load dashboard: {error}
|
||||
</div>
|
||||
<div className="electric-card p-5 text-rose-200">
|
||||
<h2 className="text-lg font-semibold text-rose-100">Dashboard load failed</h2>
|
||||
<p className="mt-2 text-sm text-rose-200/90">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status || !cost) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="electric-loader h-12 w-12 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -87,165 +170,184 @@ export default function Dashboard() {
|
||||
const maxCost = Math.max(cost.session_cost_usd, cost.daily_cost_usd, cost.monthly_cost_usd, 0.001);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Status Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-blue-600/20 rounded-lg">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Provider / Model</span>
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
<section className="hero-panel motion-rise">
|
||||
<div className="relative z-10 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[#8fb8ff]">ZeroClaw Command Deck</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-[0.03em] text-white md:text-3xl">
|
||||
Electric Runtime Dashboard
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-[#b3cbf8] md:text-base">
|
||||
Real-time telemetry, cost pulse, and operations status in a single collapsible surface.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white truncate">
|
||||
{status.provider ?? 'Unknown'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 truncate">{status.model}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-green-600/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Uptime</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="status-pill">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Live Gateway
|
||||
</span>
|
||||
<span className="status-pill">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
{status.paired ? 'Paired' : 'Unpaired'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{formatUptime(status.uptime_seconds)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Since last restart</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-purple-600/20 rounded-lg">
|
||||
<Globe className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Gateway Port</span>
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<article className="electric-card motion-rise motion-delay-1 p-4">
|
||||
<div className="metric-head">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<span>Provider / Model</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
:{status.gateway_port}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Locale: {status.locale}</p>
|
||||
</div>
|
||||
<p className="metric-value mt-3">{status.provider ?? 'Unknown'}</p>
|
||||
<p className="metric-sub mt-1 truncate">{status.model}</p>
|
||||
</article>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-orange-600/20 rounded-lg">
|
||||
<Database className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Memory Backend</span>
|
||||
<article className="electric-card motion-rise motion-delay-2 p-4">
|
||||
<div className="metric-head">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
<span>Uptime</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{status.memory_backend}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Paired: {status.paired ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="metric-value mt-3">{formatUptime(status.uptime_seconds)}</p>
|
||||
<p className="metric-sub mt-1">Since last restart</p>
|
||||
</article>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Cost Widget */}
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarSign className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">Cost Overview</h2>
|
||||
<article className="electric-card motion-rise motion-delay-3 p-4">
|
||||
<div className="metric-head">
|
||||
<Globe2 className="h-4 w-4" />
|
||||
<span>Gateway Port</span>
|
||||
</div>
|
||||
<p className="metric-value mt-3">:{status.gateway_port}</p>
|
||||
<p className="metric-sub mt-1">{status.locale}</p>
|
||||
</article>
|
||||
|
||||
<article className="electric-card motion-rise motion-delay-4 p-4">
|
||||
<div className="metric-head">
|
||||
<Database className="h-4 w-4" />
|
||||
<span>Memory Backend</span>
|
||||
</div>
|
||||
<p className="metric-value mt-3 capitalize">{status.memory_backend}</p>
|
||||
<p className="metric-sub mt-1">{status.paired ? 'Pairing active' : 'No paired devices'}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection
|
||||
title="Cost Pulse"
|
||||
subtitle="Session, daily, and monthly runtime spend"
|
||||
icon={DollarSign}
|
||||
sectionKey="cost"
|
||||
openState={sectionsOpen}
|
||||
onToggle={toggleSection}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Session', value: cost.session_cost_usd, color: 'bg-blue-500' },
|
||||
{ label: 'Daily', value: cost.daily_cost_usd, color: 'bg-green-500' },
|
||||
{ label: 'Monthly', value: cost.monthly_cost_usd, color: 'bg-purple-500' },
|
||||
].map(({ label, value, color }) => (
|
||||
{ label: 'Session', value: cost.session_cost_usd },
|
||||
{ label: 'Daily', value: cost.daily_cost_usd },
|
||||
{ label: 'Monthly', value: cost.monthly_cost_usd },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
<span className="text-white font-medium">{formatUSD(value)}</span>
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className="text-[#9bb8ec]">{label}</span>
|
||||
<span className="font-semibold text-white">{formatUSD(value)}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-[#061230]">
|
||||
<div
|
||||
className={`h-full rounded-full ${color}`}
|
||||
style={{ width: `${Math.max((value / maxCost) * 100, 2)}%` }}
|
||||
className="electric-progress h-full rounded-full"
|
||||
style={{ width: `${Math.max((value / maxCost) * 100, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-800 flex justify-between text-sm">
|
||||
<span className="text-gray-400">Total Tokens</span>
|
||||
<span className="text-white">{cost.total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-gray-400">Requests</span>
|
||||
<span className="text-white">{cost.request_count.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Channels */}
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Radio className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">Active Channels</h2>
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<div className="metric-pill">
|
||||
<span>Total Tokens</span>
|
||||
<strong>{cost.total_tokens.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div className="metric-pill">
|
||||
<span>Requests</span>
|
||||
<strong>{cost.request_count.toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(status.channels).length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No channels configured</p>
|
||||
) : (
|
||||
Object.entries(status.channels).map(([name, active]) => (
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
title="Channel Activity"
|
||||
subtitle="Live integrations and route connectivity"
|
||||
icon={Radio}
|
||||
sectionKey="channels"
|
||||
openState={sectionsOpen}
|
||||
onToggle={toggleSection}
|
||||
>
|
||||
{Object.entries(status.channels).length === 0 ? (
|
||||
<p className="text-sm text-[#8aa8df]">No channels configured.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{Object.entries(status.channels).map(([name, active]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-800/50"
|
||||
className="rounded-xl border border-[#1d3770] bg-[#05112c]/90 px-3 py-2.5"
|
||||
>
|
||||
<span className="text-sm text-white capitalize">{name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||
active ? 'bg-green-500' : 'bg-gray-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm capitalize text-white">{name}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-[#8baee7]">
|
||||
<span
|
||||
className={[
|
||||
'inline-block h-2.5 w-2.5 rounded-full',
|
||||
active ? 'bg-emerald-400 shadow-[0_0_12px_0_rgba(52,211,153,0.8)]' : 'bg-slate-500',
|
||||
].join(' ')}
|
||||
/>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Health Grid */}
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">Component Health</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(status.health.components).length === 0 ? (
|
||||
<p className="text-sm text-gray-500 col-span-2">No components reporting</p>
|
||||
) : (
|
||||
Object.entries(status.health.components).map(([name, comp]) => (
|
||||
<CollapsibleSection
|
||||
title="Component Health"
|
||||
subtitle="Runtime heartbeat and restart awareness"
|
||||
icon={Activity}
|
||||
sectionKey="health"
|
||||
openState={sectionsOpen}
|
||||
onToggle={toggleSection}
|
||||
>
|
||||
{Object.entries(status.health.components).length === 0 ? (
|
||||
<p className="text-sm text-[#8aa8df]">No component health is currently available.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Object.entries(status.health.components).map(([name, component]) => (
|
||||
<div
|
||||
key={name}
|
||||
className={`rounded-lg p-3 border ${healthBorder(comp.status)} bg-gray-800/50`}
|
||||
className={[
|
||||
'rounded-xl border bg-[#05112c]/80 px-3 py-3',
|
||||
healthBorder(component.status),
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${healthColor(comp.status)}`} />
|
||||
<span className="text-sm font-medium text-white capitalize truncate">
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold capitalize text-white">{name}</p>
|
||||
<span className={['inline-block h-2.5 w-2.5 rounded-full', healthColor(component.status)].join(' ')} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 capitalize">{comp.status}</p>
|
||||
{comp.restart_count > 0 && (
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
Restarts: {comp.restart_count}
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.12em] text-[#87a9e5]">
|
||||
{component.status}
|
||||
</p>
|
||||
{component.restart_count > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-300">
|
||||
Restarts: {component.restart_count}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
170
web/src/pages/Devices.tsx
Normal file
170
web/src/pages/Devices.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Smartphone, RefreshCw, ShieldX } from 'lucide-react';
|
||||
import type { PairedDevice } from '@/types/api';
|
||||
import { getPairedDevices, revokePairedDevice } from '@/lib/api';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return 'Unknown';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const [devices, setDevices] = useState<PairedDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = useState<string | null>(null);
|
||||
|
||||
const loadDevices = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getPairedDevices();
|
||||
setDevices(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load paired devices');
|
||||
} finally {
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDevices(false);
|
||||
}, []);
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
try {
|
||||
await revokePairedDevice(id);
|
||||
setDevices((prev) => prev.filter((device) => device.id !== id));
|
||||
setPendingRevoke(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to revoke paired device');
|
||||
setPendingRevoke(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
Paired Devices ({devices.length})
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void loadDevices(true);
|
||||
}}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-700 bg-red-900/30 p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
|
||||
<ShieldX className="mx-auto mb-3 h-10 w-10 text-gray-600" />
|
||||
<p className="text-gray-400">No paired devices found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Device ID
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Paired By
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Last Seen
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-400">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs text-white">
|
||||
{device.token_fingerprint}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">
|
||||
{device.paired_by ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
|
||||
{formatDate(device.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
|
||||
{formatDate(device.last_seen_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{pendingRevoke === device.id ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="text-xs text-red-400">Revoke?</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleRevoke(device.id);
|
||||
}}
|
||||
className="text-xs font-medium text-red-400 hover:text-red-300"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingRevoke(null)}
|
||||
className="text-xs font-medium text-gray-400 hover:text-white"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setPendingRevoke(device.id)}
|
||||
className="text-xs font-medium text-red-400 hover:text-red-300"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Puzzle, Check, Zap, Clock } from 'lucide-react';
|
||||
import type { Integration } from '@/types/api';
|
||||
import { getIntegrations } from '@/lib/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Puzzle, Check, Zap, Clock, KeyRound, X } from 'lucide-react';
|
||||
import ChatChannelsGuide from '@/components/integrations/ChatChannelsGuide';
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCredentialsField,
|
||||
IntegrationSettingsEntry,
|
||||
StatusResponse,
|
||||
} from '@/types/api';
|
||||
import {
|
||||
getIntegrations,
|
||||
getIntegrationSettings,
|
||||
getStatus,
|
||||
putIntegrationCredentials,
|
||||
} from '@/lib/api';
|
||||
|
||||
function statusBadge(status: Integration['status']) {
|
||||
switch (status) {
|
||||
@ -26,19 +38,368 @@ function statusBadge(status: Integration['status']) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatCategory(category: string): string {
|
||||
if (!category) return category;
|
||||
return category
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/Ai/g, 'AI');
|
||||
}
|
||||
|
||||
const SELECT_KEEP = '__keep__';
|
||||
const SELECT_CUSTOM = '__custom__';
|
||||
const SELECT_CLEAR = '__clear__';
|
||||
|
||||
const FALLBACK_MODEL_OPTIONS: Record<string, string[]> = {
|
||||
openrouter: ['anthropic/claude-sonnet-4-6', 'openai/gpt-5.2', 'google/gemini-3.1-pro'],
|
||||
anthropic: ['claude-sonnet-4-6', 'claude-opus-4-6'],
|
||||
openai: ['gpt-5.2', 'gpt-5.2-codex', 'gpt-4o'],
|
||||
google: ['google/gemini-3.1-pro', 'google/gemini-3-flash', 'google/gemini-2.5-pro'],
|
||||
deepseek: ['deepseek/deepseek-reasoner', 'deepseek/deepseek-chat'],
|
||||
xai: ['x-ai/grok-4', 'x-ai/grok-3'],
|
||||
mistral: ['mistral-large-latest', 'codestral-latest', 'mistral-small-latest'],
|
||||
perplexity: ['sonar-pro', 'sonar-reasoning-pro', 'sonar'],
|
||||
vercel: ['openai/gpt-5.2', 'anthropic/claude-sonnet-4-6', 'google/gemini-3.1-pro'],
|
||||
bedrock: ['anthropic.claude-sonnet-4-5-20250929-v1:0', 'anthropic.claude-opus-4-6-v1:0'],
|
||||
groq: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768'],
|
||||
together: [
|
||||
'meta-llama/Llama-3.3-70B-Instruct-Turbo',
|
||||
'Qwen/Qwen2.5-72B-Instruct-Turbo',
|
||||
'deepseek-ai/DeepSeek-R1-Distill-Llama-70B',
|
||||
],
|
||||
cohere: ['command-r-plus-08-2024', 'command-r-08-2024'],
|
||||
};
|
||||
|
||||
function customModelFormatHint(integrationId: string): string {
|
||||
if (integrationId === 'openrouter' || integrationId === 'vercel') {
|
||||
return 'Format: anthropic/claude-sonnet-4-6';
|
||||
}
|
||||
return 'Format: claude-sonnet-4-6 (or provider/model when required)';
|
||||
}
|
||||
|
||||
function modelOptionsForField(
|
||||
integrationId: string,
|
||||
field: IntegrationCredentialsField,
|
||||
): string[] {
|
||||
if (field.key !== 'default_model') return field.options ?? [];
|
||||
if (field.options?.length) return field.options;
|
||||
return FALLBACK_MODEL_OPTIONS[integrationId] ?? [];
|
||||
}
|
||||
|
||||
export default function Integrations() {
|
||||
const [integrations, setIntegrations] = useState<Integration[]>([]);
|
||||
const [settingsByName, setSettingsByName] = useState<
|
||||
Record<string, IntegrationSettingsEntry>
|
||||
>({});
|
||||
const [settingsRevision, setSettingsRevision] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [activeEditor, setActiveEditor] = useState<IntegrationSettingsEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, string>>({});
|
||||
const [dirtyFields, setDirtyFields] = useState<Record<string, boolean>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState<string | null>(null);
|
||||
const [runtimeStatus, setRuntimeStatus] = useState<Pick<StatusResponse, 'model'> | null>(null);
|
||||
const [activeAiIntegrationId, setActiveAiIntegrationId] = useState<string | null>(null);
|
||||
const [quickModelDrafts, setQuickModelDrafts] = useState<Record<string, string>>({});
|
||||
const [quickModelSavingId, setQuickModelSavingId] = useState<string | null>(null);
|
||||
const [quickModelError, setQuickModelError] = useState<string | null>(null);
|
||||
|
||||
const buildInitialFieldValues = (integration: IntegrationSettingsEntry) =>
|
||||
integration.fields.reduce<Record<string, string>>((acc, field) => {
|
||||
if (modelOptionsForField(integration.id, field).length > 0) {
|
||||
acc[field.key] = field.has_value ? SELECT_KEEP : '';
|
||||
} else {
|
||||
acc[field.key] = '';
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const modelFieldFor = (integration: IntegrationSettingsEntry) =>
|
||||
integration.fields.find((field) => field.key === 'default_model');
|
||||
|
||||
const fallbackModelFor = (integration: IntegrationSettingsEntry): string | null => {
|
||||
const modelField = modelFieldFor(integration);
|
||||
if (!modelField) return null;
|
||||
return modelOptionsForField(integration.id, modelField)[0] ?? null;
|
||||
};
|
||||
|
||||
const modelValueFor = (
|
||||
integration: IntegrationSettingsEntry,
|
||||
isActiveDefaultProvider: boolean,
|
||||
): string | null => {
|
||||
if (isActiveDefaultProvider && runtimeStatus?.model?.trim()) {
|
||||
return runtimeStatus.model.trim();
|
||||
}
|
||||
|
||||
const fieldModel = modelFieldFor(integration)?.current_value?.trim();
|
||||
if (fieldModel) {
|
||||
return fieldModel;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const activeAiIntegration = Object.values(settingsByName).find(
|
||||
(integration) => integration.id === activeAiIntegrationId,
|
||||
);
|
||||
|
||||
const loadData = async (
|
||||
showLoadingState = true,
|
||||
): Promise<Record<string, IntegrationSettingsEntry> | null> => {
|
||||
if (showLoadingState) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const [integrationList, settings, status] = await Promise.all([
|
||||
getIntegrations(),
|
||||
getIntegrationSettings(),
|
||||
getStatus().catch(() => null),
|
||||
]);
|
||||
|
||||
const nextSettingsByName = settings.integrations.reduce<
|
||||
Record<string, IntegrationSettingsEntry>
|
||||
>((acc, item) => {
|
||||
acc[item.name] = item;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setIntegrations(integrationList);
|
||||
setSettingsRevision(settings.revision);
|
||||
setSettingsByName(nextSettingsByName);
|
||||
setActiveAiIntegrationId(settings.active_default_provider_integration_id ?? null);
|
||||
setRuntimeStatus(status ? { model: status.model } : null);
|
||||
return nextSettingsByName;
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load integrations');
|
||||
setActiveAiIntegrationId(null);
|
||||
setRuntimeStatus(null);
|
||||
return null;
|
||||
} finally {
|
||||
if (showLoadingState) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getIntegrations()
|
||||
.then(setIntegrations)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveSuccess) return;
|
||||
const timer = setTimeout(() => setSaveSuccess(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [saveSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEditor) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeEditor();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [activeEditor, saving]);
|
||||
|
||||
const openEditor = (integration: IntegrationSettingsEntry) => {
|
||||
setActiveEditor(integration);
|
||||
setFieldValues(buildInitialFieldValues(integration));
|
||||
setCustomFieldValues({});
|
||||
setDirtyFields({});
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
if (saving) return;
|
||||
setActiveEditor(null);
|
||||
setFieldValues({});
|
||||
setCustomFieldValues({});
|
||||
setDirtyFields({});
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const updateField = (key: string, value: string) => {
|
||||
setFieldValues((prev) => ({ ...prev, [key]: value }));
|
||||
setDirtyFields((prev) => ({ ...prev, [key]: true }));
|
||||
};
|
||||
|
||||
const updateCustomField = (key: string, value: string) => {
|
||||
setCustomFieldValues((prev) => ({ ...prev, [key]: value }));
|
||||
setDirtyFields((prev) => ({ ...prev, [key]: true }));
|
||||
};
|
||||
|
||||
const saveCredentials = async () => {
|
||||
if (!activeEditor) return;
|
||||
|
||||
setSaveError(null);
|
||||
setQuickModelError(null);
|
||||
|
||||
const payload: Record<string, string> = {};
|
||||
for (const field of activeEditor.fields) {
|
||||
const value = fieldValues[field.key] ?? '';
|
||||
const isDirty = !!dirtyFields[field.key];
|
||||
const isSelectField = modelOptionsForField(activeEditor.id, field).length > 0;
|
||||
|
||||
let resolvedValue = value;
|
||||
if (isSelectField) {
|
||||
if (value === SELECT_KEEP) {
|
||||
if (field.required && !field.has_value) {
|
||||
setSaveError(`${field.label} is required.`);
|
||||
return;
|
||||
}
|
||||
if (isDirty) {
|
||||
continue;
|
||||
}
|
||||
} else if (value === SELECT_CUSTOM) {
|
||||
resolvedValue = customFieldValues[field.key] ?? '';
|
||||
} else if (value === SELECT_CLEAR) {
|
||||
resolvedValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = resolvedValue.trim();
|
||||
|
||||
if (isSelectField && value === SELECT_CUSTOM && !trimmed) {
|
||||
setSaveError(`Enter a custom value for ${field.label} or choose a recommended model.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.required && !trimmed && !field.has_value) {
|
||||
setSaveError(`${field.label} is required.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirty) {
|
||||
if (isSelectField && value === SELECT_KEEP) {
|
||||
continue;
|
||||
}
|
||||
payload[field.key] = resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(payload).length === 0 &&
|
||||
!activeEditor.activates_default_provider
|
||||
) {
|
||||
setSaveError('No changes to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
activeEditor.activates_default_provider &&
|
||||
activeAiIntegrationId &&
|
||||
activeEditor.id !== activeAiIntegrationId
|
||||
) {
|
||||
const currentProvider = activeAiIntegration?.name ?? 'current provider';
|
||||
const confirmed = window.confirm(
|
||||
`Switch default AI provider from ${currentProvider} to ${activeEditor.name}?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await putIntegrationCredentials(activeEditor.id, {
|
||||
revision: settingsRevision,
|
||||
fields: payload,
|
||||
});
|
||||
|
||||
await loadData(false);
|
||||
setSaveSuccess(`${activeEditor.name} credentials saved.`);
|
||||
closeEditor();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save credentials';
|
||||
if (message.includes('API 409')) {
|
||||
const refreshed = await loadData(false);
|
||||
if (refreshed) {
|
||||
const latestEditor = refreshed[activeEditor.name];
|
||||
if (latestEditor) {
|
||||
setActiveEditor(latestEditor);
|
||||
setFieldValues(buildInitialFieldValues(latestEditor));
|
||||
setCustomFieldValues({});
|
||||
setDirtyFields({});
|
||||
}
|
||||
}
|
||||
setSaveError(
|
||||
'Configuration changed elsewhere. Refreshed latest settings; re-enter values and save again.',
|
||||
);
|
||||
} else {
|
||||
setSaveError(message);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveQuickModel = async (
|
||||
integration: IntegrationSettingsEntry,
|
||||
targetModel: string,
|
||||
currentModel: string,
|
||||
isActiveDefaultProvider: boolean,
|
||||
) => {
|
||||
const trimmedTarget = targetModel.trim();
|
||||
if (!trimmedTarget || trimmedTarget === currentModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
activeAiIntegrationId &&
|
||||
!isActiveDefaultProvider &&
|
||||
integration.id !== activeAiIntegrationId
|
||||
) {
|
||||
const currentProvider = activeAiIntegration?.name ?? 'current provider';
|
||||
const confirmed = window.confirm(
|
||||
`Switch default AI provider from ${currentProvider} to ${integration.name} and set model to ${trimmedTarget}?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setQuickModelSavingId(integration.id);
|
||||
setQuickModelError(null);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await putIntegrationCredentials(integration.id, {
|
||||
revision: settingsRevision,
|
||||
fields: {
|
||||
default_model: trimmedTarget,
|
||||
},
|
||||
});
|
||||
|
||||
await loadData(false);
|
||||
setSaveSuccess(`Model updated to ${trimmedTarget} for ${integration.name}.`);
|
||||
setQuickModelDrafts((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[integration.id];
|
||||
return next;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update model';
|
||||
if (message.includes('API 409')) {
|
||||
await loadData(false);
|
||||
setQuickModelError(
|
||||
'Configuration changed elsewhere. Refreshed latest settings; choose the model again.',
|
||||
);
|
||||
} else {
|
||||
setQuickModelError(message);
|
||||
}
|
||||
} finally {
|
||||
setQuickModelSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const categories = [
|
||||
'all',
|
||||
...Array.from(new Set(integrations.map((i) => i.category))).sort(),
|
||||
@ -85,6 +446,20 @@ export default function Integrations() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{saveSuccess && (
|
||||
<div className="rounded-lg bg-green-900/30 border border-green-700 p-3 text-sm text-green-300">
|
||||
{saveSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quickModelError && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-700 p-3 text-sm text-red-300">
|
||||
{quickModelError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatChannelsGuide />
|
||||
|
||||
{/* Category Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
@ -97,7 +472,7 @@ export default function Integrations() {
|
||||
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
{cat === 'all' ? 'All' : formatCategory(cat)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -114,16 +489,56 @@ export default function Integrations() {
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3 capitalize">
|
||||
{category}
|
||||
{formatCategory(category)}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{items.map((integration) => {
|
||||
const badge = statusBadge(integration.status);
|
||||
const BadgeIcon = badge.icon;
|
||||
const editable = settingsByName[integration.name];
|
||||
const isAiIntegration = !!editable?.activates_default_provider;
|
||||
const isActiveDefaultProvider =
|
||||
!!editable &&
|
||||
isAiIntegration &&
|
||||
editable.id === activeAiIntegrationId;
|
||||
const modelField = editable ? modelFieldFor(editable) : undefined;
|
||||
const modelOptions =
|
||||
editable && modelField ? modelOptionsForField(editable.id, modelField) : [];
|
||||
const currentModel =
|
||||
editable && isAiIntegration
|
||||
? modelValueFor(editable, isActiveDefaultProvider)
|
||||
: null;
|
||||
const fallbackModel =
|
||||
editable && isAiIntegration ? fallbackModelFor(editable) : null;
|
||||
const modelSummary = currentModel
|
||||
? currentModel
|
||||
: fallbackModel
|
||||
? `default: ${fallbackModel}`
|
||||
: 'default';
|
||||
const modelBaseline = currentModel ?? fallbackModel ?? '';
|
||||
const quickDraft = editable
|
||||
? quickModelDrafts[editable.id] ?? modelBaseline
|
||||
: '';
|
||||
const quickOptions = [
|
||||
...(currentModel && !modelOptions.includes(currentModel)
|
||||
? [currentModel]
|
||||
: []),
|
||||
...modelOptions,
|
||||
];
|
||||
const showQuickModelControls =
|
||||
!!editable &&
|
||||
editable.configured &&
|
||||
isAiIntegration &&
|
||||
quickOptions.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={integration.name}
|
||||
className="bg-gray-900 rounded-xl border border-gray-800 p-5 hover:border-gray-700 transition-colors"
|
||||
className={`bg-gray-900 rounded-xl border p-5 transition-colors ${
|
||||
isActiveDefaultProvider
|
||||
? 'border-green-700/70 bg-gradient-to-b from-green-950/20 to-gray-900'
|
||||
: 'border-gray-800 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@ -134,13 +549,106 @@ export default function Integrations() {
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${badge.classes}`}
|
||||
>
|
||||
<BadgeIcon className="h-3 w-3" />
|
||||
{badge.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
||||
{isAiIntegration && editable?.configured && (
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${
|
||||
isActiveDefaultProvider
|
||||
? 'bg-emerald-900/40 text-emerald-300 border-emerald-700/60'
|
||||
: 'bg-gray-800 text-gray-300 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{isActiveDefaultProvider ? 'Default' : 'Configured'}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${badge.classes}`}
|
||||
>
|
||||
<BadgeIcon className="h-3 w-3" />
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editable && isAiIntegration && editable.configured && (
|
||||
<div className="mt-3 rounded-lg border border-gray-800 bg-gray-950/50 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Current model
|
||||
</span>
|
||||
<span className="text-xs text-gray-200 truncate" title={modelSummary}>
|
||||
{modelSummary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showQuickModelControls && editable && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={quickDraft}
|
||||
onChange={(e) =>
|
||||
setQuickModelDrafts((prev) => ({
|
||||
...prev,
|
||||
[editable.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={quickModelSavingId === editable.id}
|
||||
className="min-w-0 flex-1 px-2.5 py-1.5 rounded-lg bg-gray-950 border border-gray-700 text-xs text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
>
|
||||
{quickOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() =>
|
||||
editable &&
|
||||
void saveQuickModel(
|
||||
editable,
|
||||
quickDraft,
|
||||
modelBaseline,
|
||||
isActiveDefaultProvider,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
quickModelSavingId === editable.id ||
|
||||
!quickDraft ||
|
||||
quickDraft === modelBaseline
|
||||
}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{quickModelSavingId === editable.id ? 'Saving...' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
For custom model IDs, use Edit Keys.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-800 flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-gray-400">
|
||||
{editable.configured
|
||||
? editable.activates_default_provider
|
||||
? isActiveDefaultProvider
|
||||
? 'Default provider configured'
|
||||
: 'Provider configured'
|
||||
: 'Credentials configured'
|
||||
: 'Credentials not configured'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openEditor(editable)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-blue-700/70 bg-blue-900/30 hover:bg-blue-900/50 text-blue-300 text-xs font-medium transition-colors"
|
||||
>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
{editable.configured ? 'Edit Keys' : 'Configure'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -148,6 +656,175 @@ export default function Integrations() {
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{activeEditor && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg bg-gray-900 border border-gray-800 rounded-xl shadow-xl">
|
||||
<div className="px-5 py-4 border-b border-gray-800 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Configure {activeEditor.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{activeEditor.configured
|
||||
? 'Enter only fields you want to update.'
|
||||
: 'Enter required fields to configure this integration.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeEditor}
|
||||
disabled={saving}
|
||||
className="text-gray-400 hover:text-white transition-colors disabled:opacity-50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{activeEditor.activates_default_provider && (
|
||||
<div className="rounded-lg border border-blue-800 bg-blue-950/30 p-3 text-xs text-blue-200">
|
||||
Saving here updates credentials and switches your default AI provider to{' '}
|
||||
<strong>{activeEditor.name}</strong>. For advanced provider settings, use{' '}
|
||||
<Link to="/config" className="underline underline-offset-2 hover:text-blue-100">
|
||||
Configuration
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeEditor.fields.map((field) => (
|
||||
(() => {
|
||||
const selectOptions = modelOptionsForField(activeEditor.id, field);
|
||||
const isSelectField = selectOptions.length > 0;
|
||||
const isSecretField = field.input_type === 'secret';
|
||||
const maskedSecretValue = isSecretField
|
||||
? (field.masked_value || (field.has_value ? '••••••••' : undefined))
|
||||
: undefined;
|
||||
const activeEditorIsDefaultProvider =
|
||||
activeEditor.activates_default_provider &&
|
||||
activeEditor.id === activeAiIntegrationId;
|
||||
const currentModelValue =
|
||||
field.current_value?.trim() ||
|
||||
(activeEditorIsDefaultProvider ? runtimeStatus?.model?.trim() || '' : '');
|
||||
const keepCurrentLabel = currentModelValue
|
||||
? `Keep current model (${currentModelValue})`
|
||||
: 'Keep current model';
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-1.5">
|
||||
<span>{field.label}</span>
|
||||
{field.required && <span className="text-red-400">*</span>}
|
||||
{field.has_value && (
|
||||
<span className="text-[11px] text-green-400 bg-green-900/30 border border-green-800 px-1.5 py-0.5 rounded">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{isSelectField ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={fieldValues[field.key] ?? (field.has_value ? SELECT_KEEP : '')}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{field.has_value ? (
|
||||
<option value={SELECT_KEEP}>{keepCurrentLabel}</option>
|
||||
) : (
|
||||
<option value="" disabled>
|
||||
Select a recommended model
|
||||
</option>
|
||||
)}
|
||||
{selectOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
<option value={SELECT_CUSTOM}>Custom model...</option>
|
||||
{field.has_value && <option value={SELECT_CLEAR}>Clear current model</option>}
|
||||
</select>
|
||||
|
||||
{fieldValues[field.key] === SELECT_CUSTOM && (
|
||||
<input
|
||||
type="text"
|
||||
value={customFieldValues[field.key] ?? ''}
|
||||
onChange={(e) => updateCustomField(field.key, e.target.value)}
|
||||
placeholder={customModelFormatHint(activeEditor.id)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Pick a recommended model or choose Custom model. {customModelFormatHint(activeEditor.id)}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{maskedSecretValue && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Current value: <span className="font-mono text-gray-300">{maskedSecretValue}</span>
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
type={isSecretField ? 'password' : 'text'}
|
||||
value={fieldValues[field.key] ?? ''}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
placeholder={
|
||||
field.required
|
||||
? field.has_value
|
||||
? 'Enter a new value to replace current'
|
||||
: 'Enter value'
|
||||
: field.has_value
|
||||
? 'Type new value, or leave empty to keep current'
|
||||
: 'Optional'
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
|
||||
{saveError && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-700 p-3 text-sm text-red-300">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 border-t border-gray-800 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={closeEditor}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveCredentials}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: activeEditor.activates_default_provider
|
||||
? 'Save & Activate'
|
||||
: 'Save Keys'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -133,7 +133,7 @@ export default function Logs() {
|
||||
: entries.filter((e) => typeFilters.has(e.event.type));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<div className="flex min-h-[28rem] flex-col h-[calc(100dvh-8.5rem)]">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@ -49,6 +49,34 @@ export interface Integration {
|
||||
status: 'Available' | 'Active' | 'ComingSoon';
|
||||
}
|
||||
|
||||
export interface IntegrationCredentialsField {
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
has_value: boolean;
|
||||
input_type: 'secret' | 'text' | 'select';
|
||||
options: string[];
|
||||
current_value?: string;
|
||||
masked_value?: string;
|
||||
}
|
||||
|
||||
export interface IntegrationSettingsEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
status: Integration['status'];
|
||||
configured: boolean;
|
||||
activates_default_provider: boolean;
|
||||
fields: IntegrationCredentialsField[];
|
||||
}
|
||||
|
||||
export interface IntegrationSettingsPayload {
|
||||
revision: string;
|
||||
active_default_provider_integration_id?: string;
|
||||
integrations: IntegrationSettingsEntry[];
|
||||
}
|
||||
|
||||
export interface DiagResult {
|
||||
severity: 'ok' | 'warn' | 'error';
|
||||
category: string;
|
||||
@ -65,6 +93,14 @@ export interface MemoryEntry {
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
export interface PairedDevice {
|
||||
id: string;
|
||||
token_fingerprint: string;
|
||||
created_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
paired_by: string | null;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
session_cost_usd: number;
|
||||
daily_cost_usd: number;
|
||||
@ -95,11 +131,16 @@ export interface SSEEvent {
|
||||
}
|
||||
|
||||
export interface WsMessage {
|
||||
type: 'message' | 'chunk' | 'tool_call' | 'tool_result' | 'done' | 'error';
|
||||
type: 'message' | 'chunk' | 'tool_call' | 'tool_result' | 'done' | 'error' | 'history';
|
||||
content?: string;
|
||||
full_response?: string;
|
||||
name?: string;
|
||||
args?: any;
|
||||
output?: string;
|
||||
message?: string;
|
||||
session_id?: string;
|
||||
messages?: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
|
||||
/* Aliases */
|
||||
"baseUrl": ".",
|
||||
|
||||
@ -16,12 +16,16 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/pair": {
|
||||
target: "http://localhost:42617",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/api": {
|
||||
target: "http://localhost:5555",
|
||||
target: "http://localhost:42617",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://localhost:5555",
|
||||
target: "ws://localhost:42617",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
|
||||
21
web/vitest.config.ts
Normal file
21
web/vitest.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['e2e/**'],
|
||||
css: true,
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user