diff --git a/pkg/nodes/registry_server.go b/pkg/nodes/registry_server.go index f831842..52d8aeb 100644 --- a/pkg/nodes/registry_server.go +++ b/pkg/nodes/registry_server.go @@ -104,6 +104,7 @@ func (s *RegistryServer) Start(ctx context.Context) error { mux.HandleFunc("/webui/api/tasks", s.handleWebUITasks) mux.HandleFunc("/webui/api/task_daily_summary", s.handleWebUITaskDailySummary) mux.HandleFunc("/webui/api/ekg_stats", s.handleWebUIEKGStats) + mux.HandleFunc("/webui/api/office_state", s.handleWebUIOfficeState) mux.HandleFunc("/webui/api/exec_approvals", s.handleWebUIExecApprovals) mux.HandleFunc("/webui/api/logs/stream", s.handleWebUILogsStream) mux.HandleFunc("/webui/api/logs/recent", s.handleWebUILogsRecent) @@ -2469,6 +2470,349 @@ func (s *RegistryServer) handleWebUIEKGStats(w http.ResponseWriter, r *http.Requ }) } +func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + workspace := strings.TrimSpace(s.workspacePath) + auditPath := filepath.Join(workspace, "memory", "task-audit.jsonl") + tasksPath := filepath.Join(workspace, "memory", "tasks.json") + ekgPath := filepath.Join(workspace, "memory", "ekg-events.jsonl") + now := time.Now().UTC() + + parseTime := func(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t + } + return time.Time{} + } + latestByTask := map[string]map[string]interface{}{} + latestTimeByTask := map[string]time.Time{} + + if b, err := os.ReadFile(auditPath); err == nil { + lines := strings.Split(string(b), "\n") + for _, ln := range lines { + if strings.TrimSpace(ln) == "" { + continue + } + var row map[string]interface{} + if json.Unmarshal([]byte(ln), &row) != nil { + continue + } + source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"]))) + if source == "heartbeat" { + continue + } + taskID := strings.TrimSpace(fmt.Sprintf("%v", row["task_id"])) + if taskID == "" { + continue + } + t := parseTime(fmt.Sprintf("%v", row["time"])) + prev, ok := latestTimeByTask[taskID] + if ok && !t.IsZero() && t.Before(prev) { + continue + } + latestByTask[taskID] = row + if !t.IsZero() { + latestTimeByTask[taskID] = t + } + } + } + + if b, err := os.ReadFile(tasksPath); err == nil { + var tasks []map[string]interface{} + if json.Unmarshal(b, &tasks) == nil { + for _, t := range tasks { + id := strings.TrimSpace(fmt.Sprintf("%v", t["id"])) + if id == "" { + continue + } + row := map[string]interface{}{ + "task_id": id, + "time": fmt.Sprintf("%v", t["updated_at"]), + "status": fmt.Sprintf("%v", t["status"]), + "source": fmt.Sprintf("%v", t["source"]), + "input_preview": fmt.Sprintf("%v", t["content"]), + "log": fmt.Sprintf("%v", t["block_reason"]), + } + tm := parseTime(fmt.Sprintf("%v", row["time"])) + prev, ok := latestTimeByTask[id] + if !ok || prev.IsZero() || (!tm.IsZero() && tm.After(prev)) { + latestByTask[id] = row + if !tm.IsZero() { + latestTimeByTask[id] = tm + } + } + } + } + } + + items := make([]map[string]interface{}, 0, len(latestByTask)) + for _, row := range latestByTask { + items = append(items, row) + } + sort.Slice(items, func(i, j int) bool { + ti := parseTime(fmt.Sprintf("%v", items[i]["time"])) + tj := parseTime(fmt.Sprintf("%v", items[j]["time"])) + if ti.IsZero() && tj.IsZero() { + return fmt.Sprintf("%v", items[i]["task_id"]) > fmt.Sprintf("%v", items[j]["task_id"]) + } + if ti.IsZero() { + return false + } + if tj.IsZero() { + return true + } + return ti.After(tj) + }) + + stats := map[string]int{ + "running": 0, + "waiting": 0, + "blocked": 0, + "error": 0, + "success": 0, + "suppressed": 0, + } + for _, row := range items { + st := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) + if _, ok := stats[st]; ok { + stats[st]++ + } + } + + mainState := "idle" + mainZone := "breakroom" + switch { + case stats["error"] > 0 || stats["blocked"] > 0: + mainState = "error" + mainZone = "bug" + case stats["running"] > 0: + mainState = "executing" + mainZone = "work" + case stats["suppressed"] > 0: + mainState = "syncing" + mainZone = "server" + case stats["success"] > 0: + mainState = "writing" + mainZone = "work" + default: + mainState = "idle" + mainZone = "breakroom" + } + + mainTaskID := "" + mainDetail := "No active task" + for _, row := range items { + st := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) + if st == "running" || st == "error" || st == "blocked" || st == "waiting" { + mainTaskID = strings.TrimSpace(fmt.Sprintf("%v", row["task_id"])) + mainDetail = strings.TrimSpace(fmt.Sprintf("%v", row["input_preview"])) + if mainDetail == "" { + mainDetail = strings.TrimSpace(fmt.Sprintf("%v", row["log"])) + } + if mainDetail == "" { + mainDetail = "Task " + mainTaskID + } + break + } + } + if mainTaskID == "" && len(items) > 0 { + mainTaskID = strings.TrimSpace(fmt.Sprintf("%v", items[0]["task_id"])) + mainDetail = strings.TrimSpace(fmt.Sprintf("%v", items[0]["input_preview"])) + if mainDetail == "" { + mainDetail = strings.TrimSpace(fmt.Sprintf("%v", items[0]["log"])) + } + if mainDetail == "" { + mainDetail = "Task " + mainTaskID + } + } + + nodeState := func(n NodeInfo) string { + if !n.Online { + return "offline" + } + // A node that is still online but hasn't heartbeat recently is treated as syncing. + if !n.LastSeenAt.IsZero() && now.Sub(n.LastSeenAt) > 20*time.Second { + return "syncing" + } + return "online" + } + nodeZone := func(n NodeInfo) string { + if !n.Online { + return "bug" + } + if n.Capabilities.Model || n.Capabilities.Run { + return "work" + } + if n.Capabilities.Invoke || n.Capabilities.Camera || n.Capabilities.Screen || n.Capabilities.Canvas || n.Capabilities.Location { + return "server" + } + return "breakroom" + } + nodeDetail := func(n NodeInfo) string { + parts := make([]string, 0, 4) + if ep := strings.TrimSpace(n.Endpoint); ep != "" { + parts = append(parts, ep) + } + switch { + case strings.TrimSpace(n.OS) != "" && strings.TrimSpace(n.Arch) != "": + parts = append(parts, fmt.Sprintf("%s/%s", strings.TrimSpace(n.OS), strings.TrimSpace(n.Arch))) + case strings.TrimSpace(n.OS) != "": + parts = append(parts, strings.TrimSpace(n.OS)) + case strings.TrimSpace(n.Arch) != "": + parts = append(parts, strings.TrimSpace(n.Arch)) + } + if m := len(n.Models); m > 0 { + parts = append(parts, fmt.Sprintf("models:%d", m)) + } + if !n.LastSeenAt.IsZero() { + parts = append(parts, "seen:"+n.LastSeenAt.UTC().Format(time.RFC3339)) + } + if len(parts) == 0 { + return "node " + strings.TrimSpace(n.ID) + } + return strings.Join(parts, " · ") + } + + allNodes := []NodeInfo{} + if s.mgr != nil { + allNodes = s.mgr.List() + } + host, _ := os.Hostname() + localNode := NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: now, Online: true} + if strings.TrimSpace(host) != "" { + localNode.Name = strings.TrimSpace(host) + } + if ip := detectLocalIP(); ip != "" { + localNode.Endpoint = ip + } + hostLower := strings.ToLower(strings.TrimSpace(host)) + mainNode := localNode + otherNodes := make([]NodeInfo, 0, len(allNodes)) + for _, n := range allNodes { + idLower := strings.ToLower(strings.TrimSpace(n.ID)) + nameLower := strings.ToLower(strings.TrimSpace(n.Name)) + isLocal := idLower == "local" || nameLower == "local" || (hostLower != "" && nameLower == hostLower) + if isLocal { + if strings.TrimSpace(n.Name) != "" { + mainNode.Name = strings.TrimSpace(n.Name) + } + if strings.TrimSpace(localNode.Name) != "" { + mainNode.Name = strings.TrimSpace(localNode.Name) + } + if strings.TrimSpace(n.Endpoint) != "" { + mainNode.Endpoint = strings.TrimSpace(n.Endpoint) + } + if strings.TrimSpace(localNode.Endpoint) != "" { + mainNode.Endpoint = strings.TrimSpace(localNode.Endpoint) + } + if strings.TrimSpace(n.OS) != "" { + mainNode.OS = strings.TrimSpace(n.OS) + } + if strings.TrimSpace(n.Arch) != "" { + mainNode.Arch = strings.TrimSpace(n.Arch) + } + if len(n.Models) > 0 { + mainNode.Models = append([]string(nil), n.Models...) + } + mainNode.Online = true + mainNode.LastSeenAt = now + mainNode.Version = localNode.Version + continue + } + otherNodes = append(otherNodes, n) + } + + onlineNodes := 1 // main(local) is always considered online. + nodesPayload := make([]map[string]interface{}, 0, 24) + for _, n := range otherNodes { + if n.Online { + onlineNodes++ + } + id := strings.TrimSpace(n.ID) + if id == "" { + continue + } + name := strings.TrimSpace(n.Name) + if name == "" { + name = id + } + updatedAt := "" + if !n.LastSeenAt.IsZero() { + updatedAt = n.LastSeenAt.UTC().Format(time.RFC3339) + } + nodesPayload = append(nodesPayload, map[string]interface{}{ + "id": id, + "name": name, + "state": nodeState(n), + "zone": nodeZone(n), + "detail": nodeDetail(n), + "updated_at": updatedAt, + }) + if len(nodesPayload) >= 24 { + break + } + } + + mainDetailOut := mainDetail + if nodeInfo := nodeDetail(mainNode); strings.TrimSpace(nodeInfo) != "" { + if strings.TrimSpace(mainDetailOut) == "" || strings.EqualFold(strings.TrimSpace(mainDetailOut), "No active task") { + mainDetailOut = nodeInfo + } else { + mainDetailOut = mainDetailOut + " · " + nodeInfo + } + } + + ekgErr5m := 0 + cutoff := now.Add(-5 * time.Minute) + for _, row := range s.loadEKGRowsCached(ekgPath, 2000) { + status := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) + if status != "error" { + continue + } + ts := parseTime(fmt.Sprintf("%v", row["time"])) + if !ts.IsZero() && ts.Before(cutoff) { + continue + } + ekgErr5m++ + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "time": now.Format(time.RFC3339), + "main": map[string]interface{}{ + "id": mainNode.ID, + "name": mainNode.Name, + "state": mainState, + "detail": mainDetailOut, + "zone": mainZone, + "task_id": mainTaskID, + }, + "nodes": nodesPayload, + "stats": map[string]interface{}{ + "running": stats["running"], + "waiting": stats["waiting"], + "blocked": stats["blocked"], + "error": stats["error"], + "success": stats["success"], + "suppressed": stats["suppressed"], + "online_nodes": onlineNodes, + "ekg_error_5m": ekgErr5m, + }, + }) +} + func (s *RegistryServer) handleWebUITasks(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/webui/public/office/error-bug-spritesheet-grid.webp b/webui/public/office/error-bug-spritesheet-grid.webp new file mode 100644 index 0000000..8a17864 Binary files /dev/null and b/webui/public/office/error-bug-spritesheet-grid.webp differ diff --git a/webui/public/office/guest_anim_1.webp b/webui/public/office/guest_anim_1.webp new file mode 100644 index 0000000..6f4666e Binary files /dev/null and b/webui/public/office/guest_anim_1.webp differ diff --git a/webui/public/office/guest_anim_2.webp b/webui/public/office/guest_anim_2.webp new file mode 100644 index 0000000..281ddc5 Binary files /dev/null and b/webui/public/office/guest_anim_2.webp differ diff --git a/webui/public/office/guest_anim_3.webp b/webui/public/office/guest_anim_3.webp new file mode 100644 index 0000000..ab5fbf4 Binary files /dev/null and b/webui/public/office/guest_anim_3.webp differ diff --git a/webui/public/office/guest_anim_4.webp b/webui/public/office/guest_anim_4.webp new file mode 100644 index 0000000..4870f03 Binary files /dev/null and b/webui/public/office/guest_anim_4.webp differ diff --git a/webui/public/office/guest_anim_5.webp b/webui/public/office/guest_anim_5.webp new file mode 100644 index 0000000..0a22459 Binary files /dev/null and b/webui/public/office/guest_anim_5.webp differ diff --git a/webui/public/office/guest_anim_6.webp b/webui/public/office/guest_anim_6.webp new file mode 100644 index 0000000..0a22459 Binary files /dev/null and b/webui/public/office/guest_anim_6.webp differ diff --git a/webui/public/office/guest_role_1.png b/webui/public/office/guest_role_1.png new file mode 100644 index 0000000..d6ed12b Binary files /dev/null and b/webui/public/office/guest_role_1.png differ diff --git a/webui/public/office/guest_role_2.png b/webui/public/office/guest_role_2.png new file mode 100644 index 0000000..389052c Binary files /dev/null and b/webui/public/office/guest_role_2.png differ diff --git a/webui/public/office/guest_role_3.png b/webui/public/office/guest_role_3.png new file mode 100644 index 0000000..b2c0231 Binary files /dev/null and b/webui/public/office/guest_role_3.png differ diff --git a/webui/public/office/guest_role_4.png b/webui/public/office/guest_role_4.png new file mode 100644 index 0000000..8160616 Binary files /dev/null and b/webui/public/office/guest_role_4.png differ diff --git a/webui/public/office/guest_role_5.png b/webui/public/office/guest_role_5.png new file mode 100644 index 0000000..0e6f5b5 Binary files /dev/null and b/webui/public/office/guest_role_5.png differ diff --git a/webui/public/office/guest_role_6.png b/webui/public/office/guest_role_6.png new file mode 100644 index 0000000..bd5c87d Binary files /dev/null and b/webui/public/office/guest_role_6.png differ diff --git a/webui/public/office/office_bg.webp b/webui/public/office/office_bg.webp new file mode 100644 index 0000000..b1038a2 Binary files /dev/null and b/webui/public/office/office_bg.webp differ diff --git a/webui/public/office/office_bg_small.webp b/webui/public/office/office_bg_small.webp new file mode 100644 index 0000000..26f9c01 Binary files /dev/null and b/webui/public/office/office_bg_small.webp differ diff --git a/webui/public/office/star-idle-v5.png b/webui/public/office/star-idle-v5.png new file mode 100644 index 0000000..9129faa Binary files /dev/null and b/webui/public/office/star-idle-v5.png differ diff --git a/webui/public/office/star-working-spritesheet-grid.webp b/webui/public/office/star-working-spritesheet-grid.webp new file mode 100644 index 0000000..81e1ca5 Binary files /dev/null and b/webui/public/office/star-working-spritesheet-grid.webp differ diff --git a/webui/public/office/sync-animation-v3-grid.webp b/webui/public/office/sync-animation-v3-grid.webp new file mode 100644 index 0000000..4c650e5 Binary files /dev/null and b/webui/public/office/sync-animation-v3-grid.webp differ diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 495b087..22d6cdd 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -15,6 +15,7 @@ import TaskAudit from './pages/TaskAudit'; import EKG from './pages/EKG'; import Tasks from './pages/Tasks'; import LogCodes from './pages/LogCodes'; +import Office from './pages/Office'; export default function App() { return ( @@ -35,6 +36,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index cc4ec31..40ea604 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, ListTodo, BrainCircuit, Hash } from 'lucide-react'; +import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, ListTodo, BrainCircuit, Hash, Map } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import NavItem from './NavItem'; @@ -38,6 +38,7 @@ const Sidebar: React.FC = () => { { title: t('sidebarInsights'), items: [ + { icon: , label: t('office'), to: '/office' }, { icon: , label: t('ekg'), to: '/ekg' }, ], }, diff --git a/webui/src/components/office/OfficeScene.tsx b/webui/src/components/office/OfficeScene.tsx new file mode 100644 index 0000000..30c1b0a --- /dev/null +++ b/webui/src/components/office/OfficeScene.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { OFFICE_CANVAS, OFFICE_ZONE_POINT, OFFICE_ZONE_SLOTS, OfficeZone } from './officeLayout'; + +export type OfficeMainState = { + state?: string; + detail?: string; + zone?: string; + task_id?: string; +}; + +export type OfficeNodeState = { + id?: string; + name?: string; + state?: string; + zone?: string; + detail?: string; + updated_at?: string; +}; + +type OfficeSceneProps = { + main: OfficeMainState; + nodes: OfficeNodeState[]; +}; + +type SpriteSpec = { + src: string; + frameW: number; + frameH: number; + cols: number; + start: number; + end: number; + fps: number; + scale: number; +}; + +const TICK_FPS = 12; + +const MAIN_SPRITES: Record<'idle' | 'working' | 'syncing' | 'error', SpriteSpec> = { + idle: { + src: '/webui/office/star-idle-v5.png', + frameW: 256, + frameH: 256, + cols: 8, + start: 0, + end: 47, + fps: 12, + scale: 0.62, + }, + working: { + src: '/webui/office/star-working-spritesheet-grid.webp', + frameW: 300, + frameH: 300, + cols: 8, + start: 0, + end: 37, + fps: 12, + scale: 0.56, + }, + syncing: { + src: '/webui/office/sync-animation-v3-grid.webp', + frameW: 256, + frameH: 256, + cols: 7, + start: 1, + end: 47, + fps: 12, + scale: 0.54, + }, + error: { + src: '/webui/office/error-bug-spritesheet-grid.webp', + frameW: 220, + frameH: 220, + cols: 8, + start: 0, + end: 71, + fps: 12, + scale: 0.66, + }, +}; + +const NODE_SPRITES: SpriteSpec[] = Array.from({ length: 6 }, (_, i) => ({ + src: `/webui/office/guest_anim_${i + 1}.webp`, + frameW: 32, + frameH: 32, + cols: 8, + start: 0, + end: 7, + fps: 8, + scale: 1.55, +})); + +function normalizeZone(z: string | undefined): OfficeZone { + const v = (z || '').trim().toLowerCase(); + if (v === 'work' || v === 'server' || v === 'bug' || v === 'breakroom') return v; + return 'breakroom'; +} + +function stateTone(s: string | undefined): string { + const v = (s || '').trim().toLowerCase(); + switch (v) { + case 'running': + case 'executing': + case 'writing': + return 'bg-cyan-400'; + case 'online': + return 'bg-emerald-400'; + case 'error': + case 'blocked': + case 'offline': + return 'bg-red-400'; + case 'syncing': + case 'suppressed': + return 'bg-violet-400'; + case 'success': + return 'bg-emerald-400'; + default: + return 'bg-zinc-300'; + } +} + +function normalizeMainSpriteState(s: string | undefined): keyof typeof MAIN_SPRITES { + const v = (s || '').trim().toLowerCase(); + if (v === 'error' || v === 'blocked') return 'error'; + if (v === 'syncing' || v === 'suppressed' || v === 'sync') return 'syncing'; + if (v === 'running' || v === 'executing' || v === 'writing' || v === 'researching' || v === 'success') return 'working'; + return 'idle'; +} + +function textHash(input: string): number { + let h = 0; + for (let i = 0; i < input.length; i += 1) { + h = (h * 31 + input.charCodeAt(i)) >>> 0; + } + return h; +} + +function frameAtTick(spec: SpriteSpec, tick: number, seed = 0): number { + const frameCount = Math.max(1, spec.end - spec.start + 1); + const absoluteMs = tick * (1000 / TICK_FPS); + const frame = Math.floor((absoluteMs + seed) / (1000 / Math.max(1, spec.fps))); + return spec.start + (frame % frameCount); +} + +type SpriteProps = { + spec: SpriteSpec; + frame: number; + className?: string; +}; + +const SpriteSheet: React.FC = ({ spec, frame, className }) => { + const col = frame % spec.cols; + const row = Math.floor(frame / spec.cols); + return ( + + ); +}; + +const OfficeScene: React.FC = ({ main, nodes }) => { + const [tick, setTick] = useState(0); + + useEffect(() => { + const timer = window.setInterval(() => { + setTick((v) => (v + 1) % 10000000); + }, Math.round(1000 / TICK_FPS)); + return () => window.clearInterval(timer); + }, []); + + const bgSrc = '/webui/office/office_bg_small.webp'; + const placedNodes = useMemo(() => { + const counters: Record = { breakroom: 0, work: 0, server: 0, bug: 0 }; + return nodes.slice(0, 24).map((n) => { + const zone = normalizeZone(n.zone); + const slots = OFFICE_ZONE_SLOTS[zone]; + const idx = counters[zone] % slots.length; + counters[zone] += 1; + const stableKey = `${n.id || ''}|${n.name || ''}|${idx}`; + const avatarSeed = textHash(stableKey); + const spriteIndex = avatarSeed % NODE_SPRITES.length; + return { ...n, zone, point: slots[idx], spriteIndex, avatarSeed }; + }); + }, [nodes]); + + const mainZone = normalizeZone(main.zone); + const mainPoint = OFFICE_ZONE_POINT[mainZone]; + const mainSprite = MAIN_SPRITES[normalizeMainSpriteState(main.state)]; + const mainFrame = frameAtTick(mainSprite, tick); + + return ( + + + + + + + + + + clawgo + + + + + {placedNodes.map((n, i) => ( + + + + + + + ))} + + + + ); +}; + +export default OfficeScene; diff --git a/webui/src/components/office/officeLayout.ts b/webui/src/components/office/officeLayout.ts new file mode 100644 index 0000000..60e66fc --- /dev/null +++ b/webui/src/components/office/officeLayout.ts @@ -0,0 +1,49 @@ +export type OfficeZone = 'breakroom' | 'work' | 'server' | 'bug'; + +export const OFFICE_CANVAS = { + width: 1280, + height: 720, +}; + +export const OFFICE_ZONE_POINT: Record = { + breakroom: { x: 1070, y: 610 }, + work: { x: 640, y: 470 }, + server: { x: 820, y: 220 }, + bug: { x: 230, y: 210 }, +}; + +export const OFFICE_ZONE_SLOTS: Record> = { + breakroom: [ + { x: 1020, y: 620 }, + { x: 1090, y: 620 }, + { x: 1150, y: 620 }, + { x: 1040, y: 670 }, + { x: 1110, y: 670 }, + { x: 1180, y: 670 }, + ], + work: [ + { x: 520, y: 470 }, + { x: 600, y: 470 }, + { x: 680, y: 470 }, + { x: 760, y: 470 }, + { x: 560, y: 530 }, + { x: 640, y: 530 }, + { x: 720, y: 530 }, + { x: 800, y: 530 }, + ], + server: [ + { x: 760, y: 240 }, + { x: 830, y: 240 }, + { x: 900, y: 240 }, + { x: 780, y: 290 }, + { x: 850, y: 290 }, + ], + bug: [ + { x: 180, y: 230 }, + { x: 240, y: 230 }, + { x: 300, y: 230 }, + { x: 210, y: 280 }, + { x: 270, y: 280 }, + ], +}; + diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 1d56bba..3823984 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -20,6 +20,13 @@ const resources = { sidebarSystem: 'System', sidebarOps: 'Operations', sidebarInsights: 'Insights', + office: 'Office', + officeMainState: 'Main State', + officeNoDetail: 'No active detail', + officeSceneStats: 'Scene Stats', + officeNodeList: 'Node List', + officeNoNodes: 'No nodes', + officeEkgErr5m: 'EKG Errors (5m)', ekg: 'EKG', ekgEscalations: 'Escalations', ekgSourceStats: 'Source Stats', @@ -444,6 +451,13 @@ const resources = { sidebarSystem: '系统', sidebarOps: '运维', sidebarInsights: '洞察', + office: '办公室', + officeMainState: '主状态', + officeNoDetail: '暂无活动详情', + officeSceneStats: '场景统计', + officeNodeList: '节点列表', + officeNoNodes: '暂无节点', + officeEkgErr5m: 'EKG 近5分钟错误', ekg: 'EKG', ekgEscalations: '升级拦截次数', ekgSourceStats: '来源统计', diff --git a/webui/src/pages/Office.tsx b/webui/src/pages/Office.tsx new file mode 100644 index 0000000..c9f569e --- /dev/null +++ b/webui/src/pages/Office.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useAppContext } from '../context/AppContext'; +import OfficeScene, { OfficeMainState, OfficeNodeState } from '../components/office/OfficeScene'; + +type OfficeStats = { + running?: number; + waiting?: number; + blocked?: number; + error?: number; + success?: number; + suppressed?: number; + online_nodes?: number; + ekg_error_5m?: number; +}; + +type OfficePayload = { + ok?: boolean; + time?: string; + main?: OfficeMainState; + nodes?: OfficeNodeState[]; + stats?: OfficeStats; +}; + +const Office: React.FC = () => { + const { t } = useTranslation(); + const { q } = useAppContext(); + const [loading, setLoading] = useState(false); + const [payload, setPayload] = useState({}); + + const fetchState = useCallback(async () => { + setLoading(true); + try { + const r = await fetch(`/webui/api/office_state${q}`); + if (!r.ok) throw new Error(await r.text()); + const j = (await r.json()) as OfficePayload; + setPayload(j || {}); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }, [q]); + + useEffect(() => { + fetchState(); + const timer = setInterval(fetchState, 5000); + return () => clearInterval(timer); + }, [fetchState]); + + const main = payload.main || {}; + const nodes = Array.isArray(payload.nodes) ? payload.nodes : []; + const stats = payload.stats || {}; + + const cards = useMemo( + () => [ + { label: t('statusRunning'), value: Number(stats.running || 0) }, + { label: t('statusWaiting'), value: Number(stats.waiting || 0) }, + { label: t('statusBlocked'), value: Number(stats.blocked || 0) }, + { label: t('statusError'), value: Number(stats.error || 0) }, + { label: t('nodesOnline'), value: Number(stats.online_nodes || 0) }, + { label: t('officeEkgErr5m'), value: Number(stats.ekg_error_5m || 0) }, + ], + [stats, t] + ); + + return ( + + + + {t('office')} + + {t('officeMainState')}: {main.state || 'idle'} {main.task_id ? `· ${main.task_id}` : ''} + + + + + {loading ? t('loading') : t('refresh')} + + + + + + + + {main.detail || t('officeNoDetail')} + + + + + {t('officeSceneStats')} + + {cards.map((c) => ( + + {c.label} + {c.value} + + ))} + + + + + {t('officeNodeList')} + + {nodes.length === 0 ? ( + {t('officeNoNodes')} + ) : ( + nodes.slice(0, 20).map((n, i) => ( + + {n.name || n.id || 'node'} + + {n.state || 'idle'} · {n.zone || 'breakroom'} + + + )) + )} + + + + + + ); +}; + +export default Office;