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
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import type { GhaJobSnapshot, GhaJobStatus, GhaStepSnapshot } from "../api";
|
|
5
|
+
|
|
6
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface DagJob {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
key: string;
|
|
12
|
+
needs: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface DagLayout {
|
|
16
|
+
columns: number[];
|
|
17
|
+
jobs: DagJob[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Parse a single workflow YAML and pull out the jobs graph. Returns null when
|
|
21
|
+
// the YAML is invalid or doesn't declare any jobs — the caller falls back to
|
|
22
|
+
// a flat layout built from runtime observations.
|
|
23
|
+
function parseWorkflowJobs(yaml: string | undefined): DagJob[] | null {
|
|
24
|
+
if (!yaml) return null;
|
|
25
|
+
let doc: unknown;
|
|
26
|
+
try {
|
|
27
|
+
doc = parseYaml(yaml);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (!doc || typeof doc !== "object") return null;
|
|
32
|
+
const jobsRaw = (doc as Record<string, unknown>).jobs;
|
|
33
|
+
if (!jobsRaw || typeof jobsRaw !== "object") return null;
|
|
34
|
+
|
|
35
|
+
const jobs: DagJob[] = [];
|
|
36
|
+
for (const [key, value] of Object.entries(
|
|
37
|
+
jobsRaw as Record<string, unknown>,
|
|
38
|
+
)) {
|
|
39
|
+
if (!value || typeof value !== "object") continue;
|
|
40
|
+
const obj = value as Record<string, unknown>;
|
|
41
|
+
const label = typeof obj.name === "string" && obj.name ? obj.name : key;
|
|
42
|
+
const needsField = obj.needs;
|
|
43
|
+
const needs: string[] = Array.isArray(needsField)
|
|
44
|
+
? needsField.filter((n): n is string => typeof n === "string")
|
|
45
|
+
: typeof needsField === "string"
|
|
46
|
+
? [needsField]
|
|
47
|
+
: [];
|
|
48
|
+
jobs.push({ id: key, key, label, needs });
|
|
49
|
+
}
|
|
50
|
+
return jobs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Topological layering — every job lives in column max(needs)+1 so edges
|
|
54
|
+
// always point left-to-right. Same shape GitHub uses for its workflow run.
|
|
55
|
+
function computeLayout(jobs: DagJob[]): DagLayout {
|
|
56
|
+
const columns: number[] = jobs.map(() => 0);
|
|
57
|
+
const keyToIdx = new Map(jobs.map((j, i) => [j.key, i]));
|
|
58
|
+
|
|
59
|
+
for (let pass = 0; pass < jobs.length + 1; pass++) {
|
|
60
|
+
let changed = false;
|
|
61
|
+
jobs.forEach((job, idx) => {
|
|
62
|
+
let col = 0;
|
|
63
|
+
for (const need of job.needs) {
|
|
64
|
+
const needIdx = keyToIdx.get(need);
|
|
65
|
+
if (needIdx === undefined) continue;
|
|
66
|
+
col = Math.max(col, columns[needIdx] + 1);
|
|
67
|
+
}
|
|
68
|
+
if (col !== columns[idx]) {
|
|
69
|
+
columns[idx] = col;
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
if (!changed) break;
|
|
74
|
+
}
|
|
75
|
+
return { columns, jobs };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Merge declared jobs with runtime statuses; runtime keys may include matrix
|
|
79
|
+
// suffixes like `build-1`, so we collapse them onto the base key when
|
|
80
|
+
// possible and surface any extras as their own nodes.
|
|
81
|
+
function applyRuntimeStatuses(
|
|
82
|
+
declared: DagJob[],
|
|
83
|
+
runtime: GhaJobSnapshot[],
|
|
84
|
+
): {
|
|
85
|
+
jobs: DagJob[];
|
|
86
|
+
statusByKey: Record<string, GhaJobStatus>;
|
|
87
|
+
durationByKey: Record<string, number | undefined>;
|
|
88
|
+
snapshotByKey: Record<string, GhaJobSnapshot | undefined>;
|
|
89
|
+
} {
|
|
90
|
+
const declaredKeys = new Set(declared.map((j) => j.key));
|
|
91
|
+
const statusByKey: Record<string, GhaJobStatus> = {};
|
|
92
|
+
const durationByKey: Record<string, number | undefined> = {};
|
|
93
|
+
const snapshotByKey: Record<string, GhaJobSnapshot | undefined> = {};
|
|
94
|
+
const extras: DagJob[] = [];
|
|
95
|
+
|
|
96
|
+
for (const job of declared) statusByKey[job.key] = "pending";
|
|
97
|
+
|
|
98
|
+
for (const snap of runtime) {
|
|
99
|
+
let key = snap.name;
|
|
100
|
+
if (!declaredKeys.has(key)) {
|
|
101
|
+
const base = declared.find((j) => snap.name.startsWith(`${j.key}-`));
|
|
102
|
+
if (base) key = base.key;
|
|
103
|
+
}
|
|
104
|
+
const prev = statusByKey[key];
|
|
105
|
+
statusByKey[key] = mergeStatus(prev, snap.status);
|
|
106
|
+
if (typeof snap.durationMs === "number") {
|
|
107
|
+
durationByKey[key] = Math.max(durationByKey[key] ?? 0, snap.durationMs);
|
|
108
|
+
}
|
|
109
|
+
// Keep the first snapshot seen for the key so step expansion has
|
|
110
|
+
// something to render. Matrix consolidation is best-effort.
|
|
111
|
+
if (!snapshotByKey[key]) snapshotByKey[key] = snap;
|
|
112
|
+
if (
|
|
113
|
+
!declaredKeys.has(snap.name) &&
|
|
114
|
+
!declared.some((j) => snap.name.startsWith(`${j.key}-`))
|
|
115
|
+
) {
|
|
116
|
+
extras.push({
|
|
117
|
+
id: snap.name,
|
|
118
|
+
key: snap.name,
|
|
119
|
+
label: snap.name,
|
|
120
|
+
needs: [],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
jobs: [...declared, ...extras],
|
|
127
|
+
statusByKey,
|
|
128
|
+
durationByKey,
|
|
129
|
+
snapshotByKey,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mergeStatus(
|
|
134
|
+
prev: GhaJobStatus | undefined,
|
|
135
|
+
next: GhaJobStatus,
|
|
136
|
+
): GhaJobStatus {
|
|
137
|
+
const order: GhaJobStatus[] = [
|
|
138
|
+
"pending",
|
|
139
|
+
"skipped",
|
|
140
|
+
"success",
|
|
141
|
+
"running",
|
|
142
|
+
"failed",
|
|
143
|
+
];
|
|
144
|
+
if (!prev) return next;
|
|
145
|
+
return order.indexOf(next) > order.indexOf(prev) ? next : prev;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Styling helpers ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const STATUS_STYLES: Record<
|
|
151
|
+
GhaJobStatus,
|
|
152
|
+
{ box: string; dot: string; label: string; icon: string }
|
|
153
|
+
> = {
|
|
154
|
+
pending: {
|
|
155
|
+
box: "border-slate-700 bg-slate-900/40 text-slate-400",
|
|
156
|
+
dot: "bg-slate-500",
|
|
157
|
+
label: "Pending",
|
|
158
|
+
icon: "○",
|
|
159
|
+
},
|
|
160
|
+
running: {
|
|
161
|
+
box: "border-amber-400/60 bg-amber-500/10 text-amber-100",
|
|
162
|
+
dot: "bg-amber-400 animate-pulse",
|
|
163
|
+
label: "Running",
|
|
164
|
+
icon: "●",
|
|
165
|
+
},
|
|
166
|
+
success: {
|
|
167
|
+
box: "border-emerald-500/50 bg-emerald-500/10 text-emerald-100",
|
|
168
|
+
dot: "bg-emerald-400",
|
|
169
|
+
label: "Success",
|
|
170
|
+
icon: "✓",
|
|
171
|
+
},
|
|
172
|
+
failed: {
|
|
173
|
+
box: "border-red-500/60 bg-red-500/10 text-red-100",
|
|
174
|
+
dot: "bg-red-400",
|
|
175
|
+
label: "Failed",
|
|
176
|
+
icon: "✗",
|
|
177
|
+
},
|
|
178
|
+
skipped: {
|
|
179
|
+
box: "border-slate-600 bg-slate-800/30 text-slate-500",
|
|
180
|
+
dot: "bg-slate-500",
|
|
181
|
+
label: "Skipped",
|
|
182
|
+
icon: "↷",
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function formatDuration(ms: number | undefined): string {
|
|
187
|
+
if (!ms || ms < 0) return "";
|
|
188
|
+
if (ms < 1000) return `${ms}ms`;
|
|
189
|
+
const s = ms / 1000;
|
|
190
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
191
|
+
const m = Math.floor(s / 60);
|
|
192
|
+
const r = Math.round(s % 60);
|
|
193
|
+
return `${m}m${r ? ` ${r}s` : ""}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Step row ────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function StepRow({ step }: { step: GhaStepSnapshot }) {
|
|
199
|
+
const [open, setOpen] = useState(false);
|
|
200
|
+
const style = STATUS_STYLES[step.status];
|
|
201
|
+
const hasLog = !!step.log && step.log.trim().length > 0;
|
|
202
|
+
return (
|
|
203
|
+
<div className="border-t border-slate-800/40 first:border-t-0">
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
onClick={() => hasLog && setOpen((v) => !v)}
|
|
207
|
+
className={`w-full flex items-center gap-2 px-2 py-1.5 text-[11px] text-left ${
|
|
208
|
+
hasLog ? "hover:bg-slate-800/40 cursor-pointer" : "cursor-default"
|
|
209
|
+
}`}
|
|
210
|
+
>
|
|
211
|
+
<span className="w-3 text-slate-500 inline-flex">
|
|
212
|
+
{hasLog ? (
|
|
213
|
+
open ? (
|
|
214
|
+
<ChevronDown className="w-3 h-3" />
|
|
215
|
+
) : (
|
|
216
|
+
<ChevronRight className="w-3 h-3" />
|
|
217
|
+
)
|
|
218
|
+
) : null}
|
|
219
|
+
</span>
|
|
220
|
+
<span
|
|
221
|
+
className={`inline-flex items-center justify-center w-4 h-4 rounded-full text-[10px] leading-none ${style.dot}`}
|
|
222
|
+
title={style.label}
|
|
223
|
+
>
|
|
224
|
+
<span className="text-slate-900 font-bold">{style.icon}</span>
|
|
225
|
+
</span>
|
|
226
|
+
<span className="flex-1 truncate text-slate-200">
|
|
227
|
+
{step.phase !== "Main" && (
|
|
228
|
+
<span className="text-slate-500 mr-1">[{step.phase}]</span>
|
|
229
|
+
)}
|
|
230
|
+
{step.name}
|
|
231
|
+
</span>
|
|
232
|
+
<span className="text-[10px] text-slate-500">
|
|
233
|
+
{formatDuration(step.durationMs)}
|
|
234
|
+
</span>
|
|
235
|
+
</button>
|
|
236
|
+
{open && hasLog && (
|
|
237
|
+
<pre className="mx-2 mb-2 max-h-60 overflow-auto rounded bg-black/40 p-2 text-[10px] text-slate-300 whitespace-pre-wrap">
|
|
238
|
+
{step.log}
|
|
239
|
+
</pre>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Job card (expandable to step list) ──────────────────────────────────
|
|
246
|
+
|
|
247
|
+
function JobCard({
|
|
248
|
+
label,
|
|
249
|
+
status,
|
|
250
|
+
durationMs,
|
|
251
|
+
needs,
|
|
252
|
+
snapshot,
|
|
253
|
+
}: {
|
|
254
|
+
label: string;
|
|
255
|
+
status: GhaJobStatus;
|
|
256
|
+
durationMs?: number;
|
|
257
|
+
needs?: string[];
|
|
258
|
+
snapshot?: GhaJobSnapshot;
|
|
259
|
+
}) {
|
|
260
|
+
const [open, setOpen] = useState(false);
|
|
261
|
+
const style = STATUS_STYLES[status];
|
|
262
|
+
const steps = snapshot?.steps ?? [];
|
|
263
|
+
const hasSteps = steps.length > 0;
|
|
264
|
+
const dur = formatDuration(durationMs);
|
|
265
|
+
return (
|
|
266
|
+
<div className={`rounded-md border text-xs shadow-sm ${style.box}`}>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
onClick={() => hasSteps && setOpen((v) => !v)}
|
|
270
|
+
className={`w-full flex items-center gap-2 px-3 py-2 ${
|
|
271
|
+
hasSteps ? "cursor-pointer" : "cursor-default"
|
|
272
|
+
}`}
|
|
273
|
+
>
|
|
274
|
+
<span className="w-3 opacity-70 inline-flex">
|
|
275
|
+
{hasSteps ? (
|
|
276
|
+
open ? (
|
|
277
|
+
<ChevronDown className="w-3 h-3" />
|
|
278
|
+
) : (
|
|
279
|
+
<ChevronRight className="w-3 h-3" />
|
|
280
|
+
)
|
|
281
|
+
) : null}
|
|
282
|
+
</span>
|
|
283
|
+
<span className={`inline-block w-2 h-2 rounded-full ${style.dot}`} />
|
|
284
|
+
<span className="font-medium truncate flex-1 text-left">{label}</span>
|
|
285
|
+
<span className="text-[10px] opacity-80">{style.label}</span>
|
|
286
|
+
{dur && <span className="text-[10px] opacity-80">• {dur}</span>}
|
|
287
|
+
</button>
|
|
288
|
+
{needs && needs.length > 0 && !open && (
|
|
289
|
+
<div className="px-3 pb-2 -mt-1 text-[10px] text-slate-500 truncate">
|
|
290
|
+
needs: {needs.join(", ")}
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
{open && hasSteps && (
|
|
294
|
+
<div className="border-t border-slate-800/60 bg-slate-950/40">
|
|
295
|
+
{steps.map((s, idx) => (
|
|
296
|
+
<StepRow key={`${s.phase}-${s.name}-${idx}`} step={s} />
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Panel ───────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
export interface GhaJobsPanelProps {
|
|
307
|
+
// Source of `needs:` info for live DAG layout. Pass undefined for history
|
|
308
|
+
// views so the layout reflects what actually ran rather than the current
|
|
309
|
+
// (possibly edited) YAML.
|
|
310
|
+
workflowYaml?: string | undefined;
|
|
311
|
+
jobs: GhaJobSnapshot[];
|
|
312
|
+
caption?: string;
|
|
313
|
+
// "dag" lays jobs into dependency columns; "list" stacks them vertically
|
|
314
|
+
// (used by history rows where the DAG is misleading because the YAML may
|
|
315
|
+
// have changed since the run).
|
|
316
|
+
mode?: "dag" | "list";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export default function GhaJobsPanel({
|
|
320
|
+
workflowYaml,
|
|
321
|
+
jobs,
|
|
322
|
+
caption,
|
|
323
|
+
mode = "dag",
|
|
324
|
+
}: GhaJobsPanelProps) {
|
|
325
|
+
const declared = useMemo(
|
|
326
|
+
() => (mode === "dag" ? (parseWorkflowJobs(workflowYaml) ?? []) : []),
|
|
327
|
+
[workflowYaml, mode],
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const {
|
|
331
|
+
jobs: allJobs,
|
|
332
|
+
statusByKey,
|
|
333
|
+
durationByKey,
|
|
334
|
+
snapshotByKey,
|
|
335
|
+
} = useMemo(() => applyRuntimeStatuses(declared, jobs), [declared, jobs]);
|
|
336
|
+
|
|
337
|
+
const layout = useMemo(() => computeLayout(allJobs), [allJobs]);
|
|
338
|
+
|
|
339
|
+
const columnsMap = useMemo(() => {
|
|
340
|
+
const map = new Map<number, DagJob[]>();
|
|
341
|
+
layout.jobs.forEach((job, idx) => {
|
|
342
|
+
const col = layout.columns[idx];
|
|
343
|
+
const bucket = map.get(col) ?? [];
|
|
344
|
+
bucket.push(job);
|
|
345
|
+
map.set(col, bucket);
|
|
346
|
+
});
|
|
347
|
+
return Array.from(map.entries())
|
|
348
|
+
.sort((a, b) => a[0] - b[0])
|
|
349
|
+
.map(([col, jobs]) => ({ col, jobs }));
|
|
350
|
+
}, [layout]);
|
|
351
|
+
|
|
352
|
+
if (allJobs.length === 0) {
|
|
353
|
+
return (
|
|
354
|
+
<div className="flex-1 min-h-0 flex items-center justify-center text-xs text-slate-500 px-4 text-center">
|
|
355
|
+
No jobs detected yet. Run a workflow to see the live DAG, or open a
|
|
356
|
+
workflow YAML so the planned jobs appear here.
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── List mode (history): vertical stack of expandable jobs ──
|
|
362
|
+
if (mode === "list") {
|
|
363
|
+
return (
|
|
364
|
+
<div className="flex-1 min-h-0 overflow-auto p-3">
|
|
365
|
+
{caption && (
|
|
366
|
+
<div className="mb-2 text-[10px] uppercase tracking-wider text-slate-500">
|
|
367
|
+
{caption}
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
<div className="flex flex-col gap-2">
|
|
371
|
+
{allJobs.map((job) => (
|
|
372
|
+
<JobCard
|
|
373
|
+
key={job.id}
|
|
374
|
+
label={job.label}
|
|
375
|
+
status={statusByKey[job.key] ?? "pending"}
|
|
376
|
+
durationMs={durationByKey[job.key]}
|
|
377
|
+
snapshot={snapshotByKey[job.key]}
|
|
378
|
+
/>
|
|
379
|
+
))}
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── DAG mode (live): layered columns with connector bars ──
|
|
386
|
+
return (
|
|
387
|
+
<div className="flex-1 min-h-0 overflow-auto p-3">
|
|
388
|
+
{caption && (
|
|
389
|
+
<div className="mb-2 text-[10px] uppercase tracking-wider text-slate-500">
|
|
390
|
+
{caption}
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
<div className="flex items-start gap-6">
|
|
394
|
+
{columnsMap.map(({ col, jobs }, colIdx) => (
|
|
395
|
+
<div key={col} className="flex flex-col gap-3 min-w-[200px]">
|
|
396
|
+
{jobs.map((job) => (
|
|
397
|
+
<div key={job.id} className="relative">
|
|
398
|
+
<JobCard
|
|
399
|
+
label={job.label}
|
|
400
|
+
status={statusByKey[job.key] ?? "pending"}
|
|
401
|
+
durationMs={durationByKey[job.key]}
|
|
402
|
+
needs={job.needs}
|
|
403
|
+
snapshot={snapshotByKey[job.key]}
|
|
404
|
+
/>
|
|
405
|
+
{colIdx < columnsMap.length - 1 && (
|
|
406
|
+
<span className="absolute top-4 -right-6 w-6 h-px bg-slate-700" />
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
))}
|
|
410
|
+
</div>
|
|
411
|
+
))}
|
|
412
|
+
</div>
|
|
413
|
+
<div className="mt-4 flex flex-wrap gap-3 text-[10px] text-slate-500">
|
|
414
|
+
{(["pending", "running", "success", "failed", "skipped"] as const).map(
|
|
415
|
+
(s) => (
|
|
416
|
+
<span key={s} className="inline-flex items-center gap-1">
|
|
417
|
+
<span
|
|
418
|
+
className={`inline-block w-2 h-2 rounded-full ${STATUS_STYLES[s].dot}`}
|
|
419
|
+
/>
|
|
420
|
+
{STATUS_STYLES[s].label}
|
|
421
|
+
</span>
|
|
422
|
+
),
|
|
423
|
+
)}
|
|
424
|
+
<span className="ml-auto">
|
|
425
|
+
Jobs in the same column run in parallel; arrows imply{" "}
|
|
426
|
+
<code className="text-slate-400">needs:</code>. Click a job to expand
|
|
427
|
+
its steps.
|
|
428
|
+
</span>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
}
|