diff --git a/.gitignore b/.gitignore index fb7d482..e2affc4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/api/command.go b/api/command.go new file mode 100644 index 0000000..b1e8fe3 --- /dev/null +++ b/api/command.go @@ -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)) +} diff --git a/api/main.go b/api/main.go index c8fcdcd..3abbbcf 100644 --- a/api/main.go +++ b/api/main.go @@ -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)) } diff --git a/api/metric_probe.go b/api/metric_probe.go new file mode 100644 index 0000000..30f8f9e --- /dev/null +++ b/api/metric_probe.go @@ -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 +} diff --git a/api/server.go b/api/server.go new file mode 100644 index 0000000..1ec3cb4 --- /dev/null +++ b/api/server.go @@ -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) + } +} diff --git a/api/service_probe.go b/api/service_probe.go new file mode 100644 index 0000000..1380716 --- /dev/null +++ b/api/service_probe.go @@ -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 +} diff --git a/api/types.go b/api/types.go new file mode 100644 index 0000000..e7a4c9e --- /dev/null +++ b/api/types.go @@ -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"` +} diff --git a/dashboard/src/components/AppShell.tsx b/dashboard/src/components/AppShell.tsx index 654a4ee..1360cbb 100644 --- a/dashboard/src/components/AppShell.tsx +++ b/dashboard/src/components/AppShell.tsx @@ -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(initialTabs); const [services, setServices] = useState(null); + const [metrics, setMetrics] = useState(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' ? ( setSelectedTab('workspace')} /> ) : ( - + )} diff --git a/dashboard/src/components/ArchPipeline.tsx b/dashboard/src/components/ArchPipeline.tsx index 55e2440..6982f93 100644 --- a/dashboard/src/components/ArchPipeline.tsx +++ b/dashboard/src/components/ArchPipeline.tsx @@ -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']) => ( ); - 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({
2 {labels.agentBand} - 4 {labels.sessions} · 8 {labels.workers} + {metrics.activeSessions} {labels.sessions} · {metrics.workers} {labels.workers} {dot()}
@@ -84,7 +86,7 @@ export function ArchPipeline({ {labels.acpCard}
{acpAgents.map((agent) => ( - {agent} + {agent} ))}
@@ -95,7 +97,7 @@ export function ArchPipeline({
3 {labels.skillBand} Layer - {totalSkills * 2}+ {labels.skillsCount} + {totalSkills}+ {labels.skillsCount} {dot(stateOf('bridge'))}
@@ -111,10 +113,10 @@ export function ArchPipeline({ @@ -24,14 +25,25 @@ export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
-