Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4da6d2141 | ||
|
|
d6ae48dcec | ||
|
|
38b5f8dcc8 | ||
|
|
0cd8dc18b2 | ||
|
|
cad3a4a20c |
@@ -2496,6 +2496,25 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
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{}
|
||||
|
||||
@@ -2594,15 +2613,18 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
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["error"] > 0 || stats["blocked"] > 0:
|
||||
mainState = "error"
|
||||
mainZone = "bug"
|
||||
case stats["suppressed"] > 0:
|
||||
mainState = "syncing"
|
||||
mainZone = "server"
|
||||
case stats["waiting"] > 0:
|
||||
mainState = "idle"
|
||||
mainZone = "breakroom"
|
||||
case stats["success"] > 0:
|
||||
mainState = "writing"
|
||||
mainZone = "work"
|
||||
@@ -2613,9 +2635,24 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
|
||||
mainTaskID := ""
|
||||
mainDetail := "No active task"
|
||||
isMainStatus := func(st string) bool {
|
||||
st = strings.ToLower(strings.TrimSpace(st))
|
||||
switch mainState {
|
||||
case "executing":
|
||||
return st == "running"
|
||||
case "error":
|
||||
return st == "error" || st == "blocked"
|
||||
case "syncing":
|
||||
return st == "suppressed"
|
||||
case "writing":
|
||||
return st == "success"
|
||||
default:
|
||||
return st == "waiting" || st == "idle"
|
||||
}
|
||||
}
|
||||
for _, row := range items {
|
||||
st := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"])))
|
||||
if st == "running" || st == "error" || st == "blocked" || st == "waiting" {
|
||||
if isMainStatus(st) {
|
||||
mainTaskID = strings.TrimSpace(fmt.Sprintf("%v", row["task_id"]))
|
||||
mainDetail = strings.TrimSpace(fmt.Sprintf("%v", row["input_preview"]))
|
||||
if mainDetail == "" {
|
||||
@@ -2663,7 +2700,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
nodeDetail := func(n NodeInfo) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if ep := strings.TrimSpace(n.Endpoint); ep != "" {
|
||||
parts = append(parts, ep)
|
||||
parts = append(parts, maskIPv4(ep))
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(n.OS) != "" && strings.TrimSpace(n.Arch) != "":
|
||||
@@ -2680,9 +2717,9 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
parts = append(parts, "seen:"+n.LastSeenAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "node " + strings.TrimSpace(n.ID)
|
||||
return maskIPv4("node " + strings.TrimSpace(n.ID))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
return maskIPv4(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
allNodes := []NodeInfo{}
|
||||
@@ -2748,6 +2785,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
if name == "" {
|
||||
name = id
|
||||
}
|
||||
name = maskIPv4(name)
|
||||
updatedAt := ""
|
||||
if !n.LastSeenAt.IsZero() {
|
||||
updatedAt = n.LastSeenAt.UTC().Format(time.RFC3339)
|
||||
@@ -2773,6 +2811,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
mainDetailOut = mainDetailOut + " · " + nodeInfo
|
||||
}
|
||||
}
|
||||
mainDetailOut = maskIPv4(mainDetailOut)
|
||||
|
||||
ekgErr5m := 0
|
||||
cutoff := now.Add(-5 * time.Minute)
|
||||
@@ -2793,7 +2832,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R
|
||||
"time": now.Format(time.RFC3339),
|
||||
"main": map[string]interface{}{
|
||||
"id": mainNode.ID,
|
||||
"name": mainNode.Name,
|
||||
"name": maskIPv4(mainNode.Name),
|
||||
"state": mainState,
|
||||
"detail": mainDetailOut,
|
||||
"zone": mainZone,
|
||||
|
||||
BIN
webui/public/office/btn-back-home-sprite.png
Normal file
|
After Width: | Height: | Size: 599 B |
BIN
webui/public/office/btn-broker-sprite.png
Normal file
|
After Width: | Height: | Size: 589 B |
BIN
webui/public/office/btn-diy-sprite.png
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
webui/public/office/btn-move-house-sprite.png
Normal file
|
After Width: | Height: | Size: 503 B |
BIN
webui/public/office/btn-open-drawer-sprite.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
webui/public/office/btn-state-sprite.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
webui/public/office/cats-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
webui/public/office/coffee-machine-shadow-v1.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
webui/public/office/coffee-machine-v3-grid.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
webui/public/office/desk-v3.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
webui/public/office/flowers-bloom-v2.webp
Normal file
|
After Width: | Height: | Size: 341 KiB |
BIN
webui/public/office/memo-bg.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
webui/public/office/plants-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
webui/public/office/posters-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 580 KiB |
BIN
webui/public/office/serverroom-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 996 KiB |
BIN
webui/public/office/sofa-idle-v3.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
webui/public/office/sofa-shadow-v1.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, 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;
|
||||
@@ -33,6 +35,13 @@ type SpriteSpec = {
|
||||
scale: number;
|
||||
};
|
||||
|
||||
type ImageSpec = {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
const TICK_FPS = 12;
|
||||
|
||||
const MAIN_SPRITES: Record<'idle' | 'working' | 'syncing' | 'error', SpriteSpec> = {
|
||||
@@ -89,40 +98,107 @@ const NODE_SPRITES: SpriteSpec[] = Array.from({ length: 6 }, (_, i) => ({
|
||||
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 = {
|
||||
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,
|
||||
};
|
||||
|
||||
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';
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -141,6 +217,19 @@ function frameAtTick(spec: SpriteSpec, tick: number, seed = 0): number {
|
||||
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 posStyle(x: number, y: number, zIndex: number): React.CSSProperties {
|
||||
return {
|
||||
left: `${(x / OFFICE_CANVAS.width) * 100}%`,
|
||||
top: `${(y / OFFICE_CANVAS.height) * 100}%`,
|
||||
zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
type SpriteProps = {
|
||||
spec: SpriteSpec;
|
||||
frame: number;
|
||||
@@ -167,6 +256,48 @@ const SpriteSheet: React.FC<SpriteProps> = ({ spec, frame, className }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type PlacedSpriteProps = {
|
||||
spec: SpriteSpec;
|
||||
frame: number;
|
||||
x: number;
|
||||
y: number;
|
||||
zIndex: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const PlacedSprite: React.FC<PlacedSpriteProps> = ({ spec, frame, x, y, zIndex, title }) => (
|
||||
<div className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={posStyle(x, y, zIndex)} title={title || ''}>
|
||||
<div className="relative">
|
||||
<SpriteSheet spec={spec} frame={frame} className="absolute left-1/2 top-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type PlacedImageProps = {
|
||||
spec: ImageSpec;
|
||||
x: number;
|
||||
y: number;
|
||||
zIndex: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const PlacedImage: React.FC<PlacedImageProps> = ({ spec, x, y, zIndex, title }) => (
|
||||
<div className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={posStyle(x, y, zIndex)} title={title || ''}>
|
||||
<img
|
||||
src={spec.src}
|
||||
alt=""
|
||||
className="absolute left-1/2 top-1/2"
|
||||
style={{
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
imageRendering: 'pixelated',
|
||||
transform: `translate(-50%, -50%) scale(${spec.scale})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OfficeScene: React.FC<OfficeSceneProps> = ({ main, nodes }) => {
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
@@ -196,25 +327,52 @@ const OfficeScene: React.FC<OfficeSceneProps> = ({ main, nodes }) => {
|
||||
const mainPoint = OFFICE_ZONE_POINT[mainZone];
|
||||
const mainSprite = MAIN_SPRITES[normalizeMainSpriteState(main.state)];
|
||||
const mainFrame = frameAtTick(mainSprite, tick);
|
||||
const mainSpriteState = normalizeMainSpriteState(main.state);
|
||||
|
||||
const decorSeedBase = `${main.id || 'main'}|${main.name || ''}`;
|
||||
const plantFrameA = frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantA`);
|
||||
const plantFrameB = frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantB`);
|
||||
const plantFrameC = frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantC`);
|
||||
const posterFrame = frameFromSeed(DECOR_SPRITES.posters, `${decorSeedBase}|poster`);
|
||||
const flowerFrame = frameFromSeed(DECOR_SPRITES.flowers, `${decorSeedBase}|flower`);
|
||||
const catFrame = frameFromSeed(DECOR_SPRITES.cats, `${decorSeedBase}|cat`);
|
||||
const coffeeFrame = frameAtTick(DECOR_SPRITES.coffeeMachine, tick, 300);
|
||||
const serverFrame = mainSpriteState === 'idle' ? 0 : frameAtTick(DECOR_SPRITES.serverroom, tick, 700);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950/60">
|
||||
<div className="relative aspect-[16/9]">
|
||||
<img src={bgSrc} alt="office" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0">
|
||||
<PlacedSprite spec={DECOR_SPRITES.serverroom} frame={serverFrame} x={1021} y={142} zIndex={10} title="serverroom" />
|
||||
|
||||
<PlacedSprite spec={DECOR_SPRITES.posters} frame={posterFrame} x={252} y={66} zIndex={20} title="poster" />
|
||||
<PlacedSprite spec={DECOR_SPRITES.plants} frame={plantFrameA} x={565} y={178} zIndex={21} title="plant" />
|
||||
<PlacedSprite spec={DECOR_SPRITES.plants} frame={plantFrameB} x={230} y={185} zIndex={21} title="plant" />
|
||||
<PlacedSprite spec={DECOR_SPRITES.plants} frame={plantFrameC} x={977} y={496} zIndex={21} title="plant" />
|
||||
|
||||
<PlacedImage spec={DECOR_IMAGES.sofaShadow} x={1070} y={610} zIndex={25} title="sofa-shadow" />
|
||||
<PlacedImage spec={DECOR_IMAGES.sofaIdle} x={1070} y={610} zIndex={26} title="sofa" />
|
||||
|
||||
<PlacedImage spec={DECOR_IMAGES.coffeeShadow} x={659} y={397} zIndex={30} title="coffee-shadow" />
|
||||
<PlacedSprite spec={DECOR_SPRITES.coffeeMachine} frame={coffeeFrame} x={659} y={397} zIndex={31} title="coffee-machine" />
|
||||
|
||||
<PlacedImage spec={DECOR_IMAGES.desk} x={218} y={417} zIndex={35} title="desk" />
|
||||
<PlacedSprite spec={DECOR_SPRITES.flowers} frame={flowerFrame} x={310} y={390} zIndex={36} title="flower" />
|
||||
|
||||
<div
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
left: `${(mainPoint.x / OFFICE_CANVAS.width) * 100}%`,
|
||||
top: `${(mainPoint.y / OFFICE_CANVAS.height) * 100}%`,
|
||||
zIndex: 50,
|
||||
}}
|
||||
title={`${main.state || 'idle'} ${main.detail || ''}`.trim()}
|
||||
>
|
||||
<div className="relative">
|
||||
<SpriteSheet spec={mainSprite} frame={mainFrame} className="absolute left-1/2 top-1/2" />
|
||||
<div className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-[145%] h-2.5 w-2.5 rounded-full ring-2 ring-white/80 ${stateTone(main.state)} shadow-lg shadow-black/50`} />
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 translate-y-[62px] rounded bg-black/75 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-zinc-100">
|
||||
clawgo
|
||||
{main.name || main.id || 'main'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,6 +384,7 @@ const OfficeScene: React.FC<OfficeSceneProps> = ({ main, nodes }) => {
|
||||
style={{
|
||||
left: `${(n.point.x / OFFICE_CANVAS.width) * 100}%`,
|
||||
top: `${(n.point.y / OFFICE_CANVAS.height) * 100}%`,
|
||||
zIndex: 51,
|
||||
}}
|
||||
title={`${n.name || n.id || 'node'} · ${n.state || 'idle'}${n.detail ? ` · ${n.detail}` : ''}`}
|
||||
>
|
||||
@@ -235,10 +394,11 @@ const OfficeScene: React.FC<OfficeSceneProps> = ({ main, nodes }) => {
|
||||
frame={frameAtTick(NODE_SPRITES[n.spriteIndex], tick, n.avatarSeed % 1000)}
|
||||
className="absolute left-1/2 top-1/2"
|
||||
/>
|
||||
<div className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-[68%] h-2 w-2 rounded-full ring-1 ring-black/50 ${stateTone(n.state)}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<PlacedSprite spec={DECOR_SPRITES.cats} frame={catFrame} x={94} y={557} zIndex={80} title="cat" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,9 @@ export const OFFICE_CANVAS = {
|
||||
|
||||
export const OFFICE_ZONE_POINT: Record<OfficeZone, { x: number; y: number }> = {
|
||||
breakroom: { x: 1070, y: 610 },
|
||||
work: { x: 640, y: 470 },
|
||||
server: { x: 820, y: 220 },
|
||||
bug: { x: 230, y: 210 },
|
||||
work: { x: 300, y: 365 },
|
||||
server: { x: 1010, y: 235 },
|
||||
bug: { x: 1125, y: 245 },
|
||||
};
|
||||
|
||||
export const OFFICE_ZONE_SLOTS: Record<OfficeZone, Array<{ x: number; y: number }>> = {
|
||||
@@ -22,28 +22,27 @@ export const OFFICE_ZONE_SLOTS: Record<OfficeZone, Array<{ x: number; y: number
|
||||
{ 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 },
|
||||
{ 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: 760, y: 240 },
|
||||
{ x: 830, y: 240 },
|
||||
{ x: 900, y: 240 },
|
||||
{ x: 780, y: 290 },
|
||||
{ x: 850, y: 290 },
|
||||
{ x: 950, y: 225 },
|
||||
{ x: 1000, y: 220 },
|
||||
{ x: 1055, y: 220 },
|
||||
{ x: 930, y: 285 },
|
||||
{ x: 990, y: 285 },
|
||||
],
|
||||
bug: [
|
||||
{ x: 180, y: 230 },
|
||||
{ x: 240, y: 230 },
|
||||
{ x: 300, y: 230 },
|
||||
{ x: 210, y: 280 },
|
||||
{ x: 270, y: 280 },
|
||||
{ x: 1100, y: 230 },
|
||||
{ x: 1160, y: 230 },
|
||||
{ x: 1210, y: 235 },
|
||||
{ x: 1085, y: 290 },
|
||||
{ x: 1145, y: 295 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,22 @@ 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;
|
||||
@@ -52,6 +68,26 @@ const Office: React.FC = () => {
|
||||
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(
|
||||
() => [
|
||||
@@ -71,7 +107,7 @@ const Office: React.FC = () => {
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-semibold">{t('office')}</h1>
|
||||
<div className="text-xs text-zinc-500 mt-1">
|
||||
{t('officeMainState')}: {main.state || 'idle'} {main.task_id ? `· ${main.task_id}` : ''}
|
||||
{t('officeMainState')}: {safeMain.state || 'idle'} {safeMain.task_id ? `· ${safeMain.task_id}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={fetchState} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm flex items-center gap-2">
|
||||
@@ -82,9 +118,9 @@ const Office: React.FC = () => {
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<div className="xl:col-span-2">
|
||||
<OfficeScene main={main} nodes={nodes} />
|
||||
<OfficeScene main={safeMain} nodes={safeNodes} />
|
||||
<div className="mt-2 text-xs text-zinc-400 bg-zinc-900/40 border border-zinc-800 rounded-lg px-3 py-2">
|
||||
{main.detail || t('officeNoDetail')}
|
||||
{safeMain.detail || t('officeNoDetail')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -103,10 +139,10 @@ const Office: React.FC = () => {
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
|
||||
<div className="text-zinc-500 text-xs mb-2">{t('officeNodeList')}</div>
|
||||
<div className="max-h-64 overflow-auto space-y-1.5">
|
||||
{nodes.length === 0 ? (
|
||||
{safeNodes.length === 0 ? (
|
||||
<div className="text-zinc-500 text-sm">{t('officeNoNodes')}</div>
|
||||
) : (
|
||||
nodes.slice(0, 20).map((n, i) => (
|
||||
safeNodes.slice(0, 20).map((n, i) => (
|
||||
<div key={`${n.id || 'node'}-${i}`} className="rounded-md bg-zinc-950/70 border border-zinc-800 px-2 py-1.5">
|
||||
<div className="text-xs text-zinc-200 truncate">{n.name || n.id || 'node'}</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate">
|
||||
|
||||