5 Commits

21 changed files with 298 additions and 64 deletions

View File

@@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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>

View File

@@ -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 },
],
};

View File

@@ -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">