openclaw-node-harness 2.0.4 → 2.1.1
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/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +439 -28
- package/bin/mesh-bridge.js +69 -3
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +821 -26
- package/bin/mesh.js +411 -20
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +296 -10
- package/lib/agent-activity.js +2 -2
- package/lib/circling-parser.js +119 -0
- package/lib/exec-safety.js +105 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +24 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +530 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +252 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +483 -165
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +79 -50
- package/lib/mesh-tasks.js +132 -49
- package/lib/nats-resolve.js +4 -4
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +322 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +461 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/memory/search/route.ts +6 -3
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/api/workspace/read/route.ts +11 -0
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +67 -0
- package/mission-control/src/lib/db/index.ts +85 -1
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/src/middleware.ts +82 -0
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/uninstall.sh +37 -9
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
|
@@ -7,7 +7,9 @@ import { statusToKanban } from "@/lib/parsers/task-markdown";
|
|
|
7
7
|
import { syncTasksFromMarkdownIfChanged, syncTasksToMarkdown } from "@/lib/sync/tasks";
|
|
8
8
|
import { logActivity } from "@/lib/activity";
|
|
9
9
|
import { schedulerTick } from "@/lib/scheduler";
|
|
10
|
-
import {
|
|
10
|
+
import { generateTaskId } from "@/lib/task-id";
|
|
11
|
+
import { getNats, sc } from "@/lib/nats";
|
|
12
|
+
import { WORKSPACE_ROOT, AGENT_NAME } from "@/lib/config";
|
|
11
13
|
import path from "path";
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -67,7 +69,7 @@ export async function GET(request: NextRequest) {
|
|
|
67
69
|
title: liveWork.title,
|
|
68
70
|
status: "running",
|
|
69
71
|
kanbanColumn: "in_progress",
|
|
70
|
-
owner:
|
|
72
|
+
owner: AGENT_NAME.toLowerCase(),
|
|
71
73
|
nextAction: liveWork.nextAction || null,
|
|
72
74
|
updatedAt: new Date().toISOString(),
|
|
73
75
|
createdAt: new Date().toISOString(),
|
|
@@ -186,6 +188,15 @@ export async function POST(request: NextRequest) {
|
|
|
186
188
|
isRecurring: body.is_recurring ? 1 : 0,
|
|
187
189
|
capacityClass: body.capacity_class || "normal",
|
|
188
190
|
autoPriority: body.auto_priority || 0,
|
|
191
|
+
// Execution fields
|
|
192
|
+
execution: body.execution || null,
|
|
193
|
+
collaboration: body.collaboration ? JSON.stringify(body.collaboration) : null,
|
|
194
|
+
preferredNodes: body.preferred_nodes?.length ? JSON.stringify(body.preferred_nodes) : null,
|
|
195
|
+
excludeNodes: body.exclude_nodes?.length ? JSON.stringify(body.exclude_nodes) : null,
|
|
196
|
+
clusterId: body.cluster_id || null,
|
|
197
|
+
metric: body.metric || null,
|
|
198
|
+
budgetMinutes: body.budget_minutes || 30,
|
|
199
|
+
scope: body.scope?.length ? JSON.stringify(body.scope) : null,
|
|
189
200
|
updatedAt: now.toISOString(),
|
|
190
201
|
createdAt: now.toISOString(),
|
|
191
202
|
})
|
|
@@ -200,6 +211,13 @@ export async function POST(request: NextRequest) {
|
|
|
200
211
|
// Immediately run scheduler tick so auto-start tasks dispatch without waiting for poll
|
|
201
212
|
schedulerTick();
|
|
202
213
|
|
|
214
|
+
// Wake bridge for mesh tasks
|
|
215
|
+
if (body.execution === "mesh") {
|
|
216
|
+
getNats().then((nc) => {
|
|
217
|
+
if (nc) nc.publish("mesh.bridge.wake", sc.encode(""));
|
|
218
|
+
}).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
const created = db
|
|
204
222
|
.select()
|
|
205
223
|
.from(tasks)
|
|
@@ -223,31 +241,4 @@ export async function POST(request: NextRequest) {
|
|
|
223
241
|
{ status: 500 }
|
|
224
242
|
);
|
|
225
243
|
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Generate a task ID in the format T-YYYYMMDD-NNN.
|
|
230
|
-
* NNN is a zero-padded sequence number based on existing tasks for that date.
|
|
231
|
-
*/
|
|
232
|
-
function generateTaskId(db: ReturnType<typeof getDb>, date: Date): string {
|
|
233
|
-
const dateStr =
|
|
234
|
-
date.getFullYear().toString() +
|
|
235
|
-
(date.getMonth() + 1).toString().padStart(2, "0") +
|
|
236
|
-
date.getDate().toString().padStart(2, "0");
|
|
237
|
-
|
|
238
|
-
const prefix = `T-${dateStr}-`;
|
|
239
|
-
|
|
240
|
-
// Find highest existing sequence number for this date prefix
|
|
241
|
-
const existing = db
|
|
242
|
-
.select({ id: tasks.id })
|
|
243
|
-
.from(tasks)
|
|
244
|
-
.all()
|
|
245
|
-
.filter((t) => t.id.startsWith(prefix))
|
|
246
|
-
.map((t) => {
|
|
247
|
-
const seq = parseInt(t.id.slice(prefix.length), 10);
|
|
248
|
-
return isNaN(seq) ? 0 : seq;
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
const nextSeq = existing.length > 0 ? Math.max(...existing) + 1 : 1;
|
|
252
|
-
return `${prefix}${nextSeq.toString().padStart(3, "0")}`;
|
|
253
|
-
}
|
|
244
|
+
}
|
|
@@ -25,6 +25,17 @@ export async function GET(request: NextRequest) {
|
|
|
25
25
|
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Defeat symlink traversal: resolve the real path and re-check prefix
|
|
29
|
+
const realPath = fs.realpathSync(absPath);
|
|
30
|
+
const realRoot = fs.realpathSync(WORKSPACE_ROOT);
|
|
31
|
+
if (!realPath.startsWith(realRoot + path.sep) && realPath !== realRoot) {
|
|
32
|
+
return NextResponse.json({ error: "Path traversal denied" }, { status: 403 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(realPath)) {
|
|
36
|
+
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
const stat = fs.statSync(absPath);
|
|
29
40
|
if (stat.isDirectory()) {
|
|
30
41
|
return NextResponse.json({ error: "Path is a directory" }, { status: 400 });
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from "react";
|
|
4
|
+
import { AlertTriangle, Plus, Users2 } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
useCollabSessions,
|
|
7
|
+
useClusters,
|
|
8
|
+
useTasks,
|
|
9
|
+
addClusterMember,
|
|
10
|
+
} from "@/lib/hooks";
|
|
11
|
+
import { SessionCard } from "@/components/cowork/session-card";
|
|
12
|
+
import { ClusterCard } from "@/components/cowork/cluster-card";
|
|
13
|
+
import { CreateClusterDialog } from "@/components/cowork/create-cluster-dialog";
|
|
14
|
+
import { DispatchForm } from "@/components/cowork/dispatch-form";
|
|
15
|
+
import useSWR from "swr";
|
|
16
|
+
import { RolePicker } from "@/components/cowork/role-picker";
|
|
17
|
+
|
|
18
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
19
|
+
|
|
20
|
+
type Tab = "sessions" | "clusters" | "dispatch";
|
|
21
|
+
|
|
22
|
+
export default function CoworkPage() {
|
|
23
|
+
const [tab, setTab] = useState<Tab>("sessions");
|
|
24
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
25
|
+
const [addNodeTarget, setAddNodeTarget] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
const { sessions: activeSessions, natsAvailable } = useCollabSessions(
|
|
28
|
+
"recruiting,active",
|
|
29
|
+
5000
|
|
30
|
+
);
|
|
31
|
+
const { sessions: recentSessions } = useCollabSessions(
|
|
32
|
+
"converged,completed,aborted",
|
|
33
|
+
30000
|
|
34
|
+
);
|
|
35
|
+
const { clusters } = useClusters();
|
|
36
|
+
const { tasks } = useTasks();
|
|
37
|
+
|
|
38
|
+
// Build a map from task_id to Task for cross-referencing sessions
|
|
39
|
+
const taskMap = useMemo(() => {
|
|
40
|
+
const map = new Map<string, typeof tasks[0]>();
|
|
41
|
+
for (const t of tasks) map.set(t.id, t);
|
|
42
|
+
return map;
|
|
43
|
+
}, [tasks]);
|
|
44
|
+
|
|
45
|
+
const handleDispatchFromCluster = (clusterId: string) => {
|
|
46
|
+
setTab("dispatch");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-col h-full">
|
|
51
|
+
{/* Header */}
|
|
52
|
+
<header className="flex items-center justify-between border-b border-border px-6 py-4">
|
|
53
|
+
<div className="flex items-center gap-4">
|
|
54
|
+
<h1 className="text-lg font-semibold">Cowork</h1>
|
|
55
|
+
{activeSessions.length > 0 && (
|
|
56
|
+
<span className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs text-green-400">
|
|
57
|
+
<span className="h-1.5 w-1.5 rounded-full bg-green-400 animate-pulse" />
|
|
58
|
+
{activeSessions.length} active
|
|
59
|
+
</span>
|
|
60
|
+
)}
|
|
61
|
+
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs text-muted-foreground">
|
|
62
|
+
{clusters.length} clusters
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Tab bar */}
|
|
67
|
+
<div className="flex rounded-lg bg-accent/50 p-0.5">
|
|
68
|
+
{(["sessions", "clusters", "dispatch"] as Tab[]).map((t) => (
|
|
69
|
+
<button
|
|
70
|
+
key={t}
|
|
71
|
+
onClick={() => setTab(t)}
|
|
72
|
+
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
73
|
+
tab === t
|
|
74
|
+
? "bg-card text-foreground shadow-sm"
|
|
75
|
+
: "text-muted-foreground hover:text-foreground"
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
{t.charAt(0).toUpperCase() + t.slice(1)}
|
|
79
|
+
</button>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
{/* Content */}
|
|
85
|
+
<main className="flex-1 overflow-y-auto p-6 space-y-6">
|
|
86
|
+
{/* Sessions tab */}
|
|
87
|
+
{tab === "sessions" && (
|
|
88
|
+
<>
|
|
89
|
+
{!natsAvailable && (
|
|
90
|
+
<div className="flex items-center gap-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5 px-4 py-3 text-xs text-yellow-400">
|
|
91
|
+
<AlertTriangle className="h-4 w-4" />
|
|
92
|
+
NATS unavailable — session data may be stale
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{activeSessions.length > 0 && (
|
|
97
|
+
<section>
|
|
98
|
+
<h2 className="text-sm font-medium text-muted-foreground mb-3">
|
|
99
|
+
Active Sessions
|
|
100
|
+
</h2>
|
|
101
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
102
|
+
{activeSessions.map((s) => (
|
|
103
|
+
<SessionCard key={s.session_id} session={s} linkedTask={taskMap.get(s.task_id)} />
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{recentSessions.length > 0 && (
|
|
110
|
+
<section>
|
|
111
|
+
<h2 className="text-sm font-medium text-muted-foreground mb-3">
|
|
112
|
+
Recent Sessions
|
|
113
|
+
</h2>
|
|
114
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
115
|
+
{recentSessions.slice(0, 8).map((s) => (
|
|
116
|
+
<SessionCard key={s.session_id} session={s} linkedTask={taskMap.get(s.task_id)} />
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{activeSessions.length === 0 && recentSessions.length === 0 && (
|
|
123
|
+
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
124
|
+
<Users2 className="h-8 w-8 mb-3 opacity-30" />
|
|
125
|
+
<p className="text-sm">No collab sessions yet</p>
|
|
126
|
+
<p className="text-xs mt-1">
|
|
127
|
+
Dispatch a task from the Dispatch tab to start one
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Clusters tab */}
|
|
135
|
+
{tab === "clusters" && (
|
|
136
|
+
<>
|
|
137
|
+
<div className="flex items-center justify-between">
|
|
138
|
+
<h2 className="text-sm font-medium text-muted-foreground">
|
|
139
|
+
Clusters
|
|
140
|
+
</h2>
|
|
141
|
+
<button
|
|
142
|
+
onClick={() => setCreateOpen(true)}
|
|
143
|
+
className="flex items-center gap-1 rounded px-3 py-1.5 text-xs bg-accent-foreground text-accent transition-colors hover:opacity-90"
|
|
144
|
+
>
|
|
145
|
+
<Plus className="h-3.5 w-3.5" />
|
|
146
|
+
New Cluster
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{clusters.length > 0 ? (
|
|
151
|
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
152
|
+
{clusters.map((c) => (
|
|
153
|
+
<ClusterCard
|
|
154
|
+
key={c.id}
|
|
155
|
+
cluster={c}
|
|
156
|
+
onDispatch={handleDispatchFromCluster}
|
|
157
|
+
onAddNode={(id) => setAddNodeTarget(id)}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
) : (
|
|
162
|
+
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
163
|
+
<Users2 className="h-8 w-8 mb-3 opacity-30" />
|
|
164
|
+
<p className="text-sm">No clusters yet</p>
|
|
165
|
+
<p className="text-xs mt-1">
|
|
166
|
+
Create one to organize your mesh nodes into teams
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
<CreateClusterDialog
|
|
172
|
+
open={createOpen}
|
|
173
|
+
onClose={() => setCreateOpen(false)}
|
|
174
|
+
/>
|
|
175
|
+
|
|
176
|
+
{/* Add node mini-dialog */}
|
|
177
|
+
{addNodeTarget && (
|
|
178
|
+
<AddNodeDialog
|
|
179
|
+
clusterId={addNodeTarget}
|
|
180
|
+
existingNodeIds={
|
|
181
|
+
clusters
|
|
182
|
+
.find((c) => c.id === addNodeTarget)
|
|
183
|
+
?.members.map((m) => m.nodeId) ?? []
|
|
184
|
+
}
|
|
185
|
+
onClose={() => setAddNodeTarget(null)}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* Dispatch tab */}
|
|
192
|
+
{tab === "dispatch" && <DispatchForm />}
|
|
193
|
+
</main>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function AddNodeDialog({
|
|
199
|
+
clusterId,
|
|
200
|
+
existingNodeIds,
|
|
201
|
+
onClose,
|
|
202
|
+
}: {
|
|
203
|
+
clusterId: string;
|
|
204
|
+
existingNodeIds: string[];
|
|
205
|
+
onClose: () => void;
|
|
206
|
+
}) {
|
|
207
|
+
const { data } = useSWR<{
|
|
208
|
+
nodes: Array<{ nodeId: string; status: string }>;
|
|
209
|
+
}>("/api/mesh/nodes", fetcher);
|
|
210
|
+
const meshNodes = (data?.nodes ?? []).filter(
|
|
211
|
+
(n) => !existingNodeIds.includes(n.nodeId)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const [selectedRole, setSelectedRole] = useState("worker");
|
|
215
|
+
|
|
216
|
+
const handleAdd = async (nodeId: string) => {
|
|
217
|
+
await addClusterMember(clusterId, nodeId, selectedRole);
|
|
218
|
+
onClose();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
223
|
+
<div className="w-full max-w-sm rounded-lg border border-border bg-card shadow-xl">
|
|
224
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
225
|
+
<span className="text-sm font-semibold">Add Node</span>
|
|
226
|
+
<button
|
|
227
|
+
onClick={onClose}
|
|
228
|
+
className="text-muted-foreground hover:text-foreground text-xs"
|
|
229
|
+
>
|
|
230
|
+
close
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="px-4 py-3 space-y-2">
|
|
234
|
+
<div className="flex items-center gap-2 mb-3">
|
|
235
|
+
<span className="text-xs text-muted-foreground">Role:</span>
|
|
236
|
+
<RolePicker value={selectedRole} onChange={setSelectedRole} />
|
|
237
|
+
</div>
|
|
238
|
+
{meshNodes.map((node) => (
|
|
239
|
+
<button
|
|
240
|
+
key={node.nodeId}
|
|
241
|
+
onClick={() => handleAdd(node.nodeId)}
|
|
242
|
+
className="flex w-full items-center gap-2 rounded px-3 py-2 text-xs hover:bg-accent/50 transition-colors"
|
|
243
|
+
>
|
|
244
|
+
<span
|
|
245
|
+
className={`h-2 w-2 rounded-full ${
|
|
246
|
+
node.status === "online" ? "bg-green-400" : "bg-zinc-600"
|
|
247
|
+
}`}
|
|
248
|
+
/>
|
|
249
|
+
<span className="font-mono">{node.nodeId}</span>
|
|
250
|
+
</button>
|
|
251
|
+
))}
|
|
252
|
+
{meshNodes.length === 0 && (
|
|
253
|
+
<p className="text-xs text-muted-foreground py-2">
|
|
254
|
+
All nodes already in this cluster
|
|
255
|
+
</p>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|