create-interview-cockpit 0.23.0 → 0.23.2
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 +487 -15
- package/template/client/src/components/Sidebar.tsx +268 -49
- 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 +157 -1
- package/template/server/src/google-drive.ts +32 -9
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
Copy,
|
|
6
6
|
FilePlus,
|
|
7
7
|
Folder,
|
|
8
|
+
GitBranch,
|
|
9
|
+
KeyRound,
|
|
8
10
|
ListChecks,
|
|
9
11
|
Loader2,
|
|
10
12
|
Maximize2,
|
|
@@ -37,10 +39,19 @@ import {
|
|
|
37
39
|
serializeGhaLabWorkspace,
|
|
38
40
|
} from "../githubActionsLab";
|
|
39
41
|
import type { GithubActionsLabWorkspace } from "../types";
|
|
42
|
+
import type { GithubActionsLabEnvironmentEntry } from "../types";
|
|
40
43
|
import * as api from "../api";
|
|
41
44
|
import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
|
|
42
45
|
import GhaJobsPanel from "./GhaJobsPanel";
|
|
43
46
|
import GhaHistoryPanel from "./GhaHistoryPanel";
|
|
47
|
+
import GhaConcurrencyPanel from "./GhaConcurrencyPanel";
|
|
48
|
+
import {
|
|
49
|
+
defaultContextForEvent,
|
|
50
|
+
evaluateConcurrencyFor,
|
|
51
|
+
parseConcurrencyBlock,
|
|
52
|
+
type GhaConcurrencyContext,
|
|
53
|
+
type GhaConcurrencyRun,
|
|
54
|
+
} from "../ghaConcurrency";
|
|
44
55
|
|
|
45
56
|
// ─── Modal layout constants ──────────────────────────────────────────────
|
|
46
57
|
|
|
@@ -226,6 +237,44 @@ interface ConsoleLine {
|
|
|
226
237
|
text: string;
|
|
227
238
|
}
|
|
228
239
|
|
|
240
|
+
type GhaEnvironmentKind = "variables" | "secrets" | "env";
|
|
241
|
+
|
|
242
|
+
const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
243
|
+
|
|
244
|
+
const GHA_ENVIRONMENT_SECTIONS: Array<{
|
|
245
|
+
kind: GhaEnvironmentKind;
|
|
246
|
+
title: string;
|
|
247
|
+
addLabel: string;
|
|
248
|
+
namePlaceholder: string;
|
|
249
|
+
valuePlaceholder: string;
|
|
250
|
+
help: string;
|
|
251
|
+
}> = [
|
|
252
|
+
{
|
|
253
|
+
kind: "variables",
|
|
254
|
+
title: "Repository variables",
|
|
255
|
+
addLabel: "Add variable",
|
|
256
|
+
namePlaceholder: "API_URL",
|
|
257
|
+
valuePlaceholder: "https://example.test",
|
|
258
|
+
help: "Available in workflows as `${{ vars.NAME }}` through act's --var-file.",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
kind: "secrets",
|
|
262
|
+
title: "Repository secrets",
|
|
263
|
+
addLabel: "Add secret",
|
|
264
|
+
namePlaceholder: "NPM_TOKEN",
|
|
265
|
+
valuePlaceholder: "secret value",
|
|
266
|
+
help: "Available in workflows as `${{ secrets.NAME }}` through act's --secret-file.",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
kind: "env",
|
|
270
|
+
title: "Runner environment",
|
|
271
|
+
addLabel: "Add env",
|
|
272
|
+
namePlaceholder: "NODE_ENV",
|
|
273
|
+
valuePlaceholder: "test",
|
|
274
|
+
help: "Available to shell steps as environment variables, e.g. `$NAME`, through act's --env-file.",
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
|
|
229
278
|
export default function GithubActionsLabModal() {
|
|
230
279
|
const {
|
|
231
280
|
closeGhaLab,
|
|
@@ -279,15 +328,55 @@ export default function GithubActionsLabModal() {
|
|
|
279
328
|
// Live job snapshots reported by the server during the active run.
|
|
280
329
|
// Reset every time the user kicks off a new run.
|
|
281
330
|
const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
|
|
282
|
-
// "console" | "jobs" | "history" — controls the right pane tab.
|
|
283
|
-
const [rightTab, setRightTab] = useState<
|
|
284
|
-
"console"
|
|
331
|
+
// "console" | "jobs" | "env" | "concurrency" | "history" — controls the right pane tab.
|
|
332
|
+
const [rightTab, setRightTab] = useState<
|
|
333
|
+
"console" | "jobs" | "env" | "concurrency" | "history"
|
|
334
|
+
>("console");
|
|
335
|
+
const environmentEntryCount = useMemo(
|
|
336
|
+
() =>
|
|
337
|
+
GHA_ENVIRONMENT_SECTIONS.reduce((total, section) => {
|
|
338
|
+
return (
|
|
339
|
+
total +
|
|
340
|
+
(workspace.environment?.[section.kind] ?? []).filter(
|
|
341
|
+
(entry) => entry.enabled !== false && entry.name.trim(),
|
|
342
|
+
).length
|
|
343
|
+
);
|
|
344
|
+
}, 0),
|
|
345
|
+
[workspace.environment],
|
|
285
346
|
);
|
|
286
347
|
// Bumped each time a run completes so the History tab refetches.
|
|
287
348
|
const [historyNonce, setHistoryNonce] = useState(0);
|
|
288
349
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
289
350
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
290
351
|
const abortRef = useRef<AbortController | null>(null);
|
|
352
|
+
// ── Concurrency engine state ───────────────────────────────────────
|
|
353
|
+
// The concurrency tab is no longer a simulator — these records track
|
|
354
|
+
// real `act` invocations so we can apply GitHub's queue/cancel rules
|
|
355
|
+
// when the user clicks Run a second time before the first finishes.
|
|
356
|
+
const [concurrencyRuns, setConcurrencyRuns] = useState<GhaConcurrencyRun[]>(
|
|
357
|
+
[],
|
|
358
|
+
);
|
|
359
|
+
const [concurrencyContext, setConcurrencyContext] =
|
|
360
|
+
useState<GhaConcurrencyContext>(() =>
|
|
361
|
+
defaultContextForEvent(
|
|
362
|
+
workspace.defaultEvent ?? "push",
|
|
363
|
+
workspace.defaultWorkflow ?? ".github/workflows/ci.yml",
|
|
364
|
+
"main",
|
|
365
|
+
42,
|
|
366
|
+
"feature/login",
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
// Mirrors of state used inside async callbacks to dodge stale closures
|
|
370
|
+
// when the runner finishes and needs to drain the next pending run.
|
|
371
|
+
const concurrencyRunsRef = useRef<GhaConcurrencyRun[]>([]);
|
|
372
|
+
const activeRunIdRef = useRef<string | null>(null);
|
|
373
|
+
const runSeqRef = useRef(0);
|
|
374
|
+
const runConcurrencyRunRef = useRef<
|
|
375
|
+
((run: GhaConcurrencyRun) => Promise<void>) | null
|
|
376
|
+
>(null);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
concurrencyRunsRef.current = concurrencyRuns;
|
|
379
|
+
}, [concurrencyRuns]);
|
|
291
380
|
const consoleEndRef = useRef<HTMLDivElement | null>(null);
|
|
292
381
|
const monacoRef = useRef<Monaco | null>(null);
|
|
293
382
|
const monacoModelUrisRef = useRef<Set<string>>(new Set());
|
|
@@ -874,6 +963,48 @@ export default function GithubActionsLabModal() {
|
|
|
874
963
|
setBulkMenuOpen(false);
|
|
875
964
|
};
|
|
876
965
|
|
|
966
|
+
// ── GitHub Actions environment inputs ─────────────────────────────
|
|
967
|
+
const getEnvironmentRows = (kind: GhaEnvironmentKind) =>
|
|
968
|
+
workspace.environment?.[kind] ?? [];
|
|
969
|
+
|
|
970
|
+
const setEnvironmentRows = (
|
|
971
|
+
kind: GhaEnvironmentKind,
|
|
972
|
+
rows: GithubActionsLabEnvironmentEntry[],
|
|
973
|
+
) => {
|
|
974
|
+
setWorkspace((prev) => ({
|
|
975
|
+
...prev,
|
|
976
|
+
environment: {
|
|
977
|
+
...(prev.environment ?? {}),
|
|
978
|
+
[kind]: rows,
|
|
979
|
+
},
|
|
980
|
+
}));
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const addEnvironmentEntry = (kind: GhaEnvironmentKind) => {
|
|
984
|
+
const rows = getEnvironmentRows(kind);
|
|
985
|
+
setEnvironmentRows(kind, [...rows, { name: "", value: "", enabled: true }]);
|
|
986
|
+
setRightTab("env");
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
const updateEnvironmentEntry = (
|
|
990
|
+
kind: GhaEnvironmentKind,
|
|
991
|
+
index: number,
|
|
992
|
+
patch: Partial<GithubActionsLabEnvironmentEntry>,
|
|
993
|
+
) => {
|
|
994
|
+
const rows = getEnvironmentRows(kind).slice();
|
|
995
|
+
const current = rows[index];
|
|
996
|
+
if (!current) return;
|
|
997
|
+
rows[index] = { ...current, ...patch };
|
|
998
|
+
setEnvironmentRows(kind, rows);
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const removeEnvironmentEntry = (kind: GhaEnvironmentKind, index: number) => {
|
|
1002
|
+
setEnvironmentRows(
|
|
1003
|
+
kind,
|
|
1004
|
+
getEnvironmentRows(kind).filter((_, i) => i !== index),
|
|
1005
|
+
);
|
|
1006
|
+
};
|
|
1007
|
+
|
|
877
1008
|
// ── Save lab as context file ──────────────────────────────────────
|
|
878
1009
|
const handleSave = useCallback(async () => {
|
|
879
1010
|
if (!currentQuestion) return;
|
|
@@ -978,7 +1109,10 @@ export default function GithubActionsLabModal() {
|
|
|
978
1109
|
}, []);
|
|
979
1110
|
|
|
980
1111
|
const runCommand = useCallback(
|
|
981
|
-
async (
|
|
1112
|
+
async (run: GhaConcurrencyRun) => {
|
|
1113
|
+
const controller = new AbortController();
|
|
1114
|
+
abortRef.current = controller;
|
|
1115
|
+
activeRunIdRef.current = run.id;
|
|
982
1116
|
setRunning(true);
|
|
983
1117
|
setRunError(null);
|
|
984
1118
|
// Reset the DAG so the user always sees a fresh "pending → running →
|
|
@@ -986,12 +1120,25 @@ export default function GithubActionsLabModal() {
|
|
|
986
1120
|
// so the visualisation is immediately visible.
|
|
987
1121
|
setLiveJobs([]);
|
|
988
1122
|
setRightTab("jobs");
|
|
989
|
-
appendConsole({ kind: "input", text: `$ ${command}\n` });
|
|
1123
|
+
appendConsole({ kind: "input", text: `$ ${run.command}\n` });
|
|
1124
|
+
|
|
1125
|
+
// Flip this run's record to running so the Concurrency panel ticks.
|
|
1126
|
+
setConcurrencyRuns((rs) =>
|
|
1127
|
+
rs.map((r) =>
|
|
1128
|
+
r.id === run.id
|
|
1129
|
+
? { ...r, status: "running", startedAt: Date.now() }
|
|
1130
|
+
: r,
|
|
1131
|
+
),
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
let exitCode: number | undefined;
|
|
1135
|
+
let didError = false;
|
|
1136
|
+
let errorMsg = "";
|
|
990
1137
|
|
|
991
1138
|
try {
|
|
992
1139
|
await api.streamGhaCommand(
|
|
993
1140
|
{
|
|
994
|
-
command,
|
|
1141
|
+
command: run.command,
|
|
995
1142
|
workspace: {
|
|
996
1143
|
...workspace,
|
|
997
1144
|
activeFile,
|
|
@@ -1023,6 +1170,7 @@ export default function GithubActionsLabModal() {
|
|
|
1023
1170
|
text: `\n[error] ${message.error}\n`,
|
|
1024
1171
|
});
|
|
1025
1172
|
} else if (message.type === "complete") {
|
|
1173
|
+
exitCode = message.exitCode;
|
|
1026
1174
|
appendConsole({
|
|
1027
1175
|
kind: "info",
|
|
1028
1176
|
text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
|
|
@@ -1031,13 +1179,54 @@ export default function GithubActionsLabModal() {
|
|
|
1031
1179
|
setHistoryNonce((n) => n + 1);
|
|
1032
1180
|
}
|
|
1033
1181
|
},
|
|
1182
|
+
{ signal: controller.signal },
|
|
1034
1183
|
);
|
|
1035
1184
|
} catch (err: any) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1185
|
+
if (controller.signal.aborted) {
|
|
1186
|
+
// The run was cancelled (either by the user or by a newer run
|
|
1187
|
+
// that supersedes it). Its record was already marked cancelled
|
|
1188
|
+
// — do not surface the AbortError as a normal failure.
|
|
1189
|
+
appendConsole({
|
|
1190
|
+
kind: "info",
|
|
1191
|
+
text: `\n[cancelled] run #${run.seq}\n`,
|
|
1192
|
+
});
|
|
1193
|
+
} else {
|
|
1194
|
+
didError = true;
|
|
1195
|
+
errorMsg = err?.message || "Failed to start run";
|
|
1196
|
+
setRunError(errorMsg);
|
|
1197
|
+
appendConsole({ kind: "stderr", text: `\n[error] ${errorMsg}\n` });
|
|
1198
|
+
}
|
|
1039
1199
|
} finally {
|
|
1200
|
+
abortRef.current = null;
|
|
1201
|
+
activeRunIdRef.current = null;
|
|
1040
1202
|
setRunning(false);
|
|
1203
|
+
|
|
1204
|
+
// Finalise this run's record. If the enqueue path already marked
|
|
1205
|
+
// it cancelled (preempted by a newer run) keep that status.
|
|
1206
|
+
setConcurrencyRuns((rs) =>
|
|
1207
|
+
rs.map((r) => {
|
|
1208
|
+
if (r.id !== run.id) return r;
|
|
1209
|
+
if (r.status === "cancelled") return r;
|
|
1210
|
+
return {
|
|
1211
|
+
...r,
|
|
1212
|
+
status: didError ? "cancelled" : "completed",
|
|
1213
|
+
endedAt: Date.now(),
|
|
1214
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
1215
|
+
...(didError ? { cancelReason: errorMsg } : {}),
|
|
1216
|
+
};
|
|
1217
|
+
}),
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
// Drain the queue: pick the oldest pending and start it. We use
|
|
1221
|
+
// the ref to dodge the stale snapshot inside this async closure.
|
|
1222
|
+
const pending = concurrencyRunsRef.current.find(
|
|
1223
|
+
(r) => r.status === "pending",
|
|
1224
|
+
);
|
|
1225
|
+
if (pending) {
|
|
1226
|
+
window.setTimeout(() => {
|
|
1227
|
+
runConcurrencyRunRef.current?.(pending);
|
|
1228
|
+
}, 0);
|
|
1229
|
+
}
|
|
1041
1230
|
}
|
|
1042
1231
|
},
|
|
1043
1232
|
[
|
|
@@ -1051,10 +1240,133 @@ export default function GithubActionsLabModal() {
|
|
|
1051
1240
|
appendConsole,
|
|
1052
1241
|
],
|
|
1053
1242
|
);
|
|
1243
|
+
useEffect(() => {
|
|
1244
|
+
runConcurrencyRunRef.current = runCommand;
|
|
1245
|
+
}, [runCommand]);
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Enqueue a new run. Evaluates the active workflow's concurrency block
|
|
1249
|
+
* against the current github.* context to decide whether to cancel the
|
|
1250
|
+
* in-flight run, drop older pendings in the same group, or just queue.
|
|
1251
|
+
*/
|
|
1252
|
+
const enqueueRun = useCallback(
|
|
1253
|
+
(command: string) => {
|
|
1254
|
+
const parsed = parseConcurrencyBlock(
|
|
1255
|
+
workflow ? workspace.files[workflow] : undefined,
|
|
1256
|
+
);
|
|
1257
|
+
const ctx: GhaConcurrencyContext = {
|
|
1258
|
+
...concurrencyContext,
|
|
1259
|
+
event_name: event,
|
|
1260
|
+
};
|
|
1261
|
+
const { groupKey, cancelInProgress } = evaluateConcurrencyFor(
|
|
1262
|
+
parsed,
|
|
1263
|
+
ctx,
|
|
1264
|
+
);
|
|
1265
|
+
|
|
1266
|
+
runSeqRef.current += 1;
|
|
1267
|
+
const seq = runSeqRef.current;
|
|
1268
|
+
const newRun: GhaConcurrencyRun = {
|
|
1269
|
+
id: `${seq}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1270
|
+
seq,
|
|
1271
|
+
command,
|
|
1272
|
+
eventName: event,
|
|
1273
|
+
workflowPath: workflow,
|
|
1274
|
+
groupKey,
|
|
1275
|
+
cancelInProgress,
|
|
1276
|
+
context: ctx,
|
|
1277
|
+
status: "pending",
|
|
1278
|
+
};
|
|
1054
1279
|
|
|
1055
|
-
|
|
1280
|
+
// We need to know whether anything was actively running BEFORE we
|
|
1281
|
+
// mutate state — if so, the drain in runCommand's finally will
|
|
1282
|
+
// pick up the new pending; otherwise we kick it ourselves.
|
|
1283
|
+
const wasIdle = !activeRunIdRef.current;
|
|
1284
|
+
let preemptedActive = false;
|
|
1285
|
+
|
|
1286
|
+
setConcurrencyRuns((prev) => {
|
|
1287
|
+
const updated = [...prev];
|
|
1288
|
+
|
|
1289
|
+
// Rule 1: same group + new.cancelInProgress=true cancels the
|
|
1290
|
+
// currently running sibling. Empty groupKey means "no concurrency
|
|
1291
|
+
// block" and never coalesces with anything.
|
|
1292
|
+
if (groupKey) {
|
|
1293
|
+
const activeIdx = updated.findIndex((r) => r.status === "running");
|
|
1294
|
+
if (activeIdx >= 0) {
|
|
1295
|
+
const active = updated[activeIdx];
|
|
1296
|
+
if (active.groupKey === groupKey && newRun.cancelInProgress) {
|
|
1297
|
+
updated[activeIdx] = {
|
|
1298
|
+
...active,
|
|
1299
|
+
status: "cancelled",
|
|
1300
|
+
endedAt: Date.now(),
|
|
1301
|
+
cancelReason: `superseded by run #${seq}`,
|
|
1302
|
+
};
|
|
1303
|
+
preemptedActive = true;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Rule 2: GitHub keeps at most ONE pending per group. Any older
|
|
1308
|
+
// pending in the same group gets cancelled by the newcomer.
|
|
1309
|
+
for (let i = 0; i < updated.length; i += 1) {
|
|
1310
|
+
const r = updated[i];
|
|
1311
|
+
if (r.status === "pending" && r.groupKey === groupKey) {
|
|
1312
|
+
updated[i] = {
|
|
1313
|
+
...r,
|
|
1314
|
+
status: "cancelled",
|
|
1315
|
+
endedAt: Date.now(),
|
|
1316
|
+
cancelReason: `superseded by pending run #${seq}`,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
updated.push(newRun);
|
|
1323
|
+
return updated;
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
// Abort the in-flight fetch AFTER the state mutation so runCommand's
|
|
1327
|
+
// catch path sees the record already marked cancelled and won't
|
|
1328
|
+
// overwrite the supersede reason.
|
|
1329
|
+
if (preemptedActive) {
|
|
1330
|
+
abortRef.current?.abort();
|
|
1331
|
+
} else if (wasIdle) {
|
|
1332
|
+
window.setTimeout(() => {
|
|
1333
|
+
runConcurrencyRunRef.current?.(newRun);
|
|
1334
|
+
}, 0);
|
|
1335
|
+
}
|
|
1336
|
+
},
|
|
1337
|
+
[workflow, workspace.files, concurrencyContext, event],
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
const handleRun = () => enqueueRun(buildCommand());
|
|
1056
1341
|
const handleListJobs = () =>
|
|
1057
|
-
|
|
1342
|
+
enqueueRun(workflow ? `act -W ${workflow} -l` : "act -l");
|
|
1343
|
+
|
|
1344
|
+
/** Cancel the currently running act invocation. */
|
|
1345
|
+
const stopActiveRun = useCallback(() => {
|
|
1346
|
+
const activeId = activeRunIdRef.current;
|
|
1347
|
+
if (!activeId) return;
|
|
1348
|
+
setConcurrencyRuns((rs) =>
|
|
1349
|
+
rs.map((r) =>
|
|
1350
|
+
r.id === activeId
|
|
1351
|
+
? {
|
|
1352
|
+
...r,
|
|
1353
|
+
status: "cancelled",
|
|
1354
|
+
endedAt: Date.now(),
|
|
1355
|
+
cancelReason: "stopped by user",
|
|
1356
|
+
}
|
|
1357
|
+
: r,
|
|
1358
|
+
),
|
|
1359
|
+
);
|
|
1360
|
+
abortRef.current?.abort();
|
|
1361
|
+
}, []);
|
|
1362
|
+
|
|
1363
|
+
// Keep the Concurrency panel's context.event_name in sync with the
|
|
1364
|
+
// toolbar event picker so the read-only field always reflects reality.
|
|
1365
|
+
useEffect(() => {
|
|
1366
|
+
setConcurrencyContext((prev) =>
|
|
1367
|
+
prev.event_name === event ? prev : { ...prev, event_name: event },
|
|
1368
|
+
);
|
|
1369
|
+
}, [event]);
|
|
1058
1370
|
|
|
1059
1371
|
const clearConsole = () => setConsoleLines([]);
|
|
1060
1372
|
|
|
@@ -1109,7 +1421,7 @@ export default function GithubActionsLabModal() {
|
|
|
1109
1421
|
const cmd = consoleInput.trim();
|
|
1110
1422
|
if (!cmd || running) return;
|
|
1111
1423
|
setConsoleInput("");
|
|
1112
|
-
|
|
1424
|
+
enqueueRun(cmd);
|
|
1113
1425
|
};
|
|
1114
1426
|
|
|
1115
1427
|
// ── Monaco config ─────────────────────────────────────────────────
|
|
@@ -1831,7 +2143,7 @@ interface ImportMeta {
|
|
|
1831
2143
|
</div>
|
|
1832
2144
|
</div>
|
|
1833
2145
|
|
|
1834
|
-
{/* Right pane: tabbed Console / Jobs / History */}
|
|
2146
|
+
{/* Right pane: tabbed Console / Jobs / Env / History */}
|
|
1835
2147
|
<div
|
|
1836
2148
|
className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
|
|
1837
2149
|
style={{
|
|
@@ -1845,6 +2157,8 @@ interface ImportMeta {
|
|
|
1845
2157
|
[
|
|
1846
2158
|
{ id: "console", label: "Console" },
|
|
1847
2159
|
{ id: "jobs", label: "Jobs" },
|
|
2160
|
+
{ id: "env", label: "Env" },
|
|
2161
|
+
{ id: "concurrency", label: "Concurrency" },
|
|
1848
2162
|
{ id: "history", label: "History" },
|
|
1849
2163
|
] as const
|
|
1850
2164
|
).map((t) => (
|
|
@@ -1860,19 +2174,30 @@ interface ImportMeta {
|
|
|
1860
2174
|
{t.id === "console" && (
|
|
1861
2175
|
<Terminal className="inline w-3 h-3 mr-1 -mt-px" />
|
|
1862
2176
|
)}
|
|
2177
|
+
{t.id === "env" && (
|
|
2178
|
+
<KeyRound className="inline w-3 h-3 mr-1 -mt-px" />
|
|
2179
|
+
)}
|
|
2180
|
+
{t.id === "concurrency" && (
|
|
2181
|
+
<GitBranch className="inline w-3 h-3 mr-1 -mt-px" />
|
|
2182
|
+
)}
|
|
1863
2183
|
{t.label}
|
|
1864
2184
|
{t.id === "jobs" && running && (
|
|
1865
2185
|
<span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
1866
2186
|
)}
|
|
2187
|
+
{t.id === "env" && environmentEntryCount > 0 && (
|
|
2188
|
+
<span className="ml-1 rounded bg-amber-500/15 px-1 text-[10px] text-amber-200">
|
|
2189
|
+
{environmentEntryCount}
|
|
2190
|
+
</span>
|
|
2191
|
+
)}
|
|
1867
2192
|
</button>
|
|
1868
2193
|
))}
|
|
1869
2194
|
</div>
|
|
1870
2195
|
<div className="flex items-center gap-1">
|
|
1871
2196
|
{running && (
|
|
1872
2197
|
<button
|
|
1873
|
-
onClick={
|
|
2198
|
+
onClick={stopActiveRun}
|
|
1874
2199
|
className="p-1 rounded text-amber-300 hover:bg-slate-800/60"
|
|
1875
|
-
title="
|
|
2200
|
+
title="Cancel the running act invocation"
|
|
1876
2201
|
>
|
|
1877
2202
|
<StopCircle className="w-3.5 h-3.5" />
|
|
1878
2203
|
</button>
|
|
@@ -1964,6 +2289,153 @@ interface ImportMeta {
|
|
|
1964
2289
|
/>
|
|
1965
2290
|
)}
|
|
1966
2291
|
|
|
2292
|
+
{rightTab === "env" && (
|
|
2293
|
+
<div className="flex-1 min-h-0 overflow-auto p-3 text-xs text-slate-300">
|
|
2294
|
+
<div className="mb-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
|
|
2295
|
+
<div className="mb-1 flex items-center gap-2 text-sm font-semibold text-amber-200">
|
|
2296
|
+
<KeyRound className="h-4 w-4" />
|
|
2297
|
+
GitHub-style act inputs
|
|
2298
|
+
</div>
|
|
2299
|
+
<p className="leading-5 text-slate-400">
|
|
2300
|
+
These are written to temporary act files for each run:
|
|
2301
|
+
variables become{" "}
|
|
2302
|
+
<span className="text-amber-200">vars.*</span>, secrets
|
|
2303
|
+
become <span className="text-amber-200">secrets.*</span>,
|
|
2304
|
+
and runner env values become shell environment variables.
|
|
2305
|
+
Use fake or local-only values; saved lab snapshots store
|
|
2306
|
+
these values as plain text.
|
|
2307
|
+
</p>
|
|
2308
|
+
</div>
|
|
2309
|
+
|
|
2310
|
+
<div className="space-y-3">
|
|
2311
|
+
{GHA_ENVIRONMENT_SECTIONS.map((section) => {
|
|
2312
|
+
const rows = getEnvironmentRows(section.kind);
|
|
2313
|
+
return (
|
|
2314
|
+
<section
|
|
2315
|
+
key={section.kind}
|
|
2316
|
+
className="rounded-xl border border-slate-800 bg-slate-900/40"
|
|
2317
|
+
>
|
|
2318
|
+
<div className="flex items-start justify-between gap-2 border-b border-slate-800/70 px-3 py-2">
|
|
2319
|
+
<div>
|
|
2320
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-200">
|
|
2321
|
+
{section.title}
|
|
2322
|
+
</h3>
|
|
2323
|
+
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">
|
|
2324
|
+
{section.help}
|
|
2325
|
+
</p>
|
|
2326
|
+
</div>
|
|
2327
|
+
<button
|
|
2328
|
+
onClick={() => addEnvironmentEntry(section.kind)}
|
|
2329
|
+
className="shrink-0 rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:border-amber-500/40 hover:text-amber-200"
|
|
2330
|
+
>
|
|
2331
|
+
{section.addLabel}
|
|
2332
|
+
</button>
|
|
2333
|
+
</div>
|
|
2334
|
+
|
|
2335
|
+
<div className="space-y-2 p-3">
|
|
2336
|
+
{rows.length === 0 ? (
|
|
2337
|
+
<button
|
|
2338
|
+
onClick={() => addEnvironmentEntry(section.kind)}
|
|
2339
|
+
className="w-full rounded-lg border border-dashed border-slate-700 px-3 py-3 text-left text-[11px] text-slate-500 hover:border-amber-500/40 hover:text-amber-200"
|
|
2340
|
+
>
|
|
2341
|
+
No entries yet — click to add one.
|
|
2342
|
+
</button>
|
|
2343
|
+
) : (
|
|
2344
|
+
rows.map((row, index) => {
|
|
2345
|
+
const nameIsValid =
|
|
2346
|
+
!row.name.trim() ||
|
|
2347
|
+
GHA_ENV_NAME_RE.test(row.name.trim());
|
|
2348
|
+
return (
|
|
2349
|
+
<div
|
|
2350
|
+
key={`${section.kind}-${index}`}
|
|
2351
|
+
className="rounded-lg border border-slate-800 bg-slate-950/60 p-2"
|
|
2352
|
+
>
|
|
2353
|
+
<div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)_auto] gap-2">
|
|
2354
|
+
<input
|
|
2355
|
+
value={row.name}
|
|
2356
|
+
onChange={(e) =>
|
|
2357
|
+
updateEnvironmentEntry(
|
|
2358
|
+
section.kind,
|
|
2359
|
+
index,
|
|
2360
|
+
{ name: e.target.value },
|
|
2361
|
+
)
|
|
2362
|
+
}
|
|
2363
|
+
placeholder={section.namePlaceholder}
|
|
2364
|
+
className={`min-w-0 rounded border bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 ${
|
|
2365
|
+
nameIsValid
|
|
2366
|
+
? "border-slate-700 focus:border-amber-500/60"
|
|
2367
|
+
: "border-red-500/60 focus:border-red-400"
|
|
2368
|
+
}`}
|
|
2369
|
+
/>
|
|
2370
|
+
<input
|
|
2371
|
+
type={
|
|
2372
|
+
section.kind === "secrets"
|
|
2373
|
+
? "password"
|
|
2374
|
+
: "text"
|
|
2375
|
+
}
|
|
2376
|
+
value={row.value}
|
|
2377
|
+
onChange={(e) =>
|
|
2378
|
+
updateEnvironmentEntry(
|
|
2379
|
+
section.kind,
|
|
2380
|
+
index,
|
|
2381
|
+
{ value: e.target.value },
|
|
2382
|
+
)
|
|
2383
|
+
}
|
|
2384
|
+
placeholder={section.valuePlaceholder}
|
|
2385
|
+
className="min-w-0 rounded border border-slate-700 bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 focus:border-amber-500/60"
|
|
2386
|
+
/>
|
|
2387
|
+
<button
|
|
2388
|
+
onClick={() =>
|
|
2389
|
+
removeEnvironmentEntry(
|
|
2390
|
+
section.kind,
|
|
2391
|
+
index,
|
|
2392
|
+
)
|
|
2393
|
+
}
|
|
2394
|
+
className="rounded px-2 text-slate-500 hover:bg-red-500/10 hover:text-red-300"
|
|
2395
|
+
title="Remove entry"
|
|
2396
|
+
>
|
|
2397
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
2398
|
+
</button>
|
|
2399
|
+
</div>
|
|
2400
|
+
{!nameIsValid && (
|
|
2401
|
+
<div className="mt-1 text-[10px] text-red-300">
|
|
2402
|
+
Use letters, numbers, and underscores; do
|
|
2403
|
+
not start with a number.
|
|
2404
|
+
</div>
|
|
2405
|
+
)}
|
|
2406
|
+
</div>
|
|
2407
|
+
);
|
|
2408
|
+
})
|
|
2409
|
+
)}
|
|
2410
|
+
</div>
|
|
2411
|
+
</section>
|
|
2412
|
+
);
|
|
2413
|
+
})}
|
|
2414
|
+
</div>
|
|
2415
|
+
</div>
|
|
2416
|
+
)}
|
|
2417
|
+
|
|
2418
|
+
{rightTab === "concurrency" && (
|
|
2419
|
+
<GhaConcurrencyPanel
|
|
2420
|
+
parsed={parseConcurrencyBlock(
|
|
2421
|
+
workflow ? workspace.files[workflow] : undefined,
|
|
2422
|
+
)}
|
|
2423
|
+
workflowPath={workflow}
|
|
2424
|
+
runs={concurrencyRuns}
|
|
2425
|
+
context={concurrencyContext}
|
|
2426
|
+
onContextChange={setConcurrencyContext}
|
|
2427
|
+
onClearRuns={() => {
|
|
2428
|
+
// Only clear finished records; keep the active/pending
|
|
2429
|
+
// ones because the queue depends on them.
|
|
2430
|
+
setConcurrencyRuns((rs) =>
|
|
2431
|
+
rs.filter(
|
|
2432
|
+
(r) => r.status === "running" || r.status === "pending",
|
|
2433
|
+
),
|
|
2434
|
+
);
|
|
2435
|
+
}}
|
|
2436
|
+
/>
|
|
2437
|
+
)}
|
|
2438
|
+
|
|
1967
2439
|
{rightTab === "history" && (
|
|
1968
2440
|
<GhaHistoryPanel
|
|
1969
2441
|
{...(currentQuestion ? { questionId: currentQuestion.id } : {})}
|