create-interview-cockpit 0.18.0 → 0.20.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/package.json +1 -1
- package/template/client/src/api.ts +101 -0
- package/template/client/src/components/GhaHistoryPanel.tsx +194 -0
- package/template/client/src/components/GhaJobsPanel.tsx +432 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +583 -76
- package/template/client/src/components/LabsPanel.tsx +11 -1
- package/template/client/src/components/Sidebar.tsx +216 -59
- package/template/client/src/githubActionsLab.ts +239 -2
- package/template/client/src/store.ts +47 -0
- package/template/client/src/types.ts +6 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +327 -1
- package/template/server/src/google-drive.ts +507 -125
- package/template/server/src/index.ts +87 -1
package/package.json
CHANGED
|
@@ -486,8 +486,57 @@ export async function streamInfraCommand(
|
|
|
486
486
|
|
|
487
487
|
// ─── GitHub Actions Lab ──────────────────────────────────────────────────
|
|
488
488
|
|
|
489
|
+
export type GhaJobStatus =
|
|
490
|
+
| "pending"
|
|
491
|
+
| "running"
|
|
492
|
+
| "success"
|
|
493
|
+
| "failed"
|
|
494
|
+
| "skipped";
|
|
495
|
+
|
|
496
|
+
export interface GhaStepSnapshot {
|
|
497
|
+
name: string;
|
|
498
|
+
phase: "Main" | "Pre" | "Post";
|
|
499
|
+
status: GhaJobStatus;
|
|
500
|
+
startedAt?: string;
|
|
501
|
+
endedAt?: string;
|
|
502
|
+
durationMs?: number;
|
|
503
|
+
log?: string;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export interface GhaJobSnapshot {
|
|
507
|
+
name: string;
|
|
508
|
+
workflow?: string;
|
|
509
|
+
status: GhaJobStatus;
|
|
510
|
+
startedAt?: string;
|
|
511
|
+
endedAt?: string;
|
|
512
|
+
durationMs?: number;
|
|
513
|
+
steps?: GhaStepSnapshot[];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export interface GhaRunMetadata {
|
|
517
|
+
id: string;
|
|
518
|
+
fileId?: string;
|
|
519
|
+
questionId?: string;
|
|
520
|
+
label: string;
|
|
521
|
+
command: string;
|
|
522
|
+
status: "completed" | "failed";
|
|
523
|
+
startedAt: string;
|
|
524
|
+
completedAt: string;
|
|
525
|
+
durationMs: number;
|
|
526
|
+
exitCode: number;
|
|
527
|
+
error?: string;
|
|
528
|
+
jobs?: GhaJobSnapshot[];
|
|
529
|
+
event?: string;
|
|
530
|
+
workflow?: string;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export interface GhaRunDetails extends GhaRunMetadata {
|
|
534
|
+
log: string;
|
|
535
|
+
}
|
|
536
|
+
|
|
489
537
|
export type GhaStreamMessage =
|
|
490
538
|
| { type: "output"; kind: "stdout" | "stderr" | "info"; text: string }
|
|
539
|
+
| { type: "job"; job: GhaJobSnapshot }
|
|
491
540
|
| { type: "complete"; runId: string; exitCode: number; durationMs: number }
|
|
492
541
|
| { type: "error"; error: string };
|
|
493
542
|
|
|
@@ -538,6 +587,27 @@ export async function streamGhaCommand(
|
|
|
538
587
|
if (buffer.trim()) flushBuffer();
|
|
539
588
|
}
|
|
540
589
|
|
|
590
|
+
export async function listGhaRuns(params: {
|
|
591
|
+
questionId?: string;
|
|
592
|
+
fileId?: string;
|
|
593
|
+
limit?: number;
|
|
594
|
+
}): Promise<GhaRunMetadata[]> {
|
|
595
|
+
const search = new URLSearchParams();
|
|
596
|
+
if (params.questionId) search.set("questionId", params.questionId);
|
|
597
|
+
if (params.fileId) search.set("fileId", params.fileId);
|
|
598
|
+
if (params.limit) search.set("limit", String(params.limit));
|
|
599
|
+
const qs = search.toString();
|
|
600
|
+
const res = await fetch(`${BASE}/gha/runs${qs ? `?${qs}` : ""}`);
|
|
601
|
+
if (!res.ok) throw new Error(await res.text());
|
|
602
|
+
return res.json();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function fetchGhaRun(runId: string): Promise<GhaRunDetails> {
|
|
606
|
+
const res = await fetch(`${BASE}/gha/runs/${encodeURIComponent(runId)}`);
|
|
607
|
+
if (!res.ok) throw new Error(await res.text());
|
|
608
|
+
return res.json();
|
|
609
|
+
}
|
|
610
|
+
|
|
541
611
|
export async function fetchInfraRun(runId: string): Promise<InfraRunDetails> {
|
|
542
612
|
const res = await fetch(`${BASE}/infra/runs/${encodeURIComponent(runId)}`);
|
|
543
613
|
if (!res.ok) throw new Error(await res.text());
|
|
@@ -749,6 +819,17 @@ export async function syncWorkspaceApi(
|
|
|
749
819
|
return res.json();
|
|
750
820
|
}
|
|
751
821
|
|
|
822
|
+
export async function syncTopicApi(
|
|
823
|
+
workspaceId: string,
|
|
824
|
+
topicId: string,
|
|
825
|
+
): Promise<SyncWorkspaceResult> {
|
|
826
|
+
const res = await fetch(
|
|
827
|
+
`${BASE}/workspaces/${workspaceId}/topics/${topicId}/sync`,
|
|
828
|
+
{ method: "POST" },
|
|
829
|
+
);
|
|
830
|
+
return res.json();
|
|
831
|
+
}
|
|
832
|
+
|
|
752
833
|
export type ExportWorkspaceResult =
|
|
753
834
|
| { needsAuth: true; authUrl: string }
|
|
754
835
|
| {
|
|
@@ -775,6 +856,26 @@ export async function exportWorkspaceToDrive(
|
|
|
775
856
|
return res.json();
|
|
776
857
|
}
|
|
777
858
|
|
|
859
|
+
export async function exportTopicToDrive(
|
|
860
|
+
workspaceId: string,
|
|
861
|
+
topicId: string,
|
|
862
|
+
targetFolderId?: string,
|
|
863
|
+
): Promise<ExportWorkspaceResult> {
|
|
864
|
+
const res = await fetch(
|
|
865
|
+
`${BASE}/workspaces/${workspaceId}/topics/${topicId}/export-drive`,
|
|
866
|
+
{
|
|
867
|
+
method: "POST",
|
|
868
|
+
headers: { "Content-Type": "application/json" },
|
|
869
|
+
body: JSON.stringify({ targetFolderId }),
|
|
870
|
+
},
|
|
871
|
+
);
|
|
872
|
+
if (!res.ok) {
|
|
873
|
+
const body = await res.json().catch(() => ({}));
|
|
874
|
+
throw new Error((body as any).error || `Export failed (${res.status})`);
|
|
875
|
+
}
|
|
876
|
+
return res.json();
|
|
877
|
+
}
|
|
878
|
+
|
|
778
879
|
export interface DriveFolder {
|
|
779
880
|
id: string;
|
|
780
881
|
name: string;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
|
3
|
+
import * as api from "../api";
|
|
4
|
+
import type { GhaRunMetadata, GhaRunDetails } from "../api";
|
|
5
|
+
import GhaJobsPanel from "./GhaJobsPanel";
|
|
6
|
+
|
|
7
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function formatRelative(iso: string): string {
|
|
10
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
11
|
+
if (ms < 60_000) return "just now";
|
|
12
|
+
const m = Math.floor(ms / 60_000);
|
|
13
|
+
if (m < 60) return `${m}m ago`;
|
|
14
|
+
const h = Math.floor(m / 60);
|
|
15
|
+
if (h < 24) return `${h}h ago`;
|
|
16
|
+
const d = Math.floor(h / 24);
|
|
17
|
+
return `${d}d ago`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatDuration(ms: number): string {
|
|
21
|
+
if (ms < 1000) return `${ms}ms`;
|
|
22
|
+
const s = ms / 1000;
|
|
23
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
24
|
+
return `${Math.round(s)}s`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function statusBadge(status: string) {
|
|
28
|
+
const map: Record<string, string> = {
|
|
29
|
+
completed: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
|
|
30
|
+
failed: "bg-red-500/15 text-red-300 border-red-500/30",
|
|
31
|
+
};
|
|
32
|
+
return map[status] ?? "bg-slate-500/15 text-slate-300 border-slate-500/30";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Component ───────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export interface GhaHistoryPanelProps {
|
|
38
|
+
questionId?: string;
|
|
39
|
+
fileId?: string;
|
|
40
|
+
// Controlled flag — when true, the user has opted to include recent run
|
|
41
|
+
// history in the saved lab snapshot so chat LLM context picks it up.
|
|
42
|
+
includeInContext: boolean;
|
|
43
|
+
onToggleIncludeInContext: (next: boolean) => void;
|
|
44
|
+
// Bump this to force a refetch (e.g. after a run finishes).
|
|
45
|
+
refreshNonce: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function GhaHistoryPanel({
|
|
49
|
+
questionId,
|
|
50
|
+
fileId,
|
|
51
|
+
includeInContext,
|
|
52
|
+
onToggleIncludeInContext,
|
|
53
|
+
refreshNonce,
|
|
54
|
+
}: GhaHistoryPanelProps) {
|
|
55
|
+
const [runs, setRuns] = useState<GhaRunMetadata[]>([]);
|
|
56
|
+
const [loading, setLoading] = useState(false);
|
|
57
|
+
const [error, setError] = useState<string | null>(null);
|
|
58
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
59
|
+
const [details, setDetails] = useState<Record<string, GhaRunDetails>>({});
|
|
60
|
+
|
|
61
|
+
const load = async () => {
|
|
62
|
+
setLoading(true);
|
|
63
|
+
setError(null);
|
|
64
|
+
try {
|
|
65
|
+
const list = await api.listGhaRuns({ questionId, fileId, limit: 25 });
|
|
66
|
+
setRuns(list);
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
setError(err?.message || "Failed to load runs");
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
void load();
|
|
76
|
+
// load when filters or refresh nonce change
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [questionId, fileId, refreshNonce]);
|
|
79
|
+
|
|
80
|
+
const toggleExpanded = async (runId: string) => {
|
|
81
|
+
if (expandedId === runId) {
|
|
82
|
+
setExpandedId(null);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setExpandedId(runId);
|
|
86
|
+
if (!details[runId]) {
|
|
87
|
+
try {
|
|
88
|
+
const full = await api.fetchGhaRun(runId);
|
|
89
|
+
setDetails((prev) => ({ ...prev, [runId]: full }));
|
|
90
|
+
} catch {
|
|
91
|
+
// surface inline; the row already shows summary info
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
98
|
+
<div className="flex items-center justify-between border-b border-slate-800/60 px-3 py-2 gap-3">
|
|
99
|
+
<label className="flex items-center gap-2 text-[11px] text-slate-300 cursor-pointer select-none">
|
|
100
|
+
<input
|
|
101
|
+
type="checkbox"
|
|
102
|
+
checked={includeInContext}
|
|
103
|
+
onChange={(e) => onToggleIncludeInContext(e.target.checked)}
|
|
104
|
+
className="accent-amber-400"
|
|
105
|
+
/>
|
|
106
|
+
Include recent runs in LLM context when saving
|
|
107
|
+
</label>
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => void load()}
|
|
110
|
+
className="p-1 rounded text-slate-400 hover:text-slate-100 hover:bg-slate-800/60"
|
|
111
|
+
title="Refresh"
|
|
112
|
+
>
|
|
113
|
+
<RefreshCw
|
|
114
|
+
className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`}
|
|
115
|
+
/>
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="flex-1 min-h-0 overflow-auto">
|
|
120
|
+
{error && (
|
|
121
|
+
<div className="m-3 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-[11px] text-red-200">
|
|
122
|
+
{error}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
{!loading && runs.length === 0 && !error && (
|
|
126
|
+
<div className="px-3 py-6 text-center text-xs text-slate-500">
|
|
127
|
+
No previous runs yet. Click Run to record the first one.
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
<ul className="divide-y divide-slate-800/60">
|
|
131
|
+
{runs.map((run) => {
|
|
132
|
+
const expanded = expandedId === run.id;
|
|
133
|
+
const detail = details[run.id];
|
|
134
|
+
return (
|
|
135
|
+
<li key={run.id} className="text-[11px]">
|
|
136
|
+
<button
|
|
137
|
+
onClick={() => void toggleExpanded(run.id)}
|
|
138
|
+
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-slate-800/40 text-left"
|
|
139
|
+
>
|
|
140
|
+
{expanded ? (
|
|
141
|
+
<ChevronDown className="w-3 h-3 text-slate-500" />
|
|
142
|
+
) : (
|
|
143
|
+
<ChevronRight className="w-3 h-3 text-slate-500" />
|
|
144
|
+
)}
|
|
145
|
+
<span
|
|
146
|
+
className={`text-[10px] px-1.5 py-0.5 rounded border ${statusBadge(
|
|
147
|
+
run.status,
|
|
148
|
+
)}`}
|
|
149
|
+
>
|
|
150
|
+
{run.status === "completed" ? "OK" : "FAIL"}
|
|
151
|
+
</span>
|
|
152
|
+
<span className="font-mono text-slate-300 truncate flex-1">
|
|
153
|
+
{run.command}
|
|
154
|
+
</span>
|
|
155
|
+
<span className="text-slate-500">
|
|
156
|
+
{formatDuration(run.durationMs)}
|
|
157
|
+
</span>
|
|
158
|
+
<span className="text-slate-500 w-16 text-right">
|
|
159
|
+
{formatRelative(run.startedAt)}
|
|
160
|
+
</span>
|
|
161
|
+
</button>
|
|
162
|
+
{expanded && (
|
|
163
|
+
<div className="border-t border-slate-800/40 bg-slate-950/50">
|
|
164
|
+
<div className="px-3 py-2">
|
|
165
|
+
{/* Render as a flat list — the current workflow YAML
|
|
166
|
+
may have diverged since this run, so showing the
|
|
167
|
+
DAG would be misleading. The list mirrors what
|
|
168
|
+
actually executed. */}
|
|
169
|
+
<GhaJobsPanel
|
|
170
|
+
mode="list"
|
|
171
|
+
jobs={run.jobs ?? []}
|
|
172
|
+
caption={`Run ${run.id.slice(0, 8)} • exit=${run.exitCode}`}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
{detail?.log && (
|
|
176
|
+
<details className="px-3 pb-3">
|
|
177
|
+
<summary className="cursor-pointer text-[10px] text-slate-500 hover:text-slate-300">
|
|
178
|
+
Show raw log ({detail.log.length} chars)
|
|
179
|
+
</summary>
|
|
180
|
+
<pre className="mt-2 max-h-72 overflow-auto rounded bg-black/40 p-2 text-[10px] text-slate-300 whitespace-pre-wrap">
|
|
181
|
+
{detail.log}
|
|
182
|
+
</pre>
|
|
183
|
+
</details>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</li>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
</ul>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|