feat: wire dashboard to live runtime status

This commit is contained in:
Haitao Pan 2026-06-13 07:41:47 +08:00
parent 9895d77dbf
commit a8b5b25d84
15 changed files with 505 additions and 145 deletions

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ yarn-error.log*
next-env.d.ts
dashboard/node_modules/
dashboard/dist/
dashboard/public/status.json
/dashboard-preview.png
/dashboard-status-dropdown.png

23
api/command.go Normal file
View File

@ -0,0 +1,23 @@
package main
import (
"os/exec"
"strings"
)
func commandState(name string, args ...string) string {
out := commandOutput(name, args...)
if out == "" {
return ""
}
return strings.TrimSpace(out)
}
func commandOutput(name string, args ...string) string {
cmd := exec.Command(name, args...)
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

View File

@ -1,113 +1,9 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os/exec"
"runtime"
"strconv"
"strings"
)
type Service struct {
Name string `json:"name"`
State string `json:"state"`
Unit string `json:"unit"`
Detail string `json:"detail,omitempty"`
}
type HealthResponse struct {
Status string `json:"status"`
Arch string `json:"arch"`
OS string `json:"os"`
CPU int `json:"cpu"`
Memory string `json:"memory"`
Disk string `json:"disk"`
Services []Service `json:"services"`
}
import "log"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/services", servicesHandler)
mux.HandleFunc("/metrics/simple", metricsHandler)
addr := "127.0.0.1:8788"
log.Printf("xworkspace api listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, withCORS(mux)))
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, HealthResponse{
Status: "ok",
Arch: runtime.GOARCH,
OS: runtime.GOOS,
CPU: runtime.NumCPU(),
Memory: "unknown",
Disk: "unknown",
Services: probeServices(),
})
}
func servicesHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, probeServices())
}
func metricsHandler(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(probeServices())) + "\n"))
}
func probeServices() []Service {
units := []string{
"xworkspace-console.service",
"xworkspace-openclaw.service",
"xworkspace-bridge.service",
"xworkspace-litellm.service",
"xworkspace-vault.service",
}
services := make([]Service, 0, len(units))
for _, unit := range units {
state := commandState("systemctl", "--user", "is-active", unit)
if state == "" {
state = "unknown"
}
services = append(services, Service{
Name: strings.TrimSuffix(unit, ".service"),
Unit: unit,
State: state,
})
}
return services
}
func commandState(name string, args ...string) string {
cmd := exec.Command(name, args...)
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
log.Fatal(NewServer().ListenAndServe(addr))
}

69
api/metric_probe.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
"strconv"
"strings"
)
type MetricProbe interface {
Probe(services []Service) Metrics
}
type SystemMetricProbe struct{}
func NewMetricProbe() MetricProbe {
return SystemMetricProbe{}
}
func (p SystemMetricProbe) Probe(services []Service) Metrics {
return Metrics{
ActiveSessions: countProcesses("codex", "openclaw", "opencode", "gemini", "hermes"),
ConnectedAgents: countConnectedAgents(services),
ActiveModels: countLiteLLMModels(),
SkillsAvailable: countSkillDirs(),
Workers: countProcesses("codex", "openclaw", "opencode", "gemini", "hermes", "xworkmate-go-core"),
}
}
func countConnectedAgents(services []Service) int {
count := 0
for _, service := range services {
name := strings.ToLower(service.Name)
if service.State == "active" && (strings.Contains(name, "bridge") || strings.Contains(name, "openclaw")) {
count++
}
}
return count
}
func countProcesses(names ...string) int {
out := commandOutput("pgrep", "-af", strings.Join(names, "|"))
if out == "" {
return 0
}
count := 0
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) != "" && !strings.Contains(line, "pgrep -af") {
count++
}
}
return count
}
func countLiteLLMModels() int {
out := commandOutput("sh", "-lc", "curl -fsS --max-time 1 http://127.0.0.1:4000/v1/models 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get(\"data\", [])))' 2>/dev/null")
return parseIntOrZero(out)
}
func countSkillDirs() int {
out := commandOutput("sh", "-lc", "find \"$HOME/.openclaw/workspace/skills\" \"$HOME/.codex/skills\" \"$HOME/.agents/skills\" -name SKILL.md 2>/dev/null | wc -l")
return parseIntOrZero(out)
}
func parseIntOrZero(value string) int {
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0
}
return n
}

91
api/server.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"encoding/json"
"net/http"
"runtime"
"strconv"
)
type Server struct {
serviceProbe ServiceProbe
metricProbe MetricProbe
}
func NewServer() *Server {
serviceProbe := NewServiceProbe()
return &Server{
serviceProbe: serviceProbe,
metricProbe: NewMetricProbe(),
}
}
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.withCORS(s.routes()))
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/services", s.servicesHandler)
mux.HandleFunc("/metrics/simple", s.metricsHandler)
return mux
}
func (s *Server) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
} else {
w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func allowedOrigin(origin string) bool {
switch origin {
case "http://127.0.0.1:17000", "http://localhost:17000", "http://127.0.0.1:3000", "https://console.svc.plus":
return true
default:
return false
}
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
services := s.serviceProbe.Probe()
writeJSON(w, HealthResponse{
Status: "ok",
Arch: runtime.GOARCH,
OS: runtime.GOOS,
CPU: runtime.NumCPU(),
Memory: "unknown",
Disk: "unknown",
Services: services,
Metrics: s.metricProbe.Probe(services),
})
}
func (s *Server) servicesHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.serviceProbe.Probe())
}
func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(s.serviceProbe.Probe())) + "\n"))
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

96
api/service_probe.go Normal file
View File

@ -0,0 +1,96 @@
package main
import (
"net"
"strconv"
"time"
)
type ServiceProbe interface {
Probe() []Service
}
type serviceProbeConfig struct {
Name string
Units []string
Port int
URL string
}
type SystemServiceProbe struct {
services []serviceProbeConfig
}
func NewServiceProbe() ServiceProbe {
return SystemServiceProbe{
services: []serviceProbeConfig{
{Name: "XWorkspace Console", Units: []string{"xworkspace-console.service"}, Port: 17000, URL: "http://127.0.0.1:17000"},
{Name: "OpenClaw Gateway", Units: []string{"xworkspace-openclaw.service", "openclaw-gateway.service"}, Port: 18789, URL: "http://127.0.0.1:18789/channels"},
{Name: "XWorkmate Bridge", Units: []string{"xworkspace-bridge.service", "xworkmate-bridge.service"}, Port: 8787, URL: "http://127.0.0.1:8787/api/ping"},
{Name: "LiteLLM", Units: []string{"xworkspace-litellm.service", "litellm-proxy.service"}, Port: 4000, URL: "http://127.0.0.1:4000/ui"},
{Name: "Vault", Units: []string{"xworkspace-vault.service", "vault.service"}, Port: 8200, URL: "http://127.0.0.1:8200/ui/"},
{Name: "Terminal", Units: []string{"xworkspace-ttyd.service", "ttyd.service"}, Port: 7681, URL: "http://127.0.0.1:7681"},
},
}
}
func (p SystemServiceProbe) Probe() []Service {
services := make([]Service, 0, len(p.services))
for _, config := range p.services {
unit, state := firstSystemdUnit(config.Units)
portOpen := config.Port > 0 && isPortOpen("127.0.0.1", config.Port)
if state == "" && portOpen {
state = "active"
} else if state == "" {
state = "inactive"
}
services = append(services, Service{
Name: config.Name,
Unit: unit,
State: state,
Detail: probeDetail(unit, portOpen),
Port: config.Port,
URL: config.URL,
})
}
return services
}
func firstSystemdUnit(units []string) (string, string) {
for _, unit := range units {
if state := commandState("systemctl", "is-active", unit); state != "" && state != "unknown" {
return unit, state
}
if state := commandState("systemctl", "--user", "is-active", unit); state != "" && state != "unknown" {
return unit, state
}
}
for _, unit := range units {
if state := commandState("systemctl", "is-enabled", unit); state != "" && state != "unknown" {
return unit, ""
}
if state := commandState("systemctl", "--user", "is-enabled", unit); state != "" && state != "unknown" {
return unit, ""
}
}
return "", ""
}
func probeDetail(unit string, portOpen bool) string {
if unit == "" && portOpen {
return "port open"
}
if portOpen {
return "systemd + port open"
}
return "systemd"
}
func isPortOpen(host string, port int) bool {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 300*time.Millisecond)
if err != nil {
return false
}
_ = conn.Close()
return true
}

29
api/types.go Normal file
View File

@ -0,0 +1,29 @@
package main
type Service struct {
Name string `json:"name"`
State string `json:"state"`
Unit string `json:"unit"`
Detail string `json:"detail,omitempty"`
Port int `json:"port,omitempty"`
URL string `json:"url,omitempty"`
}
type HealthResponse struct {
Status string `json:"status"`
Arch string `json:"arch"`
OS string `json:"os"`
CPU int `json:"cpu"`
Memory string `json:"memory"`
Disk string `json:"disk"`
Services []Service `json:"services"`
Metrics Metrics `json:"metrics"`
}
type Metrics struct {
ActiveSessions int `json:"activeSessions"`
ConnectedAgents int `json:"connectedAgents"`
ActiveModels int `json:"activeModels"`
SkillsAvailable int `json:"skillsAvailable"`
Workers int `json:"workers"`
}

View File

@ -1,9 +1,9 @@
'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 { customWorkspaceTabs, fallbackMetrics, initialTabs, labelsEn, labelsZh, mockServices } from '@/lib/data';
import type { NavItem, RuntimeMetrics, Service, Tab } from '@/lib/data';
import { fetchDashboardStatus } from '@/lib/api';
import { Sidebar } from './Sidebar';
import { Topbar } from './Topbar';
import { WorkspaceTabs } from './WorkspaceTabs';
@ -14,13 +14,27 @@ export function AppShell() {
const [selectedTab, setSelectedTab] = useState('workspace');
const [tabs, setTabs] = useState<Tab[]>(initialTabs);
const [services, setServices] = useState<Service[] | null>(null);
const [metrics, setMetrics] = useState<RuntimeMetrics>(fallbackMetrics);
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));
let active = true;
const refresh = () => {
fetchDashboardStatus().then((data) => {
if (!active || !data) return;
setServices(data.services);
setMetrics(data.metrics);
});
};
refresh();
const timer = window.setInterval(refresh, 15_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
@ -61,10 +75,8 @@ export function AppShell() {
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]);
return { runningServices, runningAgents: metrics.connectedAgents, runningTasks: metrics.workers };
}, [currentServices, metrics]);
const openTab = (item: NavItem | Tab) => {
setTabs((existingTabs) => {
@ -108,6 +120,7 @@ export function AppShell() {
selectedLabel={selected && selected.id !== 'workspace' ? selected.label : null}
services={currentServices}
summary={summary}
metrics={metrics}
onToggleSidebar={() => setSidebarCollapsed((value) => !value)}
/>
@ -116,7 +129,7 @@ export function AppShell() {
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
<EmbedView tab={selected} onBack={() => setSelectedTab('workspace')} />
) : (
<WorkspaceHome labels={labels} services={currentServices} onOpenService={openTab} />
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} onOpenService={openTab} />
)}
</main>
</div>

View File

@ -1,17 +1,19 @@
'use client';
import { useState } from 'react';
import { acpAgents, agents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data';
import type { Labels, NavItem, Service } from '@/lib/data';
import { acpAgents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data';
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data';
import { Icon } from './Icon';
export function ArchPipeline({
labels,
services,
metrics,
onOpenService,
}: {
labels: Labels;
services: Service[];
metrics: RuntimeMetrics;
onOpenService: (item: NavItem) => void;
}) {
const [skillsOpen, setSkillsOpen] = useState(false);
@ -24,8 +26,8 @@ export function ArchPipeline({
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 runningAgents = new Set(acpAgents.slice(0, metrics.connectedAgents));
const totalSkills = metrics.skillsAvailable || 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' },
@ -64,7 +66,7 @@ export function ArchPipeline({
<div className="node-head">
<span className="node-index purple">2</span>
<strong>{labels.agentBand}</strong>
<small>4 {labels.sessions} · 8 {labels.workers}</small>
<small>{metrics.activeSessions} {labels.sessions} · {metrics.workers} {labels.workers}</small>
{dot()}
</div>
<div className="agent-icons">
@ -84,7 +86,7 @@ export function ArchPipeline({
<strong>{labels.acpCard}</strong>
<div className="router-grid">
{acpAgents.map((agent) => (
<em key={agent} className={runningAgents.includes(agent) ? 'busy' : ''}>{agent}</em>
<em key={agent} className={runningAgents.has(agent) ? 'busy' : ''}>{agent}</em>
))}
</div>
</div>
@ -95,7 +97,7 @@ export function ArchPipeline({
<div className="node-head">
<span className="node-index green">3</span>
<strong>{labels.skillBand} Layer</strong>
<small>{totalSkills * 2}+ {labels.skillsCount}</small>
<small>{totalSkills}+ {labels.skillsCount}</small>
{dot(stateOf('bridge'))}
</div>
<div className="skill-stack">
@ -111,10 +113,10 @@ export function ArchPipeline({
<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>{labels.sessions}</span><b>{metrics.activeSessions}</b><small>Active</small><Icon name="user" /></div>
<div><span>Agents</span><b>{metrics.connectedAgents}</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>
<div><span>Skills</span><b>{totalSkills}+</b><Icon name="sparkles" /></div>
</aside>
<button type="button" className="model-layer node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'litellm')!)}>

View File

@ -6,6 +6,7 @@ import { Icon } from './Icon';
export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
const [reloadKey, setReloadKey] = useState(0);
const frameBlocked = tab.frameMode === 'external';
return (
<div className="workspace-body">
<section className="embed-panel">
@ -16,7 +17,7 @@ export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
<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)}>
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)} disabled={frameBlocked}>
<Icon name="refresh" />
</button>
<a className="embed-tool" href={tab.href} target="_blank" rel="noreferrer" aria-label="Open in browser">
@ -24,14 +25,25 @@ export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
</a>
</div>
</div>
<iframe
key={reloadKey}
title={`${tab.label} workspace`}
src={tab.href}
allow="camera; microphone; display-capture; autoplay; clipboard-read; clipboard-write; fullscreen"
allowFullScreen
referrerPolicy="no-referrer"
/>
{frameBlocked ? (
<div className="external-embed-fallback">
<div>
<span className="external-embed-icon"><Icon name={tab.icon ?? 'external'} /></span>
<strong>{tab.label}</strong>
<p>This service blocks embedded frames. Open it in a dedicated browser tab.</p>
<a href={tab.href} target="_blank" rel="noreferrer">Open {tab.label}</a>
</div>
</div>
) : (
<iframe
key={reloadKey}
title={`${tab.label} workspace`}
src={tab.href}
allow="camera; microphone; display-capture; autoplay; clipboard-read; clipboard-write; fullscreen"
allowFullScreen
referrerPolicy="no-referrer"
/>
)}
</section>
</div>
);

View File

@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import type { Labels, Service } from '@/lib/data';
import type { Labels, RuntimeMetrics, Service } from '@/lib/data';
import { Icon } from './Icon';
import { StatusAggregate } from './StatusAggregate';
@ -10,12 +10,14 @@ export function Topbar({
selectedLabel,
services,
summary,
metrics,
onToggleSidebar,
}: {
labels: Labels;
selectedLabel: string | null;
services: Service[];
summary: { runningServices: number; runningAgents: number };
metrics: RuntimeMetrics;
onToggleSidebar: () => void;
}) {
const breadcrumbItems = [labels.product, labels.workspace, selectedLabel].filter(Boolean) as string[];
@ -45,7 +47,7 @@ export function Topbar({
</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="user" />{metrics.activeSessions} Sessions</span>
<span className="status-pill"><Icon name="clock" />{time}</span>
<button className="round-button" type="button" aria-label="Notifications">
<Icon name="bell" />

View File

@ -1,13 +1,15 @@
import type { Labels, NavItem, Service } from '@/lib/data';
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data';
import { ArchPipeline } from './ArchPipeline';
export function WorkspaceHome({
labels,
services,
metrics,
onOpenService,
}: {
labels: Labels;
services: Service[];
metrics: RuntimeMetrics;
onOpenService: (item: NavItem) => void;
}) {
return (
@ -21,7 +23,7 @@ export function WorkspaceHome({
</div>
</div>
<ArchPipeline labels={labels} services={services} onOpenService={onOpenService} />
<ArchPipeline labels={labels} services={services} metrics={metrics} onOpenService={onOpenService} />
</div>
</section>
</div>

View File

@ -1,17 +1,50 @@
import type { Service } from './data';
import type { DashboardStatus, Service } from './data';
const BASE = 'http://127.0.0.1:8788';
const normalizeServiceState = (state?: string): Service['state'] => {
if (state === 'active' || state === 'running' || state === 'Running') return 'Running';
if (state === 'inactive' || state === 'failed' || state === 'Stopped') return 'Stopped';
return 'Degraded';
};
const mapService = (item: { name?: string; unit?: string; state?: string; detail?: string; port?: number; url?: string }): Service => ({
name: item.name ?? item.unit ?? 'unknown',
unit: item.unit,
detail: item.detail,
port: item.port,
url: item.url,
state: normalizeServiceState(item.state),
});
export async function fetchDashboardStatus(): Promise<DashboardStatus | null> {
try {
const response = await fetch(`${BASE}/health`, { cache: 'no-store' });
if (!response.ok) return null;
const data = await response.json();
if (!Array.isArray(data.services)) return null;
return {
services: data.services.map(mapService),
metrics: {
activeSessions: Number(data.metrics?.activeSessions ?? 0),
connectedAgents: Number(data.metrics?.connectedAgents ?? 0),
activeModels: Number(data.metrics?.activeModels ?? 0),
skillsAvailable: Number(data.metrics?.skillsAvailable ?? 0),
workers: Number(data.metrics?.workers ?? 0),
},
};
} catch {
return null;
}
}
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',
}));
return data.map(mapService);
} catch {
return null;
}

View File

@ -6,11 +6,29 @@ export type Tab = {
icon?: string;
closable?: boolean;
source?: 'builtin' | 'custom';
frameMode?: 'iframe' | 'external';
};
export type Service = {
name: string;
state: 'Running' | 'Degraded' | 'Stopped';
unit?: string;
detail?: string;
port?: number;
url?: string;
};
export type RuntimeMetrics = {
activeSessions: number;
connectedAgents: number;
activeModels: number;
skillsAvailable: number;
workers: number;
};
export type DashboardStatus = {
services: Service[];
metrics: RuntimeMetrics;
};
export type NavItem = {
@ -25,12 +43,13 @@ export type ServiceDef = NavItem & {
group: number;
port?: number;
match?: string[];
frameMode?: Tab['frameMode'];
};
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: 'openclaw', label: 'OpenClaw', icon: 'claw', href: 'http://127.0.0.1:18789/channels', kind: 'embed', group: 1, port: 18789, match: ['openclaw', 'gateway'], frameMode: 'external' },
{ id: 'vault', label: 'Vault Server', icon: 'shield', href: 'http://127.0.0.1:8200/ui/', kind: 'embed', group: 1, port: 8200, match: ['vault'], frameMode: 'external' },
{ 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 },
@ -78,6 +97,14 @@ export const mockServices: Service[] = [
{ name: 'XWorkmate Bridge', state: 'Running' },
];
export const fallbackMetrics: RuntimeMetrics = {
activeSessions: 0,
connectedAgents: 0,
activeModels: 0,
skillsAvailable: 0,
workers: 0,
};
export const agents = [
{ name: 'Codex Agent', state: 'Idle', workspace: 'xworkspace-console', task: 'Homepage redesign' },
{ name: 'Hermes Agent', state: 'Running', workspace: 'messaging', task: 'Gateway sync' },

View File

@ -925,6 +925,65 @@ button {
overflow: hidden;
}
.external-embed-fallback {
display: grid;
place-items: center;
justify-self: center;
width: min(100%, 1440px, calc((100vh - 238px) * 1.6));
min-height: min(640px, calc(100vh - 238px));
aspect-ratio: 16 / 10;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.external-embed-fallback > div {
display: grid;
justify-items: center;
gap: 10px;
max-width: 360px;
text-align: center;
}
.external-embed-icon {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 8px;
background: var(--soft);
color: var(--accent);
}
.external-embed-icon .icon {
width: 20px;
height: 20px;
}
.external-embed-fallback strong {
font-size: 18px;
}
.external-embed-fallback p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.external-embed-fallback a {
display: inline-flex;
align-items: center;
height: 34px;
padding: 0 14px;
border-radius: 7px;
background: var(--text);
color: var(--panel);
font-size: 13px;
font-weight: 750;
text-decoration: none;
}
.terminal-drawer {
margin: 18px 22px 28px;
padding: 18px;
@ -2113,6 +2172,11 @@ button.node-card {
background: var(--soft);
}
.embed-tool:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.embed-tool .icon {
width: 16px;
height: 16px;