@walwal-harness/cli 7.1.7 → 7.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HR-Resource/index.json +19 -43
- package/HR-Resource/marketing-marketing-cross-border-ecommerce/SKILL.md +108 -223
- package/HR-Resource/marketing-marketing-global-marketplace-operator/SKILL.md +175 -0
- package/HR-Resource/marketing-marketing-google-seo-specialist/SKILL.md +91 -0
- package/HR-Resource/marketing-marketing-instagram-commerce-strategist/SKILL.md +81 -0
- package/HR-Resource/marketing-marketing-korea-global-market-localization-strategist/SKILL.md +186 -0
- package/HR-Resource/marketing-marketing-livestream-commerce-coach/SKILL.md +88 -272
- package/HR-Resource/marketing-marketing-naver-seo-specialist/SKILL.md +88 -0
- package/HR-Resource/marketing-marketing-short-video-editing-coach/SKILL.md +99 -395
- package/HR-Resource/marketing-marketing-youtube-shopping-strategist/SKILL.md +82 -0
- package/README.md +41 -0
- package/apps/harness-dashboard/app/api/env/route.ts +62 -0
- package/apps/harness-dashboard/app/api/log/route.ts +19 -0
- package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
- package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
- package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
- package/apps/harness-dashboard/app/globals.css +17 -0
- package/apps/harness-dashboard/app/layout.tsx +29 -0
- package/apps/harness-dashboard/app/page.tsx +35 -0
- package/apps/harness-dashboard/components/Drawer.tsx +97 -0
- package/apps/harness-dashboard/components/Header.tsx +40 -0
- package/apps/harness-dashboard/components/MissionTimeline.tsx +210 -0
- package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
- package/apps/harness-dashboard/components/Scene.tsx +563 -0
- package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
- package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
- package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
- package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
- package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
- package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
- package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +517 -0
- package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +156 -0
- package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
- package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
- package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
- package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
- package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
- package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
- package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
- package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
- package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
- package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
- package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
- package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
- package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
- package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
- package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
- package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
- package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
- package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
- package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
- package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
- package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
- package/apps/harness-dashboard/lib/furniture.ts +165 -0
- package/apps/harness-dashboard/lib/harness-root.ts +20 -0
- package/apps/harness-dashboard/lib/harness-state.ts +1162 -0
- package/apps/harness-dashboard/lib/i18n.ts +26 -0
- package/apps/harness-dashboard/lib/iso.ts +150 -0
- package/apps/harness-dashboard/lib/log-filter.ts +23 -0
- package/apps/harness-dashboard/lib/markdown.tsx +306 -0
- package/apps/harness-dashboard/lib/path-planning.ts +72 -0
- package/apps/harness-dashboard/lib/safe-json.ts +24 -0
- package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
- package/apps/harness-dashboard/lib/types.ts +329 -0
- package/apps/harness-dashboard/lib/watcher.ts +64 -0
- package/apps/harness-dashboard/next-env.d.ts +6 -0
- package/apps/harness-dashboard/next.config.mjs +9 -0
- package/apps/harness-dashboard/package-lock.json +4280 -0
- package/apps/harness-dashboard/package.json +38 -0
- package/apps/harness-dashboard/playwright.config.ts +23 -0
- package/apps/harness-dashboard/postcss.config.js +6 -0
- package/apps/harness-dashboard/tailwind.config.ts +49 -0
- package/apps/harness-dashboard/tsconfig.json +24 -0
- package/apps/harness-dashboard/vitest.config.ts +15 -0
- package/assets/templates/AGENTS-ko /341/204/207/341/205/251/341/206/250/341/204/211/341/205/241/341/204/207/341/205/251/341/206/253.md" +114 -0
- package/package.json +11 -1
- package/scripts/harness-dashboard-up.sh +24 -4
- package/HR-Resource/engineering-engineering-feishu-integration-developer/SKILL.md +0 -615
- package/HR-Resource/engineering-engineering-wechat-mini-program-developer/SKILL.md +0 -367
- package/HR-Resource/marketing-marketing-baidu-seo-specialist/SKILL.md +0 -243
- package/HR-Resource/marketing-marketing-bilibili-content-strategist/SKILL.md +0 -216
- package/HR-Resource/marketing-marketing-china-ecommerce-operator/SKILL.md +0 -300
- package/HR-Resource/marketing-marketing-china-market-localization-strategist/SKILL.md +0 -300
- package/HR-Resource/marketing-marketing-douyin-strategist/SKILL.md +0 -166
- package/HR-Resource/marketing-marketing-kuaishou-strategist/SKILL.md +0 -240
- package/HR-Resource/marketing-marketing-wechat-official-account/SKILL.md +0 -162
- package/HR-Resource/marketing-marketing-weibo-strategist/SKILL.md +0 -257
- package/HR-Resource/marketing-marketing-xiaohongshu-specialist/SKILL.md +0 -155
- package/HR-Resource/marketing-marketing-zhihu-strategist/SKILL.md +0 -179
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import type {
|
|
4
|
+
AgentState,
|
|
5
|
+
EnvFileSummary,
|
|
6
|
+
EscalationEntry,
|
|
7
|
+
HarnessSnapshot,
|
|
8
|
+
MissionDoc,
|
|
9
|
+
OpsServiceHealth,
|
|
10
|
+
OrgNodeDef,
|
|
11
|
+
WorkerSnapshot,
|
|
12
|
+
} from "@/lib/types";
|
|
13
|
+
import { useHarnessStream, type ConnectionState } from "@/hooks/useHarnessStream";
|
|
14
|
+
import { Drawer, type DrawerTab } from "./Drawer";
|
|
15
|
+
import { AgentLogTab } from "./drawer/AgentLogTab";
|
|
16
|
+
import { OwnerHistoryTab } from "./drawer/OwnerHistoryTab";
|
|
17
|
+
import { MissionDocTab } from "./drawer/MissionDocTab";
|
|
18
|
+
import { MissionFlowTab } from "./drawer/MissionFlowTab";
|
|
19
|
+
import { GotchasTab } from "./drawer/GotchasTab";
|
|
20
|
+
import { MissionTimeline } from "./MissionTimeline";
|
|
21
|
+
import { OrgTree } from "./OrgTree";
|
|
22
|
+
|
|
23
|
+
interface SceneProps {
|
|
24
|
+
snapshot: HarnessSnapshot;
|
|
25
|
+
lang?: "ko" | "en";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
|
|
29
|
+
const { snapshot, connectionState } = useHarnessStream(initial);
|
|
30
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
31
|
+
const [drawerTab, setDrawerTab] = useState<DrawerTab>("mission-flow");
|
|
32
|
+
const [selectedAgent, setSelectedAgent] = useState<AgentState | null>(null);
|
|
33
|
+
const [selectedNode, setSelectedNode] = useState<OrgNodeDef | null>(null);
|
|
34
|
+
const [selectedMission, setSelectedMission] = useState<MissionDoc | null>(null);
|
|
35
|
+
|
|
36
|
+
const handleAgentClick = (id: string) => {
|
|
37
|
+
const a = snapshot.agents.find((x) => x.id === id) ?? null;
|
|
38
|
+
setSelectedAgent(a);
|
|
39
|
+
setSelectedNode(null);
|
|
40
|
+
setDrawerTab("logs");
|
|
41
|
+
setDrawerOpen(true);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleNodeClick = (node: OrgNodeDef) => {
|
|
45
|
+
setSelectedNode(node);
|
|
46
|
+
setSelectedAgent(null);
|
|
47
|
+
// Auto-select current mission for non-owner nodes
|
|
48
|
+
if (!selectedMission && snapshot.missions?.[0]) {
|
|
49
|
+
setSelectedMission(snapshot.missions[0]);
|
|
50
|
+
}
|
|
51
|
+
if (node.id === "owner") {
|
|
52
|
+
setDrawerTab("history");
|
|
53
|
+
} else if (node.id.startsWith("worker-")) {
|
|
54
|
+
setDrawerTab("mission-flow");
|
|
55
|
+
} else {
|
|
56
|
+
setDrawerTab("mission-flow");
|
|
57
|
+
}
|
|
58
|
+
setDrawerOpen(true);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleMissionClick = (mission: MissionDoc) => {
|
|
62
|
+
setSelectedMission(mission);
|
|
63
|
+
setSelectedNode(null);
|
|
64
|
+
setSelectedAgent(null);
|
|
65
|
+
setDrawerTab("mission-flow");
|
|
66
|
+
setDrawerOpen(true);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const drawerTitle =
|
|
70
|
+
selectedMission && drawerTab === "mission-flow"
|
|
71
|
+
? selectedMission.missionId
|
|
72
|
+
: selectedNode?.id === "owner"
|
|
73
|
+
? "Owner · History"
|
|
74
|
+
: selectedNode
|
|
75
|
+
? selectedNode.label
|
|
76
|
+
: "Mission Flow";
|
|
77
|
+
|
|
78
|
+
// Suppress unused lang warning — kept for future i18n
|
|
79
|
+
void lang;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="mx-auto max-w-[1920px] px-4 py-5">
|
|
83
|
+
{snapshot.escalations.length > 0 && (
|
|
84
|
+
<EscalationStrip escalations={snapshot.escalations} />
|
|
85
|
+
)}
|
|
86
|
+
<div className="min-w-0">
|
|
87
|
+
<section className="min-w-0">
|
|
88
|
+
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
|
|
89
|
+
<div>
|
|
90
|
+
<p className="text-[11px] font-mono uppercase tracking-[0.24em] text-cyan-300/80">
|
|
91
|
+
Company Structure
|
|
92
|
+
</p>
|
|
93
|
+
<h1 className="mt-1 text-2xl font-semibold text-gray-100">
|
|
94
|
+
{snapshot.projectName || "walwal-harness"} · Organization
|
|
95
|
+
</h1>
|
|
96
|
+
</div>
|
|
97
|
+
<ActivityIndicator snapshot={snapshot} connectionState={connectionState} />
|
|
98
|
+
</div>
|
|
99
|
+
<div
|
|
100
|
+
data-testid="brick-office-stage"
|
|
101
|
+
className="w-full overflow-hidden rounded-md border border-gray-700/80 bg-[#12151b] shadow-2xl"
|
|
102
|
+
>
|
|
103
|
+
<div className="pointer-events-none flex items-center justify-between border-b border-white/10 bg-black/30 px-4 py-2 font-mono text-[10px] text-gray-300 backdrop-blur">
|
|
104
|
+
<span>Owner → CEO → CXX → Workers</span>
|
|
105
|
+
<span>
|
|
106
|
+
{snapshot.missions?.[0]?.missionId ?? "no active mission"}
|
|
107
|
+
{snapshot.missions?.[0]?.cxxPresent.length
|
|
108
|
+
? ` · ${snapshot.missions[0].cxxPresent.join(" / ")}`
|
|
109
|
+
: ""}
|
|
110
|
+
</span>
|
|
111
|
+
</div>
|
|
112
|
+
<OrgTree
|
|
113
|
+
snapshot={snapshot}
|
|
114
|
+
activeNodeId={selectedNode?.id ?? null}
|
|
115
|
+
onNodeClick={handleNodeClick}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
</section>
|
|
119
|
+
|
|
120
|
+
<section className="mt-4">
|
|
121
|
+
<div className="mb-2">
|
|
122
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/60">Mission History</p>
|
|
123
|
+
<p className="text-[11px] text-gray-500 mt-0.5">goal · hot-fix 명령별 파생 작업 흐름</p>
|
|
124
|
+
</div>
|
|
125
|
+
<MissionTimeline
|
|
126
|
+
missions={snapshot.missions ?? []}
|
|
127
|
+
activeMissionId={selectedMission?.missionId ?? null}
|
|
128
|
+
onMissionClick={handleMissionClick}
|
|
129
|
+
/>
|
|
130
|
+
</section>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Hidden DOM index for E2E + a11y */}
|
|
134
|
+
<ul className="sr-only" data-testid="scene-index">
|
|
135
|
+
{snapshot.rooms.map((r) => (
|
|
136
|
+
<li
|
|
137
|
+
key={r.id}
|
|
138
|
+
data-testid={`room-${r.id}`}
|
|
139
|
+
data-room-id={r.id}
|
|
140
|
+
>
|
|
141
|
+
{r.label_ko}
|
|
142
|
+
</li>
|
|
143
|
+
))}
|
|
144
|
+
{snapshot.agents.map((a) => (
|
|
145
|
+
<li
|
|
146
|
+
key={a.id}
|
|
147
|
+
data-testid={`minifig-${a.id}`}
|
|
148
|
+
data-agent-id={a.id}
|
|
149
|
+
data-minifig-state={a.minifigState}
|
|
150
|
+
data-room={a.room}
|
|
151
|
+
>
|
|
152
|
+
{a.name}
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
|
|
157
|
+
<Drawer
|
|
158
|
+
open={drawerOpen}
|
|
159
|
+
tab={drawerTab}
|
|
160
|
+
title={drawerTitle}
|
|
161
|
+
onClose={() => setDrawerOpen(false)}
|
|
162
|
+
onTabChange={setDrawerTab}
|
|
163
|
+
>
|
|
164
|
+
{drawerTab === "mission-flow" && (
|
|
165
|
+
<MissionFlowTab
|
|
166
|
+
mission={selectedMission ?? snapshot.missions?.[0] ?? null}
|
|
167
|
+
ownerHistory={snapshot.ownerHistory ?? []}
|
|
168
|
+
/>
|
|
169
|
+
)}
|
|
170
|
+
{drawerTab === "history" && (
|
|
171
|
+
<OwnerHistoryTab
|
|
172
|
+
ownerHistory={snapshot.ownerHistory ?? []}
|
|
173
|
+
mission={selectedMission ?? snapshot.missions?.[0] ?? null}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
{drawerTab === "gotchas" && (
|
|
177
|
+
<GotchasTab gotchas={snapshot.gotchas ?? []} />
|
|
178
|
+
)}
|
|
179
|
+
{drawerTab === "mission-doc" && selectedNode && (
|
|
180
|
+
<MissionDocTab
|
|
181
|
+
missions={snapshot.missions ?? []}
|
|
182
|
+
role={
|
|
183
|
+
selectedNode.role === "owner"
|
|
184
|
+
? "ceo"
|
|
185
|
+
: (selectedNode.role as "ceo" | "cto" | "cqo" | "coo" | "cdo" | "ops")
|
|
186
|
+
}
|
|
187
|
+
fromLabel={selectedNode.role === "ceo" ? "Owner" : "CEO"}
|
|
188
|
+
toLabel={
|
|
189
|
+
selectedNode.role === "cto"
|
|
190
|
+
? "Workers"
|
|
191
|
+
: selectedNode.role === "cqo"
|
|
192
|
+
? "Owner Report"
|
|
193
|
+
: "—"
|
|
194
|
+
}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
{drawerTab === "logs" && selectedNode && (
|
|
198
|
+
<AgentLogTab agentId={selectedNode.agentIds[0] ?? selectedNode.id} />
|
|
199
|
+
)}
|
|
200
|
+
{drawerTab === "logs" && selectedAgent && !selectedNode && (
|
|
201
|
+
<AgentLogTab agentId={selectedAgent.id} />
|
|
202
|
+
)}
|
|
203
|
+
{drawerTab === "logs" && !selectedNode && !selectedAgent && (
|
|
204
|
+
<div className="text-gray-500 text-xs">Select a node to view logs.</div>
|
|
205
|
+
)}
|
|
206
|
+
{drawerTab === "mission-flow" && !selectedMission && !snapshot.missions?.length && (
|
|
207
|
+
<div className="text-gray-500 text-xs">No missions found in .harness/documents/</div>
|
|
208
|
+
)}
|
|
209
|
+
</Drawer>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ActivityIndicator({
|
|
215
|
+
snapshot,
|
|
216
|
+
connectionState,
|
|
217
|
+
}: {
|
|
218
|
+
snapshot: HarnessSnapshot;
|
|
219
|
+
connectionState: ConnectionState;
|
|
220
|
+
}) {
|
|
221
|
+
const [now, setNow] = useState(Date.now());
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const id = setInterval(() => setNow(Date.now()), 5000);
|
|
224
|
+
return () => clearInterval(id);
|
|
225
|
+
}, []);
|
|
226
|
+
const ts = Date.parse(snapshot.ts);
|
|
227
|
+
const ageSec = Number.isFinite(ts) ? Math.max(0, Math.floor((now - ts) / 1000)) : null;
|
|
228
|
+
const isStale = ageSec !== null && ageSec > 30;
|
|
229
|
+
return (
|
|
230
|
+
<div className="flex items-center gap-2 text-[10px] font-mono">
|
|
231
|
+
<span
|
|
232
|
+
data-testid="connection-state"
|
|
233
|
+
data-state={connectionState}
|
|
234
|
+
className={`inline-block rounded px-2 py-0.5 ${
|
|
235
|
+
connectionState === "open"
|
|
236
|
+
? "bg-aura-typing/20 text-aura-typing"
|
|
237
|
+
: connectionState === "stale"
|
|
238
|
+
? "bg-aura-talking/20 text-aura-talking"
|
|
239
|
+
: connectionState === "failed"
|
|
240
|
+
? "bg-aura-alert/20 text-aura-alert"
|
|
241
|
+
: "bg-gray-500/20 text-gray-400"
|
|
242
|
+
}`}
|
|
243
|
+
>
|
|
244
|
+
SSE: {connectionState}
|
|
245
|
+
</span>
|
|
246
|
+
<span
|
|
247
|
+
data-testid="activity-age"
|
|
248
|
+
data-stale={isStale}
|
|
249
|
+
className={`inline-block rounded px-2 py-0.5 ${
|
|
250
|
+
isStale
|
|
251
|
+
? "bg-aura-alert/20 text-aura-alert"
|
|
252
|
+
: "bg-aura-typing/20 text-aura-typing"
|
|
253
|
+
}`}
|
|
254
|
+
>
|
|
255
|
+
{ageSec === null
|
|
256
|
+
? "—"
|
|
257
|
+
: isStale
|
|
258
|
+
? `stale ${ageSec}s — 회사가 멈춰 보입니다`
|
|
259
|
+
: `live ${ageSec}s ago`}
|
|
260
|
+
</span>
|
|
261
|
+
{snapshot.errorBanner && (
|
|
262
|
+
<span className="inline-block rounded bg-aura-alert/20 px-2 py-0.5 text-aura-alert">
|
|
263
|
+
banner: {snapshot.errorBanner.level}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function Panel({
|
|
271
|
+
title,
|
|
272
|
+
eyebrow,
|
|
273
|
+
children,
|
|
274
|
+
}: {
|
|
275
|
+
title: string;
|
|
276
|
+
eyebrow?: string;
|
|
277
|
+
children: React.ReactNode;
|
|
278
|
+
}) {
|
|
279
|
+
return (
|
|
280
|
+
<section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
|
|
281
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
282
|
+
<div>
|
|
283
|
+
{eyebrow && (
|
|
284
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">
|
|
285
|
+
{eyebrow}
|
|
286
|
+
</p>
|
|
287
|
+
)}
|
|
288
|
+
<h2 className="text-sm font-semibold text-gray-100">{title}</h2>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
{children}
|
|
292
|
+
</section>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function Metric({ label, value, tone }: { label: string; value: React.ReactNode; tone: "neutral" | "cyan" | "green" }) {
|
|
297
|
+
const color = tone === "cyan" ? "text-cyan-300" : tone === "green" ? "text-emerald-300" : "text-gray-200";
|
|
298
|
+
return (
|
|
299
|
+
<div className="rounded border border-gray-700/70 bg-black/20 px-2 py-2">
|
|
300
|
+
<div className="font-mono text-[10px] uppercase text-gray-500">{label}</div>
|
|
301
|
+
<div className={`mt-1 text-lg font-semibold ${color}`}>{value}</div>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function StatusLine({ label, value }: { label: string; value: React.ReactNode }) {
|
|
307
|
+
return (
|
|
308
|
+
<div className="flex items-center justify-between gap-2 rounded bg-black/20 px-2 py-1 font-mono">
|
|
309
|
+
<span className="text-gray-500">{label}</span>
|
|
310
|
+
<span className="truncate text-gray-200">{value}</span>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function EmptyLine({ text }: { text: string }) {
|
|
316
|
+
return <div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">{text}</div>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function statusTone(status: WorkerSnapshot["status"]): string {
|
|
320
|
+
if (status === "spawned" || status === "running") return "bg-cyan-400/15 text-cyan-300";
|
|
321
|
+
if (status === "recorded") return "bg-amber-400/15 text-amber-300";
|
|
322
|
+
if (status === "blocked") return "bg-rose-500/15 text-rose-300";
|
|
323
|
+
return "bg-gray-500/15 text-gray-300";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function featureTone(status: string): string {
|
|
327
|
+
if (status === "passed") return "text-emerald-300";
|
|
328
|
+
if (status === "failed" || status === "blocked") return "text-rose-300";
|
|
329
|
+
if (status === "in_progress" || status === "ready") return "text-cyan-300";
|
|
330
|
+
return "text-gray-500";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function serviceTone(status: OpsServiceHealth["status"]): string {
|
|
334
|
+
if (status === "ok") return "bg-emerald-400/15 text-emerald-300";
|
|
335
|
+
if (status === "degraded") return "bg-amber-400/15 text-amber-300";
|
|
336
|
+
if (status === "down") return "bg-rose-500/15 text-rose-300";
|
|
337
|
+
return "bg-gray-500/15 text-gray-300";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function EscalationStrip({ escalations }: { escalations: EscalationEntry[] }) {
|
|
341
|
+
return (
|
|
342
|
+
<div
|
|
343
|
+
data-testid="escalation-strip"
|
|
344
|
+
role="alert"
|
|
345
|
+
className="mb-3 flex flex-wrap items-center gap-2 rounded border border-aura-alert/60 bg-aura-alert/10 px-3 py-2 text-xs font-mono text-aura-alert"
|
|
346
|
+
>
|
|
347
|
+
<span className="font-semibold uppercase tracking-widest">
|
|
348
|
+
↑ {escalations.length} escalation{escalations.length === 1 ? "" : "s"}
|
|
349
|
+
</span>
|
|
350
|
+
{escalations.slice(0, 3).map((e) => (
|
|
351
|
+
<span
|
|
352
|
+
key={e.id}
|
|
353
|
+
data-testid={`escalation-${e.id}`}
|
|
354
|
+
className="rounded bg-aura-alert/20 px-2 py-0.5 text-[10px]"
|
|
355
|
+
>
|
|
356
|
+
{e.id} · {e.reason}
|
|
357
|
+
</span>
|
|
358
|
+
))}
|
|
359
|
+
{escalations.length > 3 && (
|
|
360
|
+
<span className="text-[10px] opacity-70">+{escalations.length - 3} more</span>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Keep unused helper functions to avoid breaking other panels if needed later
|
|
367
|
+
void Panel;
|
|
368
|
+
void Metric;
|
|
369
|
+
void StatusLine;
|
|
370
|
+
void EmptyLine;
|
|
371
|
+
void statusTone;
|
|
372
|
+
void featureTone;
|
|
373
|
+
void serviceTone;
|
|
374
|
+
|
|
375
|
+
function OpsPanel({ services }: { services: OpsServiceHealth[] }) {
|
|
376
|
+
return (
|
|
377
|
+
<section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
|
|
378
|
+
<div className="mb-3">
|
|
379
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">production monitor</p>
|
|
380
|
+
<h2 className="text-sm font-semibold text-gray-100">Service-Ops</h2>
|
|
381
|
+
</div>
|
|
382
|
+
{services.length === 0 ? (
|
|
383
|
+
<div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">No production services configured.</div>
|
|
384
|
+
) : (
|
|
385
|
+
<div className="space-y-1.5">
|
|
386
|
+
{services.map((s) => (
|
|
387
|
+
<div key={`${s.name}-${s.port}`} className="flex items-center justify-between gap-2 rounded border border-gray-700/60 bg-black/20 px-2 py-1.5">
|
|
388
|
+
<div className="min-w-0">
|
|
389
|
+
<div className="truncate text-xs font-medium text-gray-100">{s.name}</div>
|
|
390
|
+
<div className="font-mono text-[10px] text-gray-500">
|
|
391
|
+
{s.host}:{s.port}{s.health_path ? ` · ${s.health_path}` : ""}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
<div className="text-right">
|
|
395
|
+
<span className={`rounded px-2 py-0.5 font-mono text-[10px] ${serviceTone(s.status)}`}>
|
|
396
|
+
{s.status}
|
|
397
|
+
</span>
|
|
398
|
+
<div className="mt-1 font-mono text-[10px] text-gray-500">
|
|
399
|
+
{s.health_status ?? s.port_state ?? "—"}
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
))}
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
</section>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function EnvPanel({ envFiles }: { envFiles: EnvFileSummary[] }) {
|
|
411
|
+
const first = envFiles[0];
|
|
412
|
+
const firstKey = first?.keys[0]?.key ?? "";
|
|
413
|
+
const [file, setFile] = useState(first?.path ?? ".env");
|
|
414
|
+
const [key, setKey] = useState(firstKey);
|
|
415
|
+
const [value, setValue] = useState("");
|
|
416
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
417
|
+
|
|
418
|
+
const submitEnv = async () => {
|
|
419
|
+
setStatus("saving");
|
|
420
|
+
try {
|
|
421
|
+
const res = await fetch("/api/env", {
|
|
422
|
+
method: "POST",
|
|
423
|
+
headers: { "Content-Type": "application/json" },
|
|
424
|
+
body: JSON.stringify({ file, key, value }),
|
|
425
|
+
});
|
|
426
|
+
const body = await res.json().catch(() => ({}));
|
|
427
|
+
if (!res.ok || !body.ok) {
|
|
428
|
+
setStatus(body.error ?? "failed");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
setValue("");
|
|
432
|
+
setStatus(body.replaced ? "updated" : "added");
|
|
433
|
+
} catch {
|
|
434
|
+
setStatus("failed");
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
return (
|
|
439
|
+
<section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
|
|
440
|
+
<div className="mb-3">
|
|
441
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">masked .env overview</p>
|
|
442
|
+
<h2 className="text-sm font-semibold text-gray-100">Environment Control</h2>
|
|
443
|
+
</div>
|
|
444
|
+
{envFiles.length === 0 ? (
|
|
445
|
+
<div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">No .env files found at project root.</div>
|
|
446
|
+
) : (
|
|
447
|
+
<div className="space-y-2">
|
|
448
|
+
<div className="rounded border border-cyan-400/30 bg-cyan-400/5 p-2">
|
|
449
|
+
<div className="grid gap-2 sm:grid-cols-[1fr_1fr]">
|
|
450
|
+
<label className="grid gap-1 font-mono text-[10px] text-gray-400">
|
|
451
|
+
file
|
|
452
|
+
<select
|
|
453
|
+
value={file}
|
|
454
|
+
onChange={(e) => {
|
|
455
|
+
setFile(e.target.value);
|
|
456
|
+
const nextFile = envFiles.find((item) => item.path === e.target.value);
|
|
457
|
+
setKey(nextFile?.keys[0]?.key ?? "");
|
|
458
|
+
}}
|
|
459
|
+
className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
|
|
460
|
+
>
|
|
461
|
+
{envFiles.map((item) => (
|
|
462
|
+
<option key={item.path} value={item.path}>
|
|
463
|
+
{item.path}
|
|
464
|
+
</option>
|
|
465
|
+
))}
|
|
466
|
+
</select>
|
|
467
|
+
</label>
|
|
468
|
+
<label className="grid gap-1 font-mono text-[10px] text-gray-400">
|
|
469
|
+
key
|
|
470
|
+
<select
|
|
471
|
+
value={key}
|
|
472
|
+
onChange={(e) => setKey(e.target.value)}
|
|
473
|
+
className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
|
|
474
|
+
>
|
|
475
|
+
{(envFiles.find((item) => item.path === file)?.keys ?? []).map((item) => (
|
|
476
|
+
<option key={item.key} value={item.key}>
|
|
477
|
+
{item.key}
|
|
478
|
+
</option>
|
|
479
|
+
))}
|
|
480
|
+
</select>
|
|
481
|
+
</label>
|
|
482
|
+
</div>
|
|
483
|
+
<div className="mt-2 flex gap-2">
|
|
484
|
+
<input
|
|
485
|
+
value={value}
|
|
486
|
+
onChange={(e) => setValue(e.target.value)}
|
|
487
|
+
placeholder="new value, never stored in browser"
|
|
488
|
+
className="min-w-0 flex-1 rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100 placeholder:text-gray-600"
|
|
489
|
+
/>
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
onClick={submitEnv}
|
|
493
|
+
disabled={!file || !key}
|
|
494
|
+
className="rounded border border-cyan-400/50 px-3 py-1 font-mono text-[10px] uppercase text-cyan-200 hover:bg-cyan-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
|
495
|
+
>
|
|
496
|
+
Apply
|
|
497
|
+
</button>
|
|
498
|
+
</div>
|
|
499
|
+
{status && <div className="mt-1 font-mono text-[10px] text-amber-300">{status}</div>}
|
|
500
|
+
</div>
|
|
501
|
+
{envFiles.map((file) => (
|
|
502
|
+
<div key={file.path} className="rounded border border-gray-700/70 bg-black/20 p-2">
|
|
503
|
+
<div className="flex items-center justify-between gap-2">
|
|
504
|
+
<div className="font-mono text-xs text-gray-100">{file.path}</div>
|
|
505
|
+
<div className="font-mono text-[10px] text-gray-500">{file.key_count} keys</div>
|
|
506
|
+
</div>
|
|
507
|
+
<div className="mt-2 grid gap-1">
|
|
508
|
+
{file.keys.slice(0, 5).map((k) => (
|
|
509
|
+
<div key={k.key} className="flex items-center justify-between gap-2 font-mono text-[10px]">
|
|
510
|
+
<span className="truncate text-gray-400">{k.key}</span>
|
|
511
|
+
<span className={k.category === "secret" ? "text-rose-300" : "text-cyan-300/80"}>
|
|
512
|
+
{k.masked}
|
|
513
|
+
</span>
|
|
514
|
+
</div>
|
|
515
|
+
))}
|
|
516
|
+
</div>
|
|
517
|
+
<p className="mt-2 text-[10px] leading-relaxed text-gray-500">
|
|
518
|
+
Server-side editor target. Values stay masked until an explicit edit flow is opened.
|
|
519
|
+
</p>
|
|
520
|
+
</div>
|
|
521
|
+
))}
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
</section>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Keep OpsPanel and EnvPanel available for future reuse
|
|
529
|
+
void OpsPanel;
|
|
530
|
+
void EnvPanel;
|
|
531
|
+
|
|
532
|
+
function ArchiveList({ snapshot }: { snapshot: HarnessSnapshot }) {
|
|
533
|
+
const list = snapshot.archive.all ?? snapshot.archive.recent ?? [];
|
|
534
|
+
if (list.length === 0) {
|
|
535
|
+
return <div className="text-gray-500">No archived sprints yet.</div>;
|
|
536
|
+
}
|
|
537
|
+
return (
|
|
538
|
+
<ul className="space-y-1">
|
|
539
|
+
{list.map((entry) => (
|
|
540
|
+
<li
|
|
541
|
+
key={entry.dir}
|
|
542
|
+
className="flex justify-between rounded border border-brick-wall bg-brick-wall/30 px-2 py-1"
|
|
543
|
+
>
|
|
544
|
+
<span>{entry.label}</span>
|
|
545
|
+
<span
|
|
546
|
+
className={
|
|
547
|
+
entry.result === "PASS"
|
|
548
|
+
? "text-aura-typing"
|
|
549
|
+
: entry.result === "FAIL"
|
|
550
|
+
? "text-aura-alert"
|
|
551
|
+
: "text-gray-500"
|
|
552
|
+
}
|
|
553
|
+
>
|
|
554
|
+
{entry.result}
|
|
555
|
+
</span>
|
|
556
|
+
</li>
|
|
557
|
+
))}
|
|
558
|
+
</ul>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Keep ArchiveList available for future reuse
|
|
563
|
+
void ArchiveList;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Wordmark for the dashboard. Avoids LEGO trade dress: no studded brick logos,
|
|
2
|
+
// no 2x4 standard ratio, no LEGO red/yellow scheme. Plain typographic mark.
|
|
3
|
+
export function Wordmark({ className = "" }: { className?: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<span
|
|
6
|
+
className={`font-mono text-xl tracking-[0.18em] uppercase ${className}`}
|
|
7
|
+
aria-label="Brick Office"
|
|
8
|
+
>
|
|
9
|
+
<span className="text-white">Brick</span>
|
|
10
|
+
<span className="text-aura-typing"> · </span>
|
|
11
|
+
<span className="text-gray-200">Office</span>
|
|
12
|
+
</span>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
interface AgentLogTabProps {
|
|
5
|
+
agentId: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function AgentLogTab({ agentId }: AgentLogTabProps) {
|
|
9
|
+
const [lines, setLines] = useState<string[] | null>(null);
|
|
10
|
+
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
setLines(null);
|
|
16
|
+
setError(null);
|
|
17
|
+
fetch(`/api/log?agent=${encodeURIComponent(agentId)}&limit=50`)
|
|
18
|
+
.then((r) => r.json())
|
|
19
|
+
.then((d) => {
|
|
20
|
+
if (cancelled) return;
|
|
21
|
+
setLines(d.lines ?? []);
|
|
22
|
+
})
|
|
23
|
+
.catch((e) => {
|
|
24
|
+
if (cancelled) return;
|
|
25
|
+
setError(String(e));
|
|
26
|
+
});
|
|
27
|
+
return () => {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
};
|
|
30
|
+
}, [agentId]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (lines && scrollRef.current) {
|
|
34
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
35
|
+
}
|
|
36
|
+
}, [lines]);
|
|
37
|
+
|
|
38
|
+
if (error) {
|
|
39
|
+
return <div className="text-aura-alert">Error loading log: {error}</div>;
|
|
40
|
+
}
|
|
41
|
+
if (lines === null) {
|
|
42
|
+
return <div className="text-gray-500">Loading…</div>;
|
|
43
|
+
}
|
|
44
|
+
if (lines.length === 0) {
|
|
45
|
+
return <div className="text-gray-500">No log lines for {agentId}.</div>;
|
|
46
|
+
}
|
|
47
|
+
return (
|
|
48
|
+
<div ref={scrollRef} className="space-y-1 max-h-full">
|
|
49
|
+
{lines.map((line, i) => (
|
|
50
|
+
<div key={i} className="whitespace-pre-wrap break-words text-[11px] leading-relaxed">
|
|
51
|
+
{line}
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|