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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
AlertCircle,
|
|
4
4
|
Check,
|
|
@@ -26,31 +26,488 @@ import {
|
|
|
26
26
|
Trash2,
|
|
27
27
|
X,
|
|
28
28
|
} from "lucide-react";
|
|
29
|
+
import MonacoEditorLib from "@monaco-editor/react";
|
|
30
|
+
import type {
|
|
31
|
+
BeforeMount,
|
|
32
|
+
Monaco,
|
|
33
|
+
OnChange,
|
|
34
|
+
OnMount,
|
|
35
|
+
} from "@monaco-editor/react";
|
|
36
|
+
import { useStore } from "../store";
|
|
37
|
+
import {
|
|
38
|
+
cloneInfraLabWorkspace,
|
|
39
|
+
DEFAULT_INFRA_LAB,
|
|
40
|
+
getInfraLabFileOrder,
|
|
41
|
+
serializeInfraLabWorkspace,
|
|
42
|
+
} from "../infraLab";
|
|
43
|
+
import type { InfraLabWorkspace } from "../types";
|
|
44
|
+
import type {
|
|
45
|
+
InfraCommandStreamMessage,
|
|
46
|
+
InfraRunAction,
|
|
47
|
+
InfraRunDetails,
|
|
48
|
+
InfraRunListItem,
|
|
49
|
+
} from "../api";
|
|
50
|
+
import * as api from "../api";
|
|
51
|
+
import ReactMarkdown from "react-markdown";
|
|
52
|
+
import remarkGfm from "remark-gfm";
|
|
29
53
|
|
|
30
54
|
const MIN_W = 900;
|
|
31
55
|
const MIN_H = 560;
|
|
32
56
|
const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
|
|
33
57
|
const DEFAULT_H = Math.min(820, window.innerHeight - 48);
|
|
34
58
|
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
35
|
-
import type { InfraCommandStreamMessage } from "../api";
|
|
36
59
|
|
|
37
60
|
interface ConsoleLine {
|
|
38
61
|
id: string;
|
|
39
62
|
kind: "stdout" | "stderr" | "info" | "input";
|
|
40
63
|
text: string;
|
|
41
64
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
|
|
66
|
+
const EDITOR_FONT =
|
|
67
|
+
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
|
68
|
+
const DOCKER_DEMO_URL = "http://localhost:4288";
|
|
69
|
+
const INFRA_CONSOLE_HELP = `Supported commands:
|
|
70
|
+
terraform init | validate | plan | apply -auto-approve | destroy -auto-approve | output | state | version
|
|
71
|
+
docker version | docker info | docker ps -a | docker images | docker inspect <name>
|
|
72
|
+
docker build -t <tag> . | docker run --rm <image> | docker exec <container> <cmd>
|
|
73
|
+
docker compose up -d --build | docker compose ps | docker compose logs | docker compose down [-v]
|
|
74
|
+
docker network ls | docker volume ls | docker logs <container> | docker stop/rm <container>
|
|
75
|
+
curl -s http://localhost:4288/api/ping | pwd | ls | cat <workspace-file>
|
|
76
|
+
|
|
77
|
+
Notes:
|
|
78
|
+
- Run one command at a time; shell pipes/operators are disabled in this lab console.
|
|
79
|
+
- Use detached compose runs (docker compose up -d ...) so the console does not stay attached forever.
|
|
80
|
+
- Docker prune commands require --force or -f so they do not hang on an interactive prompt.
|
|
81
|
+
- The command runs from the saved infra workspace session directory.`;
|
|
82
|
+
|
|
83
|
+
let infraMonacoConfigured = false;
|
|
84
|
+
|
|
85
|
+
interface InfraFileTreeNode {
|
|
86
|
+
name: string;
|
|
87
|
+
path: string;
|
|
88
|
+
folders: InfraFileTreeNode[];
|
|
89
|
+
files: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function baseName(filePath: string): string {
|
|
93
|
+
return filePath.split("/").pop() || filePath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildInfraFileTree(paths: string[]): InfraFileTreeNode {
|
|
97
|
+
const root: InfraFileTreeNode = {
|
|
98
|
+
name: "",
|
|
99
|
+
path: "",
|
|
100
|
+
folders: [],
|
|
101
|
+
files: [],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (const filePath of paths) {
|
|
105
|
+
const parts = filePath.split("/").filter(Boolean);
|
|
106
|
+
let node = root;
|
|
107
|
+
|
|
108
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
109
|
+
const name = parts[index];
|
|
110
|
+
const folderPath = parts.slice(0, index + 1).join("/");
|
|
111
|
+
let child = node.folders.find((entry) => entry.path === folderPath);
|
|
112
|
+
|
|
113
|
+
if (!child) {
|
|
114
|
+
child = {
|
|
115
|
+
name,
|
|
116
|
+
path: folderPath,
|
|
117
|
+
folders: [],
|
|
118
|
+
files: [],
|
|
119
|
+
};
|
|
120
|
+
node.folders.push(child);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
node = child;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
node.files.push(filePath);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return root;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getInfraEditorLanguage(fileName: string): string {
|
|
133
|
+
const lower = fileName.toLowerCase();
|
|
134
|
+
|
|
135
|
+
if (lower.endsWith(".tf") || lower.endsWith(".tfvars")) return "terraform";
|
|
136
|
+
if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
|
|
137
|
+
if (
|
|
138
|
+
lower.endsWith(".js") ||
|
|
139
|
+
lower.endsWith(".jsx") ||
|
|
140
|
+
lower.endsWith(".mjs")
|
|
141
|
+
) {
|
|
142
|
+
return "javascript";
|
|
143
|
+
}
|
|
144
|
+
if (lower.endsWith(".json")) return "json";
|
|
145
|
+
if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
|
|
146
|
+
if (baseName(fileName).toLowerCase() === "dockerfile") return "dockerfile";
|
|
147
|
+
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
|
|
148
|
+
return "plaintext";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hasTerraformFiles(files: Record<string, string>): boolean {
|
|
152
|
+
return Object.keys(files).some((fileName) => {
|
|
153
|
+
const lower = fileName.toLowerCase();
|
|
154
|
+
return lower.endsWith(".tf") || lower.endsWith(".tfvars");
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasDockerComposeFile(files: Record<string, string>): boolean {
|
|
159
|
+
return Object.keys(files).some((fileName) => {
|
|
160
|
+
const lower = baseName(fileName).toLowerCase();
|
|
161
|
+
return (
|
|
162
|
+
lower === "compose.yaml" ||
|
|
163
|
+
lower === "compose.yml" ||
|
|
164
|
+
lower === "docker-compose.yaml" ||
|
|
165
|
+
lower === "docker-compose.yml"
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hasDockerfile(files: Record<string, string>): boolean {
|
|
171
|
+
return Object.keys(files).some(
|
|
172
|
+
(fileName) => baseName(fileName).toLowerCase() === "dockerfile",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isDockerFileWorkspace(workspace: InfraLabWorkspace): boolean {
|
|
177
|
+
return (
|
|
178
|
+
workspace.provider === "docker" &&
|
|
179
|
+
(hasDockerComposeFile(workspace.files) || hasDockerfile(workspace.files))
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function configureInfraMonaco(monaco: Monaco) {
|
|
184
|
+
const hasTerraform = monaco.languages
|
|
185
|
+
.getLanguages()
|
|
186
|
+
.some((language: { id: string }) => language.id === "terraform");
|
|
187
|
+
|
|
188
|
+
if (!hasTerraform) {
|
|
189
|
+
monaco.languages.register({
|
|
190
|
+
id: "terraform",
|
|
191
|
+
extensions: [".tf", ".tfvars"],
|
|
192
|
+
aliases: ["Terraform", "tf", "tfvars"],
|
|
193
|
+
});
|
|
194
|
+
monaco.languages.setLanguageConfiguration("terraform", {
|
|
195
|
+
comments: {
|
|
196
|
+
lineComment: "#",
|
|
197
|
+
blockComment: ["/*", "*/"],
|
|
198
|
+
},
|
|
199
|
+
brackets: [
|
|
200
|
+
["{", "}"],
|
|
201
|
+
["[", "]"],
|
|
202
|
+
["(", ")"],
|
|
203
|
+
],
|
|
204
|
+
autoClosingPairs: [
|
|
205
|
+
{ open: "{", close: "}" },
|
|
206
|
+
{ open: "[", close: "]" },
|
|
207
|
+
{ open: "(", close: ")" },
|
|
208
|
+
{ open: '"', close: '"' },
|
|
209
|
+
],
|
|
210
|
+
surroundingPairs: [
|
|
211
|
+
{ open: "{", close: "}" },
|
|
212
|
+
{ open: "[", close: "]" },
|
|
213
|
+
{ open: "(", close: ")" },
|
|
214
|
+
{ open: '"', close: '"' },
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
monaco.languages.setMonarchTokensProvider("terraform", {
|
|
218
|
+
keywords: [
|
|
219
|
+
"terraform",
|
|
220
|
+
"provider",
|
|
221
|
+
"resource",
|
|
222
|
+
"data",
|
|
223
|
+
"variable",
|
|
224
|
+
"output",
|
|
225
|
+
"locals",
|
|
226
|
+
"module",
|
|
227
|
+
"required_providers",
|
|
228
|
+
"required_version",
|
|
229
|
+
"source",
|
|
230
|
+
"version",
|
|
231
|
+
"for",
|
|
232
|
+
"in",
|
|
233
|
+
"if",
|
|
234
|
+
],
|
|
235
|
+
constants: ["true", "false", "null"],
|
|
236
|
+
tokenizer: {
|
|
237
|
+
root: [
|
|
238
|
+
[/#.*$/, "comment"],
|
|
239
|
+
[/\/\/.*$/, "comment"],
|
|
240
|
+
[/\/\*/, "comment", "@comment"],
|
|
241
|
+
[/"/, "string", "@string"],
|
|
242
|
+
[/[{}()[\]]/, "@brackets"],
|
|
243
|
+
[/[a-zA-Z_][\w-]*(?=\s*{)/, "type.identifier"],
|
|
244
|
+
[/[a-zA-Z_][\w-]*(?=\s*=)/, "attribute.name"],
|
|
245
|
+
[
|
|
246
|
+
/[a-zA-Z_][\w-]*/,
|
|
247
|
+
{
|
|
248
|
+
cases: {
|
|
249
|
+
"@keywords": "keyword",
|
|
250
|
+
"@constants": "constant",
|
|
251
|
+
"@default": "identifier",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
[/\d+(\.\d+)?/, "number"],
|
|
256
|
+
[/[=.:,]/, "delimiter"],
|
|
257
|
+
],
|
|
258
|
+
string: [
|
|
259
|
+
[/[^\\"]+/, "string"],
|
|
260
|
+
[/\\./, "string.escape"],
|
|
261
|
+
[/"/, "string", "@pop"],
|
|
262
|
+
],
|
|
263
|
+
comment: [
|
|
264
|
+
[/[^/*]+/, "comment"],
|
|
265
|
+
[/\*\//, "comment", "@pop"],
|
|
266
|
+
[/[/*]/, "comment"],
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!infraMonacoConfigured) {
|
|
273
|
+
// These app files are edited as an in-browser lab, not inside a real
|
|
274
|
+
// node_modules-backed project. Keep Monaco useful for syntax checks while
|
|
275
|
+
// preventing generated NestJS/Express/Redis imports from looking broken.
|
|
276
|
+
const tsLang = monaco.languages.typescript as any;
|
|
277
|
+
if (tsLang?.typescriptDefaults && tsLang?.javascriptDefaults) {
|
|
278
|
+
const compilerOptions = {
|
|
279
|
+
target: tsLang.ScriptTarget.ESNext,
|
|
280
|
+
module: tsLang.ModuleKind.ESNext,
|
|
281
|
+
moduleResolution:
|
|
282
|
+
tsLang.ModuleResolutionKind.Bundler ??
|
|
283
|
+
tsLang.ModuleResolutionKind.NodeJs,
|
|
284
|
+
allowJs: true,
|
|
285
|
+
allowNonTsExtensions: true,
|
|
286
|
+
allowSyntheticDefaultImports: true,
|
|
287
|
+
esModuleInterop: true,
|
|
288
|
+
experimentalDecorators: true,
|
|
289
|
+
emitDecoratorMetadata: true,
|
|
290
|
+
resolveJsonModule: true,
|
|
291
|
+
skipLibCheck: true,
|
|
292
|
+
strict: false,
|
|
293
|
+
};
|
|
294
|
+
const diagnosticsOptions = {
|
|
295
|
+
noSemanticValidation: false,
|
|
296
|
+
noSyntaxValidation: false,
|
|
297
|
+
diagnosticCodesToIgnore: [2307, 2688, 2792, 7016],
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
tsLang.typescriptDefaults.setCompilerOptions(compilerOptions);
|
|
301
|
+
tsLang.javascriptDefaults.setCompilerOptions(compilerOptions);
|
|
302
|
+
tsLang.typescriptDefaults.setDiagnosticsOptions(diagnosticsOptions);
|
|
303
|
+
tsLang.javascriptDefaults.setDiagnosticsOptions(diagnosticsOptions);
|
|
304
|
+
|
|
305
|
+
const labAmbientTypes = `
|
|
306
|
+
declare var process: {
|
|
307
|
+
env: Record<string, string | undefined>;
|
|
308
|
+
argv: string[];
|
|
309
|
+
cwd(): string;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
declare var Buffer: {
|
|
313
|
+
from(value: string, encoding?: string): { toString(encoding?: string): string };
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
declare module 'reflect-metadata' {}
|
|
317
|
+
|
|
318
|
+
declare module '@nestjs/common' {
|
|
319
|
+
export function Body(...args: any[]): ParameterDecorator;
|
|
320
|
+
export function Controller(...args: any[]): ClassDecorator;
|
|
321
|
+
export function Get(...args: any[]): MethodDecorator;
|
|
322
|
+
export function Headers(...args: any[]): ParameterDecorator;
|
|
323
|
+
export function Module(...args: any[]): ClassDecorator;
|
|
324
|
+
export function Param(...args: any[]): ParameterDecorator;
|
|
325
|
+
export function Post(...args: any[]): MethodDecorator;
|
|
326
|
+
export function Query(...args: any[]): ParameterDecorator;
|
|
327
|
+
export function Req(...args: any[]): ParameterDecorator;
|
|
328
|
+
export function Res(...args: any[]): ParameterDecorator;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
declare module '@nestjs/core' {
|
|
332
|
+
export const NestFactory: {
|
|
333
|
+
create(module: any, options?: any): Promise<any>;
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
declare module '@nestjs/platform-express' {}
|
|
338
|
+
|
|
339
|
+
declare module 'cookie-parser' {
|
|
340
|
+
function cookieParser(...args: any[]): any;
|
|
341
|
+
export default cookieParser;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
declare module 'ioredis' {
|
|
345
|
+
export default class Redis {
|
|
346
|
+
constructor(...args: any[]);
|
|
347
|
+
get(key: string): Promise<string | null>;
|
|
348
|
+
setex(key: string, seconds: number, value: string): Promise<any>;
|
|
349
|
+
del(key: string): Promise<any>;
|
|
350
|
+
expire(key: string, seconds: number): Promise<any>;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
declare module 'express' {
|
|
355
|
+
export type Request = any;
|
|
356
|
+
export type Response = any;
|
|
357
|
+
const express: any;
|
|
358
|
+
export default express;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
declare module 'crypto' {
|
|
362
|
+
const crypto: any;
|
|
363
|
+
export default crypto;
|
|
364
|
+
export function randomUUID(): string;
|
|
365
|
+
export function randomBytes(size: number): { toString(encoding?: string): string };
|
|
366
|
+
export function createHash(algorithm: string): { update(value: string): any; digest(encoding?: string): string };
|
|
367
|
+
}
|
|
368
|
+
`;
|
|
369
|
+
|
|
370
|
+
tsLang.typescriptDefaults.addExtraLib(
|
|
371
|
+
labAmbientTypes,
|
|
372
|
+
"file:///infra-lab/types/node-and-nest.d.ts",
|
|
373
|
+
);
|
|
374
|
+
tsLang.javascriptDefaults.addExtraLib(
|
|
375
|
+
labAmbientTypes,
|
|
376
|
+
"file:///infra-lab/types/node-and-nest.d.ts",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
monaco.editor.defineTheme("infra-lab-dark", {
|
|
381
|
+
base: "vs-dark",
|
|
382
|
+
inherit: true,
|
|
383
|
+
rules: [
|
|
384
|
+
{ token: "comment", foreground: "64748b", fontStyle: "italic" },
|
|
385
|
+
{ token: "keyword", foreground: "22d3ee", fontStyle: "bold" },
|
|
386
|
+
{ token: "type.identifier", foreground: "a78bfa" },
|
|
387
|
+
{ token: "attribute.name", foreground: "93c5fd" },
|
|
388
|
+
{ token: "string", foreground: "86efac" },
|
|
389
|
+
{ token: "number", foreground: "fbbf24" },
|
|
390
|
+
{ token: "constant", foreground: "f472b6" },
|
|
391
|
+
],
|
|
392
|
+
colors: {
|
|
393
|
+
"editor.background": "#020617",
|
|
394
|
+
"editor.foreground": "#dbeafe",
|
|
395
|
+
"editorLineNumber.foreground": "#475569",
|
|
396
|
+
"editorLineNumber.activeForeground": "#67e8f9",
|
|
397
|
+
"editorCursor.foreground": "#22d3ee",
|
|
398
|
+
"editor.lineHighlightBackground": "#0f172a",
|
|
399
|
+
"editor.selectionBackground": "#155e75AA",
|
|
400
|
+
"editor.inactiveSelectionBackground": "#164e6333",
|
|
401
|
+
"editorIndentGuide.background1": "#1e293b",
|
|
402
|
+
"editorIndentGuide.activeBackground1": "#0e7490",
|
|
403
|
+
"editorWidget.background": "#0f172a",
|
|
404
|
+
"editorWidget.border": "#334155",
|
|
405
|
+
"minimap.background": "#020617",
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
infraMonacoConfigured = true;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function InfraMonacoEditor({
|
|
413
|
+
value,
|
|
414
|
+
fileName,
|
|
415
|
+
onChange,
|
|
416
|
+
onSave,
|
|
417
|
+
onPlan,
|
|
418
|
+
}: {
|
|
419
|
+
value: string;
|
|
420
|
+
fileName: string;
|
|
421
|
+
onChange: (value: string) => void;
|
|
422
|
+
onSave: () => void;
|
|
423
|
+
onPlan: () => void;
|
|
424
|
+
}) {
|
|
425
|
+
const language = getInfraEditorLanguage(fileName);
|
|
426
|
+
const modelPath = `file:///infra-lab/${fileName}`;
|
|
427
|
+
const onChangeRef = useRef(onChange);
|
|
428
|
+
const onSaveRef = useRef(onSave);
|
|
429
|
+
const onPlanRef = useRef(onPlan);
|
|
430
|
+
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
onChangeRef.current = onChange;
|
|
433
|
+
onSaveRef.current = onSave;
|
|
434
|
+
onPlanRef.current = onPlan;
|
|
435
|
+
}, [onChange, onSave, onPlan]);
|
|
436
|
+
|
|
437
|
+
const handleBeforeMount = useCallback<BeforeMount>((monaco) => {
|
|
438
|
+
configureInfraMonaco(monaco);
|
|
439
|
+
}, []);
|
|
440
|
+
|
|
441
|
+
const handleMount = useCallback<OnMount>((editor, monaco) => {
|
|
442
|
+
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
|
443
|
+
onSaveRef.current();
|
|
444
|
+
});
|
|
445
|
+
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
|
|
446
|
+
onPlanRef.current();
|
|
447
|
+
});
|
|
448
|
+
}, []);
|
|
449
|
+
|
|
450
|
+
const handleEditorChange = useCallback<OnChange>((next) => {
|
|
451
|
+
onChangeRef.current(next ?? "");
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
const editorOptions = useMemo(
|
|
455
|
+
() => ({
|
|
456
|
+
fontFamily: EDITOR_FONT,
|
|
457
|
+
fontSize: 13,
|
|
458
|
+
lineHeight: 22,
|
|
459
|
+
minimap: {
|
|
460
|
+
enabled: true,
|
|
461
|
+
renderCharacters: false,
|
|
462
|
+
scale: 0.8,
|
|
463
|
+
},
|
|
464
|
+
wordWrap: "off" as const,
|
|
465
|
+
automaticLayout: true,
|
|
466
|
+
smoothScrolling: true,
|
|
467
|
+
cursorSmoothCaretAnimation: "on" as const,
|
|
468
|
+
scrollBeyondLastLine: false,
|
|
469
|
+
renderWhitespace: "selection" as const,
|
|
470
|
+
bracketPairColorization: { enabled: true },
|
|
471
|
+
guides: {
|
|
472
|
+
indentation: true,
|
|
473
|
+
bracketPairs: true,
|
|
474
|
+
},
|
|
475
|
+
padding: { top: 14, bottom: 14 },
|
|
476
|
+
lineNumbersMinChars: 4,
|
|
477
|
+
glyphMargin: true,
|
|
478
|
+
folding: true,
|
|
479
|
+
foldingHighlight: true,
|
|
480
|
+
links: true,
|
|
481
|
+
formatOnPaste: true,
|
|
482
|
+
formatOnType: true,
|
|
483
|
+
suggest: { preview: true },
|
|
484
|
+
scrollbar: {
|
|
485
|
+
vertical: "auto" as const,
|
|
486
|
+
horizontal: "auto" as const,
|
|
487
|
+
useShadows: true,
|
|
488
|
+
},
|
|
489
|
+
stickyScroll: { enabled: true },
|
|
490
|
+
}),
|
|
491
|
+
[],
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
<div className="h-full w-full overflow-hidden rounded-xl border border-slate-800 bg-slate-950 shadow-inner select-text">
|
|
496
|
+
<MonacoEditorLib
|
|
497
|
+
height="100%"
|
|
498
|
+
width="100%"
|
|
499
|
+
language={language}
|
|
500
|
+
path={modelPath}
|
|
501
|
+
theme="infra-lab-dark"
|
|
502
|
+
value={value}
|
|
503
|
+
beforeMount={handleBeforeMount}
|
|
504
|
+
onMount={handleMount}
|
|
505
|
+
onChange={handleEditorChange}
|
|
506
|
+
options={editorOptions}
|
|
507
|
+
/>
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
54
511
|
|
|
55
512
|
function updateInfraFile(
|
|
56
513
|
workspace: InfraLabWorkspace,
|
|
@@ -241,6 +698,15 @@ export default function InfraLabModal() {
|
|
|
241
698
|
|
|
242
699
|
const fileOrder = getInfraLabFileOrder(workspace);
|
|
243
700
|
const currentFile = workspace.files[activeFile] ?? "";
|
|
701
|
+
const dockerFileWorkspace = isDockerFileWorkspace(workspace);
|
|
702
|
+
const terraformWorkspace = hasTerraformFiles(workspace.files);
|
|
703
|
+
const dockerComposeWorkspace = hasDockerComposeFile(workspace.files);
|
|
704
|
+
const labTitle = dockerFileWorkspace ? "Docker Lab" : "Infrastructure Lab";
|
|
705
|
+
const primaryEditorActionLabel = dockerFileWorkspace
|
|
706
|
+
? dockerComposeWorkspace
|
|
707
|
+
? "docker compose up -d --build"
|
|
708
|
+
: "docker build -t interview-cockpit/docker-deep-dive-api:local ."
|
|
709
|
+
: "terraform plan";
|
|
244
710
|
|
|
245
711
|
const persistWorkspace = useCallback(
|
|
246
712
|
async (forceNew: boolean) => {
|
|
@@ -396,6 +862,19 @@ export default function InfraLabModal() {
|
|
|
396
862
|
const inspectorJson = selectedRun?.planJson ?? selectedRun?.validationJson;
|
|
397
863
|
const logOutput =
|
|
398
864
|
selectedRun?.logs || runError || "Run output will appear here.";
|
|
865
|
+
const hasRunOutput = Boolean(selectedRun || runError);
|
|
866
|
+
const profileLabel =
|
|
867
|
+
workspace.executionMode === "docker"
|
|
868
|
+
? "Local Docker"
|
|
869
|
+
: workspace.executionMode === "localstack"
|
|
870
|
+
? "AWS LocalStack"
|
|
871
|
+
: "Plan Only";
|
|
872
|
+
const modeDescription =
|
|
873
|
+
workspace.executionMode === "docker"
|
|
874
|
+
? "Local Docker container target"
|
|
875
|
+
: workspace.executionMode === "localstack"
|
|
876
|
+
? "Local emulator target"
|
|
877
|
+
: "Speculative plan target";
|
|
399
878
|
|
|
400
879
|
// ── Practice console ──────────────────────────────────────────
|
|
401
880
|
const [bottomTab, setBottomTab] = useState<"output" | "console" | "chat">(
|
|
@@ -404,6 +883,7 @@ export default function InfraLabModal() {
|
|
|
404
883
|
const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
|
|
405
884
|
const [cmdInput, setCmdInput] = useState("");
|
|
406
885
|
const [consoleRunning, setConsoleRunning] = useState(false);
|
|
886
|
+
const [lastConsoleCommand, setLastConsoleCommand] = useState("");
|
|
407
887
|
const consoleOutputRef = useRef<HTMLDivElement>(null);
|
|
408
888
|
const cmdAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
|
|
409
889
|
|
|
@@ -421,53 +901,80 @@ export default function InfraLabModal() {
|
|
|
421
901
|
}
|
|
422
902
|
}, [consoleLines, bottomTab]);
|
|
423
903
|
|
|
904
|
+
const runConsoleCommand = useCallback(
|
|
905
|
+
async (rawCommand: string) => {
|
|
906
|
+
const cmd = rawCommand.trim();
|
|
907
|
+
if (!cmd || consoleRunning) return;
|
|
908
|
+
setLastConsoleCommand(cmd);
|
|
909
|
+
if (rawCommand === cmdInput) setCmdInput("");
|
|
910
|
+
|
|
911
|
+
if (cmd === "clear") {
|
|
912
|
+
setConsoleLines([]);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (cmd === "help" || cmd === "?") {
|
|
917
|
+
appendLine("info", `${INFRA_CONSOLE_HELP}\n`);
|
|
918
|
+
setBottomTab("console");
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
setBottomTab("console");
|
|
923
|
+
setConsoleRunning(true);
|
|
924
|
+
const abort = { aborted: false };
|
|
925
|
+
cmdAbortRef.current = abort;
|
|
926
|
+
// Don't echo here — the server sends back an info line "$ <command>"
|
|
927
|
+
// as the very first message, which serves as the canonical prompt echo.
|
|
928
|
+
try {
|
|
929
|
+
await api.streamInfraCommand(
|
|
930
|
+
{
|
|
931
|
+
questionId: currentQuestion?.id,
|
|
932
|
+
fileId: activeInfraId ?? undefined,
|
|
933
|
+
label: labName,
|
|
934
|
+
command: cmd,
|
|
935
|
+
workspace,
|
|
936
|
+
},
|
|
937
|
+
(msg: InfraCommandStreamMessage) => {
|
|
938
|
+
if (abort.aborted) return;
|
|
939
|
+
if (msg.type === "output") appendLine(msg.kind, msg.text);
|
|
940
|
+
else if (msg.type === "error") appendLine("stderr", msg.error);
|
|
941
|
+
else if (msg.type === "complete") {
|
|
942
|
+
setSelectedRun(msg.run);
|
|
943
|
+
setSelectedRunId(msg.run.id);
|
|
944
|
+
if (activeInfraId) void refreshRunHistory(activeInfraId);
|
|
945
|
+
if (msg.run.workspaceSnapshot) {
|
|
946
|
+
// Merge any server-generated files (e.g. .terraform.lock.hcl) back
|
|
947
|
+
// into the workspace so they appear in the file tree.
|
|
948
|
+
setWorkspace((prev) => ({
|
|
949
|
+
...prev,
|
|
950
|
+
files: { ...prev.files, ...msg.run.workspaceSnapshot!.files },
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
);
|
|
956
|
+
} catch (err: unknown) {
|
|
957
|
+
if (!abort.aborted)
|
|
958
|
+
appendLine("stderr", (err as Error)?.message ?? "Command failed");
|
|
959
|
+
} finally {
|
|
960
|
+
if (!abort.aborted) setConsoleRunning(false);
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
[
|
|
964
|
+
consoleRunning,
|
|
965
|
+
currentQuestion,
|
|
966
|
+
activeInfraId,
|
|
967
|
+
labName,
|
|
968
|
+
workspace,
|
|
969
|
+
appendLine,
|
|
970
|
+
cmdInput,
|
|
971
|
+
refreshRunHistory,
|
|
972
|
+
],
|
|
973
|
+
);
|
|
974
|
+
|
|
424
975
|
const handleRunCommand = useCallback(async () => {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
setCmdInput("");
|
|
428
|
-
setConsoleRunning(true);
|
|
429
|
-
const abort = { aborted: false };
|
|
430
|
-
cmdAbortRef.current = abort;
|
|
431
|
-
// Don't echo here — the server sends back an info line "$ terraform ..."
|
|
432
|
-
// as the very first message, which serves as the canonical prompt echo.
|
|
433
|
-
try {
|
|
434
|
-
await api.streamInfraCommand(
|
|
435
|
-
{
|
|
436
|
-
questionId: currentQuestion?.id,
|
|
437
|
-
fileId: activeInfraId ?? undefined,
|
|
438
|
-
label: labName,
|
|
439
|
-
command: cmd,
|
|
440
|
-
workspace,
|
|
441
|
-
},
|
|
442
|
-
(msg: InfraCommandStreamMessage) => {
|
|
443
|
-
if (abort.aborted) return;
|
|
444
|
-
if (msg.type === "output") appendLine(msg.kind, msg.text);
|
|
445
|
-
else if (msg.type === "error") appendLine("stderr", msg.error);
|
|
446
|
-
else if (msg.type === "complete" && msg.run.workspaceSnapshot) {
|
|
447
|
-
// Merge any server-generated files (e.g. .terraform.lock.hcl) back
|
|
448
|
-
// into the workspace so they appear in the file tree.
|
|
449
|
-
setWorkspace((prev) => ({
|
|
450
|
-
...prev,
|
|
451
|
-
files: { ...prev.files, ...msg.run.workspaceSnapshot!.files },
|
|
452
|
-
}));
|
|
453
|
-
}
|
|
454
|
-
},
|
|
455
|
-
);
|
|
456
|
-
} catch (err: unknown) {
|
|
457
|
-
if (!abort.aborted)
|
|
458
|
-
appendLine("stderr", (err as Error)?.message ?? "Command failed");
|
|
459
|
-
} finally {
|
|
460
|
-
if (!abort.aborted) setConsoleRunning(false);
|
|
461
|
-
}
|
|
462
|
-
}, [
|
|
463
|
-
cmdInput,
|
|
464
|
-
consoleRunning,
|
|
465
|
-
currentQuestion,
|
|
466
|
-
activeInfraId,
|
|
467
|
-
labName,
|
|
468
|
-
workspace,
|
|
469
|
-
appendLine,
|
|
470
|
-
]);
|
|
976
|
+
await runConsoleCommand(cmdInput);
|
|
977
|
+
}, [cmdInput, runConsoleCommand]);
|
|
471
978
|
|
|
472
979
|
const handleStop = useCallback(() => {
|
|
473
980
|
cmdAbortRef.current.aborted = true;
|
|
@@ -583,16 +1090,36 @@ export default function InfraLabModal() {
|
|
|
583
1090
|
}
|
|
584
1091
|
}, [chatInput, chatLoading, chatMessages, workspace.files, currentQuestion]);
|
|
585
1092
|
|
|
1093
|
+
const [runOutputCopied, setRunOutputCopied] = useState(false);
|
|
586
1094
|
const [consoleCopied, setConsoleCopied] = useState(false);
|
|
1095
|
+
const [consoleCommandCopied, setConsoleCommandCopied] = useState(false);
|
|
1096
|
+
|
|
1097
|
+
const handleCopyRunOutput = useCallback(() => {
|
|
1098
|
+
if (!hasRunOutput) return;
|
|
1099
|
+
void navigator.clipboard.writeText(logOutput).then(() => {
|
|
1100
|
+
setRunOutputCopied(true);
|
|
1101
|
+
setTimeout(() => setRunOutputCopied(false), 1800);
|
|
1102
|
+
});
|
|
1103
|
+
}, [hasRunOutput, logOutput]);
|
|
587
1104
|
|
|
588
1105
|
const handleCopyConsole = useCallback(() => {
|
|
589
|
-
|
|
1106
|
+
if (consoleLines.length === 0) return;
|
|
1107
|
+
const text = consoleLines.map((l) => l.text).join("");
|
|
590
1108
|
void navigator.clipboard.writeText(text).then(() => {
|
|
591
1109
|
setConsoleCopied(true);
|
|
592
1110
|
setTimeout(() => setConsoleCopied(false), 1800);
|
|
593
1111
|
});
|
|
594
1112
|
}, [consoleLines]);
|
|
595
1113
|
|
|
1114
|
+
const handleCopyConsoleCommand = useCallback(() => {
|
|
1115
|
+
const command = cmdInput.trim() || lastConsoleCommand;
|
|
1116
|
+
if (!command) return;
|
|
1117
|
+
void navigator.clipboard.writeText(command).then(() => {
|
|
1118
|
+
setConsoleCommandCopied(true);
|
|
1119
|
+
setTimeout(() => setConsoleCommandCopied(false), 1800);
|
|
1120
|
+
});
|
|
1121
|
+
}, [cmdInput, lastConsoleCommand]);
|
|
1122
|
+
|
|
596
1123
|
const [chatCopiedId, setChatCopiedId] = useState<string | null>(null);
|
|
597
1124
|
|
|
598
1125
|
const handleCopyMessage = useCallback((id: string, content: string) => {
|
|
@@ -602,6 +1129,37 @@ export default function InfraLabModal() {
|
|
|
602
1129
|
});
|
|
603
1130
|
}, []);
|
|
604
1131
|
|
|
1132
|
+
// ── Docker demo panel ─────────────────────────────────────────
|
|
1133
|
+
const [dockerDemoLoading, setDockerDemoLoading] = useState(false);
|
|
1134
|
+
const [dockerDemoResponse, setDockerDemoResponse] = useState(
|
|
1135
|
+
"Start the Compose stack, then hit the API from here.",
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
const hitDockerDemo = useCallback(
|
|
1139
|
+
async (path: string, init?: RequestInit) => {
|
|
1140
|
+
setDockerDemoLoading(true);
|
|
1141
|
+
try {
|
|
1142
|
+
const response = await fetch(`${DOCKER_DEMO_URL}${path}`, init);
|
|
1143
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1144
|
+
const body = contentType.includes("application/json")
|
|
1145
|
+
? await response.json()
|
|
1146
|
+
: await response.text();
|
|
1147
|
+
setDockerDemoResponse(
|
|
1148
|
+
typeof body === "string" ? body : JSON.stringify(body, null, 2),
|
|
1149
|
+
);
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
setDockerDemoResponse(
|
|
1152
|
+
`Could not reach ${DOCKER_DEMO_URL}. Run docker compose up -d --build first.\n\n${
|
|
1153
|
+
(error as Error)?.message ?? String(error)
|
|
1154
|
+
}`,
|
|
1155
|
+
);
|
|
1156
|
+
} finally {
|
|
1157
|
+
setDockerDemoLoading(false);
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
[],
|
|
1161
|
+
);
|
|
1162
|
+
|
|
605
1163
|
// ── Panel collapse ─────────────────────────────────────────────
|
|
606
1164
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
607
1165
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
@@ -671,50 +1229,86 @@ export default function InfraLabModal() {
|
|
|
671
1229
|
[workspace.files, activeFile],
|
|
672
1230
|
);
|
|
673
1231
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
| { kind: "folder"; path: string; label: string; depth: number }
|
|
677
|
-
| { kind: "file"; path: string; label: string; depth: number };
|
|
678
|
-
|
|
679
|
-
const treeEntries = (() => {
|
|
680
|
-
const seenFolders = new Set<string>();
|
|
681
|
-
const entries: TreeEntry[] = [];
|
|
682
|
-
const sorted = [...fileOrder].sort((a, b) => {
|
|
683
|
-
const ad = a.split("/").length;
|
|
684
|
-
const bd = b.split("/").length;
|
|
685
|
-
return ad !== bd ? ad - bd : a.localeCompare(b);
|
|
686
|
-
});
|
|
687
|
-
for (const filePath of sorted) {
|
|
688
|
-
const parts = filePath.split("/");
|
|
689
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
690
|
-
const folderPath = parts.slice(0, i + 1).join("/");
|
|
691
|
-
if (!seenFolders.has(folderPath)) {
|
|
692
|
-
seenFolders.add(folderPath);
|
|
693
|
-
entries.push({
|
|
694
|
-
kind: "folder",
|
|
695
|
-
path: folderPath,
|
|
696
|
-
label: parts[i],
|
|
697
|
-
depth: i,
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
entries.push({
|
|
702
|
-
kind: "file",
|
|
703
|
-
path: filePath,
|
|
704
|
-
label: parts[parts.length - 1],
|
|
705
|
-
depth: parts.length - 1,
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
return entries;
|
|
709
|
-
})();
|
|
1232
|
+
const fileOrderKey = fileOrder.join("\0");
|
|
1233
|
+
const fileTree = useMemo(() => buildInfraFileTree(fileOrder), [fileOrderKey]);
|
|
710
1234
|
|
|
711
|
-
const
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1235
|
+
const renderFileNode = (fileName: string, depth: number) => {
|
|
1236
|
+
const isActive = fileName === activeFile;
|
|
1237
|
+
const canDelete = Object.keys(workspace.files).length > 1;
|
|
1238
|
+
|
|
1239
|
+
return (
|
|
1240
|
+
<div
|
|
1241
|
+
key={`file:${fileName}`}
|
|
1242
|
+
style={{ paddingLeft: `${depth * 12}px` }}
|
|
1243
|
+
className="group relative"
|
|
1244
|
+
>
|
|
1245
|
+
<button
|
|
1246
|
+
onClick={() => {
|
|
1247
|
+
setActiveFile(fileName);
|
|
1248
|
+
setWorkspace((current) => ({
|
|
1249
|
+
...current,
|
|
1250
|
+
activeFile: fileName,
|
|
1251
|
+
}));
|
|
1252
|
+
}}
|
|
1253
|
+
className={`w-full rounded-lg border px-2 py-1.5 pr-6 text-left text-xs transition-colors ${
|
|
1254
|
+
isActive
|
|
1255
|
+
? "border-cyan-500/60 bg-cyan-500/10 text-cyan-200 shadow-[0_0_0_1px_rgba(34,211,238,0.08)]"
|
|
1256
|
+
: "border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700 hover:text-slate-200"
|
|
1257
|
+
}`}
|
|
1258
|
+
title={fileName}
|
|
1259
|
+
>
|
|
1260
|
+
{baseName(fileName)}
|
|
1261
|
+
</button>
|
|
1262
|
+
{canDelete && (
|
|
1263
|
+
<button
|
|
1264
|
+
onClick={() => handleDeleteFile(fileName)}
|
|
1265
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 rounded text-slate-700 opacity-0 group-hover:opacity-100 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
1266
|
+
title={`Delete ${fileName}`}
|
|
1267
|
+
>
|
|
1268
|
+
<Trash2 className="w-3 h-3" />
|
|
1269
|
+
</button>
|
|
1270
|
+
)}
|
|
1271
|
+
</div>
|
|
1272
|
+
);
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const renderFolderNode = (folder: InfraFileTreeNode, depth: number) => {
|
|
1276
|
+
const isCollapsed = collapsedFolders.has(folder.path);
|
|
1277
|
+
|
|
1278
|
+
return (
|
|
1279
|
+
<div key={`folder:${folder.path}`} className="space-y-0.5">
|
|
1280
|
+
<button
|
|
1281
|
+
onClick={() =>
|
|
1282
|
+
setCollapsedFolders((prev) => {
|
|
1283
|
+
const next = new Set(prev);
|
|
1284
|
+
if (next.has(folder.path)) next.delete(folder.path);
|
|
1285
|
+
else next.add(folder.path);
|
|
1286
|
+
return next;
|
|
1287
|
+
})
|
|
1288
|
+
}
|
|
1289
|
+
style={{ paddingLeft: `${depth * 12 + 2}px` }}
|
|
1290
|
+
className="w-full flex items-center gap-1 py-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
|
1291
|
+
title={folder.path}
|
|
1292
|
+
>
|
|
1293
|
+
{isCollapsed ? (
|
|
1294
|
+
<ChevronRight className="w-3 h-3 shrink-0" />
|
|
1295
|
+
) : (
|
|
1296
|
+
<ChevronDown className="w-3 h-3 shrink-0" />
|
|
1297
|
+
)}
|
|
1298
|
+
<Folder className="w-3 h-3 shrink-0" />
|
|
1299
|
+
<span className="truncate">{folder.name}/</span>
|
|
1300
|
+
</button>
|
|
1301
|
+
{!isCollapsed && (
|
|
1302
|
+
<div className="space-y-0.5">
|
|
1303
|
+
{folder.files.map((fileName) =>
|
|
1304
|
+
renderFileNode(fileName, depth + 1),
|
|
1305
|
+
)}
|
|
1306
|
+
{folder.folders.map((child) => renderFolderNode(child, depth + 1))}
|
|
1307
|
+
</div>
|
|
1308
|
+
)}
|
|
1309
|
+
</div>
|
|
1310
|
+
);
|
|
1311
|
+
};
|
|
718
1312
|
|
|
719
1313
|
const windowStyle: React.CSSProperties = maximized
|
|
720
1314
|
? {
|
|
@@ -785,7 +1379,7 @@ export default function InfraLabModal() {
|
|
|
785
1379
|
>
|
|
786
1380
|
<GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
787
1381
|
<span className="text-[11px] uppercase tracking-[0.2em] text-cyan-400/80 shrink-0">
|
|
788
|
-
|
|
1382
|
+
{labTitle}
|
|
789
1383
|
</span>
|
|
790
1384
|
{/* Lab name */}
|
|
791
1385
|
<input
|
|
@@ -800,15 +1394,24 @@ export default function InfraLabModal() {
|
|
|
800
1394
|
onMouseDown={(e) => e.stopPropagation()}
|
|
801
1395
|
value={workspace.executionMode}
|
|
802
1396
|
onChange={(event) =>
|
|
803
|
-
setWorkspace((current) =>
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1397
|
+
setWorkspace((current) => {
|
|
1398
|
+
const executionMode =
|
|
1399
|
+
event.target.value === "docker"
|
|
1400
|
+
? "docker"
|
|
1401
|
+
: event.target.value === "plan-only"
|
|
1402
|
+
? "plan-only"
|
|
1403
|
+
: "localstack";
|
|
1404
|
+
return {
|
|
1405
|
+
...current,
|
|
1406
|
+
provider: executionMode === "docker" ? "docker" : "aws",
|
|
1407
|
+
executionMode,
|
|
1408
|
+
};
|
|
1409
|
+
})
|
|
808
1410
|
}
|
|
809
1411
|
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 focus:outline-none focus:border-cyan-500 shrink-0"
|
|
810
1412
|
>
|
|
811
1413
|
<option value="localstack">AWS LocalStack Profile</option>
|
|
1414
|
+
<option value="docker">Local Docker Profile</option>
|
|
812
1415
|
<option value="plan-only">Plan-Only Profile</option>
|
|
813
1416
|
</select>
|
|
814
1417
|
{/* Actions */}
|
|
@@ -822,30 +1425,81 @@ export default function InfraLabModal() {
|
|
|
822
1425
|
Saved
|
|
823
1426
|
</span>
|
|
824
1427
|
)}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1428
|
+
{terraformWorkspace && (
|
|
1429
|
+
<>
|
|
1430
|
+
<button
|
|
1431
|
+
onClick={() => void runInfra("validate")}
|
|
1432
|
+
disabled={!!runningAction || !currentQuestion}
|
|
1433
|
+
className="inline-flex items-center gap-1.5 rounded border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-200 hover:bg-emerald-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1434
|
+
>
|
|
1435
|
+
{runningAction === "validate" ? (
|
|
1436
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1437
|
+
) : (
|
|
1438
|
+
<Play className="w-3 h-3" />
|
|
1439
|
+
)}
|
|
1440
|
+
Validate
|
|
1441
|
+
</button>
|
|
1442
|
+
<button
|
|
1443
|
+
onClick={() => void runInfra("plan")}
|
|
1444
|
+
disabled={!!runningAction || !currentQuestion}
|
|
1445
|
+
className="inline-flex items-center gap-1.5 rounded border border-cyan-500/30 bg-cyan-500/10 px-2.5 py-1 text-xs font-medium text-cyan-100 hover:bg-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1446
|
+
>
|
|
1447
|
+
{runningAction === "plan" ? (
|
|
1448
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1449
|
+
) : (
|
|
1450
|
+
<Play className="w-3 h-3" />
|
|
1451
|
+
)}
|
|
1452
|
+
Plan
|
|
1453
|
+
</button>
|
|
1454
|
+
</>
|
|
1455
|
+
)}
|
|
1456
|
+
{dockerFileWorkspace && (
|
|
1457
|
+
<>
|
|
1458
|
+
{hasDockerfile(workspace.files) && (
|
|
1459
|
+
<button
|
|
1460
|
+
onClick={() =>
|
|
1461
|
+
void runConsoleCommand(
|
|
1462
|
+
"docker build -t interview-cockpit/docker-deep-dive-api:local .",
|
|
1463
|
+
)
|
|
1464
|
+
}
|
|
1465
|
+
disabled={consoleRunning || !currentQuestion}
|
|
1466
|
+
className="inline-flex items-center gap-1.5 rounded border border-sky-500/30 bg-sky-500/10 px-2.5 py-1 text-xs font-medium text-sky-100 hover:bg-sky-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1467
|
+
>
|
|
1468
|
+
{consoleRunning ? (
|
|
1469
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1470
|
+
) : (
|
|
1471
|
+
<Play className="w-3 h-3" />
|
|
1472
|
+
)}
|
|
1473
|
+
Build
|
|
1474
|
+
</button>
|
|
1475
|
+
)}
|
|
1476
|
+
{dockerComposeWorkspace && (
|
|
1477
|
+
<button
|
|
1478
|
+
onClick={() =>
|
|
1479
|
+
void runConsoleCommand("docker compose up -d --build")
|
|
1480
|
+
}
|
|
1481
|
+
disabled={consoleRunning || !currentQuestion}
|
|
1482
|
+
className="inline-flex items-center gap-1.5 rounded border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-200 hover:bg-emerald-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1483
|
+
>
|
|
1484
|
+
{consoleRunning ? (
|
|
1485
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1486
|
+
) : (
|
|
1487
|
+
<Play className="w-3 h-3" />
|
|
1488
|
+
)}
|
|
1489
|
+
Compose Up
|
|
1490
|
+
</button>
|
|
1491
|
+
)}
|
|
1492
|
+
{dockerComposeWorkspace && (
|
|
1493
|
+
<button
|
|
1494
|
+
onClick={() => void runConsoleCommand("docker compose down")}
|
|
1495
|
+
disabled={consoleRunning || !currentQuestion}
|
|
1496
|
+
className="inline-flex items-center gap-1.5 rounded border border-slate-700 bg-slate-800 px-2.5 py-1 text-xs font-medium text-slate-200 hover:border-slate-600 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1497
|
+
>
|
|
1498
|
+
Down
|
|
1499
|
+
</button>
|
|
1500
|
+
)}
|
|
1501
|
+
</>
|
|
1502
|
+
)}
|
|
849
1503
|
<button
|
|
850
1504
|
onClick={() => void persistWorkspace(false)}
|
|
851
1505
|
disabled={saving || !currentQuestion}
|
|
@@ -973,69 +1627,8 @@ export default function InfraLabModal() {
|
|
|
973
1627
|
|
|
974
1628
|
{/* File tree */}
|
|
975
1629
|
<div className="space-y-0.5">
|
|
976
|
-
{
|
|
977
|
-
|
|
978
|
-
const isCollapsed = collapsedFolders.has(entry.path);
|
|
979
|
-
return (
|
|
980
|
-
<button
|
|
981
|
-
key={`folder:${entry.path}`}
|
|
982
|
-
onClick={() =>
|
|
983
|
-
setCollapsedFolders((prev) => {
|
|
984
|
-
const next = new Set(prev);
|
|
985
|
-
if (next.has(entry.path)) next.delete(entry.path);
|
|
986
|
-
else next.add(entry.path);
|
|
987
|
-
return next;
|
|
988
|
-
})
|
|
989
|
-
}
|
|
990
|
-
style={{ paddingLeft: `${entry.depth * 12 + 2}px` }}
|
|
991
|
-
className="w-full flex items-center gap-1 py-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
|
992
|
-
>
|
|
993
|
-
{isCollapsed ? (
|
|
994
|
-
<ChevronRight className="w-3 h-3 shrink-0" />
|
|
995
|
-
) : (
|
|
996
|
-
<ChevronDown className="w-3 h-3 shrink-0" />
|
|
997
|
-
)}
|
|
998
|
-
<Folder className="w-3 h-3 shrink-0" />
|
|
999
|
-
<span className="truncate">{entry.label}/</span>
|
|
1000
|
-
</button>
|
|
1001
|
-
);
|
|
1002
|
-
}
|
|
1003
|
-
const isActive = entry.path === activeFile;
|
|
1004
|
-
const canDelete = Object.keys(workspace.files).length > 1;
|
|
1005
|
-
return (
|
|
1006
|
-
<div
|
|
1007
|
-
key={`file:${entry.path}`}
|
|
1008
|
-
style={{ paddingLeft: `${entry.depth * 12}px` }}
|
|
1009
|
-
className="group relative"
|
|
1010
|
-
>
|
|
1011
|
-
<button
|
|
1012
|
-
onClick={() => {
|
|
1013
|
-
setActiveFile(entry.path);
|
|
1014
|
-
setWorkspace((current) => ({
|
|
1015
|
-
...current,
|
|
1016
|
-
activeFile: entry.path,
|
|
1017
|
-
}));
|
|
1018
|
-
}}
|
|
1019
|
-
className={`w-full rounded-lg border px-2 py-1.5 pr-6 text-left text-xs transition-colors ${
|
|
1020
|
-
isActive
|
|
1021
|
-
? "border-cyan-500/60 bg-cyan-500/10 text-cyan-200"
|
|
1022
|
-
: "border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700 hover:text-slate-200"
|
|
1023
|
-
}`}
|
|
1024
|
-
>
|
|
1025
|
-
{entry.label}
|
|
1026
|
-
</button>
|
|
1027
|
-
{canDelete && (
|
|
1028
|
-
<button
|
|
1029
|
-
onClick={() => handleDeleteFile(entry.path)}
|
|
1030
|
-
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 rounded text-slate-700 opacity-0 group-hover:opacity-100 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
1031
|
-
title={`Delete ${entry.path}`}
|
|
1032
|
-
>
|
|
1033
|
-
<Trash2 className="w-3 h-3" />
|
|
1034
|
-
</button>
|
|
1035
|
-
)}
|
|
1036
|
-
</div>
|
|
1037
|
-
);
|
|
1038
|
-
})}
|
|
1630
|
+
{fileTree.files.map((fileName) => renderFileNode(fileName, 0))}
|
|
1631
|
+
{fileTree.folders.map((folder) => renderFolderNode(folder, 0))}
|
|
1039
1632
|
</div>
|
|
1040
1633
|
</div>
|
|
1041
1634
|
</aside>
|
|
@@ -1059,16 +1652,15 @@ export default function InfraLabModal() {
|
|
|
1059
1652
|
{activeFile}
|
|
1060
1653
|
</p>
|
|
1061
1654
|
<p className="text-xs text-slate-500">
|
|
1062
|
-
|
|
1063
|
-
|
|
1655
|
+
{dockerFileWorkspace
|
|
1656
|
+
? `Edit Dockerfile, Compose, and app files, then press Cmd/Ctrl+Enter to run ${primaryEditorActionLabel}.`
|
|
1657
|
+
: "Edit Terraform and app files, then run Terraform, Docker, or workspace commands through the console."}
|
|
1064
1658
|
</p>
|
|
1065
1659
|
</div>
|
|
1066
1660
|
</div>
|
|
1067
1661
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
1068
1662
|
<span className="rounded-full border border-slate-700 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-400">
|
|
1069
|
-
{
|
|
1070
|
-
? "AWS LocalStack"
|
|
1071
|
-
: "Plan Only"}
|
|
1663
|
+
{profileLabel}
|
|
1072
1664
|
</span>
|
|
1073
1665
|
<button
|
|
1074
1666
|
onClick={() => setRightCollapsed((v) => !v)}
|
|
@@ -1087,35 +1679,23 @@ export default function InfraLabModal() {
|
|
|
1087
1679
|
</button>
|
|
1088
1680
|
</div>
|
|
1089
1681
|
</div>
|
|
1090
|
-
<div className="flex-1 min-h-0
|
|
1091
|
-
<
|
|
1682
|
+
<div className="flex-1 min-h-0 bg-slate-950 p-3 select-text">
|
|
1683
|
+
<InfraMonacoEditor
|
|
1092
1684
|
value={currentFile}
|
|
1093
|
-
|
|
1685
|
+
fileName={activeFile}
|
|
1686
|
+
onChange={(value) =>
|
|
1094
1687
|
setWorkspace((current) =>
|
|
1095
|
-
updateInfraFile(current, activeFile,
|
|
1688
|
+
updateInfraFile(current, activeFile, value),
|
|
1096
1689
|
)
|
|
1097
1690
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
const indent = " ";
|
|
1105
|
-
const next =
|
|
1106
|
-
el.value.slice(0, start) + indent + el.value.slice(end);
|
|
1107
|
-
setWorkspace((current) =>
|
|
1108
|
-
updateInfraFile(current, activeFile, next),
|
|
1109
|
-
);
|
|
1110
|
-
// restore cursor after React re-render
|
|
1111
|
-
requestAnimationFrame(() => {
|
|
1112
|
-
el.selectionStart = start + indent.length;
|
|
1113
|
-
el.selectionEnd = start + indent.length;
|
|
1114
|
-
});
|
|
1691
|
+
onSave={() => void persistWorkspace(false)}
|
|
1692
|
+
onPlan={() => {
|
|
1693
|
+
if (dockerFileWorkspace) {
|
|
1694
|
+
void runConsoleCommand(primaryEditorActionLabel);
|
|
1695
|
+
} else {
|
|
1696
|
+
void runInfra("plan");
|
|
1115
1697
|
}
|
|
1116
1698
|
}}
|
|
1117
|
-
spellCheck={false}
|
|
1118
|
-
className="h-full w-full resize-none rounded-xl border border-slate-800 bg-slate-950 px-4 py-3 font-mono text-sm leading-6 text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
1119
1699
|
/>
|
|
1120
1700
|
</div>
|
|
1121
1701
|
<div className="h-72 border-t border-slate-800 bg-slate-950/80 flex flex-col">
|
|
@@ -1157,38 +1737,74 @@ export default function InfraLabModal() {
|
|
|
1157
1737
|
Chat
|
|
1158
1738
|
</button>
|
|
1159
1739
|
<div className="flex-1" />
|
|
1160
|
-
{bottomTab === "output" &&
|
|
1161
|
-
<
|
|
1162
|
-
className={`mr-2 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
|
|
1163
|
-
selectedRun.status === "completed"
|
|
1164
|
-
? "bg-emerald-500/15 text-emerald-300"
|
|
1165
|
-
: "bg-red-500/15 text-red-300"
|
|
1166
|
-
}`}
|
|
1167
|
-
>
|
|
1168
|
-
{selectedRun.status}
|
|
1169
|
-
</span>
|
|
1170
|
-
)}
|
|
1171
|
-
{bottomTab === "console" && consoleLines.length > 0 && (
|
|
1172
|
-
<div className="flex items-center gap-1 mr-2">
|
|
1740
|
+
{bottomTab === "output" && (
|
|
1741
|
+
<div className="mr-2 flex items-center gap-1.5">
|
|
1173
1742
|
<button
|
|
1174
|
-
onClick={
|
|
1175
|
-
|
|
1176
|
-
|
|
1743
|
+
onClick={handleCopyRunOutput}
|
|
1744
|
+
disabled={!hasRunOutput}
|
|
1745
|
+
className="inline-flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-[10px] font-medium text-slate-400 hover:border-cyan-500/40 hover:bg-cyan-500/10 hover:text-cyan-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1746
|
+
title="Copy run output"
|
|
1177
1747
|
>
|
|
1178
|
-
{
|
|
1748
|
+
{runOutputCopied ? (
|
|
1179
1749
|
<ClipboardCheck className="w-3 h-3 text-emerald-400" />
|
|
1180
1750
|
) : (
|
|
1181
1751
|
<Clipboard className="w-3 h-3" />
|
|
1182
1752
|
)}
|
|
1753
|
+
{runOutputCopied ? "Copied" : "Copy log"}
|
|
1183
1754
|
</button>
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1755
|
+
{selectedRun && (
|
|
1756
|
+
<span
|
|
1757
|
+
className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
|
|
1758
|
+
selectedRun.status === "completed"
|
|
1759
|
+
? "bg-emerald-500/15 text-emerald-300"
|
|
1760
|
+
: "bg-red-500/15 text-red-300"
|
|
1761
|
+
}`}
|
|
1762
|
+
>
|
|
1763
|
+
{selectedRun.status}
|
|
1764
|
+
</span>
|
|
1765
|
+
)}
|
|
1190
1766
|
</div>
|
|
1191
1767
|
)}
|
|
1768
|
+
{bottomTab === "console" &&
|
|
1769
|
+
(consoleLines.length > 0 ||
|
|
1770
|
+
!!cmdInput.trim() ||
|
|
1771
|
+
!!lastConsoleCommand) && (
|
|
1772
|
+
<div className="flex items-center gap-1.5 mr-2">
|
|
1773
|
+
<button
|
|
1774
|
+
onClick={handleCopyConsole}
|
|
1775
|
+
disabled={consoleLines.length === 0}
|
|
1776
|
+
className="inline-flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-[10px] font-medium text-slate-400 hover:border-emerald-500/40 hover:bg-emerald-500/10 hover:text-emerald-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1777
|
+
title="Copy output"
|
|
1778
|
+
>
|
|
1779
|
+
{consoleCopied ? (
|
|
1780
|
+
<ClipboardCheck className="w-3 h-3 text-emerald-400" />
|
|
1781
|
+
) : (
|
|
1782
|
+
<Clipboard className="w-3 h-3" />
|
|
1783
|
+
)}
|
|
1784
|
+
{consoleCopied ? "Copied" : "Copy output"}
|
|
1785
|
+
</button>
|
|
1786
|
+
<button
|
|
1787
|
+
onClick={handleCopyConsoleCommand}
|
|
1788
|
+
disabled={!cmdInput.trim() && !lastConsoleCommand}
|
|
1789
|
+
className="inline-flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-[10px] font-medium text-slate-400 hover:border-cyan-500/40 hover:bg-cyan-500/10 hover:text-cyan-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1790
|
+
title="Copy current command, or the last command if the prompt is empty"
|
|
1791
|
+
>
|
|
1792
|
+
{consoleCommandCopied ? (
|
|
1793
|
+
<ClipboardCheck className="w-3 h-3 text-emerald-400" />
|
|
1794
|
+
) : (
|
|
1795
|
+
<Clipboard className="w-3 h-3" />
|
|
1796
|
+
)}
|
|
1797
|
+
{consoleCommandCopied ? "Copied cmd" : "Copy cmd"}
|
|
1798
|
+
</button>
|
|
1799
|
+
<button
|
|
1800
|
+
onClick={() => setConsoleLines([])}
|
|
1801
|
+
disabled={consoleLines.length === 0}
|
|
1802
|
+
className="text-[10px] text-slate-600 hover:text-slate-400 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
1803
|
+
>
|
|
1804
|
+
clear
|
|
1805
|
+
</button>
|
|
1806
|
+
</div>
|
|
1807
|
+
)}
|
|
1192
1808
|
{bottomTab === "chat" && chatMessages.length > 0 && (
|
|
1193
1809
|
<button
|
|
1194
1810
|
onClick={() => setChatMessages([])}
|
|
@@ -1215,8 +1831,14 @@ export default function InfraLabModal() {
|
|
|
1215
1831
|
>
|
|
1216
1832
|
{consoleLines.length === 0 && (
|
|
1217
1833
|
<p className="text-slate-600">
|
|
1218
|
-
Type a
|
|
1219
|
-
|
|
1834
|
+
Type a Terraform, Docker, or local workspace command and
|
|
1835
|
+
press Enter. Try{" "}
|
|
1836
|
+
<span className="text-slate-500">help</span>,{" "}
|
|
1837
|
+
<span className="text-slate-500">
|
|
1838
|
+
docker context show
|
|
1839
|
+
</span>
|
|
1840
|
+
, or{" "}
|
|
1841
|
+
<span className="text-slate-500">docker network ls</span>.
|
|
1220
1842
|
</p>
|
|
1221
1843
|
)}
|
|
1222
1844
|
{consoleLines.map((line) => (
|
|
@@ -1259,7 +1881,7 @@ export default function InfraLabModal() {
|
|
|
1259
1881
|
void handleRunCommand();
|
|
1260
1882
|
}
|
|
1261
1883
|
}}
|
|
1262
|
-
placeholder="terraform version"
|
|
1884
|
+
placeholder="terraform version, docker network ls, or help"
|
|
1263
1885
|
disabled={consoleRunning}
|
|
1264
1886
|
className="flex-1 bg-transparent font-mono text-xs text-slate-200 placeholder-slate-600 outline-none disabled:opacity-50"
|
|
1265
1887
|
autoComplete="off"
|
|
@@ -1300,7 +1922,9 @@ export default function InfraLabModal() {
|
|
|
1300
1922
|
<p className="text-xs text-slate-600 pt-1">
|
|
1301
1923
|
Ask anything about your workspace — e.g.{" "}
|
|
1302
1924
|
<span className="text-slate-500">
|
|
1303
|
-
|
|
1925
|
+
{dockerFileWorkspace
|
|
1926
|
+
? '"Why can the api reach Redis at redis:6379?"'
|
|
1927
|
+
: '"What does this provider block do?"'}
|
|
1304
1928
|
</span>
|
|
1305
1929
|
</p>
|
|
1306
1930
|
)}
|
|
@@ -1447,7 +2071,11 @@ export default function InfraLabModal() {
|
|
|
1447
2071
|
void handleChatSend();
|
|
1448
2072
|
}
|
|
1449
2073
|
}}
|
|
1450
|
-
placeholder=
|
|
2074
|
+
placeholder={
|
|
2075
|
+
dockerFileWorkspace
|
|
2076
|
+
? "Ask about Docker images, containers, Compose, ports, networks, or volumes…"
|
|
2077
|
+
: "Ask about your Terraform code…"
|
|
2078
|
+
}
|
|
1451
2079
|
disabled={chatLoading}
|
|
1452
2080
|
className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-20"
|
|
1453
2081
|
/>
|
|
@@ -1478,6 +2106,110 @@ export default function InfraLabModal() {
|
|
|
1478
2106
|
className="p-4 overflow-y-auto h-full"
|
|
1479
2107
|
style={{ display: rightCollapsed ? "none" : undefined }}
|
|
1480
2108
|
>
|
|
2109
|
+
{dockerFileWorkspace && (
|
|
2110
|
+
<div className="mb-4 rounded-xl border border-cyan-500/20 bg-cyan-500/10 p-4">
|
|
2111
|
+
<div className="flex items-start justify-between gap-3">
|
|
2112
|
+
<div>
|
|
2113
|
+
<p className="text-sm font-semibold text-cyan-100">
|
|
2114
|
+
Docker Deep Dive
|
|
2115
|
+
</p>
|
|
2116
|
+
<p className="mt-1 text-xs leading-5 text-cyan-100/70">
|
|
2117
|
+
Build an image, run multiple containers, hit the exposed
|
|
2118
|
+
API, inspect logs, compare env vars, and reset volumes.
|
|
2119
|
+
</p>
|
|
2120
|
+
</div>
|
|
2121
|
+
<span className="rounded-full border border-cyan-400/30 px-2 py-1 text-[10px] uppercase tracking-wide text-cyan-200">
|
|
2122
|
+
port 4288
|
|
2123
|
+
</span>
|
|
2124
|
+
</div>
|
|
2125
|
+
|
|
2126
|
+
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
2127
|
+
{[
|
|
2128
|
+
["Up", "docker compose up -d --build"],
|
|
2129
|
+
["PS", "docker compose ps"],
|
|
2130
|
+
["Logs", "docker logs docker-deep-dive-api"],
|
|
2131
|
+
[
|
|
2132
|
+
"Exec",
|
|
2133
|
+
'docker compose exec -T api node -e "console.log(process.env.REDIS_URL)"',
|
|
2134
|
+
],
|
|
2135
|
+
["Images", "docker images"],
|
|
2136
|
+
["Down", "docker compose down"],
|
|
2137
|
+
].map(([label, command]) => (
|
|
2138
|
+
<button
|
|
2139
|
+
key={command}
|
|
2140
|
+
onClick={() => void runConsoleCommand(command)}
|
|
2141
|
+
disabled={consoleRunning}
|
|
2142
|
+
className="rounded-lg border border-cyan-500/20 bg-slate-950/70 px-2 py-1.5 text-left text-[11px] font-medium text-cyan-100 hover:border-cyan-400/40 hover:bg-cyan-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
2143
|
+
title={command}
|
|
2144
|
+
>
|
|
2145
|
+
{label}
|
|
2146
|
+
</button>
|
|
2147
|
+
))}
|
|
2148
|
+
</div>
|
|
2149
|
+
|
|
2150
|
+
<div className="mt-4 rounded-xl border border-slate-800 bg-slate-950/80 p-3">
|
|
2151
|
+
<div className="flex items-center justify-between gap-2">
|
|
2152
|
+
<p className="text-xs font-semibold text-slate-200">
|
|
2153
|
+
Demo API
|
|
2154
|
+
</p>
|
|
2155
|
+
<button
|
|
2156
|
+
onClick={() =>
|
|
2157
|
+
window.open(DOCKER_DEMO_URL, "_blank", "noopener")
|
|
2158
|
+
}
|
|
2159
|
+
className="text-[10px] text-cyan-300 hover:text-cyan-200 transition-colors"
|
|
2160
|
+
>
|
|
2161
|
+
open page
|
|
2162
|
+
</button>
|
|
2163
|
+
</div>
|
|
2164
|
+
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
2165
|
+
<button
|
|
2166
|
+
onClick={() => void hitDockerDemo("/api/ping")}
|
|
2167
|
+
disabled={dockerDemoLoading}
|
|
2168
|
+
className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
|
|
2169
|
+
>
|
|
2170
|
+
Ping
|
|
2171
|
+
</button>
|
|
2172
|
+
<button
|
|
2173
|
+
onClick={() =>
|
|
2174
|
+
void hitDockerDemo("/api/redis/increment/panel")
|
|
2175
|
+
}
|
|
2176
|
+
disabled={dockerDemoLoading}
|
|
2177
|
+
className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
|
|
2178
|
+
>
|
|
2179
|
+
Redis ++
|
|
2180
|
+
</button>
|
|
2181
|
+
<button
|
|
2182
|
+
onClick={() =>
|
|
2183
|
+
void hitDockerDemo("/api/cache/panel", {
|
|
2184
|
+
method: "POST",
|
|
2185
|
+
headers: { "content-type": "application/json" },
|
|
2186
|
+
body: JSON.stringify({
|
|
2187
|
+
value: `panel saved ${new Date().toISOString()}`,
|
|
2188
|
+
}),
|
|
2189
|
+
})
|
|
2190
|
+
}
|
|
2191
|
+
disabled={dockerDemoLoading}
|
|
2192
|
+
className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
|
|
2193
|
+
>
|
|
2194
|
+
Set cache
|
|
2195
|
+
</button>
|
|
2196
|
+
<button
|
|
2197
|
+
onClick={() => void hitDockerDemo("/api/cache/panel")}
|
|
2198
|
+
disabled={dockerDemoLoading}
|
|
2199
|
+
className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
|
|
2200
|
+
>
|
|
2201
|
+
Read cache
|
|
2202
|
+
</button>
|
|
2203
|
+
</div>
|
|
2204
|
+
<pre className="mt-3 max-h-52 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-900 px-3 py-2 font-mono text-[11px] leading-5 text-slate-300">
|
|
2205
|
+
{dockerDemoLoading
|
|
2206
|
+
? "Calling demo API…"
|
|
2207
|
+
: dockerDemoResponse}
|
|
2208
|
+
</pre>
|
|
2209
|
+
</div>
|
|
2210
|
+
</div>
|
|
2211
|
+
)}
|
|
2212
|
+
|
|
1481
2213
|
<div className="rounded-xl border border-slate-800 bg-slate-900/80 p-4">
|
|
1482
2214
|
<p className="text-sm font-semibold text-slate-100">
|
|
1483
2215
|
Execution Profile
|
|
@@ -1485,15 +2217,15 @@ export default function InfraLabModal() {
|
|
|
1485
2217
|
<dl className="mt-3 space-y-3 text-sm">
|
|
1486
2218
|
<div>
|
|
1487
2219
|
<dt className="text-slate-500">Provider</dt>
|
|
1488
|
-
<dd className="mt-1 text-slate-200">
|
|
2220
|
+
<dd className="mt-1 text-slate-200">
|
|
2221
|
+
{workspace.provider === "docker"
|
|
2222
|
+
? "Docker + local containers"
|
|
2223
|
+
: "AWS"}
|
|
2224
|
+
</dd>
|
|
1489
2225
|
</div>
|
|
1490
2226
|
<div>
|
|
1491
2227
|
<dt className="text-slate-500">Mode</dt>
|
|
1492
|
-
<dd className="mt-1 text-slate-200">
|
|
1493
|
-
{workspace.executionMode === "localstack"
|
|
1494
|
-
? "Local emulator target"
|
|
1495
|
-
: "Speculative plan target"}
|
|
1496
|
-
</dd>
|
|
2228
|
+
<dd className="mt-1 text-slate-200">{modeDescription}</dd>
|
|
1497
2229
|
</div>
|
|
1498
2230
|
<div>
|
|
1499
2231
|
<dt className="text-slate-500">Files</dt>
|
|
@@ -1525,7 +2257,7 @@ export default function InfraLabModal() {
|
|
|
1525
2257
|
</div>
|
|
1526
2258
|
{!selectedRun && (
|
|
1527
2259
|
<p className="mt-3 text-sm text-slate-500">
|
|
1528
|
-
Run
|
|
2260
|
+
Run a command to inspect persisted artifacts here.
|
|
1529
2261
|
</p>
|
|
1530
2262
|
)}
|
|
1531
2263
|
{selectedRun && (
|
|
@@ -1663,8 +2395,7 @@ export default function InfraLabModal() {
|
|
|
1663
2395
|
<div className="mt-3 space-y-2">
|
|
1664
2396
|
{runHistory.length === 0 && (
|
|
1665
2397
|
<p className="text-sm text-slate-500">
|
|
1666
|
-
Saved runs will appear here after the first
|
|
1667
|
-
plan.
|
|
2398
|
+
Saved runs will appear here after the first lab command.
|
|
1668
2399
|
</p>
|
|
1669
2400
|
)}
|
|
1670
2401
|
{runHistory.map((run) => (
|