feat: wire dashboard to live runtime status
This commit is contained in:
parent
9895d77dbf
commit
a8b5b25d84
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
23
api/command.go
Normal 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))
|
||||
}
|
||||
108
api/main.go
108
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))
|
||||
}
|
||||
|
||||
69
api/metric_probe.go
Normal file
69
api/metric_probe.go
Normal 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
91
api/server.go
Normal 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
96
api/service_probe.go
Normal 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
29
api/types.go
Normal 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"`
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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')!)}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user