create-interview-cockpit 0.17.3 → 0.19.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/App.tsx +3 -0
- package/template/client/src/api.ts +184 -8
- 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 +1048 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +603 -60
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +294 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +130 -10
- package/template/client/src/types.ts +33 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +793 -0
- package/template/server/src/google-drive.ts +542 -149
- package/template/server/src/index.ts +327 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import DocRefModal from "./components/DocRefModal";
|
|
|
9
9
|
import AiSettingsModal from "./components/AiSettingsModal";
|
|
10
10
|
import CodeRunnerModal from "./components/CodeRunnerModal";
|
|
11
11
|
import InfraLabModal from "./components/InfraLabModal";
|
|
12
|
+
import GithubActionsLabModal from "./components/GithubActionsLabModal";
|
|
12
13
|
import DeploymentLabModal from "./components/DeploymentLabModal";
|
|
13
14
|
import CanvasLabModal from "./components/CanvasLabModal";
|
|
14
15
|
import {
|
|
@@ -44,6 +45,7 @@ export default function App() {
|
|
|
44
45
|
closeSettings,
|
|
45
46
|
showCodeRunner,
|
|
46
47
|
showInfraLab,
|
|
48
|
+
showGhaLab,
|
|
47
49
|
showDeploymentLab,
|
|
48
50
|
showCanvasLab,
|
|
49
51
|
closeCodeRunner,
|
|
@@ -191,6 +193,7 @@ export default function App() {
|
|
|
191
193
|
{showSettings && <AiSettingsModal />}
|
|
192
194
|
{showCodeRunner && <CodeRunnerModal />}
|
|
193
195
|
{showInfraLab && <InfraLabModal />}
|
|
196
|
+
{showGhaLab && <GithubActionsLabModal />}
|
|
194
197
|
{showDeploymentLab && <DeploymentLabModal />}
|
|
195
198
|
{showCanvasLab && <CanvasLabModal />}
|
|
196
199
|
</div>
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ContextFileOrigin,
|
|
6
6
|
WorkspacesRegistry,
|
|
7
7
|
InfraLabWorkspace,
|
|
8
|
+
GithubActionsLabWorkspace,
|
|
8
9
|
} from "./types";
|
|
9
10
|
|
|
10
11
|
const BASE = "/api";
|
|
@@ -79,8 +80,8 @@ export interface InfraRunListItem {
|
|
|
79
80
|
startedAt: string;
|
|
80
81
|
completedAt: string;
|
|
81
82
|
durationMs: number;
|
|
82
|
-
provider: "aws";
|
|
83
|
-
executionMode: "plan-only" | "localstack";
|
|
83
|
+
provider: "aws" | "docker";
|
|
84
|
+
executionMode: "plan-only" | "localstack" | "docker";
|
|
84
85
|
diagnostics: InfraDiagnostic[];
|
|
85
86
|
planSummary?: InfraPlanSummary;
|
|
86
87
|
error?: string;
|
|
@@ -243,6 +244,19 @@ export async function updateQuestion(
|
|
|
243
244
|
return res.json();
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
export async function copyQuestion(
|
|
248
|
+
id: string,
|
|
249
|
+
data: { parentQuestionId?: string | null; targetTopicId?: string } = {},
|
|
250
|
+
): Promise<Question[]> {
|
|
251
|
+
const res = await fetch(`${BASE}/questions/${id}/copy`, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: { "Content-Type": "application/json" },
|
|
254
|
+
body: JSON.stringify(data),
|
|
255
|
+
});
|
|
256
|
+
if (!res.ok) throw new Error(await res.text());
|
|
257
|
+
return res.json();
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
export async function deleteQuestion(id: string): Promise<void> {
|
|
247
261
|
await fetch(`${BASE}/questions/${id}`, { method: "DELETE" });
|
|
248
262
|
}
|
|
@@ -470,6 +484,130 @@ export async function streamInfraCommand(
|
|
|
470
484
|
}
|
|
471
485
|
}
|
|
472
486
|
|
|
487
|
+
// ─── GitHub Actions Lab ──────────────────────────────────────────────────
|
|
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
|
+
|
|
537
|
+
export type GhaStreamMessage =
|
|
538
|
+
| { type: "output"; kind: "stdout" | "stderr" | "info"; text: string }
|
|
539
|
+
| { type: "job"; job: GhaJobSnapshot }
|
|
540
|
+
| { type: "complete"; runId: string; exitCode: number; durationMs: number }
|
|
541
|
+
| { type: "error"; error: string };
|
|
542
|
+
|
|
543
|
+
export async function streamGhaCommand(
|
|
544
|
+
input: {
|
|
545
|
+
questionId?: string;
|
|
546
|
+
fileId?: string;
|
|
547
|
+
label?: string;
|
|
548
|
+
command: string;
|
|
549
|
+
workspace: GithubActionsLabWorkspace;
|
|
550
|
+
},
|
|
551
|
+
onMessage: (message: GhaStreamMessage) => void,
|
|
552
|
+
): Promise<void> {
|
|
553
|
+
const res = await fetch(`${BASE}/gha/run-stream`, {
|
|
554
|
+
method: "POST",
|
|
555
|
+
headers: { "Content-Type": "application/json" },
|
|
556
|
+
body: JSON.stringify(input),
|
|
557
|
+
});
|
|
558
|
+
if (!res.ok || !res.body) {
|
|
559
|
+
const err = await res.text().catch(() => "act run failed");
|
|
560
|
+
throw new Error(err || "act run failed");
|
|
561
|
+
}
|
|
562
|
+
const reader = res.body.getReader();
|
|
563
|
+
const decoder = new TextDecoder();
|
|
564
|
+
let buffer = "";
|
|
565
|
+
|
|
566
|
+
const flushBuffer = () => {
|
|
567
|
+
const events = buffer.replace(/\r/g, "").split("\n\n");
|
|
568
|
+
buffer = events.pop() ?? "";
|
|
569
|
+
for (const event of events) {
|
|
570
|
+
if (!event.trim()) continue;
|
|
571
|
+
const payload = event
|
|
572
|
+
.split("\n")
|
|
573
|
+
.filter((line) => line.startsWith("data:"))
|
|
574
|
+
.map((line) => line.slice(5).trimStart())
|
|
575
|
+
.join("\n");
|
|
576
|
+
if (!payload) continue;
|
|
577
|
+
onMessage(JSON.parse(payload) as GhaStreamMessage);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
while (true) {
|
|
582
|
+
const { value, done } = await reader.read();
|
|
583
|
+
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
|
|
584
|
+
flushBuffer();
|
|
585
|
+
if (done) break;
|
|
586
|
+
}
|
|
587
|
+
if (buffer.trim()) flushBuffer();
|
|
588
|
+
}
|
|
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
|
+
|
|
473
611
|
export async function fetchInfraRun(runId: string): Promise<InfraRunDetails> {
|
|
474
612
|
const res = await fetch(`${BASE}/infra/runs/${encodeURIComponent(runId)}`);
|
|
475
613
|
if (!res.ok) throw new Error(await res.text());
|
|
@@ -664,16 +802,34 @@ export async function activateWorkspaceApi(
|
|
|
664
802
|
return res.json();
|
|
665
803
|
}
|
|
666
804
|
|
|
667
|
-
export
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
805
|
+
export type SyncWorkspaceResult =
|
|
806
|
+
| { needsAuth: true; authUrl: string }
|
|
807
|
+
| {
|
|
808
|
+
needsAuth?: false;
|
|
809
|
+
topicsUpserted: number;
|
|
810
|
+
filesImported: number;
|
|
811
|
+
filesSkipped: number;
|
|
812
|
+
errors: string[];
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
export async function syncWorkspaceApi(
|
|
816
|
+
id: string,
|
|
817
|
+
): Promise<SyncWorkspaceResult> {
|
|
673
818
|
const res = await fetch(`${BASE}/workspaces/${id}/sync`, { method: "POST" });
|
|
674
819
|
return res.json();
|
|
675
820
|
}
|
|
676
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
|
+
|
|
677
833
|
export type ExportWorkspaceResult =
|
|
678
834
|
| { needsAuth: true; authUrl: string }
|
|
679
835
|
| {
|
|
@@ -700,6 +856,26 @@ export async function exportWorkspaceToDrive(
|
|
|
700
856
|
return res.json();
|
|
701
857
|
}
|
|
702
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
|
+
|
|
703
879
|
export interface DriveFolder {
|
|
704
880
|
id: string;
|
|
705
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
|
+
}
|