diff --git a/pkg/api/server.go b/pkg/api/server.go index 5cf7bf2..13fdbbe 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -105,7 +105,6 @@ func (s *Server) 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) @@ -2471,425 +2470,6 @@ func (s *Server) handleWebUIEKGStats(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) 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{} - } - normalizeTaskStatus := func(raw string) string { - st := strings.ToLower(strings.TrimSpace(raw)) - switch st { - case "running", "doing", "in_progress", "in-progress", "executing", "processing", "active": - return "running" - case "waiting", "queued", "queue", "todo", "pending", "paused", "idle": - return "waiting" - case "blocked": - return "blocked" - case "error", "failed", "fail": - return "error" - case "success", "done", "completed", "complete": - return "success" - case "suppressed", "skip", "skipped": - return "suppressed" - default: - return st - } - } - officeStateForStatus := func(status string, ts time.Time) string { - st := normalizeTaskStatus(status) - switch st { - case "running": - return "working" - case "error", "blocked": - return "error" - case "suppressed": - return "syncing" - case "success": - // Briefly keep success in working pose, then fall back to idle. - if !ts.IsZero() && now.Sub(ts) <= 90*time.Second { - return "working" - } - return "idle" - default: - return "idle" - } - } - officeZoneForState := func(state string) string { - switch strings.ToLower(strings.TrimSpace(state)) { - case "working": - return "work" - case "syncing": - return "server" - case "error": - return "bug" - default: - return "breakroom" - } - } - isFreshTaskState := func(status string, ts time.Time) bool { - if ts.IsZero() { - return false - } - window := 20 * time.Minute - switch status { - case "running": - window = 3 * time.Hour - case "waiting": - window = 30 * time.Minute - case "blocked", "error": - window = 2 * time.Hour - case "success", "suppressed": - window = 12 * time.Minute - } - return !ts.Before(now.Add(-window)) - } - ipv4Pattern := regexp.MustCompile(`\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b`) - maskIPv4 := func(text string) string { - if strings.TrimSpace(text) == "" { - return text - } - return ipv4Pattern.ReplaceAllStringFunc(text, func(ip string) string { - parts := strings.Split(ip, ".") - if len(parts) != 4 { - return ip - } - for _, p := range parts { - n, err := strconv.Atoi(p) - if err != nil || n < 0 || n > 255 { - return ip - } - } - return parts[0] + "." + parts[1] + ".**.**" - }) - } - 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"])) - row["status"] = normalizeTaskStatus(fmt.Sprintf("%v", row["status"])) - st := fmt.Sprintf("%v", row["status"]) - if !isFreshTaskState(st, t) { - continue - } - 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": normalizeTaskStatus(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"])) - st := fmt.Sprintf("%v", row["status"]) - if !isFreshTaskState(st, tm) { - continue - } - 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 := normalizeTaskStatus(fmt.Sprintf("%v", row["status"])) - if _, ok := stats[st]; ok { - stats[st]++ - } - } - - mainState := "idle" - mainZone := "breakroom" - mainTaskID := "" - mainDetail := "No active task" - if 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 - } - st := normalizeTaskStatus(fmt.Sprintf("%v", items[0]["status"])) - ts := parseTime(fmt.Sprintf("%v", items[0]["time"])) - mainState = officeStateForStatus(st, ts) - mainZone = officeZoneForState(mainState) - } - - nodeState := func(n nodes.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" - } - if n.Capabilities.Model || n.Capabilities.Run { - return "working" - } - return "idle" - } - nodeZone := func(n nodes.NodeInfo) string { - st := nodeState(n) - if st == "offline" { - return "bug" - } - if st == "syncing" { - return "server" - } - if st == "working" && (n.Capabilities.Model || n.Capabilities.Run) { - return "work" - } - if st == "working" { - return "server" - } - return "breakroom" - } - nodeDetail := func(n nodes.NodeInfo) string { - parts := make([]string, 0, 4) - if ep := strings.TrimSpace(n.Endpoint); ep != "" { - parts = append(parts, maskIPv4(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 maskIPv4("node " + strings.TrimSpace(n.ID)) - } - return maskIPv4(strings.Join(parts, " · ")) - } - - allNodes := []nodes.NodeInfo{} - if s.mgr != nil { - allNodes = s.mgr.List() - } - host, _ := os.Hostname() - localNode := nodes.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([]nodes.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 - } - name = maskIPv4(name) - 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 - } - } - mainDetailOut = maskIPv4(mainDetailOut) - - 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": maskIPv4(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 *Server) handleWebUITasks(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/webui/public/office/btn-back-home-sprite.png b/webui/public/office/btn-back-home-sprite.png deleted file mode 100644 index 8140664..0000000 Binary files a/webui/public/office/btn-back-home-sprite.png and /dev/null differ diff --git a/webui/public/office/btn-broker-sprite.png b/webui/public/office/btn-broker-sprite.png deleted file mode 100644 index 90a9c50..0000000 Binary files a/webui/public/office/btn-broker-sprite.png and /dev/null differ diff --git a/webui/public/office/btn-diy-sprite.png b/webui/public/office/btn-diy-sprite.png deleted file mode 100644 index 096604d..0000000 Binary files a/webui/public/office/btn-diy-sprite.png and /dev/null differ diff --git a/webui/public/office/btn-move-house-sprite.png b/webui/public/office/btn-move-house-sprite.png deleted file mode 100644 index 065c0ca..0000000 Binary files a/webui/public/office/btn-move-house-sprite.png and /dev/null differ diff --git a/webui/public/office/btn-open-drawer-sprite.png b/webui/public/office/btn-open-drawer-sprite.png deleted file mode 100644 index a2dc26e..0000000 Binary files a/webui/public/office/btn-open-drawer-sprite.png and /dev/null differ diff --git a/webui/public/office/btn-state-sprite.png b/webui/public/office/btn-state-sprite.png deleted file mode 100644 index 29b4a19..0000000 Binary files a/webui/public/office/btn-state-sprite.png and /dev/null differ diff --git a/webui/public/office/cats-spritesheet.webp b/webui/public/office/cats-spritesheet.webp deleted file mode 100644 index 8c1bd47..0000000 Binary files a/webui/public/office/cats-spritesheet.webp and /dev/null differ diff --git a/webui/public/office/coffee-machine-shadow-v1.png b/webui/public/office/coffee-machine-shadow-v1.png deleted file mode 100644 index e53d8c4..0000000 Binary files a/webui/public/office/coffee-machine-shadow-v1.png and /dev/null differ diff --git a/webui/public/office/coffee-machine-v3-grid.webp b/webui/public/office/coffee-machine-v3-grid.webp deleted file mode 100644 index 2ed8eba..0000000 Binary files a/webui/public/office/coffee-machine-v3-grid.webp and /dev/null differ diff --git a/webui/public/office/desk-v3.webp b/webui/public/office/desk-v3.webp deleted file mode 100644 index 1cd58bc..0000000 Binary files a/webui/public/office/desk-v3.webp and /dev/null differ diff --git a/webui/public/office/error-bug-spritesheet-grid.webp b/webui/public/office/error-bug-spritesheet-grid.webp deleted file mode 100644 index 8a17864..0000000 Binary files a/webui/public/office/error-bug-spritesheet-grid.webp and /dev/null differ diff --git a/webui/public/office/flowers-bloom-v2.webp b/webui/public/office/flowers-bloom-v2.webp deleted file mode 100644 index bd2a609..0000000 Binary files a/webui/public/office/flowers-bloom-v2.webp and /dev/null differ diff --git a/webui/public/office/guest_anim_1.webp b/webui/public/office/guest_anim_1.webp deleted file mode 100644 index 6f4666e..0000000 Binary files a/webui/public/office/guest_anim_1.webp and /dev/null differ diff --git a/webui/public/office/guest_anim_2.webp b/webui/public/office/guest_anim_2.webp deleted file mode 100644 index 281ddc5..0000000 Binary files a/webui/public/office/guest_anim_2.webp and /dev/null differ diff --git a/webui/public/office/guest_anim_3.webp b/webui/public/office/guest_anim_3.webp deleted file mode 100644 index ab5fbf4..0000000 Binary files a/webui/public/office/guest_anim_3.webp and /dev/null differ diff --git a/webui/public/office/guest_anim_4.webp b/webui/public/office/guest_anim_4.webp deleted file mode 100644 index 4870f03..0000000 Binary files a/webui/public/office/guest_anim_4.webp and /dev/null differ diff --git a/webui/public/office/guest_anim_5.webp b/webui/public/office/guest_anim_5.webp deleted file mode 100644 index 0a22459..0000000 Binary files a/webui/public/office/guest_anim_5.webp and /dev/null differ diff --git a/webui/public/office/guest_anim_6.webp b/webui/public/office/guest_anim_6.webp deleted file mode 100644 index 0a22459..0000000 Binary files a/webui/public/office/guest_anim_6.webp and /dev/null differ diff --git a/webui/public/office/guest_role_1.png b/webui/public/office/guest_role_1.png deleted file mode 100644 index d6ed12b..0000000 Binary files a/webui/public/office/guest_role_1.png and /dev/null differ diff --git a/webui/public/office/guest_role_2.png b/webui/public/office/guest_role_2.png deleted file mode 100644 index 389052c..0000000 Binary files a/webui/public/office/guest_role_2.png and /dev/null differ diff --git a/webui/public/office/guest_role_3.png b/webui/public/office/guest_role_3.png deleted file mode 100644 index b2c0231..0000000 Binary files a/webui/public/office/guest_role_3.png and /dev/null differ diff --git a/webui/public/office/guest_role_4.png b/webui/public/office/guest_role_4.png deleted file mode 100644 index 8160616..0000000 Binary files a/webui/public/office/guest_role_4.png and /dev/null differ diff --git a/webui/public/office/guest_role_5.png b/webui/public/office/guest_role_5.png deleted file mode 100644 index 0e6f5b5..0000000 Binary files a/webui/public/office/guest_role_5.png and /dev/null differ diff --git a/webui/public/office/guest_role_6.png b/webui/public/office/guest_role_6.png deleted file mode 100644 index bd5c87d..0000000 Binary files a/webui/public/office/guest_role_6.png and /dev/null differ diff --git a/webui/public/office/memo-bg.webp b/webui/public/office/memo-bg.webp deleted file mode 100644 index 438d16c..0000000 Binary files a/webui/public/office/memo-bg.webp and /dev/null differ diff --git a/webui/public/office/office_bg.webp b/webui/public/office/office_bg.webp deleted file mode 100644 index b1038a2..0000000 Binary files a/webui/public/office/office_bg.webp and /dev/null differ diff --git a/webui/public/office/office_bg_small.webp b/webui/public/office/office_bg_small.webp deleted file mode 100644 index 26f9c01..0000000 Binary files a/webui/public/office/office_bg_small.webp and /dev/null differ diff --git a/webui/public/office/plants-spritesheet.webp b/webui/public/office/plants-spritesheet.webp deleted file mode 100644 index 1c949fa..0000000 Binary files a/webui/public/office/plants-spritesheet.webp and /dev/null differ diff --git a/webui/public/office/posters-spritesheet.webp b/webui/public/office/posters-spritesheet.webp deleted file mode 100644 index 2686385..0000000 Binary files a/webui/public/office/posters-spritesheet.webp and /dev/null differ diff --git a/webui/public/office/serverroom-spritesheet.webp b/webui/public/office/serverroom-spritesheet.webp deleted file mode 100644 index 0c3d700..0000000 Binary files a/webui/public/office/serverroom-spritesheet.webp and /dev/null differ diff --git a/webui/public/office/sofa-idle-v3.png b/webui/public/office/sofa-idle-v3.png deleted file mode 100644 index 24b29d2..0000000 Binary files a/webui/public/office/sofa-idle-v3.png and /dev/null differ diff --git a/webui/public/office/sofa-shadow-v1.png b/webui/public/office/sofa-shadow-v1.png deleted file mode 100644 index 72ae19c..0000000 Binary files a/webui/public/office/sofa-shadow-v1.png and /dev/null differ diff --git a/webui/public/office/star-idle-v5.png b/webui/public/office/star-idle-v5.png deleted file mode 100644 index 9129faa..0000000 Binary files a/webui/public/office/star-idle-v5.png and /dev/null differ diff --git a/webui/public/office/star-working-spritesheet-grid.webp b/webui/public/office/star-working-spritesheet-grid.webp deleted file mode 100644 index 81e1ca5..0000000 Binary files a/webui/public/office/star-working-spritesheet-grid.webp and /dev/null differ diff --git a/webui/public/office/sync-animation-v3-grid.webp b/webui/public/office/sync-animation-v3-grid.webp deleted file mode 100644 index 4c650e5..0000000 Binary files a/webui/public/office/sync-animation-v3-grid.webp and /dev/null differ diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 22d6cdd..495b087 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -15,7 +15,6 @@ 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 ( @@ -36,7 +35,6 @@ export default function App() { } /> } /> } /> - } /> diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 40ea604..cc4ec31 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, Map } from 'lucide-react'; +import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, ListTodo, BrainCircuit, Hash } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import NavItem from './NavItem'; @@ -38,7 +38,6 @@ 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 deleted file mode 100644 index e72cbf0..0000000 --- a/webui/src/components/office/OfficeScene.tsx +++ /dev/null @@ -1,931 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { OFFICE_CANVAS, OFFICE_ZONE_POINT, OFFICE_ZONE_SLOTS, OfficeZone } from './officeLayout'; - -export type OfficeMainState = { - id?: string; - name?: string; - 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 Point = { x: number; y: number }; -type CameraState = { x: number; y: number; zoom: number }; -type MainVisualState = 'idle' | 'working' | 'syncing' | 'error'; - -type SpriteSpec = { - src: string; - frameW: number; - frameH: number; - cols: number; - start: number; - end: number; - fps: number; - scale: number; -}; - -type ImageSpec = { - src: string; - width: number; - height: number; - scale: number; -}; - -type BubbleState = { - text: string; - expiresAt: number; -}; - -type DecorFrames = { - plantA: number; - plantB: number; - plantC: number; - poster: number; - flower: number; - cat: number; -}; - -const TICK_FPS = 12; -const RENDER_INTERVAL_MS = 33; -const MIN_ZOOM = 1; -const MAX_ZOOM = 2.4; - -const MAIN_SPRITES: Record = { - 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, -})); - -const DECOR_SPRITES = { - plants: { - src: '/webui/office/plants-spritesheet.webp', - frameW: 160, - frameH: 160, - cols: 4, - start: 0, - end: 15, - fps: 0, - scale: 1, - } as SpriteSpec, - posters: { - src: '/webui/office/posters-spritesheet.webp', - frameW: 160, - frameH: 160, - cols: 4, - start: 0, - end: 31, - fps: 0, - scale: 1, - } as SpriteSpec, - flowers: { - src: '/webui/office/flowers-bloom-v2.webp', - frameW: 128, - frameH: 128, - cols: 4, - start: 0, - end: 15, - fps: 0, - scale: 0.8, - } as SpriteSpec, - cats: { - src: '/webui/office/cats-spritesheet.webp', - frameW: 160, - frameH: 160, - cols: 4, - start: 0, - end: 15, - fps: 0, - scale: 1, - } as SpriteSpec, - coffeeMachine: { - src: '/webui/office/coffee-machine-v3-grid.webp', - frameW: 230, - frameH: 230, - cols: 12, - start: 0, - end: 94, - fps: 10, - scale: 1, - } as SpriteSpec, - serverroom: { - src: '/webui/office/serverroom-spritesheet.webp', - frameW: 180, - frameH: 251, - cols: 40, - start: 0, - end: 38, - fps: 6, - scale: 1, - } as SpriteSpec, -}; - -const DECOR_IMAGES = { - bg: { - src: '/webui/office/office_bg.webp', - width: OFFICE_CANVAS.width, - height: OFFICE_CANVAS.height, - scale: 1, - } as ImageSpec, - sofaIdle: { - src: '/webui/office/sofa-idle-v3.png', - width: 212, - height: 143, - scale: 1, - } as ImageSpec, - sofaShadow: { - src: '/webui/office/sofa-shadow-v1.png', - width: 233, - height: 81, - scale: 1, - } as ImageSpec, - desk: { - src: '/webui/office/desk-v3.webp', - width: 304, - height: 264, - scale: 1, - } as ImageSpec, - coffeeShadow: { - src: '/webui/office/coffee-machine-shadow-v1.png', - width: 245, - height: 111, - scale: 1, - } as ImageSpec, -}; - -const MAIN_BUBBLE_TEXTS: Record = { - idle: ['Standby mode.', 'Waiting for the next task.', 'System is stable.'], - working: ['Working on it.', 'Executing workflow.', 'Processing tasks now.'], - syncing: ['Sync in progress.', 'Collecting updates.', 'Linking status streams.'], - error: ['Error detected.', 'Need intervention.', 'Investigating abnormal state.'], -}; - -const NODE_BUBBLE_TEXTS: Record = { - idle: ['Idle.', 'Ready.', 'No active job.'], - working: ['Working.', 'Running.', 'Task in progress.'], - syncing: ['Syncing.', 'Updating status.', 'Heartbeat active.'], - error: ['Failure.', 'Blocked.', 'Error surfaced.'], - offline: ['Disconnected.', 'No heartbeat.', 'Offline.'], -}; - -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 normalizeMainState(s: string | undefined): MainVisualState { - const v = (s || '').trim().toLowerCase(); - if (v.includes('error') || v.includes('blocked')) return 'error'; - if (v.includes('sync') || v.includes('suppressed')) return 'syncing'; - if (v.includes('run') || v.includes('execut') || v.includes('writing') || v.includes('research') || v.includes('success')) return 'working'; - return 'idle'; -} - -function normalizeNodeState(s: string | undefined): MainVisualState | 'offline' { - const v = (s || '').trim().toLowerCase(); - if (v.includes('offline')) return 'offline'; - if (v.includes('error') || v.includes('blocked')) return 'error'; - if (v.includes('sync') || v.includes('suppressed')) return 'syncing'; - if (v.includes('run') || v.includes('execut') || v.includes('writing') || v.includes('research') || v.includes('success') || v.includes('online')) { - return 'working'; - } - return 'idle'; -} - -function zoneForMainState(state: MainVisualState): OfficeZone { - switch (state) { - case 'working': - return 'work'; - case 'syncing': - return 'server'; - case 'error': - return 'bug'; - default: - return 'breakroom'; - } -} - -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); -} - -function frameFromSeed(spec: SpriteSpec, seedText: string): number { - const frameCount = Math.max(1, spec.end - spec.start + 1); - return spec.start + (textHash(seedText) % frameCount); -} - -function randFrame(spec: SpriteSpec): number { - const frameCount = Math.max(1, spec.end - spec.start + 1); - return spec.start + Math.floor(Math.random() * frameCount); -} - -function lerpPoint(current: Point, target: Point, alpha: number): Point { - const x = current.x + (target.x - current.x) * alpha; - const y = current.y + (target.y - current.y) * alpha; - if (Math.abs(target.x - x) < 0.3 && Math.abs(target.y - y) < 0.3) return target; - return { x, y }; -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -function clampCamera(cam: CameraState): CameraState { - const zoom = clamp(cam.zoom, MIN_ZOOM, MAX_ZOOM); - const viewW = OFFICE_CANVAS.width / zoom; - const viewH = OFFICE_CANVAS.height / zoom; - const maxX = Math.max(0, OFFICE_CANVAS.width - viewW); - const maxY = Math.max(0, OFFICE_CANVAS.height - viewH); - return { - zoom, - x: clamp(cam.x, 0, maxX), - y: clamp(cam.y, 0, maxY), - }; -} - -function worldToScreen(point: Point, camera: CameraState): Point { - const viewW = OFFICE_CANVAS.width / camera.zoom; - const viewH = OFFICE_CANVAS.height / camera.zoom; - return { - x: ((point.x - camera.x) / viewW) * 100, - y: ((point.y - camera.y) / viewH) * 100, - }; -} - -function pickRandom(arr: T[]): T | null { - if (!arr.length) return null; - return arr[Math.floor(Math.random() * arr.length)] || null; -} - -type SpriteProps = { - spec: SpriteSpec; - frame: number; - scaleMultiplier?: number; - className?: string; -}; - -const SpriteSheet: React.FC = ({ spec, frame, scaleMultiplier = 1, className }) => { - const col = frame % spec.cols; - const row = Math.floor(frame / spec.cols); - return ( -
- ); -}; - -type PlacedSpriteProps = { - spec: SpriteSpec; - frame: number; - point: Point; - camera: CameraState; - zIndex: number; - title?: string; - scaleMultiplier?: number; - onClick?: () => void; -}; - -const PlacedSprite: React.FC = ({ spec, frame, point, camera, zIndex, title, scaleMultiplier = 1, onClick }) => { - const pos = worldToScreen(point, camera); - return ( -
-
- -
-
- ); -}; - -type PlacedImageProps = { - spec: ImageSpec; - point: Point; - camera: CameraState; - zIndex: number; - title?: string; - scaleMultiplier?: number; - onClick?: () => void; -}; - -const PlacedImage: React.FC = ({ spec, point, camera, zIndex, title, scaleMultiplier = 1, onClick }) => { - const pos = worldToScreen(point, camera); - return ( -
- -
- ); -}; - -type BubbleProps = { - point: Point; - camera: CameraState; - text: string; - zIndex: number; -}; - -const Bubble: React.FC = ({ point, camera, text, zIndex }) => { - const p = worldToScreen({ x: point.x, y: point.y - 70 }, camera); - return ( -
-
- {text} -
-
- ); -}; - -const OfficeScene: React.FC = ({ main, nodes }) => { - const viewportRef = useRef(null); - const cameraRef = useRef({ x: 0, y: 0, zoom: 1 }); - const panRef = useRef<{ active: boolean; x: number; y: number } | null>(null); - const nextBubbleAtRef = useRef(Date.now() + 2400); - - const [tick, setTick] = useState(0); - const [showDebug, setShowDebug] = useState(false); - const [manualState, setManualState] = useState(null); - const [manualStateUntil, setManualStateUntil] = useState(0); - const [panEnabled, setPanEnabled] = useState(() => true); - const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 }); - const [pointerWorld, setPointerWorld] = useState(null); - - const liveMainState = normalizeMainState(main.state); - const effectiveMainState = manualState ?? liveMainState; - const mainZone = zoneForMainState(effectiveMainState); - const mainTarget = OFFICE_ZONE_POINT[mainZone]; - const prevLiveStateRef = useRef(liveMainState); - - useEffect(() => { - if (manualState && prevLiveStateRef.current !== liveMainState) { - setManualState(null); - setManualStateUntil(0); - } - prevLiveStateRef.current = liveMainState; - }, [liveMainState, manualState]); - - useEffect(() => { - if (!manualState || manualStateUntil <= 0) return; - const timer = window.setInterval(() => { - if (Date.now() >= manualStateUntil) { - setManualState(null); - setManualStateUntil(0); - } - }, 400); - return () => window.clearInterval(timer); - }, [manualState, manualStateUntil]); - - const placedNodes = useMemo(() => { - const counters: Record = { breakroom: 0, work: 0, server: 0, bug: 0 }; - return nodes.slice(0, 24).map((n, i) => { - const zone = normalizeZone(n.zone); - const slots = OFFICE_ZONE_SLOTS[zone]; - const idx = counters[zone] % slots.length; - counters[zone] += 1; - const key = `${n.id || 'node'}-${i}`; - const avatarSeed = textHash(`${n.id || ''}|${n.name || ''}|${idx}`); - return { - ...n, - key, - zone, - point: slots[idx], - spriteIndex: avatarSeed % NODE_SPRITES.length, - avatarSeed, - }; - }); - }, [nodes]); - - const [mainPos, setMainPos] = useState(mainTarget); - const [nodePos, setNodePos] = useState>({}); - const [mainBubble, setMainBubble] = useState(null); - const [nodeBubbles, setNodeBubbles] = useState>({}); - - const decorSeedBase = `${main.id || 'main'}|${main.name || ''}`; - const seededDecorFrames = useMemo(() => ({ - plantA: frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantA`), - plantB: frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantB`), - plantC: frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantC`), - poster: frameFromSeed(DECOR_SPRITES.posters, `${decorSeedBase}|poster`), - flower: frameFromSeed(DECOR_SPRITES.flowers, `${decorSeedBase}|flower`), - cat: frameFromSeed(DECOR_SPRITES.cats, `${decorSeedBase}|cat`), - }), [decorSeedBase]); - - const [decorFrames, setDecorFrames] = useState(seededDecorFrames); - const [coffeePaused, setCoffeePaused] = useState(false); - const [serverMode, setServerMode] = useState<'auto' | 'on' | 'off'>('auto'); - - const targetsRef = useRef<{ main: Point; nodes: typeof placedNodes }>({ main: mainTarget, nodes: placedNodes }); - const stateRef = useRef<{ mainState: MainVisualState; nodes: typeof placedNodes }>({ mainState: effectiveMainState, nodes: placedNodes }); - - useEffect(() => { - targetsRef.current = { main: mainTarget, nodes: placedNodes }; - stateRef.current = { mainState: effectiveMainState, nodes: placedNodes }; - }, [mainTarget, placedNodes, effectiveMainState]); - - useEffect(() => { - setDecorFrames(seededDecorFrames); - }, [seededDecorFrames]); - - useEffect(() => { - setNodePos((prev) => { - const next: Record = {}; - for (const n of placedNodes) { - next[n.key] = prev[n.key] || n.point; - } - return next; - }); - }, [placedNodes]); - - useEffect(() => { - const timer = window.setInterval(() => { - setTick((v) => (v + 1) % 100000000); - setMainPos((prev) => lerpPoint(prev, targetsRef.current.main, 0.2)); - setNodePos((prev) => { - const next: Record = {}; - for (const n of targetsRef.current.nodes) { - const cur = prev[n.key] || n.point; - next[n.key] = lerpPoint(cur, n.point, 0.24); - } - return next; - }); - }, RENDER_INTERVAL_MS); - return () => window.clearInterval(timer); - }, []); - - useEffect(() => { - const timer = window.setInterval(() => { - const now = Date.now(); - setMainBubble((prev) => (prev && prev.expiresAt > now ? prev : null)); - setNodeBubbles((prev) => { - const next: Record = {}; - for (const [k, v] of Object.entries(prev)) { - if (v.expiresAt > now) next[k] = v; - } - return next; - }); - - if (now < nextBubbleAtRef.current) return; - - const mainPool = MAIN_BUBBLE_TEXTS[stateRef.current.mainState] || MAIN_BUBBLE_TEXTS.idle; - const mainLine = pickRandom(mainPool); - if (mainLine) setMainBubble({ text: mainLine, expiresAt: now + 2600 }); - - const nextNodeBubbles: Record = {}; - const candidates = [...stateRef.current.nodes]; - const bubbleCount = Math.min(2, candidates.length); - for (let i = 0; i < bubbleCount; i += 1) { - const pick = candidates.splice(Math.floor(Math.random() * candidates.length), 1)[0]; - if (!pick) continue; - const nodeState = normalizeNodeState(pick.state); - const line = pickRandom(NODE_BUBBLE_TEXTS[nodeState] || NODE_BUBBLE_TEXTS.idle); - if (line) nextNodeBubbles[pick.key] = { text: line, expiresAt: now + 2300 }; - } - if (Object.keys(nextNodeBubbles).length > 0) { - setNodeBubbles((prev) => ({ ...prev, ...nextNodeBubbles })); - } - - nextBubbleAtRef.current = now + 3200 + Math.floor(Math.random() * 2000); - }, 450); - return () => window.clearInterval(timer); - }, []); - - useEffect(() => { - cameraRef.current = camera; - }, [camera]); - - const updatePointerWorld = useCallback((clientX: number, clientY: number) => { - const rect = viewportRef.current?.getBoundingClientRect(); - if (!rect) return; - const px = clamp(clientX - rect.left, 0, rect.width); - const py = clamp(clientY - rect.top, 0, rect.height); - const cam = cameraRef.current; - const viewW = OFFICE_CANVAS.width / cam.zoom; - const viewH = OFFICE_CANVAS.height / cam.zoom; - setPointerWorld({ - x: cam.x + (px / Math.max(1, rect.width)) * viewW, - y: cam.y + (py / Math.max(1, rect.height)) * viewH, - }); - }, []); - - const handlePointerDown = useCallback((e: React.PointerEvent) => { - if (!panEnabled) return; - panRef.current = { active: true, x: e.clientX, y: e.clientY }; - (e.currentTarget as HTMLDivElement).setPointerCapture?.(e.pointerId); - }, [panEnabled]); - - const handlePointerUp = useCallback((e: React.PointerEvent) => { - panRef.current = null; - (e.currentTarget as HTMLDivElement).releasePointerCapture?.(e.pointerId); - }, []); - - const handlePointerMove = useCallback((e: React.PointerEvent) => { - updatePointerWorld(e.clientX, e.clientY); - const rect = viewportRef.current?.getBoundingClientRect(); - const pan = panRef.current; - if (!rect || !pan || !pan.active || !panEnabled) return; - - const dx = e.clientX - pan.x; - const dy = e.clientY - pan.y; - panRef.current = { active: true, x: e.clientX, y: e.clientY }; - - setCamera((prev) => { - const viewW = OFFICE_CANVAS.width / prev.zoom; - const viewH = OFFICE_CANVAS.height / prev.zoom; - const worldPerPixelX = viewW / Math.max(1, rect.width); - const worldPerPixelY = viewH / Math.max(1, rect.height); - return clampCamera({ - ...prev, - x: prev.x - dx * worldPerPixelX, - y: prev.y - dy * worldPerPixelY, - }); - }); - }, [panEnabled, updatePointerWorld]); - - const zoomBy = useCallback((delta: number) => { - setCamera((prev) => { - const nextZoom = clamp(prev.zoom + delta, MIN_ZOOM, MAX_ZOOM); - const centerX = prev.x + OFFICE_CANVAS.width / (2 * prev.zoom); - const centerY = prev.y + OFFICE_CANVAS.height / (2 * prev.zoom); - return clampCamera({ - zoom: nextZoom, - x: centerX - OFFICE_CANVAS.width / (2 * nextZoom), - y: centerY - OFFICE_CANVAS.height / (2 * nextZoom), - }); - }); - }, []); - - const resetView = useCallback(() => { - setCamera({ x: 0, y: 0, zoom: 1 }); - }, []); - - const setVisualState = useCallback((state: MainVisualState | null) => { - setManualState(state); - if (state === null) { - setManualStateUntil(0); - return; - } - setManualStateUntil(Date.now() + 15_000); - }, []); - - const cycleServerMode = useCallback(() => { - setServerMode((prev) => (prev === 'auto' ? 'on' : prev === 'on' ? 'off' : 'auto')); - }, []); - - const coffeeFrame = coffeePaused ? 0 : frameAtTick(DECOR_SPRITES.coffeeMachine, tick, 300); - const serverOn = serverMode === 'on' || (serverMode === 'auto' && effectiveMainState !== 'idle'); - const serverFrame = serverOn ? frameAtTick(DECOR_SPRITES.serverroom, tick, 700) : 0; - const mainFrame = frameAtTick(MAIN_SPRITES[effectiveMainState], tick); - const manualLeftSec = manualState ? Math.max(0, Math.ceil((manualStateUntil - Date.now()) / 1000)) : 0; - - const furnitureScale = camera.zoom; - - return ( -
-
-
- - - - - setDecorFrames((v) => ({ ...v, poster: randFrame(DECOR_SPRITES.posters) }))} - /> - setDecorFrames((v) => ({ ...v, plantA: randFrame(DECOR_SPRITES.plants) }))} - /> - setDecorFrames((v) => ({ ...v, plantB: randFrame(DECOR_SPRITES.plants) }))} - /> - setDecorFrames((v) => ({ ...v, plantC: randFrame(DECOR_SPRITES.plants) }))} - /> - - - setVisualState('idle')} - /> - - - setCoffeePaused((v) => !v)} - /> - - setVisualState('working')} - /> - setDecorFrames((v) => ({ ...v, flower: randFrame(DECOR_SPRITES.flowers) }))} - /> - - -
{ - const p = worldToScreen({ x: mainPos.x, y: mainPos.y + 62 }, camera); - return { left: `${p.x}%`, top: `${p.y}%` }; - })(), - zIndex: 55, - }} - > -
{main.name || main.id || 'main'}
-
- - {mainBubble && mainBubble.expiresAt > Date.now() ? ( - - ) : null} - - {placedNodes.map((n) => { - const p = nodePos[n.key] || n.point; - const nodeFrame = frameAtTick(NODE_SPRITES[n.spriteIndex], tick, n.avatarSeed % 1000); - const nodeBubble = nodeBubbles[n.key]; - return ( - - - {nodeBubble && nodeBubble.expiresAt > Date.now() ? ( - - ) : null} - - ); - })} - - setDecorFrames((v) => ({ ...v, cat: randFrame(DECOR_SPRITES.cats) }))} - /> - - {showDebug ? ( - <> - {Object.entries(OFFICE_ZONE_POINT).map(([zone, p]) => { - const s = worldToScreen(p, camera); - return ( -
-
-
{zone}
-
- ); - })} - {Object.entries(OFFICE_ZONE_SLOTS).map(([zone, slots]) => - slots.map((p, idx) => { - const s = worldToScreen(p, camera); - return ( -
-
-
- ); - }) - )} - - ) : null} -
- -
- - - - - - - - - - -
- -
- state={effectiveMainState} live={liveMainState} mode={manualState ? `manual(${manualLeftSec}s)` : 'follow'} server={serverMode} coffee={coffeePaused ? 'paused' : 'on'} -
- - {showDebug ? ( -
- cam x={camera.x.toFixed(1)} y={camera.y.toFixed(1)} zoom={camera.zoom.toFixed(2)} - {pointerWorld ? ` | pointer ${Math.round(pointerWorld.x)},${Math.round(pointerWorld.y)}` : ''} - {` | nodes ${placedNodes.length}`} -
- ) : null} -
-
- ); -}; - -export default OfficeScene; diff --git a/webui/src/components/office/officeLayout.ts b/webui/src/components/office/officeLayout.ts deleted file mode 100644 index 290b0de..0000000 --- a/webui/src/components/office/officeLayout.ts +++ /dev/null @@ -1,48 +0,0 @@ -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: 300, y: 365 }, - server: { x: 1010, y: 235 }, - bug: { x: 1125, y: 245 }, -}; - -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: 240, y: 350 }, - { x: 300, y: 345 }, - { x: 360, y: 350 }, - { x: 420, y: 360 }, - { x: 260, y: 420 }, - { x: 320, y: 425 }, - { x: 380, y: 425 }, - { x: 440, y: 430 }, - ], - server: [ - { x: 950, y: 225 }, - { x: 1000, y: 220 }, - { x: 1055, y: 220 }, - { x: 930, y: 285 }, - { x: 990, y: 285 }, - ], - bug: [ - { x: 1100, y: 230 }, - { x: 1160, y: 230 }, - { x: 1210, y: 235 }, - { x: 1085, y: 290 }, - { x: 1145, y: 295 }, - ], -}; diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 3823984..1d56bba 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -20,13 +20,6 @@ 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', @@ -451,13 +444,6 @@ 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 deleted file mode 100644 index dd66071..0000000 --- a/webui/src/pages/Office.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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'; - -const IPV4_PATTERN = /\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b/g; - -function maskIPv4(text: string | undefined): string { - const raw = String(text || ''); - return raw.replace(IPV4_PATTERN, (ip) => { - const parts = ip.split('.'); - if (parts.length !== 4) return ip; - const valid = parts.every((p) => { - const n = Number(p); - return Number.isInteger(n) && n >= 0 && n <= 255; - }); - if (!valid) return ip; - return `${parts[0]}.${parts[1]}.**.**`; - }); -} - -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 sep = q ? '&' : '?'; - const r = await fetch(`/webui/api/office_state${q}${sep}_=${Date.now()}`, { cache: 'no-store' }); - 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, 3000); - return () => clearInterval(timer); - }, [fetchState]); - - const main = payload.main || {}; - const nodes = Array.isArray(payload.nodes) ? payload.nodes : []; - const stats = payload.stats || {}; - const safeMain = useMemo( - () => ({ - ...main, - id: maskIPv4(main.id), - name: maskIPv4(main.name), - detail: maskIPv4(main.detail), - task_id: maskIPv4(main.task_id), - }), - [main] - ); - const safeNodes = useMemo( - () => - nodes.map((n) => ({ - ...n, - id: maskIPv4(n.id), - name: maskIPv4(n.name), - detail: maskIPv4(n.detail), - })), - [nodes] - ); - - 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')}: {safeMain.state || 'idle'} {safeMain.task_id ? `· ${safeMain.task_id}` : ''} -
-
- -
- -
-
- -
- {safeMain.detail || t('officeNoDetail')} -
-
-
-
-
{t('officeSceneStats')}
-
- {cards.map((c) => ( -
-
{c.label}
-
{c.value}
-
- ))} -
-
- -
-
{t('officeNodeList')}
-
- {safeNodes.length === 0 ? ( -
{t('officeNoNodes')}
- ) : ( - safeNodes.slice(0, 20).map((n, i) => ( -
-
{n.name || n.id || 'node'}
-
- {n.state || 'idle'} · {n.zone || 'breakroom'} -
-
- )) - )} -
-
-
-
-
- ); -}; - -export default Office;