feat: refresh dashboard homepage
This commit is contained in:
parent
498628083c
commit
413369aec9
14
config/systemd/user/xworkspace-api.service
Normal file
14
config/systemd/user/xworkspace-api.service
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=XWorkspace status API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/xworkspace-console/api
|
||||
ExecStart=/usr/local/go/bin/go run .
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
124
dashboard/src/components/AppShell.tsx
Normal file
124
dashboard/src/components/AppShell.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { agents, customWorkspaceTabs, initialTabs, labelsEn, labelsZh, mockServices, tasks } from '@/lib/data';
|
||||
import type { NavItem, Service, Tab } from '@/lib/data';
|
||||
import { fetchServices } from '@/lib/api';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Topbar } from './Topbar';
|
||||
import { WorkspaceTabs } from './WorkspaceTabs';
|
||||
import { WorkspaceHome } from './WorkspaceHome';
|
||||
import { EmbedView } from './EmbedView';
|
||||
|
||||
export function AppShell() {
|
||||
const [selectedTab, setSelectedTab] = useState('workspace');
|
||||
const [tabs, setTabs] = useState<Tab[]>(initialTabs);
|
||||
const [services, setServices] = useState<Service[] | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [language, setLanguage] = useState<'en' | 'zh'>('en');
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||
const [remoteMode, setRemoteMode] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices().then((data) => data && setServices(data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('xws-remote-mode');
|
||||
if (stored !== null) {
|
||||
setRemoteMode(stored === '1');
|
||||
} else if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setRemoteMode(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleRemoteMode = () => {
|
||||
setRemoteMode((value) => {
|
||||
window.localStorage.setItem('xws-remote-mode', value ? '0' : '1');
|
||||
return !value;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
if (event.key >= '1' && event.key <= '9') {
|
||||
const index = Number(event.key) - 1;
|
||||
setTabs((existingTabs) => {
|
||||
if (existingTabs[index]) setSelectedTab(existingTabs[index].id);
|
||||
return existingTabs;
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const currentServices = services ?? mockServices;
|
||||
const selected = tabs.find((tab) => tab.id === selectedTab);
|
||||
const labels = language === 'zh' ? labelsZh : labelsEn;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
|
||||
const runningAgents = agents.filter((agent) => agent.state === 'Running').length;
|
||||
const runningTasks = tasks.filter((task) => task[2] === 'Running').length;
|
||||
return { runningServices, runningAgents, runningTasks };
|
||||
}, [currentServices]);
|
||||
|
||||
const openTab = (item: NavItem | Tab) => {
|
||||
setTabs((existingTabs) => {
|
||||
if (existingTabs.some((tab) => tab.id === item.id)) return existingTabs;
|
||||
return [...existingTabs, { ...item, closable: true }];
|
||||
});
|
||||
setSelectedTab(item.id);
|
||||
};
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
setTabs((existingTabs) => {
|
||||
const nextTabs = existingTabs.filter((tab) => tab.id !== tabId);
|
||||
if (tabId === selectedTab) setSelectedTab(nextTabs[nextTabs.length - 1]?.id ?? 'workspace');
|
||||
return nextTabs;
|
||||
});
|
||||
};
|
||||
|
||||
const addCustomTab = () => {
|
||||
const nextTab = customWorkspaceTabs.find((tab) => !tabs.some((open) => open.id === tab.id)) ?? customWorkspaceTabs[0];
|
||||
openTab(nextTab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : '', remoteMode ? 'remote-mode' : ''].join(' ')}>
|
||||
<Sidebar
|
||||
labels={labels}
|
||||
collapsed={sidebarCollapsed}
|
||||
selectedTab={selectedTab}
|
||||
onToggle={() => setSidebarCollapsed((value) => !value)}
|
||||
onOpen={openTab}
|
||||
onToggleLanguage={() => setLanguage((value) => (value === 'en' ? 'zh' : 'en'))}
|
||||
onToggleTheme={() => setTheme((value) => (value === 'light' ? 'dark' : 'light'))}
|
||||
theme={theme}
|
||||
remoteMode={remoteMode}
|
||||
onToggleRemoteMode={toggleRemoteMode}
|
||||
/>
|
||||
|
||||
<main className="workspace">
|
||||
<Topbar
|
||||
labels={labels}
|
||||
selectedLabel={selected && selected.id !== 'workspace' ? selected.label : null}
|
||||
services={currentServices}
|
||||
summary={summary}
|
||||
onToggleSidebar={() => setSidebarCollapsed((value) => !value)}
|
||||
/>
|
||||
|
||||
<WorkspaceTabs tabs={tabs} selectedTab={selectedTab} onSelect={setSelectedTab} onClose={closeTab} onAdd={addCustomTab} />
|
||||
|
||||
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
|
||||
<EmbedView tab={selected} onBack={() => setSelectedTab('workspace')} />
|
||||
) : (
|
||||
<WorkspaceHome labels={labels} services={currentServices} onOpenService={openTab} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
dashboard/src/components/ArchPipeline.tsx
Normal file
164
dashboard/src/components/ArchPipeline.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { acpAgents, agents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data';
|
||||
import type { Labels, NavItem, Service } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function ArchPipeline({
|
||||
labels,
|
||||
services,
|
||||
onOpenService,
|
||||
}: {
|
||||
labels: Labels;
|
||||
services: Service[];
|
||||
onOpenService: (item: NavItem) => void;
|
||||
}) {
|
||||
const [skillsOpen, setSkillsOpen] = useState(false);
|
||||
|
||||
const stateOf = (id: string): Service['state'] | undefined => {
|
||||
const def = serviceRegistry.find((item) => item.id === id);
|
||||
const service = services.find((entry) => def?.match?.some((token) => entry.name.toLowerCase().includes(token)));
|
||||
return service?.state;
|
||||
};
|
||||
const dot = (state?: Service['state']) => (
|
||||
<i className={state === 'Running' ? 'dot good' : state ? 'dot bad' : 'dot idle'} />
|
||||
);
|
||||
const runningAgents = agents.filter((agent) => agent.state === 'Running').map((agent) => agent.name.split(' ')[0]);
|
||||
const totalSkills = skillGroups.reduce((count, group) => count + group.skills.length, 0);
|
||||
const externalModels = [
|
||||
{ name: 'GPT-5.5', mark: '◎', tone: 'openai' },
|
||||
{ name: 'DeepSeek V4', mark: 'D', tone: 'deepseek' },
|
||||
{ name: 'Gemini 3.1', mark: '✦', tone: 'gemini' },
|
||||
{ name: 'GLM 5', mark: 'Z', tone: 'glm' },
|
||||
{ name: 'MiniMax', mark: '〽', tone: 'minimax' },
|
||||
{ name: 'Kimi', mark: 'K', tone: 'kimi' },
|
||||
{ name: 'Claude', mark: 'AI', tone: 'claude' },
|
||||
{ name: 'and more', mark: '…', tone: 'more' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="arch-pipeline blueprint" aria-label="Architecture pipeline">
|
||||
<svg className="pipeline-wires" viewBox="0 0 1120 520" aria-hidden="true">
|
||||
<defs>
|
||||
<marker id="flow-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
</defs>
|
||||
<path d="M196 182H300C316 182 316 132 332 132H410" />
|
||||
<path d="M196 182H300C316 182 316 246 332 246H410" />
|
||||
<path d="M622 132H698C714 132 714 152 730 152H782" />
|
||||
<path d="M622 246H698C714 246 714 198 730 198H782" />
|
||||
<path d="M492 306V354H166C146 354 146 396 126 396H72" />
|
||||
<path d="M564 306V354H830V312" />
|
||||
<path d="M72 396H420V408" />
|
||||
<path d="M830 312V396H996" />
|
||||
</svg>
|
||||
<span className="flow-node flow-node-a"><Icon name="memory" /></span>
|
||||
<span className="flow-node flow-node-b"><Icon name="cube" /></span>
|
||||
<span className="flow-alert"><Icon name="alert" /></span>
|
||||
|
||||
<div className="entry-card node-card">
|
||||
<span className="node-icon chat"><Icon name="messages" /></span>
|
||||
<strong>User Entry</strong>
|
||||
<small>APP Chat / Web Chat<br />XWorkmate Bridge</small>
|
||||
</div>
|
||||
|
||||
<button type="button" className="gateway-card node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'openclaw')!)}>
|
||||
<div className="gateway-meta">
|
||||
<span className="node-index blue">1</span>
|
||||
<span className="node-title">{labels.gatewayBand}</span>
|
||||
</div>
|
||||
{dot(stateOf('openclaw'))}
|
||||
<strong>OpenClaw Gateway</strong>
|
||||
<small>v2026.6.1</small>
|
||||
<small>127.0.0.1:18789</small>
|
||||
<small>token auth</small>
|
||||
<small>Local Only</small>
|
||||
</button>
|
||||
|
||||
<div className="agent-plane node-card">
|
||||
<div className="node-head">
|
||||
<span className="node-index purple">2</span>
|
||||
<strong>{labels.agentBand}</strong>
|
||||
<small>4 {labels.sessions} · 8 {labels.workers}</small>
|
||||
{dot()}
|
||||
</div>
|
||||
<div className="agent-icons">
|
||||
{['Main Agent', 'Memory', 'Scheduler', 'SubAgents', 'ACP Router'].map((item, index) => (
|
||||
<span key={item}><Icon name={index === 1 ? 'database' : index === 2 ? 'plus' : index === 4 ? 'network' : 'bot'} />{item}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="agent-inner-grid">
|
||||
<div className="mini-node">
|
||||
<strong>{labels.memoryCard}</strong>
|
||||
<span>QMD (Vector Search)</span>
|
||||
<span>MEMORY.md</span>
|
||||
<span>memory/*.md (Logs)</span>
|
||||
<span>Session Index</span>
|
||||
</div>
|
||||
<div className="mini-node">
|
||||
<strong>{labels.acpCard}</strong>
|
||||
<div className="router-grid">
|
||||
{acpAgents.map((agent) => (
|
||||
<em key={agent} className={runningAgents.includes(agent) ? 'busy' : ''}>{agent}</em>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="skill-plane node-card" aria-expanded={skillsOpen} onClick={() => setSkillsOpen((value) => !value)}>
|
||||
<div className="node-head">
|
||||
<span className="node-index green">3</span>
|
||||
<strong>{labels.skillBand} Layer</strong>
|
||||
<small>{totalSkills * 2}+ {labels.skillsCount}</small>
|
||||
{dot(stateOf('bridge'))}
|
||||
</div>
|
||||
<div className="skill-stack">
|
||||
{skillGroups.map((group) => (
|
||||
<div key={group.name} className="skill-item">
|
||||
<span><Icon name={group.name === 'Image' ? 'chart' : group.name === 'Workflow' ? 'sparkles' : 'folder'} /></span>
|
||||
<strong>{group.name} Skills</strong>
|
||||
<small>{group.skills.join(' · ')}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<aside className="workspace-status node-card">
|
||||
<strong>{labels.workspaceStatus}</strong>
|
||||
<div><span>{labels.sessions}</span><b>333</b><small>Active</small><Icon name="user" /></div>
|
||||
<div><span>Agents</span><b>7</b><small>Connected</small><Icon name="bot" /></div>
|
||||
<div><span>Memory</span><b>Enabled</b><Icon name="database" /></div>
|
||||
<div><span>Skills</span><b>30+</b><Icon name="sparkles" /></div>
|
||||
</aside>
|
||||
|
||||
<button type="button" className="model-layer node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'litellm')!)}>
|
||||
<div className="node-head">
|
||||
<span className="node-index amber">4</span>
|
||||
<strong>{labels.modelBand} Layer</strong>
|
||||
<small>LiteLLM · 4000 · OpenAI-compatible · Anthropic-compatible</small>
|
||||
{dot(stateOf('litellm'))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="external-layer node-card">
|
||||
<div className="node-head">
|
||||
<span className="node-index red">5</span>
|
||||
<strong>External Model Services</strong>
|
||||
</div>
|
||||
<div className="model-row">
|
||||
{externalModels.map((model) => (
|
||||
<span key={model.name}>
|
||||
<i className={`model-logo ${model.tone}`}>{model.mark}</i>
|
||||
{model.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { findServiceDef };
|
||||
31
dashboard/src/components/EmbedView.tsx
Normal file
31
dashboard/src/components/EmbedView.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Tab } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
return (
|
||||
<div className="workspace-body">
|
||||
<section className="embed-panel">
|
||||
<div className="embed-toolbar">
|
||||
<button type="button" className="embed-tool" aria-label="Back to workspace" onClick={onBack}>
|
||||
<Icon name="arrow-left" />
|
||||
</button>
|
||||
<strong>{tab.label}</strong>
|
||||
<span className="embed-url" title={tab.href}>{tab.href}</span>
|
||||
<div className="embed-toolbar-actions">
|
||||
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)}>
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
<a className="embed-tool" href={tab.href} target="_blank" rel="noreferrer" aria-label="Open in browser">
|
||||
<Icon name="external" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<iframe key={reloadKey} title={`${tab.label} workspace`} src={tab.href} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
dashboard/src/components/Icon.tsx
Normal file
55
dashboard/src/components/Icon.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
const paths: Record<string, React.ReactNode> = {
|
||||
home: <path d="M3 10.5 12 3l9 7.5v9a1.5 1.5 0 0 1-1.5 1.5H15v-6H9v6H4.5A1.5 1.5 0 0 1 3 19.5z" />,
|
||||
bot: <path d="M8 9h8a4 4 0 0 1 4 4v4.5A2.5 2.5 0 0 1 17.5 20h-11A2.5 2.5 0 0 1 4 17.5V13a4 4 0 0 1 4-4Zm1 4h.01M15 13h.01M9 17h6M12 5v4M9 5h6" />,
|
||||
box: <path d="m12 3 8 4v10l-8 4-8-4V7zM4 7l8 4 8-4M12 11v10" />,
|
||||
clock: <path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4.5V12l3 2" />,
|
||||
database: <path d="M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3Zm0 0v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />,
|
||||
memory: <path d="M7 4h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3Zm2 4h6v8H9zM9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2" />,
|
||||
messages: <path d="M5 6.5A3.5 3.5 0 0 1 8.5 3h7A3.5 3.5 0 0 1 19 6.5v4A3.5 3.5 0 0 1 15.5 14H11l-4 3v-3A3.5 3.5 0 0 1 3.5 10.5v-4Z" />,
|
||||
network: <path d="M12 5v4M12 15v4M5 12h4M15 12h4M9 9l-3-3M15 9l3-3M9 15l-3 3M15 15l3 3M9 12a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
sparkles: <path d="m12 3 1.7 5.1L19 10l-5.3 1.9L12 17l-1.7-5.1L5 10l5.3-1.9zM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8zM5 15l.8 2.2L8 18l-2.2.8L5 21l-.8-2.2L2 18l2.2-.8z" />,
|
||||
tasks: <path d="M8 6h11M8 12h11M8 18h11M4.5 6l1 1 1.8-2M4.5 12l1 1 1.8-2M4.5 18l1 1 1.8-2" />,
|
||||
folder: <path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5z" />,
|
||||
claw: <path d="M12 3v5M7 5l2.5 4M17 5l-2.5 4M5 13a7 7 0 0 0 14 0M8 13a4 4 0 0 0 8 0" />,
|
||||
bridge: <path d="M4 17h16M6 17V9l6-4 6 4v8M8 17v-5h8v5" />,
|
||||
chart: <path d="M4 19V5M4 19h16M7 15l3-4 4 2 4-7" />,
|
||||
shield: <path d="M12 3 20 6v5c0 5-3.5 8-8 10-4.5-2-8-5-8-10V6z" />,
|
||||
cube: <path d="m12 3 8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9" />,
|
||||
terminal: <path d="m5 8 4 4-4 4M11 17h8" />,
|
||||
settings: <path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v3M12 18v3M4.2 5.6l2.1 2.1M17.7 16.3l2.1 2.1M3 12h3M18 12h3M4.2 18.4l2.1-2.1M17.7 7.7l2.1-2.1" />,
|
||||
menu: <path d="M5 7h14M5 12h14M5 17h14" />,
|
||||
globe: <path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-8 9h16M12 3c2.2 2.4 3.3 5.4 3.3 9S14.2 18.6 12 21M12 3C9.8 5.4 8.7 8.4 8.7 12S9.8 18.6 12 21" />,
|
||||
wifi: <path d="M4 9a12 12 0 0 1 16 0M7 12a7.5 7.5 0 0 1 10 0M10 15a3 3 0 0 1 4 0M12 19h.01" />,
|
||||
bell: <path d="M18 16H6l1.4-2V10a4.6 4.6 0 0 1 9.2 0v4zM10 19h4" />,
|
||||
user: <path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4 21a8 8 0 0 1 16 0" />,
|
||||
languages: <path d="M4 6h9M8.5 4v2c0 4-2.2 7.2-5.5 9M6 10c1.5 2.2 3.8 4.1 6.6 5.5M14 18h7M17.5 6l4.5 12M20.3 13h-5.6" />,
|
||||
moon: <path d="M20 14.5A7.5 7.5 0 1 1 9.5 4 6 6 0 0 0 20 14.5Z" />,
|
||||
sun: <path d="M12 3v2.2M12 18.8V21M4.9 4.9l1.6 1.6M17.5 17.5l1.6 1.6M3 12h2.2M18.8 12H21M4.9 19.1l1.6-1.6M17.5 6.5l1.6-1.6M12 7.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z" />,
|
||||
'arrow-left': <path d="M19 12H5M12 5l-7 7 7 7" />,
|
||||
refresh: <path d="M20 11A8 8 0 1 0 18.9 15M20 4v7h-7" />,
|
||||
external: <path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V6.5A1.5 1.5 0 0 1 5.5 5H10" />,
|
||||
check: <path d="m5 12 5 5L20 7" />,
|
||||
alert: <path d="M12 9v4M12 17h.01M10.3 4.3 2.8 17.5A2 2 0 0 0 4.5 20.5h15a2 2 0 0 0 1.7-3L13.7 4.3a2 2 0 0 0-3.4 0Z" />,
|
||||
'chevron-left': <path d="m15 18-6-6 6-6" />,
|
||||
'chevron-down': <path d="m6 9 6 6 6-6" />,
|
||||
'chevron-right': <path d="m9 18 6-6-6-6" />,
|
||||
'chevrons-left': <path d="m13.5 17-5-5 5-5M19 17l-5-5 5-5" />,
|
||||
'chevrons-right': <path d="m10.5 17 5-5-5-5M5 17l5-5-5-5" />,
|
||||
'panel-collapse': <path d="M20 6H4M20 12h-8M20 18H4M8 9.5 5.5 12 8 14.5" />,
|
||||
'panel-expand': <path d="M4 6h16M12 12h8M4 18h16M6 9.5 8.5 12 6 14.5" />,
|
||||
rocket: <path d="M5 16c-1 1-1.5 4-1.5 4S6.5 19.5 7.5 18.5M14 4c3 0 6 3 6 6-2.5 5-7 9-11 10l-5-5C5 11 9.5 6.5 14 4Zm0 4a2 2 0 1 0 .01 4A2 2 0 0 0 14 8Z" />,
|
||||
help: <path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-2.5 6.5a2.5 2.5 0 1 1 3.7 2.2c-.8.45-1.2.9-1.2 1.8M12 17h.01" />,
|
||||
};
|
||||
|
||||
export function Icon({ name }: { name: string }) {
|
||||
return (
|
||||
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8">
|
||||
{paths[name] ?? paths.cube}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
67
dashboard/src/components/PanelsRow.tsx
Normal file
67
dashboard/src/components/PanelsRow.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { agents, findServiceDef, skillGroups } from '@/lib/data';
|
||||
import type { Labels, Service } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function PanelsRow({ labels, services }: { labels: Labels; services: Service[] }) {
|
||||
const [range, setRange] = useState('7d');
|
||||
const runningAgents = agents.filter((agent) => agent.state === 'Running').length;
|
||||
const totalSkills = skillGroups.reduce((count, group) => count + group.skills.length, 0);
|
||||
|
||||
return (
|
||||
<div className="panels-row">
|
||||
<section className="home-panel">
|
||||
<div className="home-panel-head">
|
||||
<h2>{labels.serviceHealth}</h2>
|
||||
<span>{services.length}</span>
|
||||
</div>
|
||||
<div className="health-row">
|
||||
{services.map((service) => {
|
||||
const def = findServiceDef(service.name);
|
||||
const running = service.state === 'Running';
|
||||
return (
|
||||
<div className="health-item" key={service.name} title={service.name}>
|
||||
<span className={running ? 'health-icon good' : 'health-icon bad'}>
|
||||
<Icon name={def?.icon ?? 'cube'} />
|
||||
</span>
|
||||
<small>{def?.label ?? service.name}</small>
|
||||
<em>{running ? labels.healthy : labels.degraded}</em>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="home-panel">
|
||||
<div className="home-panel-head">
|
||||
<h2>{labels.systemOverview}</h2>
|
||||
</div>
|
||||
<div className="overview-grid">
|
||||
<div><strong>4</strong><small>{labels.activeSessions}</small></div>
|
||||
<div><strong>{runningAgents}/{agents.length}</strong><small>{labels.connectedAgents}</small></div>
|
||||
<div><strong>52</strong><small>{labels.activeModels}</small></div>
|
||||
<div><strong>{totalSkills}+</strong><small>{labels.skillsAvailable}</small></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="home-panel">
|
||||
<div className="home-panel-head">
|
||||
<h2>{labels.activity}</h2>
|
||||
<span className="range-tabs" aria-label="Service activity range">
|
||||
{[labels.today, '7d', '2w', '1m'].map((item) => (
|
||||
<span key={item} className={range === item ? 'active' : ''} onClick={() => setRange(item)}>{item}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="service-chart mini" aria-hidden="true">
|
||||
<svg viewBox="0 0 640 80">
|
||||
<path className="grid-line" d="M24 70H620" />
|
||||
<path className="chart-main" d="M30 62C82 46 114 66 156 47S224 24 280 40 346 58 402 38 494 19 540 33 582 48 618 24" />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
dashboard/src/components/Sidebar.tsx
Normal file
91
dashboard/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { navSections } from '@/lib/data';
|
||||
import type { Labels, NavItem } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function Sidebar({
|
||||
labels,
|
||||
collapsed,
|
||||
selectedTab,
|
||||
onToggle,
|
||||
onOpen,
|
||||
onToggleLanguage,
|
||||
onToggleTheme,
|
||||
theme,
|
||||
remoteMode,
|
||||
onToggleRemoteMode,
|
||||
}: {
|
||||
labels: Labels;
|
||||
collapsed: boolean;
|
||||
selectedTab: string;
|
||||
onToggle: () => void;
|
||||
onOpen: (item: NavItem) => void;
|
||||
onToggleLanguage: () => void;
|
||||
onToggleTheme: () => void;
|
||||
theme: 'light' | 'dark';
|
||||
remoteMode: boolean;
|
||||
onToggleRemoteMode: () => void;
|
||||
}) {
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({ services: true, infra: true });
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark"><Icon name="bot" /></span>
|
||||
</div>
|
||||
|
||||
<nav className="side-nav" aria-label="XWorkspace navigation">
|
||||
{navSections.map((section) => (
|
||||
<div className="nav-group" key={section.id}>
|
||||
{section.titleKey && !collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nav-section-toggle"
|
||||
aria-expanded={openSections[section.id] !== false}
|
||||
onClick={() => setOpenSections((value) => ({ ...value, [section.id]: !(value[section.id] !== false) }))}
|
||||
>
|
||||
<span>{labels[section.titleKey]}</span>
|
||||
<Icon name={openSections[section.id] !== false ? 'chevron-down' : 'chevron-right'} />
|
||||
</button>
|
||||
) : null}
|
||||
{collapsed || openSections[section.id] !== false
|
||||
? section.items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className={selectedTab === item.id ? 'active' : ''}
|
||||
title={collapsed ? item.label : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpen(item);
|
||||
}}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
{item.id === 'sessions' ? <em>333</em> : null}
|
||||
{item.id === 'skills' ? <em>30+</em> : null}
|
||||
{item.id === 'models' ? <em>52</em> : null}
|
||||
</a>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-tools">
|
||||
<button className="sidebar-tool-button" type="button" aria-label={collapsed ? labels.expand : labels.collapse} onClick={onToggle}>
|
||||
<Icon name={collapsed ? 'panel-expand' : 'panel-collapse'} />
|
||||
</button>
|
||||
<button className="sidebar-tool-button" type="button" aria-label={labels.languageLabel} title={labels.languageLabel} onClick={onToggleLanguage}>
|
||||
<span className="language-mark">{labels.lang}</span>
|
||||
</button>
|
||||
<button className="sidebar-tool-button" type="button" aria-label={labels.themeLabel} title={labels.themeLabel} onClick={onToggleTheme}>
|
||||
<Icon name={theme === 'light' ? 'moon' : 'sun'} />
|
||||
<strong>{theme === 'light' ? labels.themeDark : labels.themeLight}</strong>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
77
dashboard/src/components/StatusAggregate.tsx
Normal file
77
dashboard/src/components/StatusAggregate.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Labels, Service } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function StatusAggregate({
|
||||
labels,
|
||||
services,
|
||||
summary,
|
||||
}: {
|
||||
labels: Labels;
|
||||
services: Service[];
|
||||
summary: { runningServices: number; runningAgents: number };
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const total = services.length;
|
||||
const allOk = summary.runningServices === total;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="status-aggregate" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={allOk ? 'status-pill ok' : 'status-pill warn'}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className="status-dot" />
|
||||
{summary.runningServices}/{total}
|
||||
<Icon name={allOk ? 'check' : 'alert'} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="status-popover" role="dialog" aria-label="System status">
|
||||
<div className="status-popover-row">
|
||||
<Icon name="wifi" />
|
||||
<span>{labels.connected}</span>
|
||||
<i className="dot good" />
|
||||
</div>
|
||||
<div className="status-popover-row">
|
||||
<Icon name="bot" />
|
||||
<span>{summary.runningAgents} {labels.agentsRunning}</span>
|
||||
<i className="dot good" />
|
||||
</div>
|
||||
<div className="status-popover-row">
|
||||
<Icon name="shield" />
|
||||
<span>{labels.vaultReady}</span>
|
||||
<i className="dot good" />
|
||||
</div>
|
||||
<div className="status-popover-divider" />
|
||||
{services.map((service) => (
|
||||
<div className="status-popover-row" key={service.name}>
|
||||
<span className="service-name">{service.name}</span>
|
||||
<i className={service.state === 'Running' ? 'dot good' : 'dot bad'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
dashboard/src/components/TerminalDrawer.tsx
Normal file
79
dashboard/src/components/TerminalDrawer.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import type { Labels } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function TerminalDrawer({
|
||||
labels,
|
||||
collapsed,
|
||||
expanded,
|
||||
onCollapse,
|
||||
onToggle,
|
||||
}: {
|
||||
labels: Labels;
|
||||
collapsed: boolean;
|
||||
expanded: boolean;
|
||||
onCollapse: () => void;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [height, setHeight] = useState(250);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragStart = useRef({ y: 0, height: 250 });
|
||||
|
||||
const onDragStart = (event: React.PointerEvent) => {
|
||||
if (expanded || collapsed) return;
|
||||
event.preventDefault();
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
dragStart.current = { y: event.clientY, height };
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const onDragMove = (event: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
const delta = dragStart.current.y - event.clientY;
|
||||
const max = Math.floor(window.innerHeight * 0.8);
|
||||
setHeight(Math.min(max, Math.max(120, dragStart.current.height + delta)));
|
||||
};
|
||||
|
||||
const onDragEnd = (event: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
(event.target as HTMLElement).releasePointerCapture(event.pointerId);
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={[expanded ? 'terminal-drawer expanded' : 'terminal-drawer', collapsed ? 'collapsed' : '', dragging ? 'dragging' : ''].join(' ')}>
|
||||
<div
|
||||
className="terminal-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize terminal"
|
||||
onPointerDown={onDragStart}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={onDragEnd}
|
||||
onPointerCancel={onDragEnd}
|
||||
>
|
||||
<span />
|
||||
</div>
|
||||
<div className="terminal-head clickable" onClick={onCollapse} role="button" aria-expanded={!collapsed}>
|
||||
<div>
|
||||
<Icon name={collapsed ? 'chevron-right' : 'chevron-down'} />
|
||||
<Icon name="terminal" />
|
||||
<strong>{labels.terminal}</strong>
|
||||
</div>
|
||||
<div className="terminal-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<a href="http://127.0.0.1:7681" target="_blank" rel="noreferrer">{labels.newTab}</a>
|
||||
<button type="button" onClick={onCollapse}>{collapsed ? labels.expand : labels.collapse}</button>
|
||||
<button type="button" onClick={onToggle}>{expanded ? labels.restore : labels.maximize}</button>
|
||||
<button type="button" aria-label="Terminal menu">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="terminal-frame" style={!expanded && !collapsed ? { height } : undefined}>
|
||||
{!collapsed ? (
|
||||
<iframe title="ttyd terminal" src="http://127.0.0.1:7681" loading="lazy" style={dragging ? { pointerEvents: 'none' } : undefined} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
59
dashboard/src/components/Topbar.tsx
Normal file
59
dashboard/src/components/Topbar.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Labels, Service } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
import { StatusAggregate } from './StatusAggregate';
|
||||
|
||||
export function Topbar({
|
||||
labels,
|
||||
selectedLabel,
|
||||
services,
|
||||
summary,
|
||||
onToggleSidebar,
|
||||
}: {
|
||||
labels: Labels;
|
||||
selectedLabel: string | null;
|
||||
services: Service[];
|
||||
summary: { runningServices: number; runningAgents: number };
|
||||
onToggleSidebar: () => void;
|
||||
}) {
|
||||
const breadcrumbItems = [labels.product, labels.workspace, selectedLabel].filter(Boolean) as string[];
|
||||
const [time, setTime] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => setTime(new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()));
|
||||
updateTime();
|
||||
const timer = window.setInterval(updateTime, 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button className="menu-button" type="button" aria-label="Toggle sidebar" onClick={onToggleSidebar}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<nav className="breadcrumb" aria-label="Breadcrumb">
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>
|
||||
{index > 0 ? <span className="breadcrumb-separator">/</span> : null}
|
||||
<span className={index === breadcrumbItems.length - 1 ? 'breadcrumb-current' : ''}>{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="status-strip">
|
||||
<StatusAggregate labels={labels} services={services} summary={summary} />
|
||||
<span className="status-pill"><Icon name="user" />333 Sessions</span>
|
||||
<span className="status-pill"><Icon name="clock" />{time}</span>
|
||||
<button className="round-button" type="button" aria-label="Notifications">
|
||||
<Icon name="bell" />
|
||||
</button>
|
||||
<button className="profile-button" type="button" aria-label="Profile">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
29
dashboard/src/components/WorkspaceHome.tsx
Normal file
29
dashboard/src/components/WorkspaceHome.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Labels, NavItem, Service } from '@/lib/data';
|
||||
import { ArchPipeline } from './ArchPipeline';
|
||||
|
||||
export function WorkspaceHome({
|
||||
labels,
|
||||
services,
|
||||
onOpenService,
|
||||
}: {
|
||||
labels: Labels;
|
||||
services: Service[];
|
||||
onOpenService: (item: NavItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="workspace-body">
|
||||
<section className="console-board">
|
||||
<div className="command-panel">
|
||||
<div className="board-heading">
|
||||
<div>
|
||||
<h1>{labels.homepageTitle}</h1>
|
||||
<p>{labels.homepageSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArchPipeline labels={labels} services={services} onOpenService={onOpenService} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
dashboard/src/components/WorkspaceTabs.tsx
Normal file
50
dashboard/src/components/WorkspaceTabs.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import type { Tab } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function WorkspaceTabs({
|
||||
tabs,
|
||||
selectedTab,
|
||||
onSelect,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
selectedTab: string;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="workspace-tabs" aria-label="Workspace tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
className={selectedTab === tab.id ? 'tab active' : 'tab'}
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
>
|
||||
{tab.icon ? <Icon name={tab.icon} /> : null}
|
||||
<span>{tab.label}</span>
|
||||
{tab.closable ? (
|
||||
<span
|
||||
className="tab-close"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<button className="tab add" type="button" onClick={onAdd}>
|
||||
+
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
18
dashboard/src/lib/api.ts
Normal file
18
dashboard/src/lib/api.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Service } from './data';
|
||||
|
||||
const BASE = 'http://127.0.0.1:8788';
|
||||
|
||||
export async function fetchServices(): Promise<Service[] | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE}/services`, { cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) return null;
|
||||
return data.map((item: { name?: string; unit?: string; state?: string }) => ({
|
||||
name: item.name ?? item.unit ?? 'unknown',
|
||||
state: item.state === 'active' ? 'Running' : item.state === 'inactive' ? 'Stopped' : 'Degraded',
|
||||
}));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
213
dashboard/src/lib/data.ts
Normal file
213
dashboard/src/lib/data.ts
Normal file
@ -0,0 +1,213 @@
|
||||
export type Tab = {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
kind: 'internal' | 'external' | 'embed';
|
||||
icon?: string;
|
||||
closable?: boolean;
|
||||
source?: 'builtin' | 'custom';
|
||||
};
|
||||
|
||||
export type Service = {
|
||||
name: string;
|
||||
state: 'Running' | 'Degraded' | 'Stopped';
|
||||
};
|
||||
|
||||
export type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
kind: Tab['kind'];
|
||||
};
|
||||
|
||||
export type ServiceDef = NavItem & {
|
||||
group: number;
|
||||
port?: number;
|
||||
match?: string[];
|
||||
};
|
||||
|
||||
export const serviceRegistry: ServiceDef[] = [
|
||||
{ id: 'workspace', label: 'Overview', icon: 'home', href: '#workspace', kind: 'internal', group: 0 },
|
||||
{ id: 'openclaw', label: 'OpenClaw', icon: 'claw', href: 'http://127.0.0.1:18789/channels', kind: 'embed', group: 1, port: 18789, match: ['openclaw', 'gateway'] },
|
||||
{ id: 'vault', label: 'Vault Server', icon: 'shield', href: 'http://localhost:8200', kind: 'embed', group: 1, port: 8200, match: ['vault'] },
|
||||
{ id: 'litellm', label: 'LiteLLM Admin UI', icon: 'chart', href: 'http://localhost:4000/ui', kind: 'embed', group: 1, port: 4000, match: ['litellm', 'lite'] },
|
||||
{ id: 'bridge', label: 'Bridge', icon: 'bridge', href: '#bridge', kind: 'internal', group: 2, match: ['bridge'] },
|
||||
{ id: 'runtime', label: 'Runtime', icon: 'cube', href: '#runtime', kind: 'internal', group: 2 },
|
||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal', href: 'http://127.0.0.1:7681', kind: 'embed', group: 2, port: 7681 },
|
||||
];
|
||||
|
||||
export const navSections: { id: string; titleKey: string; items: ServiceDef[] }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
titleKey: '',
|
||||
items: [
|
||||
serviceRegistry.find((item) => item.id === 'workspace')!,
|
||||
{ id: 'architecture', label: 'Architecture', icon: 'network', href: '#architecture', kind: 'internal', group: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
titleKey: 'navServices',
|
||||
items: [
|
||||
...serviceRegistry.filter((item) => item.group === 1),
|
||||
serviceRegistry.find((item) => item.id === 'terminal')!,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const findServiceDef = (serviceName: string): ServiceDef | undefined => {
|
||||
const name = serviceName.toLowerCase();
|
||||
return serviceRegistry.find((def) => def.match?.some((token) => name.includes(token)));
|
||||
};
|
||||
|
||||
export const customWorkspaceTabs: Tab[] = [
|
||||
{ id: 'runtime-console', label: 'Runtime', href: '#runtime-console', kind: 'internal', icon: 'cube', closable: true, source: 'custom' },
|
||||
{ id: 'bridge-console', label: 'Bridge', href: '#bridge-console', kind: 'internal', icon: 'bridge', closable: true, source: 'custom' },
|
||||
];
|
||||
|
||||
export const initialTabs: Tab[] = [
|
||||
{ id: 'workspace', label: 'Workspace', href: '#workspace', kind: 'internal', icon: 'home', source: 'builtin' },
|
||||
];
|
||||
|
||||
export const mockServices: Service[] = [
|
||||
{ name: 'OpenClaw Gateway', state: 'Running' },
|
||||
{ name: 'Bridge', state: 'Running' },
|
||||
{ name: 'LiteLLM', state: 'Running' },
|
||||
{ name: 'Vault', state: 'Running' },
|
||||
{ name: 'XWorkmate Bridge', state: 'Running' },
|
||||
];
|
||||
|
||||
export const agents = [
|
||||
{ name: 'Codex Agent', state: 'Idle', workspace: 'xworkspace-console', task: 'Homepage redesign' },
|
||||
{ name: 'Hermes Agent', state: 'Running', workspace: 'messaging', task: 'Gateway sync' },
|
||||
{ name: 'Gemini Agent', state: 'Idle', workspace: 'research', task: 'Waiting for input' },
|
||||
{ name: 'Claude Agent', state: 'Running', workspace: 'docs', task: 'Design review' },
|
||||
{ name: 'Qwen Agent', state: 'Idle', workspace: 'runtime', task: 'No active task' },
|
||||
];
|
||||
|
||||
export const tasks = [
|
||||
['Generate Report', 'Hermes', 'Running'],
|
||||
['Data Analysis', 'Codex', 'Completed'],
|
||||
['Create Presentation', 'Gemini', 'Completed'],
|
||||
['Code Refactor', 'Claude', 'Failed'],
|
||||
['Document Summary', 'Qwen', 'Completed'],
|
||||
];
|
||||
|
||||
export const skillGroups = [
|
||||
{ name: 'Content', skills: ['AI News Video', 'Product Video', 'IT Evolution Video'] },
|
||||
{ name: 'Document', skills: ['PDF', 'DOCX', 'XLSX', 'PPTX'] },
|
||||
{ name: 'Image', skills: ['Image Cog', 'Resize', 'WAN Image'] },
|
||||
{ name: 'Workflow', skills: ['Content Writer', 'CN Matrix', 'Automation'] },
|
||||
];
|
||||
|
||||
export const acpAgents = ['Claude', 'Gemini', 'Codex', 'Hermes', 'Qwen', 'OpenCode'];
|
||||
export const modelTargets = 'GPT-5.5 · DeepSeek V4 · Gemini 3.1 · GLM 5 · Kimi · Claude';
|
||||
|
||||
export type Labels = Record<string, string>;
|
||||
|
||||
export const labelsZh: Labels = {
|
||||
product: 'XWorkspace',
|
||||
controlPlane: '控制面',
|
||||
workspace: '工作空间',
|
||||
collapse: '收起',
|
||||
expand: '展开',
|
||||
connected: '已连接',
|
||||
agentsRunning: '个 Agent 运行中',
|
||||
vaultReady: 'Vault 就绪',
|
||||
homepageTitle: 'AI Workspace',
|
||||
homepageSubtitle: '在一个工作空间里统一组织 Runtime、Gateway 和本地 AI 服务。',
|
||||
activity: '服务活动',
|
||||
serviceCards: '服务卡片',
|
||||
serviceHealth: '服务健康',
|
||||
systemOverview: '系统总览',
|
||||
workspaceStatus: '工作空间状态',
|
||||
today: '今天',
|
||||
newTab: '新标签',
|
||||
terminal: '终端',
|
||||
maximize: '最大化',
|
||||
restore: '还原',
|
||||
themeLight: '浅色',
|
||||
themeDark: '深色',
|
||||
lang: '中/EN',
|
||||
languageLabel: '语言',
|
||||
themeLabel: '主题',
|
||||
remoteLabel: '远程模式',
|
||||
remoteOn: '开',
|
||||
remoteOff: '关',
|
||||
gatewayBand: '网关',
|
||||
agentBand: 'Agent 控制面',
|
||||
skillBand: '技能运行时',
|
||||
modelBand: '模型路由',
|
||||
memoryCard: '记忆系统',
|
||||
acpCard: 'ACP 路由',
|
||||
sessions: '会话',
|
||||
workers: '工作进程',
|
||||
skillsCount: '个技能',
|
||||
providers: '家模型供应商',
|
||||
navServices: '服务',
|
||||
navInfra: '基础设施',
|
||||
healthy: '健康',
|
||||
degraded: '异常',
|
||||
activeSessions: '活跃会话',
|
||||
connectedAgents: '已连接 Agent',
|
||||
activeModels: '可用模型',
|
||||
skillsAvailable: '可用技能',
|
||||
secLocal: '100% 本地 · 数据不出服务器',
|
||||
secToken: 'Token 认证 · 安全访问控制',
|
||||
secLoopback: '无公网暴露 · 仅本地回环',
|
||||
secE2e: '端到端加密 · 全服务保护',
|
||||
};
|
||||
|
||||
export const labelsEn: Labels = {
|
||||
product: 'XWorkmate',
|
||||
controlPlane: 'Control Plane',
|
||||
workspace: 'Workspace',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
connected: 'Connected',
|
||||
agentsRunning: 'Agents Running',
|
||||
vaultReady: 'Vault Ready',
|
||||
homepageTitle: 'AI Workspace',
|
||||
homepageSubtitle: 'Runtime, gateway and local AI services are organized in one workspace.',
|
||||
activity: 'Service Activity',
|
||||
serviceCards: 'Service Cards',
|
||||
serviceHealth: 'Service Health',
|
||||
systemOverview: 'System Overview',
|
||||
workspaceStatus: 'Workspace Status',
|
||||
today: 'Today',
|
||||
newTab: 'New Tab',
|
||||
terminal: 'Terminal',
|
||||
maximize: 'Maximize',
|
||||
restore: 'Restore',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
lang: 'EN/中',
|
||||
languageLabel: 'Language',
|
||||
themeLabel: 'Theme',
|
||||
remoteLabel: 'Remote Mode',
|
||||
remoteOn: 'On',
|
||||
remoteOff: 'Off',
|
||||
gatewayBand: 'Gateway',
|
||||
agentBand: 'Agent Control Plane',
|
||||
skillBand: 'Skill Runtime',
|
||||
modelBand: 'Model Routing',
|
||||
memoryCard: 'Memory System',
|
||||
acpCard: 'ACP Router',
|
||||
sessions: 'Sessions',
|
||||
workers: 'Workers',
|
||||
skillsCount: 'skills',
|
||||
providers: 'providers',
|
||||
navServices: 'Services',
|
||||
navInfra: 'Infrastructure',
|
||||
healthy: 'Healthy',
|
||||
degraded: 'Degraded',
|
||||
activeSessions: 'Active Sessions',
|
||||
connectedAgents: 'Connected Agents',
|
||||
activeModels: 'Active Models',
|
||||
skillsAvailable: 'Skills Available',
|
||||
secLocal: '100% Local · No data leaves your server',
|
||||
secToken: 'Token Auth · Secure access control',
|
||||
secLoopback: 'No Public Exposure · Local loopback only',
|
||||
secE2e: 'End-to-End Encrypted · All services protected',
|
||||
};
|
||||
@ -1,578 +1,10 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import './styles.css';
|
||||
|
||||
type Tab = {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
kind: 'internal' | 'external' | 'embed';
|
||||
icon?: string;
|
||||
closable?: boolean;
|
||||
source?: 'builtin' | 'custom';
|
||||
};
|
||||
|
||||
type Service = {
|
||||
name: string;
|
||||
state: 'Running' | 'Degraded' | 'Stopped';
|
||||
};
|
||||
|
||||
type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
kind: Tab['kind'];
|
||||
};
|
||||
|
||||
const navGroups: NavItem[][] = [
|
||||
[
|
||||
{ id: 'workspace', label: 'Workspace', icon: 'home', href: '#workspace', kind: 'internal' },
|
||||
],
|
||||
[
|
||||
{ id: 'openclaw', label: 'OpenClaw', icon: 'claw', href: 'http://127.0.0.1:18789/channels', kind: 'embed' },
|
||||
{ id: 'bridge', label: 'Bridge', icon: 'bridge', href: '#bridge', kind: 'internal' },
|
||||
{ id: 'litellm', label: 'LiteLLM', icon: 'chart', href: 'http://127.0.0.1:4000/ui', kind: 'embed' },
|
||||
{ id: 'vault', label: 'Vault', icon: 'shield', href: 'http://127.0.0.1:8200', kind: 'embed' },
|
||||
],
|
||||
[
|
||||
{ id: 'runtime', label: 'Runtime', icon: 'cube', href: '#runtime', kind: 'internal' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal', href: 'http://127.0.0.1:7681', kind: 'embed' },
|
||||
],
|
||||
];
|
||||
|
||||
const builtinServiceTabs: Tab[] = [
|
||||
{ id: 'openclaw', label: 'OpenClaw', href: 'http://127.0.0.1:18789/channels', kind: 'embed', icon: 'claw', closable: true, source: 'builtin' },
|
||||
{ id: 'vault', label: 'Vault', href: 'http://127.0.0.1:8200', kind: 'embed', icon: 'shield', closable: true, source: 'builtin' },
|
||||
{ id: 'litellm', label: 'LiteLLM', href: 'http://127.0.0.1:4000/ui', kind: 'embed', icon: 'chart', closable: true, source: 'builtin' },
|
||||
{ id: 'terminal', label: 'Terminal', href: 'http://127.0.0.1:7681', kind: 'embed', icon: 'terminal', closable: true, source: 'builtin' },
|
||||
];
|
||||
|
||||
const customWorkspaceTabs: Tab[] = [
|
||||
{ id: 'runtime-console', label: 'Runtime', href: '#runtime-console', kind: 'internal', icon: 'cube', closable: true, source: 'custom' },
|
||||
{ id: 'bridge-console', label: 'Bridge', href: '#bridge-console', kind: 'internal', icon: 'bridge', closable: true, source: 'custom' },
|
||||
];
|
||||
|
||||
const initialTabs: Tab[] = [
|
||||
{ id: 'workspace', label: 'Workspace', href: '#workspace', kind: 'internal', icon: 'home', source: 'builtin' },
|
||||
builtinServiceTabs[0],
|
||||
];
|
||||
|
||||
const mockServices: Service[] = [
|
||||
{ name: 'OpenClaw Gateway', state: 'Running' },
|
||||
{ name: 'Bridge', state: 'Running' },
|
||||
{ name: 'LiteLLM', state: 'Running' },
|
||||
{ name: 'Vault', state: 'Running' },
|
||||
{ name: 'XWorkmate Bridge', state: 'Running' },
|
||||
];
|
||||
|
||||
const agents = [
|
||||
{ name: 'Codex Agent', state: 'Idle', workspace: 'xworkspace-console', task: 'Homepage redesign' },
|
||||
{ name: 'Hermes Agent', state: 'Running', workspace: 'messaging', task: 'Gateway sync' },
|
||||
{ name: 'Gemini Agent', state: 'Idle', workspace: 'research', task: 'Waiting for input' },
|
||||
{ name: 'Claude Agent', state: 'Running', workspace: 'docs', task: 'Design review' },
|
||||
{ name: 'Qwen Agent', state: 'Idle', workspace: 'runtime', task: 'No active task' },
|
||||
];
|
||||
|
||||
const tasks = [
|
||||
['Generate Report', 'Hermes', 'Running'],
|
||||
['Data Analysis', 'Codex', 'Completed'],
|
||||
['Create Presentation', 'Gemini', 'Completed'],
|
||||
['Code Refactor', 'Claude', 'Failed'],
|
||||
['Document Summary', 'Qwen', 'Completed'],
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [selectedTab, setSelectedTab] = useState('workspace');
|
||||
const [tabs, setTabs] = useState(initialTabs);
|
||||
const [services, setServices] = useState<Service[] | null>(null);
|
||||
const [terminalExpanded, setTerminalExpanded] = useState(false);
|
||||
const [terminalCollapsed, setTerminalCollapsed] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [language, setLanguage] = useState<'en' | 'zh'>('en');
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://127.0.0.1:8788/services')
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
setServices(
|
||||
data.map((item) => ({
|
||||
name: item.name ?? item.unit,
|
||||
state: item.state === 'active' ? 'Running' : item.state === 'inactive' ? 'Stopped' : 'Degraded',
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setServices(null));
|
||||
}, []);
|
||||
|
||||
const currentServices = services ?? mockServices;
|
||||
const selected = tabs.find((tab) => tab.id === selectedTab);
|
||||
const labels = language === 'zh'
|
||||
? {
|
||||
product: 'XWorkspace',
|
||||
workspace: '工作空间',
|
||||
collapse: '收起',
|
||||
expand: '展开',
|
||||
connected: '已连接',
|
||||
agentsRunning: '个 Agent 运行中',
|
||||
vaultReady: 'Vault 就绪',
|
||||
homepageTitle: 'AI Workspace 控制面板',
|
||||
homepageSubtitle: '在一个工作空间里统一组织 Runtime、Gateway 和本地 AI 服务。',
|
||||
workspaceReady: '工作空间就绪',
|
||||
activity: '服务活动',
|
||||
coreServices: '核心服务',
|
||||
serviceCards: '服务卡片',
|
||||
today: '今天',
|
||||
newTab: '新标签',
|
||||
terminal: '终端',
|
||||
maximize: '最大化',
|
||||
restore: '还原',
|
||||
themeLight: '浅色',
|
||||
themeDark: '深色',
|
||||
lang: '中/EN',
|
||||
languageLabel: '语言',
|
||||
themeLabel: '主题',
|
||||
}
|
||||
: {
|
||||
product: 'XWorkspace',
|
||||
workspace: 'Workspace',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
connected: 'Connected',
|
||||
agentsRunning: 'Agents Running',
|
||||
vaultReady: 'Vault Ready',
|
||||
homepageTitle: 'AI Workspace Control Plane',
|
||||
homepageSubtitle: 'Runtime, gateway and local AI services are organized in one workspace.',
|
||||
workspaceReady: 'Workspace Ready',
|
||||
activity: 'Service Activity',
|
||||
coreServices: 'Core Services',
|
||||
serviceCards: 'Service Cards',
|
||||
today: 'Today',
|
||||
newTab: 'New Tab',
|
||||
terminal: 'Terminal',
|
||||
maximize: 'Maximize',
|
||||
restore: 'Restore',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
lang: 'EN/中',
|
||||
languageLabel: 'Language',
|
||||
themeLabel: 'Theme',
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
labels.product,
|
||||
labels.workspace,
|
||||
selected && selected.id !== 'workspace' ? selected.label : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
|
||||
const runningAgents = agents.filter((agent) => agent.state === 'Running').length;
|
||||
const runningTasks = tasks.filter((task) => task[2] === 'Running').length;
|
||||
return { runningServices, runningAgents, runningTasks };
|
||||
}, [currentServices]);
|
||||
|
||||
const openTab = (item: NavItem | Tab) => {
|
||||
setTabs((existingTabs) => {
|
||||
if (existingTabs.some((tab) => tab.id === item.id)) return existingTabs;
|
||||
return [...existingTabs, { ...item, closable: true }];
|
||||
});
|
||||
setSelectedTab(item.id);
|
||||
};
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
setTabs((existingTabs) => {
|
||||
const nextTabs = existingTabs.filter((tab) => tab.id !== tabId);
|
||||
if (tabId === selectedTab) setSelectedTab(nextTabs.at(-1)?.id ?? 'workspace');
|
||||
return nextTabs;
|
||||
});
|
||||
};
|
||||
|
||||
const addCustomTab = () => {
|
||||
const nextTab = customWorkspaceTabs.find((tab) => !tabs.some((open) => open.id === tab.id)) ?? customWorkspaceTabs[0];
|
||||
openTab(nextTab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : ''].join(' ')}>
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-x">X</span>
|
||||
<strong>{labels.product}</strong>
|
||||
</div>
|
||||
|
||||
<nav className="side-nav" aria-label="XWorkspace navigation">
|
||||
{navGroups.map((group, groupIndex) => (
|
||||
<div className="nav-group" key={`group-${groupIndex}`}>
|
||||
{group.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className={selectedTab === item.id ? 'active' : ''}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openTab(item);
|
||||
}}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-tools">
|
||||
<button className="sidebar-tool-button" type="button" onClick={() => setLanguage((value) => (value === 'en' ? 'zh' : 'en'))}>
|
||||
<span>{labels.languageLabel}</span>
|
||||
<strong>{labels.lang}</strong>
|
||||
</button>
|
||||
<button className="sidebar-tool-button" type="button" onClick={() => setTheme((value) => (value === 'light' ? 'dark' : 'light'))}>
|
||||
<span>{labels.themeLabel}</span>
|
||||
<strong>{theme === 'light' ? labels.themeDark : labels.themeLight}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="collapse-button" type="button" aria-label={sidebarCollapsed ? labels.expand : labels.collapse} onClick={() => setSidebarCollapsed((value) => !value)}>
|
||||
<Icon name={sidebarCollapsed ? 'chevrons-right' : 'chevrons-left'} />
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="workspace">
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button className="menu-button" type="button" aria-label="Toggle sidebar" onClick={() => setSidebarCollapsed((value) => !value)}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<nav className="breadcrumb" aria-label="Breadcrumb">
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<React.Fragment key={`${item}-${index}`}>
|
||||
{index > 0 ? <span className="breadcrumb-separator">/</span> : null}
|
||||
<span className={index === breadcrumbItems.length - 1 ? 'breadcrumb-current' : ''}>{item}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="status-strip">
|
||||
<StatusItem label="Asia/Shanghai" icon="globe" />
|
||||
<StatusItem label={labels.connected} icon="wifi" good />
|
||||
<StatusItem label={language === 'zh' ? `${summary.runningAgents} ${labels.agentsRunning}` : `${summary.runningAgents} ${labels.agentsRunning}`} icon="bot" good />
|
||||
<StatusItem label={labels.vaultReady} icon="shield" good />
|
||||
<strong>10:30</strong>
|
||||
<button className="round-button" type="button" aria-label="Notifications">
|
||||
<Icon name="bell" />
|
||||
</button>
|
||||
<button className="round-button" type="button" aria-label="Profile">
|
||||
<Icon name="user" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="workspace-tabs" aria-label="Workspace tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
className={selectedTab === tab.id ? 'tab active' : 'tab'}
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTab(tab.id)}
|
||||
>
|
||||
{tab.icon ? <Icon name={tab.icon} /> : null}
|
||||
<span>{tab.label}</span>
|
||||
{tab.closable ? (
|
||||
<span
|
||||
className="tab-close"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="tab add"
|
||||
type="button"
|
||||
onClick={addCustomTab}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
|
||||
<EmbedWorkspace tab={selected} />
|
||||
) : (
|
||||
<WorkspaceHome labels={labels} services={currentServices} summary={summary} onOpenHome={() => setSelectedTab('workspace')} />
|
||||
)}
|
||||
|
||||
<TerminalDrawer
|
||||
labels={labels}
|
||||
collapsed={terminalCollapsed}
|
||||
expanded={terminalExpanded}
|
||||
onCollapse={() => setTerminalCollapsed((value) => !value)}
|
||||
onToggle={() => setTerminalExpanded((value) => !value)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHome({
|
||||
labels,
|
||||
services,
|
||||
summary,
|
||||
onOpenHome,
|
||||
}: {
|
||||
labels: {
|
||||
homepageTitle: string;
|
||||
homepageSubtitle: string;
|
||||
workspaceReady: string;
|
||||
activity: string;
|
||||
serviceCards: string;
|
||||
coreServices: string;
|
||||
today: string;
|
||||
};
|
||||
services: Service[];
|
||||
summary: { runningServices: number; runningAgents: number; runningTasks: number };
|
||||
onOpenHome: () => void;
|
||||
}) {
|
||||
const cardsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const scrollCards = (direction: 'left' | 'right') => {
|
||||
const viewport = cardsRef.current;
|
||||
if (!viewport) return;
|
||||
const distance = Math.max(280, Math.floor(viewport.clientWidth * 0.72));
|
||||
viewport.scrollBy({ left: direction === 'left' ? -distance : distance, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workspace-body">
|
||||
<section className="console-board">
|
||||
<div className="command-panel">
|
||||
<div className="board-heading">
|
||||
<div>
|
||||
<h1>{labels.homepageTitle}</h1>
|
||||
<p>{labels.homepageSubtitle}</p>
|
||||
</div>
|
||||
<span className="healthy-chip"><span /> {labels.workspaceReady}</span>
|
||||
</div>
|
||||
|
||||
<div className="activity-card">
|
||||
<div className="activity-head">
|
||||
<h2>{labels.activity}</h2>
|
||||
<div className="range-tabs" aria-label="Service activity range">
|
||||
{[labels.today, '7d', '2w', '1m'].map((range, index) => <span className={index === 1 ? 'active' : ''} key={range}>{range}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="service-chart" aria-hidden="true">
|
||||
<svg viewBox="0 0 640 220">
|
||||
<path className="grid-line" d="M24 40H620M24 92H620M24 144H620M24 196H620" />
|
||||
<path className="chart-muted" d="M30 150C80 116 98 184 150 130S230 54 286 112 357 168 410 118 492 88 534 122 584 168 618 110" />
|
||||
<path className="chart-main" d="M30 166C82 124 114 176 156 126S224 62 280 104 346 154 402 102 494 52 540 88 582 128 618 64" />
|
||||
<circle cx="280" cy="104" r="7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="service-carousel">
|
||||
<div className="carousel-head">
|
||||
<h2>{labels.serviceCards}</h2>
|
||||
<div className="carousel-actions">
|
||||
<button type="button" aria-label="Scroll service cards left" onClick={() => scrollCards('left')}>
|
||||
<Icon name="chevron-left" />
|
||||
</button>
|
||||
<button type="button" aria-label="Scroll service cards right" onClick={() => scrollCards('right')}>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="service-cards-scroll"
|
||||
ref={cardsRef}
|
||||
onWheel={(event) => {
|
||||
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
||||
event.currentTarget.scrollBy({ left: event.deltaY, behavior: 'auto' });
|
||||
}}
|
||||
>
|
||||
<a className="service-cards homepage-link" href="#workspace" onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenHome();
|
||||
}}>
|
||||
{services.map((service, index) => (
|
||||
<article className={index === 1 ? 'service-card tilted' : 'service-card'} key={service.name}>
|
||||
<Icon name={service.name.toLowerCase().includes('vault') ? 'shield' : service.name.toLowerCase().includes('lite') ? 'chart' : service.name.toLowerCase().includes('bridge') ? 'bridge' : 'claw'} />
|
||||
<span>{service.name}</span>
|
||||
<strong>{service.state}</strong>
|
||||
</article>
|
||||
))}
|
||||
<article className="service-card ghost-card">
|
||||
<Icon name="cube" />
|
||||
<span>Future Probe</span>
|
||||
<strong>Reserved</strong>
|
||||
</article>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section className="service-summary panel">
|
||||
<div className="panel-head">
|
||||
<h2>{labels.coreServices}</h2>
|
||||
</div>
|
||||
<div className="service-list compact">
|
||||
{services.map((service) => (
|
||||
<div className="service-row" key={service.name}>
|
||||
<span className={service.state === 'Running' ? 'service-dot good' : 'service-dot warn'} />
|
||||
<strong>{service.name}</strong>
|
||||
<span>{service.state}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbedWorkspace({ tab }: { tab: Tab }) {
|
||||
return (
|
||||
<div className="workspace-body">
|
||||
<section className="embed-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>{tab.label}</h2>
|
||||
<p>{tab.href}</p>
|
||||
</div>
|
||||
<a href={tab.href} target="_blank" rel="noreferrer">Open in browser</a>
|
||||
</div>
|
||||
<iframe title={`${tab.label} workspace`} src={tab.href} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalDrawer({
|
||||
labels,
|
||||
collapsed,
|
||||
expanded,
|
||||
onCollapse,
|
||||
onToggle,
|
||||
}: {
|
||||
labels: {
|
||||
terminal: string;
|
||||
newTab: string;
|
||||
maximize: string;
|
||||
restore: string;
|
||||
collapse: string;
|
||||
expand: string;
|
||||
};
|
||||
collapsed: boolean;
|
||||
expanded: boolean;
|
||||
onCollapse: () => void;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={[expanded ? 'terminal-drawer expanded' : 'terminal-drawer', collapsed ? 'collapsed' : ''].join(' ')}>
|
||||
<div className="terminal-head">
|
||||
<div>
|
||||
<Icon name="terminal" />
|
||||
<strong>{labels.terminal}</strong>
|
||||
</div>
|
||||
<div className="terminal-actions">
|
||||
<a href="http://127.0.0.1:7681" target="_blank" rel="noreferrer">{labels.newTab}</a>
|
||||
<button type="button" onClick={onCollapse}>{collapsed ? labels.expand : labels.collapse}</button>
|
||||
<button type="button" onClick={onToggle}>{expanded ? labels.restore : labels.maximize}</button>
|
||||
<button type="button" aria-label="Terminal menu">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="terminal-frame">
|
||||
<iframe title="ttyd terminal" src="http://127.0.0.1:7681" />
|
||||
<pre aria-hidden="true">
|
||||
<span>ubuntu@workspace:~$</span> openclaw status{'\n'}
|
||||
Gateway Running{'\n'}
|
||||
Bridge Running{'\n'}
|
||||
LiteLLM Running{'\n'}
|
||||
Vault Running{'\n'}
|
||||
XWorkmate Bridge Running{'\n\n'}
|
||||
<span>ubuntu@workspace:~$</span> _
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, action, children }: React.PropsWithChildren<{ title: string; action?: string }>) {
|
||||
return (
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<h2>{title}</h2>
|
||||
{action ? <button type="button">{action}</button> : null}
|
||||
</div>
|
||||
{children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusItem({ label, icon, good }: { label: string; icon: string; good?: boolean }) {
|
||||
return (
|
||||
<span className="status-item">
|
||||
<Icon name={icon} />
|
||||
{good ? <i /> : null}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ state }: { state: string }) {
|
||||
const normalized = state.toLowerCase();
|
||||
const variant = normalized.includes('fail') || normalized.includes('stop') ? 'bad' : normalized.includes('idle') ? 'idle' : 'good';
|
||||
return <span className={`badge ${variant}`}>{state}</span>;
|
||||
}
|
||||
|
||||
function Icon({ name }: { name: string }) {
|
||||
const paths: Record<string, React.ReactNode> = {
|
||||
home: <path d="M3 10.5 12 3l9 7.5v9a1.5 1.5 0 0 1-1.5 1.5H15v-6H9v6H4.5A1.5 1.5 0 0 1 3 19.5z" />,
|
||||
bot: <path d="M8 9h8a4 4 0 0 1 4 4v4.5A2.5 2.5 0 0 1 17.5 20h-11A2.5 2.5 0 0 1 4 17.5V13a4 4 0 0 1 4-4Zm1 4h.01M15 13h.01M9 17h6M12 5v4M9 5h6" />,
|
||||
tasks: <path d="M8 6h11M8 12h11M8 18h11M4.5 6l1 1 1.8-2M4.5 12l1 1 1.8-2M4.5 18l1 1 1.8-2" />,
|
||||
folder: <path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5z" />,
|
||||
claw: <path d="M12 3v5M7 5l2.5 4M17 5l-2.5 4M5 13a7 7 0 0 0 14 0M8 13a4 4 0 0 0 8 0" />,
|
||||
bridge: <path d="M4 17h16M6 17V9l6-4 6 4v8M8 17v-5h8v5" />,
|
||||
chart: <path d="M4 19V5M4 19h16M7 15l3-4 4 2 4-7" />,
|
||||
shield: <path d="M12 3 20 6v5c0 5-3.5 8-8 10-4.5-2-8-5-8-10V6z" />,
|
||||
cube: <path d="m12 3 8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9" />,
|
||||
terminal: <path d="m5 8 4 4-4 4M11 17h8" />,
|
||||
settings: <path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v3M12 18v3M4.2 5.6l2.1 2.1M17.7 16.3l2.1 2.1M3 12h3M18 12h3M4.2 18.4l2.1-2.1M17.7 7.7l2.1-2.1" />,
|
||||
menu: <path d="M5 7h14M5 12h14M5 17h14" />,
|
||||
globe: <path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-8 9h16M12 3c2.2 2.4 3.3 5.4 3.3 9S14.2 18.6 12 21M12 3C9.8 5.4 8.7 8.4 8.7 12S9.8 18.6 12 21" />,
|
||||
wifi: <path d="M4 9a12 12 0 0 1 16 0M7 12a7.5 7.5 0 0 1 10 0M10 15a3 3 0 0 1 4 0M12 19h.01" />,
|
||||
bell: <path d="M18 16H6l1.4-2V10a4.6 4.6 0 0 1 9.2 0v4zM10 19h4" />,
|
||||
user: <path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4 21a8 8 0 0 1 16 0" />,
|
||||
languages: <path d="M4 6h9M8.5 4v2c0 4-2.2 7.2-5.5 9M6 10c1.5 2.2 3.8 4.1 6.6 5.5M14 18h7M17.5 6l4.5 12M20.3 13h-5.6" />,
|
||||
moon: <path d="M20 14.5A7.5 7.5 0 1 1 9.5 4 6 6 0 0 0 20 14.5Z" />,
|
||||
sun: <path d="M12 3v2.2M12 18.8V21M4.9 4.9l1.6 1.6M17.5 17.5l1.6 1.6M3 12h2.2M18.8 12H21M4.9 19.1l1.6-1.6M17.5 6.5l1.6-1.6M12 7.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z" />,
|
||||
'arrow-left': <path d="M19 12H5M12 5l-7 7 7 7" />,
|
||||
'chevron-left': <path d="m15 18-6-6 6-6" />,
|
||||
'chevron-right': <path d="m9 18 6-6-6-6" />,
|
||||
'chevrons-left': <path d="m13.5 17-5-5 5-5M19 17l-5-5 5-5" />,
|
||||
'chevrons-right': <path d="m10.5 17 5-5-5-5M5 17l5-5-5-5" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8">
|
||||
{paths[name] ?? paths.cube}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppShell />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 17000,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user