better-symphony 1.0.0
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/CLAUDE.md +60 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/web/app.css +2 -0
- package/dist/web/index.html +13 -0
- package/dist/web/main.js +235 -0
- package/package.json +62 -0
- package/src/agent/claude-runner.ts +576 -0
- package/src/agent/protocol.ts +2 -0
- package/src/agent/runner.ts +2 -0
- package/src/agent/session.ts +113 -0
- package/src/cli.ts +354 -0
- package/src/config/loader.ts +379 -0
- package/src/config/types.ts +382 -0
- package/src/index.ts +53 -0
- package/src/linear-cli.ts +414 -0
- package/src/logging/logger.ts +143 -0
- package/src/orchestrator/multi-orchestrator.ts +266 -0
- package/src/orchestrator/orchestrator.ts +1357 -0
- package/src/orchestrator/scheduler.ts +195 -0
- package/src/orchestrator/state.ts +201 -0
- package/src/prompts/github-system-prompt.md +51 -0
- package/src/prompts/linear-system-prompt.md +44 -0
- package/src/tracker/client.ts +577 -0
- package/src/tracker/github-issues-tracker.ts +280 -0
- package/src/tracker/github-pr-tracker.ts +298 -0
- package/src/tracker/index.ts +9 -0
- package/src/tracker/interface.ts +76 -0
- package/src/tracker/linear-tracker.ts +147 -0
- package/src/tracker/queries.ts +281 -0
- package/src/tracker/types.ts +125 -0
- package/src/tui/App.tsx +157 -0
- package/src/tui/LogView.tsx +120 -0
- package/src/tui/StatusBar.tsx +72 -0
- package/src/tui/TabBar.tsx +55 -0
- package/src/tui/sink.ts +47 -0
- package/src/tui/types.ts +6 -0
- package/src/tui/useOrchestrator.ts +244 -0
- package/src/web/server.ts +182 -0
- package/src/web/sink.ts +67 -0
- package/src/web-ui/App.tsx +60 -0
- package/src/web-ui/components/agent-table.tsx +57 -0
- package/src/web-ui/components/header.tsx +72 -0
- package/src/web-ui/components/log-stream.tsx +111 -0
- package/src/web-ui/components/retry-table.tsx +58 -0
- package/src/web-ui/components/stats-cards.tsx +142 -0
- package/src/web-ui/components/ui/badge.tsx +30 -0
- package/src/web-ui/components/ui/button.tsx +39 -0
- package/src/web-ui/components/ui/card.tsx +32 -0
- package/src/web-ui/globals.css +27 -0
- package/src/web-ui/index.html +13 -0
- package/src/web-ui/lib/use-sse.ts +98 -0
- package/src/web-ui/lib/utils.ts +25 -0
- package/src/web-ui/main.tsx +4 -0
- package/src/workspace/hooks.ts +97 -0
- package/src/workspace/manager.ts +211 -0
- package/src/workspace/render-hook.ts +13 -0
- package/workflows/dev.md +127 -0
- package/workflows/github-issues.md +107 -0
- package/workflows/pr-review.md +89 -0
- package/workflows/prd.md +170 -0
- package/workflows/ralph.md +95 -0
- package/workflows/smoke.md +66 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Dashboard Server
|
|
3
|
+
* Bun.serve() with API routes, SSE streaming, and static file serving.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolve } from "path";
|
|
7
|
+
import { logger } from "../logging/logger.js";
|
|
8
|
+
import { WebLogBuffer, createWebSink } from "./sink.js";
|
|
9
|
+
import type { RuntimeSnapshot } from "../orchestrator/state.js";
|
|
10
|
+
import type { LogSink } from "../logging/logger.js";
|
|
11
|
+
|
|
12
|
+
interface OrchestratorHandle {
|
|
13
|
+
getSnapshot(): RuntimeSnapshot | null;
|
|
14
|
+
forcePoll(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WebServerOptions {
|
|
19
|
+
port: number;
|
|
20
|
+
host: string;
|
|
21
|
+
orchestrator: OrchestratorHandle;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STATIC_DIR = resolve(import.meta.dir, "../../dist/web");
|
|
25
|
+
|
|
26
|
+
const MIME_TYPES: Record<string, string> = {
|
|
27
|
+
".html": "text/html",
|
|
28
|
+
".js": "application/javascript",
|
|
29
|
+
".css": "text/css",
|
|
30
|
+
".json": "application/json",
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".ico": "image/x-icon",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function getMimeType(path: string): string {
|
|
37
|
+
const ext = path.substring(path.lastIndexOf("."));
|
|
38
|
+
return MIME_TYPES[ext] ?? "application/octet-stream";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function startWebServer(options: WebServerOptions) {
|
|
42
|
+
const { port, host, orchestrator } = options;
|
|
43
|
+
const logBuffer = new WebLogBuffer(2000);
|
|
44
|
+
const webSink: LogSink = createWebSink(logBuffer);
|
|
45
|
+
logger.addSink(webSink);
|
|
46
|
+
|
|
47
|
+
const sseClients = new Set<ReadableStreamDefaultController>();
|
|
48
|
+
|
|
49
|
+
// Push updates to all SSE clients every second
|
|
50
|
+
const pushInterval = setInterval(() => {
|
|
51
|
+
if (sseClients.size === 0) return;
|
|
52
|
+
|
|
53
|
+
const snapshot = orchestrator.getSnapshot();
|
|
54
|
+
const logs = logBuffer.drain();
|
|
55
|
+
const payload = JSON.stringify({
|
|
56
|
+
type: "update",
|
|
57
|
+
snapshot,
|
|
58
|
+
logs,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const message = `data: ${payload}\n\n`;
|
|
63
|
+
for (const controller of sseClients) {
|
|
64
|
+
try {
|
|
65
|
+
controller.enqueue(message);
|
|
66
|
+
} catch {
|
|
67
|
+
sseClients.delete(controller);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}, 1000);
|
|
71
|
+
|
|
72
|
+
const server = Bun.serve({
|
|
73
|
+
port,
|
|
74
|
+
hostname: host,
|
|
75
|
+
|
|
76
|
+
async fetch(req) {
|
|
77
|
+
const url = new URL(req.url);
|
|
78
|
+
const { pathname } = url;
|
|
79
|
+
|
|
80
|
+
// ── API Routes ────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
if (pathname === "/api/snapshot") {
|
|
83
|
+
return Response.json(orchestrator.getSnapshot());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (pathname === "/api/events") {
|
|
87
|
+
const stream = new ReadableStream({
|
|
88
|
+
start(controller) {
|
|
89
|
+
sseClients.add(controller);
|
|
90
|
+
// Send initial state with log backfill
|
|
91
|
+
const snapshot = orchestrator.getSnapshot();
|
|
92
|
+
const initial = JSON.stringify({
|
|
93
|
+
type: "initial",
|
|
94
|
+
snapshot,
|
|
95
|
+
logs: logBuffer.getRecent(200),
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
});
|
|
98
|
+
controller.enqueue(`data: ${initial}\n\n`);
|
|
99
|
+
},
|
|
100
|
+
cancel(controller) {
|
|
101
|
+
sseClients.delete(controller);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return new Response(stream, {
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "text/event-stream",
|
|
108
|
+
"Cache-Control": "no-cache",
|
|
109
|
+
Connection: "keep-alive",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (pathname === "/api/force-poll" && req.method === "POST") {
|
|
115
|
+
await orchestrator.forcePoll();
|
|
116
|
+
return Response.json({ ok: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (pathname === "/api/shutdown" && req.method === "POST") {
|
|
120
|
+
// Respond first, then stop
|
|
121
|
+
setTimeout(async () => {
|
|
122
|
+
await orchestrator.stop();
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}, 200);
|
|
125
|
+
return Response.json({ ok: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (pathname === "/api/restart" && req.method === "POST") {
|
|
129
|
+
// Stop the server to release the port, then re-exec
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
clearInterval(pushInterval);
|
|
132
|
+
server.stop();
|
|
133
|
+
const args = process.argv;
|
|
134
|
+
Bun.spawn(args, {
|
|
135
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
136
|
+
env: process.env,
|
|
137
|
+
});
|
|
138
|
+
setTimeout(() => process.exit(0), 500);
|
|
139
|
+
}, 100);
|
|
140
|
+
return Response.json({ ok: true });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Static Files ──────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const filePath = pathname === "/" ? "/index.html" : pathname;
|
|
146
|
+
const fullPath = resolve(STATIC_DIR, `.${filePath}`);
|
|
147
|
+
|
|
148
|
+
// Prevent directory traversal
|
|
149
|
+
if (!fullPath.startsWith(STATIC_DIR)) {
|
|
150
|
+
return new Response("Forbidden", { status: 403 });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const file = Bun.file(fullPath);
|
|
154
|
+
if (await file.exists()) {
|
|
155
|
+
return new Response(file, {
|
|
156
|
+
headers: { "Content-Type": getMimeType(fullPath) },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// SPA fallback
|
|
161
|
+
const indexFile = Bun.file(resolve(STATIC_DIR, "index.html"));
|
|
162
|
+
if (await indexFile.exists()) {
|
|
163
|
+
return new Response(indexFile, {
|
|
164
|
+
headers: { "Content-Type": "text/html" },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return new Response("Not Found", { status: 404 });
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
logger.info(`Web dashboard at http://${host === "0.0.0.0" ? "localhost" : host}:${port}`);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
server,
|
|
176
|
+
stop() {
|
|
177
|
+
clearInterval(pushInterval);
|
|
178
|
+
server.stop();
|
|
179
|
+
logger.removeSink(webSink);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/web/sink.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Log Sink
|
|
3
|
+
* Buffers log lines for SSE streaming to the web dashboard.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LogSink, LogEntry } from "../logging/logger.js";
|
|
7
|
+
import type { LogLine } from "../tui/types.js";
|
|
8
|
+
|
|
9
|
+
export class WebLogBuffer {
|
|
10
|
+
private buffer: LogLine[] = [];
|
|
11
|
+
private pending: LogLine[] = [];
|
|
12
|
+
private maxSize: number;
|
|
13
|
+
|
|
14
|
+
constructor(maxSize: number) {
|
|
15
|
+
this.maxSize = maxSize;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
push(line: LogLine): void {
|
|
19
|
+
this.buffer.push(line);
|
|
20
|
+
this.pending.push(line);
|
|
21
|
+
if (this.buffer.length > this.maxSize) {
|
|
22
|
+
this.buffer = this.buffer.slice(-this.maxSize);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Return and clear accumulated lines since last drain. */
|
|
27
|
+
drain(): LogLine[] {
|
|
28
|
+
const lines = this.pending;
|
|
29
|
+
this.pending = [];
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Return the last N lines from the full buffer. */
|
|
34
|
+
getRecent(n: number): LogLine[] {
|
|
35
|
+
return this.buffer.slice(-n);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWebSink(buffer: WebLogBuffer): LogSink {
|
|
40
|
+
return (entry: LogEntry) => {
|
|
41
|
+
const type: LogLine["type"] =
|
|
42
|
+
entry.level === "error"
|
|
43
|
+
? "error"
|
|
44
|
+
: entry.level === "warn"
|
|
45
|
+
? "info"
|
|
46
|
+
: entry.level === "info"
|
|
47
|
+
? "info"
|
|
48
|
+
: "comment";
|
|
49
|
+
|
|
50
|
+
const source =
|
|
51
|
+
(entry.context.issue_identifier as string) ?? "orchestrator";
|
|
52
|
+
|
|
53
|
+
let message = entry.message;
|
|
54
|
+
const extras: string[] = [];
|
|
55
|
+
for (const [key, value] of Object.entries(entry.context)) {
|
|
56
|
+
if (key === "issue_identifier" || key === "issue_id" || key === "session_id") continue;
|
|
57
|
+
if (value !== undefined && value !== null) {
|
|
58
|
+
extras.push(`${key}=${value}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (extras.length > 0) {
|
|
62
|
+
message += ` (${extras.join(", ")})`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
buffer.push({ source, message, type, timestamp: Date.now() });
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { useSSE } from "./lib/use-sse";
|
|
3
|
+
import { Header } from "./components/header";
|
|
4
|
+
import { StatsCards } from "./components/stats-cards";
|
|
5
|
+
import { AgentTable } from "./components/agent-table";
|
|
6
|
+
import { RetryTable } from "./components/retry-table";
|
|
7
|
+
import { LogStream } from "./components/log-stream";
|
|
8
|
+
|
|
9
|
+
export function App() {
|
|
10
|
+
const { snapshot, logs, connected, startTime, actions } = useSSE();
|
|
11
|
+
const [sourceFilter, setSourceFilter] = useState("all");
|
|
12
|
+
const [polling, setPolling] = useState(false);
|
|
13
|
+
|
|
14
|
+
const handleAgentClick = useCallback((issueIdentifier: string) => {
|
|
15
|
+
setSourceFilter(issueIdentifier);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
const handleForcePoll = useCallback(async () => {
|
|
19
|
+
setPolling(true);
|
|
20
|
+
try { await actions.forcePoll(); } finally { setPolling(false); }
|
|
21
|
+
}, [actions.forcePoll]);
|
|
22
|
+
|
|
23
|
+
// Keyboard shortcuts
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
26
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
|
|
27
|
+
if (e.key === "r") handleForcePoll();
|
|
28
|
+
};
|
|
29
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
30
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
31
|
+
}, [handleForcePoll]);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col h-screen bg-background text-foreground overflow-x-hidden">
|
|
35
|
+
<Header
|
|
36
|
+
connected={connected}
|
|
37
|
+
startTime={startTime}
|
|
38
|
+
polling={polling}
|
|
39
|
+
onForcePoll={handleForcePoll}
|
|
40
|
+
onRestart={actions.restart}
|
|
41
|
+
onShutdown={actions.shutdown}
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
<main className="flex flex-col flex-1 min-h-0 p-4 sm:p-6 gap-4">
|
|
45
|
+
<StatsCards snapshot={snapshot} onAgentClick={handleAgentClick} />
|
|
46
|
+
|
|
47
|
+
<div className="flex flex-col gap-4">
|
|
48
|
+
<div>
|
|
49
|
+
<h2 className="text-sm font-medium text-muted-foreground mb-2">Running Agents</h2>
|
|
50
|
+
<AgentTable running={snapshot?.running ?? []} onAgentClick={handleAgentClick} />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<RetryTable retrying={snapshot?.retrying ?? []} />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<LogStream logs={logs} sourceFilter={sourceFilter} onSourceFilterChange={setSourceFilter} />
|
|
57
|
+
</main>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Badge } from "./ui/badge";
|
|
2
|
+
import { formatElapsed } from "../lib/utils";
|
|
3
|
+
import type { RuntimeSnapshot } from "../lib/use-sse";
|
|
4
|
+
|
|
5
|
+
interface AgentTableProps {
|
|
6
|
+
running: RuntimeSnapshot["running"];
|
|
7
|
+
onAgentClick?: (issueIdentifier: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AgentTable({ running, onAgentClick }: AgentTableProps) {
|
|
11
|
+
if (running.length === 0) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="rounded-lg border border-border p-8 text-center text-muted-foreground">
|
|
14
|
+
No agents running
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="rounded-lg border border-border overflow-hidden">
|
|
21
|
+
<table className="w-full text-sm">
|
|
22
|
+
<thead>
|
|
23
|
+
<tr className="border-b border-border bg-muted/50">
|
|
24
|
+
<th className="text-left p-3 font-medium text-muted-foreground">Issue</th>
|
|
25
|
+
<th className="text-left p-3 font-medium text-muted-foreground">Workflow</th>
|
|
26
|
+
<th className="text-left p-3 font-medium text-muted-foreground">State</th>
|
|
27
|
+
<th className="text-right p-3 font-medium text-muted-foreground">Turns</th>
|
|
28
|
+
<th className="text-right p-3 font-medium text-muted-foreground">Duration</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
{running.map((agent) => (
|
|
33
|
+
<tr
|
|
34
|
+
key={agent.issue_id}
|
|
35
|
+
className="border-b border-border last:border-0 hover:bg-muted/30 cursor-pointer"
|
|
36
|
+
onClick={() => onAgentClick?.(agent.issue_identifier)}
|
|
37
|
+
>
|
|
38
|
+
<td className="p-3 font-mono">{agent.issue_identifier}</td>
|
|
39
|
+
<td className="p-3">
|
|
40
|
+
{agent.workflow ? (
|
|
41
|
+
<Badge variant="secondary">{agent.workflow}</Badge>
|
|
42
|
+
) : (
|
|
43
|
+
<span className="text-muted-foreground">-</span>
|
|
44
|
+
)}
|
|
45
|
+
</td>
|
|
46
|
+
<td className="p-3">
|
|
47
|
+
<Badge variant="success">{agent.state}</Badge>
|
|
48
|
+
</td>
|
|
49
|
+
<td className="p-3 text-right font-mono">{agent.turn_count}</td>
|
|
50
|
+
<td className="p-3 text-right font-mono">{formatElapsed(agent.started_at)}</td>
|
|
51
|
+
</tr>
|
|
52
|
+
))}
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Button } from "./ui/button";
|
|
3
|
+
import { formatDuration } from "../lib/utils";
|
|
4
|
+
import { RefreshCw, Power, RotateCcw } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface HeaderProps {
|
|
7
|
+
connected: boolean;
|
|
8
|
+
startTime: number;
|
|
9
|
+
polling: boolean;
|
|
10
|
+
onForcePoll: () => void;
|
|
11
|
+
onRestart: () => void;
|
|
12
|
+
onShutdown: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Header({ connected, startTime, polling, onForcePoll, onRestart, onShutdown }: HeaderProps) {
|
|
16
|
+
const [uptime, setUptime] = useState("0s");
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const timer = setInterval(() => {
|
|
20
|
+
setUptime(formatDuration((Date.now() - startTime) / 1000));
|
|
21
|
+
}, 1000);
|
|
22
|
+
return () => clearInterval(timer);
|
|
23
|
+
}, [startTime]);
|
|
24
|
+
|
|
25
|
+
const [confirmAction, setConfirmAction] = useState<"restart" | "shutdown" | null>(null);
|
|
26
|
+
|
|
27
|
+
const handleConfirm = () => {
|
|
28
|
+
if (confirmAction === "restart") onRestart();
|
|
29
|
+
if (confirmAction === "shutdown") onShutdown();
|
|
30
|
+
setConfirmAction(null);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 sm:px-6 py-3">
|
|
35
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
36
|
+
<h1 className="text-lg font-semibold shrink-0">Better Symphony</h1>
|
|
37
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
38
|
+
<div className={`h-2 w-2 rounded-full ${connected ? "bg-success" : "bg-destructive"}`} />
|
|
39
|
+
{connected ? "Connected" : "Disconnected"}
|
|
40
|
+
</div>
|
|
41
|
+
<span className="text-sm text-muted-foreground">Uptime: {uptime}</span>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="flex items-center gap-2">
|
|
45
|
+
{confirmAction ? (
|
|
46
|
+
<>
|
|
47
|
+
<span className="text-sm text-warning mr-2">
|
|
48
|
+
Confirm {confirmAction}?
|
|
49
|
+
</span>
|
|
50
|
+
<Button size="sm" variant="destructive" onClick={handleConfirm}>Yes</Button>
|
|
51
|
+
<Button size="sm" variant="outline" onClick={() => setConfirmAction(null)}>Cancel</Button>
|
|
52
|
+
</>
|
|
53
|
+
) : (
|
|
54
|
+
<>
|
|
55
|
+
<Button size="sm" variant="outline" onClick={onForcePoll} disabled={polling}>
|
|
56
|
+
<RefreshCw className={`h-3.5 w-3.5 ${polling ? "animate-spin" : ""}`} />
|
|
57
|
+
Poll Now
|
|
58
|
+
</Button>
|
|
59
|
+
<Button size="sm" variant="outline" onClick={() => setConfirmAction("restart")}>
|
|
60
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
61
|
+
Restart
|
|
62
|
+
</Button>
|
|
63
|
+
<Button size="sm" variant="destructive" onClick={() => setConfirmAction("shutdown")}>
|
|
64
|
+
<Power className="h-3.5 w-3.5" />
|
|
65
|
+
Shutdown
|
|
66
|
+
</Button>
|
|
67
|
+
</>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
</header>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useMemo } from "react";
|
|
2
|
+
import { Badge } from "./ui/badge";
|
|
3
|
+
import { Button } from "./ui/button";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { ArrowDown } from "lucide-react";
|
|
6
|
+
import type { LogLine } from "../lib/use-sse";
|
|
7
|
+
|
|
8
|
+
interface LogStreamProps {
|
|
9
|
+
logs: LogLine[];
|
|
10
|
+
sourceFilter: string;
|
|
11
|
+
onSourceFilterChange: (source: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TYPE_COLORS: Record<LogLine["type"], string> = {
|
|
15
|
+
error: "text-destructive",
|
|
16
|
+
info: "text-blue-400",
|
|
17
|
+
comment: "text-muted-foreground",
|
|
18
|
+
line: "text-foreground",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function LogStream({ logs, sourceFilter, onSourceFilterChange }: LogStreamProps) {
|
|
22
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const [autoFollow, setAutoFollow] = useState(true);
|
|
24
|
+
|
|
25
|
+
// Derive unique sources
|
|
26
|
+
const sources = useMemo(() => {
|
|
27
|
+
const set = new Set<string>();
|
|
28
|
+
for (const log of logs) set.add(log.source);
|
|
29
|
+
return Array.from(set).sort();
|
|
30
|
+
}, [logs]);
|
|
31
|
+
|
|
32
|
+
const filteredLogs = useMemo(() => {
|
|
33
|
+
if (sourceFilter === "all") return logs;
|
|
34
|
+
return logs.filter((l) => l.source === sourceFilter);
|
|
35
|
+
}, [logs, sourceFilter]);
|
|
36
|
+
|
|
37
|
+
// Auto-scroll to bottom
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (autoFollow && containerRef.current) {
|
|
40
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
41
|
+
}
|
|
42
|
+
}, [filteredLogs, autoFollow]);
|
|
43
|
+
|
|
44
|
+
const handleScroll = () => {
|
|
45
|
+
if (!containerRef.current) return;
|
|
46
|
+
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
|
47
|
+
const atBottom = scrollHeight - scrollTop - clientHeight < 40;
|
|
48
|
+
setAutoFollow(atBottom);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-col flex-1 min-h-0">
|
|
53
|
+
<div className="flex items-center justify-between mb-2">
|
|
54
|
+
<h2 className="text-sm font-medium text-muted-foreground">
|
|
55
|
+
Logs
|
|
56
|
+
{sourceFilter !== "all" && (
|
|
57
|
+
<button
|
|
58
|
+
className="ml-2 text-xs text-blue-400 hover:text-blue-300"
|
|
59
|
+
onClick={() => onSourceFilterChange("all")}
|
|
60
|
+
>
|
|
61
|
+
(filtered: {sourceFilter}) ×
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</h2>
|
|
65
|
+
<div className="flex items-center gap-2">
|
|
66
|
+
<select
|
|
67
|
+
className="text-xs bg-muted border border-border rounded px-2 py-1 text-foreground"
|
|
68
|
+
value={sourceFilter}
|
|
69
|
+
onChange={(e) => onSourceFilterChange(e.target.value)}
|
|
70
|
+
>
|
|
71
|
+
<option value="all">All sources</option>
|
|
72
|
+
{sources.map((s) => (
|
|
73
|
+
<option key={s} value={s}>{s}</option>
|
|
74
|
+
))}
|
|
75
|
+
</select>
|
|
76
|
+
{!autoFollow && (
|
|
77
|
+
<Button size="sm" variant="ghost" onClick={() => setAutoFollow(true)}>
|
|
78
|
+
<ArrowDown className="h-3.5 w-3.5" />
|
|
79
|
+
</Button>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div
|
|
85
|
+
ref={containerRef}
|
|
86
|
+
onScroll={handleScroll}
|
|
87
|
+
className="flex-1 min-h-0 overflow-y-auto rounded-lg border border-border bg-black/30 p-2 font-mono text-xs leading-5"
|
|
88
|
+
>
|
|
89
|
+
{filteredLogs.map((log, i) => (
|
|
90
|
+
<div key={i} className="flex gap-2 hover:bg-muted/20 px-1">
|
|
91
|
+
<span className="text-muted-foreground shrink-0 w-[70px]">
|
|
92
|
+
{new Date(log.timestamp).toLocaleTimeString()}
|
|
93
|
+
</span>
|
|
94
|
+
<Badge
|
|
95
|
+
variant={log.source === "orchestrator" ? "secondary" : "outline"}
|
|
96
|
+
className="shrink-0 text-[10px] px-1.5 py-0 h-4 mt-0.5"
|
|
97
|
+
>
|
|
98
|
+
{log.source.length > 12 ? log.source.slice(0, 12) : log.source}
|
|
99
|
+
</Badge>
|
|
100
|
+
<span className={cn("break-all", TYPE_COLORS[log.type])}>
|
|
101
|
+
{log.message}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
{filteredLogs.length === 0 && (
|
|
106
|
+
<div className="text-muted-foreground text-center py-8">No logs yet</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Badge } from "./ui/badge";
|
|
2
|
+
import type { RuntimeSnapshot } from "../lib/use-sse";
|
|
3
|
+
|
|
4
|
+
function formatTimeUntil(dateStr: string): string {
|
|
5
|
+
const ms = new Date(dateStr).getTime() - Date.now();
|
|
6
|
+
if (ms <= 0) return "now";
|
|
7
|
+
const s = Math.ceil(ms / 1000);
|
|
8
|
+
if (s < 60) return `${s}s`;
|
|
9
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RetryTableProps {
|
|
13
|
+
retrying: RuntimeSnapshot["retrying"];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function RetryTable({ retrying }: RetryTableProps) {
|
|
17
|
+
if (retrying.length === 0) return null;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<h2 className="text-sm font-medium text-muted-foreground mb-2">Retrying</h2>
|
|
22
|
+
<div className="rounded-lg border border-border overflow-hidden">
|
|
23
|
+
<table className="w-full text-sm">
|
|
24
|
+
<thead>
|
|
25
|
+
<tr className="border-b border-border bg-muted/50">
|
|
26
|
+
<th className="text-left p-3 font-medium text-muted-foreground">Issue</th>
|
|
27
|
+
<th className="text-left p-3 font-medium text-muted-foreground">Workflow</th>
|
|
28
|
+
<th className="text-right p-3 font-medium text-muted-foreground">Attempt</th>
|
|
29
|
+
<th className="text-right p-3 font-medium text-muted-foreground">Retry In</th>
|
|
30
|
+
<th className="text-left p-3 font-medium text-muted-foreground">Error</th>
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
{retrying.map((entry) => (
|
|
35
|
+
<tr key={entry.issue_id} className="border-b border-border last:border-0 hover:bg-muted/30">
|
|
36
|
+
<td className="p-3 font-mono">{entry.identifier}</td>
|
|
37
|
+
<td className="p-3">
|
|
38
|
+
{entry.workflow ? (
|
|
39
|
+
<Badge variant="secondary">{entry.workflow}</Badge>
|
|
40
|
+
) : (
|
|
41
|
+
<span className="text-muted-foreground">-</span>
|
|
42
|
+
)}
|
|
43
|
+
</td>
|
|
44
|
+
<td className="p-3 text-right font-mono">#{entry.attempt}</td>
|
|
45
|
+
<td className="p-3 text-right font-mono">
|
|
46
|
+
<Badge variant="warning">{formatTimeUntil(entry.due_at)}</Badge>
|
|
47
|
+
</td>
|
|
48
|
+
<td className="p-3 text-muted-foreground truncate max-w-xs">
|
|
49
|
+
{entry.error ?? "-"}
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
))}
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|