create-interview-cockpit 0.22.0 → 0.23.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/package.json +1 -1
- package/template/client/src/api.ts +2 -0
- package/template/client/src/components/GhaConcurrencyPanel.tsx +281 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1238 -33
- package/template/client/src/components/WorkspaceSwitcher.tsx +6 -1
- package/template/client/src/ghaConcurrency.ts +216 -0
- package/template/client/src/githubActionsLab.ts +41 -0
- package/template/client/src/types.ts +17 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +198 -1
- package/template/server/src/google-drive.ts +25 -9
- package/template/server/src/index.ts +0 -1
package/package.json
CHANGED
|
@@ -549,11 +549,13 @@ export async function streamGhaCommand(
|
|
|
549
549
|
workspace: GithubActionsLabWorkspace;
|
|
550
550
|
},
|
|
551
551
|
onMessage: (message: GhaStreamMessage) => void,
|
|
552
|
+
options?: { signal?: AbortSignal },
|
|
552
553
|
): Promise<void> {
|
|
553
554
|
const res = await fetch(`${BASE}/gha/run-stream`, {
|
|
554
555
|
method: "POST",
|
|
555
556
|
headers: { "Content-Type": "application/json" },
|
|
556
557
|
body: JSON.stringify(input),
|
|
558
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
557
559
|
});
|
|
558
560
|
if (!res.ok || !res.body) {
|
|
559
561
|
const err = await res.text().catch(() => "act run failed");
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { GitBranch, Info, Layers, Trash2, XCircle } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
evaluateConcurrencyFor,
|
|
5
|
+
type GhaConcurrencyContext,
|
|
6
|
+
type GhaConcurrencyRun,
|
|
7
|
+
type ParsedConcurrency,
|
|
8
|
+
} from "../ghaConcurrency";
|
|
9
|
+
|
|
10
|
+
// ─── Status pill ────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function StatusPill({ status }: { status: GhaConcurrencyRun["status"] }) {
|
|
13
|
+
const cls =
|
|
14
|
+
status === "running"
|
|
15
|
+
? "bg-amber-500/15 text-amber-200 border-amber-500/30"
|
|
16
|
+
: status === "pending"
|
|
17
|
+
? "bg-slate-700/40 text-slate-300 border-slate-600/40"
|
|
18
|
+
: status === "completed"
|
|
19
|
+
? "bg-emerald-500/15 text-emerald-200 border-emerald-500/30"
|
|
20
|
+
: "bg-red-500/10 text-red-300 border-red-500/30";
|
|
21
|
+
return (
|
|
22
|
+
<span
|
|
23
|
+
className={`inline-block rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wide ${cls}`}
|
|
24
|
+
>
|
|
25
|
+
{status}
|
|
26
|
+
</span>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Component ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
parsed: ParsedConcurrency | null;
|
|
34
|
+
workflowPath: string;
|
|
35
|
+
runs: GhaConcurrencyRun[];
|
|
36
|
+
context: GhaConcurrencyContext;
|
|
37
|
+
onContextChange: (next: GhaConcurrencyContext) => void;
|
|
38
|
+
onClearRuns: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Live viewer of how GitHub's concurrency rules are being applied to the
|
|
43
|
+
* real `act` runs triggered from the lab. The user edits the simulated
|
|
44
|
+
* `github.*` context here; that context is then used at Run-button time
|
|
45
|
+
* to compute (groupKey, cancelInProgress) for the new run.
|
|
46
|
+
*/
|
|
47
|
+
export default function GhaConcurrencyPanel({
|
|
48
|
+
parsed,
|
|
49
|
+
workflowPath,
|
|
50
|
+
runs,
|
|
51
|
+
context,
|
|
52
|
+
onContextChange,
|
|
53
|
+
onClearRuns,
|
|
54
|
+
}: Props) {
|
|
55
|
+
// Preview the (groupKey, cancelInProgress) the NEXT triggered run would
|
|
56
|
+
// get with the current context — useful so the user can confirm their
|
|
57
|
+
// setup before clicking Run.
|
|
58
|
+
const preview = useMemo(
|
|
59
|
+
() => evaluateConcurrencyFor(parsed, context),
|
|
60
|
+
[parsed, context],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const groups = useMemo(() => {
|
|
64
|
+
const map = new Map<string, GhaConcurrencyRun[]>();
|
|
65
|
+
for (const r of runs) {
|
|
66
|
+
if (!map.has(r.groupKey)) map.set(r.groupKey, []);
|
|
67
|
+
map.get(r.groupKey)!.push(r);
|
|
68
|
+
}
|
|
69
|
+
return Array.from(map.entries()).map(([key, items]) => ({
|
|
70
|
+
key,
|
|
71
|
+
items: items.slice().sort((a, b) => a.seq - b.seq),
|
|
72
|
+
}));
|
|
73
|
+
}, [runs]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex-1 min-h-0 overflow-auto p-3 text-xs text-slate-300 space-y-3">
|
|
77
|
+
{/* ── Explanation ──────────────────────────────────────────────── */}
|
|
78
|
+
<section className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
|
|
79
|
+
<div className="mb-1 flex items-center gap-2 text-sm font-semibold text-amber-200">
|
|
80
|
+
<GitBranch className="h-4 w-4" />
|
|
81
|
+
Concurrency applied to real runs
|
|
82
|
+
</div>
|
|
83
|
+
<p className="text-slate-400 leading-5">
|
|
84
|
+
Click <span className="text-amber-200">Run</span> in the toolbar to
|
|
85
|
+
trigger a real <span className="text-amber-200">act</span> invocation.
|
|
86
|
+
When you click Run again while one is still in flight the lab will
|
|
87
|
+
apply GitHub's rules: same group with{" "}
|
|
88
|
+
<span className="text-amber-200">cancel-in-progress: true</span>{" "}
|
|
89
|
+
cancels the active run, otherwise the new run is queued behind it.
|
|
90
|
+
Different group keys queue too (the console only shows one run at a
|
|
91
|
+
time), and would run in parallel on real GitHub.
|
|
92
|
+
</p>
|
|
93
|
+
</section>
|
|
94
|
+
|
|
95
|
+
{/* ── Parsed block ─────────────────────────────────────────────── */}
|
|
96
|
+
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-3">
|
|
97
|
+
<div className="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-200">
|
|
98
|
+
<Layers className="h-3.5 w-3.5 text-amber-300" />
|
|
99
|
+
Workflow concurrency block
|
|
100
|
+
<span className="ml-auto font-normal normal-case text-slate-500 truncate">
|
|
101
|
+
{workflowPath || <em>no workflow selected</em>}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
{parsed ? (
|
|
105
|
+
<div className="grid grid-cols-[110px_1fr] gap-y-1 text-[11px] font-mono">
|
|
106
|
+
<span className="text-slate-500">group</span>
|
|
107
|
+
<span className="text-slate-200 break-all">{parsed.groupExpr}</span>
|
|
108
|
+
<span className="text-slate-500">cancel</span>
|
|
109
|
+
<span className="text-slate-200 break-all">
|
|
110
|
+
{parsed.cancelExpr}
|
|
111
|
+
</span>
|
|
112
|
+
<span className="text-slate-500">next → key</span>
|
|
113
|
+
<span className="text-amber-200 break-all">
|
|
114
|
+
{preview.groupKey || <em className="text-slate-600">empty</em>}
|
|
115
|
+
</span>
|
|
116
|
+
<span className="text-slate-500">next → cancel?</span>
|
|
117
|
+
<span
|
|
118
|
+
className={
|
|
119
|
+
preview.cancelInProgress ? "text-emerald-300" : "text-slate-400"
|
|
120
|
+
}
|
|
121
|
+
>
|
|
122
|
+
{preview.cancelInProgress ? "true" : "false"}
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<div className="text-slate-400 leading-5">
|
|
127
|
+
No <span className="text-amber-200">concurrency:</span> block in
|
|
128
|
+
this workflow. New runs queue serially in the lab; GitHub would run
|
|
129
|
+
them in parallel. Add a block to try it:
|
|
130
|
+
<pre className="mt-2 rounded-lg border border-slate-800 bg-slate-950/70 p-2 text-[11px] text-slate-200 overflow-auto">{`concurrency:
|
|
131
|
+
group: ci-\${{ github.event_name }}-\${{ github.head_ref || github.ref }}
|
|
132
|
+
cancel-in-progress: \${{ github.event_name == 'pull_request' }}`}</pre>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</section>
|
|
136
|
+
|
|
137
|
+
{/* ── Simulated github.* context ───────────────────────────────── */}
|
|
138
|
+
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-3">
|
|
139
|
+
<div className="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-200">
|
|
140
|
+
<Info className="h-3.5 w-3.5 text-amber-300" />
|
|
141
|
+
Simulated github context (applied to the next run)
|
|
142
|
+
</div>
|
|
143
|
+
<p className="mb-2 text-slate-500 leading-5">
|
|
144
|
+
<span className="text-amber-200">github.event_name</span> comes from
|
|
145
|
+
the toolbar Event selector; the rest is edited here. These values are
|
|
146
|
+
used only to evaluate the concurrency expression — the actual act
|
|
147
|
+
invocation isn't affected.
|
|
148
|
+
</p>
|
|
149
|
+
<div className="grid grid-cols-2 gap-2">
|
|
150
|
+
{(
|
|
151
|
+
[
|
|
152
|
+
{ key: "ref", label: "github.ref" },
|
|
153
|
+
{ key: "head_ref", label: "github.head_ref" },
|
|
154
|
+
{ key: "workflow", label: "github.workflow" },
|
|
155
|
+
] as const
|
|
156
|
+
).map((field) => (
|
|
157
|
+
<label key={field.key} className="block">
|
|
158
|
+
<span className="block text-[10px] uppercase tracking-wider text-slate-500">
|
|
159
|
+
{field.label}
|
|
160
|
+
</span>
|
|
161
|
+
<input
|
|
162
|
+
value={context[field.key]}
|
|
163
|
+
onChange={(e) =>
|
|
164
|
+
onContextChange({ ...context, [field.key]: e.target.value })
|
|
165
|
+
}
|
|
166
|
+
className="mt-0.5 w-full rounded border border-slate-800 bg-slate-950/60 px-2 py-1 font-mono text-[11px] text-slate-200 outline-none focus:border-amber-500/40"
|
|
167
|
+
/>
|
|
168
|
+
</label>
|
|
169
|
+
))}
|
|
170
|
+
<div>
|
|
171
|
+
<span className="block text-[10px] uppercase tracking-wider text-slate-500">
|
|
172
|
+
github.event_name
|
|
173
|
+
</span>
|
|
174
|
+
<div className="mt-0.5 w-full rounded border border-slate-800 bg-slate-950/30 px-2 py-1 font-mono text-[11px] text-slate-500">
|
|
175
|
+
{context.event_name}{" "}
|
|
176
|
+
<span className="text-slate-600">(from toolbar)</span>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</section>
|
|
181
|
+
|
|
182
|
+
{/* ── Run timeline ─────────────────────────────────────────────── */}
|
|
183
|
+
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-3">
|
|
184
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
185
|
+
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-200">
|
|
186
|
+
<Layers className="h-3.5 w-3.5 text-amber-300" />
|
|
187
|
+
Runs grouped by concurrency key
|
|
188
|
+
</div>
|
|
189
|
+
{runs.length > 0 && (
|
|
190
|
+
<button
|
|
191
|
+
onClick={onClearRuns}
|
|
192
|
+
className="text-slate-500 hover:text-slate-200"
|
|
193
|
+
title="Clear concurrency history"
|
|
194
|
+
>
|
|
195
|
+
<Trash2 className="h-3 w-3" />
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{runs.length === 0 ? (
|
|
201
|
+
<p className="text-slate-500 leading-5">
|
|
202
|
+
No runs yet. Click <span className="text-amber-200">Run</span> in
|
|
203
|
+
the toolbar twice in a row to watch the rules kick in.
|
|
204
|
+
</p>
|
|
205
|
+
) : (
|
|
206
|
+
<div className="space-y-3">
|
|
207
|
+
{groups.map((g) => (
|
|
208
|
+
<div
|
|
209
|
+
key={g.key || "__no_group__"}
|
|
210
|
+
className="rounded-lg border border-slate-800 bg-slate-950/40 p-2"
|
|
211
|
+
>
|
|
212
|
+
<div className="mb-1.5 flex items-center justify-between gap-2 text-[10px] font-mono text-slate-400">
|
|
213
|
+
<span className="text-amber-200 break-all">
|
|
214
|
+
{g.key || (
|
|
215
|
+
<em className="text-slate-600">(no concurrency block)</em>
|
|
216
|
+
)}
|
|
217
|
+
</span>
|
|
218
|
+
<span className="text-slate-600">
|
|
219
|
+
{g.items.length} run(s)
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
<ul className="space-y-1">
|
|
223
|
+
{g.items.map((r) => (
|
|
224
|
+
<RunRow key={r.id} run={r} />
|
|
225
|
+
))}
|
|
226
|
+
</ul>
|
|
227
|
+
</div>
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</section>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function RunRow({ run }: { run: GhaConcurrencyRun }) {
|
|
237
|
+
// Re-render this single row every 250ms while running so the elapsed
|
|
238
|
+
// timer ticks. The rest of the panel stays still.
|
|
239
|
+
const [, force] = useState(0);
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (run.status !== "running") return;
|
|
242
|
+
const id = window.setInterval(() => force((n) => n + 1), 250);
|
|
243
|
+
return () => window.clearInterval(id);
|
|
244
|
+
}, [run.status]);
|
|
245
|
+
|
|
246
|
+
const elapsed = (() => {
|
|
247
|
+
if (run.startedAt == null) return null;
|
|
248
|
+
const end = run.endedAt ?? Date.now();
|
|
249
|
+
return Math.max(0, end - run.startedAt);
|
|
250
|
+
})();
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<li className="rounded border border-slate-800/70 bg-slate-900/40 p-1.5">
|
|
254
|
+
<div className="flex items-center gap-2 text-[11px]">
|
|
255
|
+
<span className="font-mono text-slate-500">#{run.seq}</span>
|
|
256
|
+
<StatusPill status={run.status} />
|
|
257
|
+
<span className="font-mono text-slate-400 truncate">
|
|
258
|
+
{run.eventName} ·{" "}
|
|
259
|
+
{run.context.head_ref || run.context.ref || run.workflowPath}
|
|
260
|
+
</span>
|
|
261
|
+
<span className="ml-auto font-mono text-slate-500">
|
|
262
|
+
{elapsed != null ? `${(elapsed / 1000).toFixed(1)}s` : ""}
|
|
263
|
+
{run.exitCode != null && (
|
|
264
|
+
<span className="ml-1 text-slate-600">exit={run.exitCode}</span>
|
|
265
|
+
)}
|
|
266
|
+
</span>
|
|
267
|
+
{run.status === "cancelled" && (
|
|
268
|
+
<XCircle className="h-3 w-3 text-red-400" />
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
<div className="mt-0.5 truncate font-mono text-[10px] text-slate-600">
|
|
272
|
+
{run.command}
|
|
273
|
+
</div>
|
|
274
|
+
{run.cancelReason && (
|
|
275
|
+
<div className="mt-1 text-[10px] text-red-300/80">
|
|
276
|
+
{run.cancelReason}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</li>
|
|
280
|
+
);
|
|
281
|
+
}
|