feat: refresh dashboard homepage

This commit is contained in:
Haitao Pan 2026-06-12 21:58:59 +08:00
parent 498628083c
commit 413369aec9
18 changed files with 3004 additions and 575 deletions

View 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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@ -14,7 +14,11 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@ -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,