@payconductor/houndsman 0.1.4 → 0.2.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/src/cli/index.ts +31 -21
- package/src/core/commands/findings.ts +137 -11
- package/src/core/findings-state.ts +19 -0
- package/src/core/github-review.ts +452 -0
- package/src/server/client/App.tsx +174 -49
- package/src/server/client/ArtifactsPanel.tsx +2 -2
- package/src/server/client/AutoRefresh.tsx +38 -0
- package/src/server/client/Filters.tsx +72 -53
- package/src/server/client/FindingCard.tsx +212 -71
- package/src/server/client/FindingsList.tsx +8 -6
- package/src/server/client/RunList.tsx +30 -7
- package/src/server/client/SubmitModal.tsx +418 -0
- package/src/server/client/SummaryBar.tsx +65 -12
- package/src/server/client/constants.ts +9 -12
- package/src/server/client/format.ts +40 -0
- package/src/server/client/icons.tsx +143 -0
- package/src/server/client/types.ts +69 -0
- package/src/server/index.ts +352 -88
- package/src/tests/__snapshots__/baseline.test.ts.snap +0 -0
- package/src/tests/baseline.test.ts +493 -0
- package/src/tests/cli.test.ts +167 -0
- package/src/tests/github-review.test.ts +320 -0
- package/src/tests/server.test.ts +117 -0
package/src/server/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
10
|
import { mediaPath } from "../core/assets";
|
|
11
11
|
import { type HoundsmanConfig, resolveArtifactDir } from "../core/config";
|
|
12
12
|
import {
|
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
emptyState,
|
|
16
16
|
type FindingDecision,
|
|
17
17
|
type FindingExecState,
|
|
18
|
+
type FindingState,
|
|
18
19
|
type FindingsFilter,
|
|
19
20
|
type FindingsStateFile,
|
|
20
21
|
FindingsStateStore,
|
|
22
|
+
type FindingsSummary,
|
|
21
23
|
type FindingUpdate,
|
|
22
24
|
fileReader,
|
|
23
25
|
filterFindings,
|
|
@@ -26,8 +28,16 @@ import {
|
|
|
26
28
|
summarize,
|
|
27
29
|
syncState,
|
|
28
30
|
} from "../core/findings-state";
|
|
31
|
+
import {
|
|
32
|
+
buildSubmitPreview,
|
|
33
|
+
defaultGhRunner,
|
|
34
|
+
type GhRunner,
|
|
35
|
+
resolvePrTarget,
|
|
36
|
+
type SubmitSelection,
|
|
37
|
+
submitFindings,
|
|
38
|
+
} from "../core/github-review";
|
|
29
39
|
import { STATE_FILE } from "../core/paths";
|
|
30
|
-
import { pingFindingsStatus, readRunStatus } from "../core/run-status";
|
|
40
|
+
import { pingFindingsStatus, type RunStatus, readRunStatus } from "../core/run-status";
|
|
31
41
|
import type { Severity } from "../core/types";
|
|
32
42
|
import { listRunSlugs, readRunFile, readRunJson, runDirForSlug } from "./artifacts";
|
|
33
43
|
import { buildClientBundle } from "./client-build";
|
|
@@ -67,6 +77,15 @@ function notFound(detail = "não encontrado"): Response {
|
|
|
67
77
|
// Estado / dados
|
|
68
78
|
// ---------------------------------------------------------------------------
|
|
69
79
|
|
|
80
|
+
/** Item da lista de execuções (sidebar) — já ordenado do mais recente ao antigo. */
|
|
81
|
+
type RunListItem = {
|
|
82
|
+
slug: string;
|
|
83
|
+
branch: string;
|
|
84
|
+
updatedAt?: string;
|
|
85
|
+
status: RunStatus | null;
|
|
86
|
+
summary: FindingsSummary;
|
|
87
|
+
};
|
|
88
|
+
|
|
70
89
|
function buildForRun(dir: string, slug: string): FindingsStateFile | null {
|
|
71
90
|
const manifest = readRunJson<{ branch?: string }>(dir, "manifest.json");
|
|
72
91
|
return buildStateFromRunDir(dir, manifest?.branch ?? slug, fileReader(process.cwd()));
|
|
@@ -88,23 +107,36 @@ function loadState(dir: string, slug: string): FindingsStateFile {
|
|
|
88
107
|
return emptyState(slug);
|
|
89
108
|
}
|
|
90
109
|
|
|
91
|
-
function listRuns(artifactDir: string):
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
function listRuns(artifactDir: string): RunListItem[] {
|
|
111
|
+
return (
|
|
112
|
+
listRunSlugs(artifactDir)
|
|
113
|
+
.map((slug) => {
|
|
114
|
+
const dir = join(artifactDir, slug);
|
|
115
|
+
const state = loadState(dir, slug);
|
|
116
|
+
const manifest = readRunJson<{ branch?: string; generatedAt?: string }>(
|
|
117
|
+
dir,
|
|
118
|
+
"manifest.json",
|
|
119
|
+
);
|
|
120
|
+
const status = readRunStatus(dir);
|
|
121
|
+
return {
|
|
122
|
+
slug,
|
|
123
|
+
branch: state.branch || manifest?.branch || slug,
|
|
124
|
+
updatedAt: status?.updatedAt ?? state.updatedAt,
|
|
125
|
+
status: status?.status ?? null,
|
|
126
|
+
summary: summarize(state),
|
|
127
|
+
};
|
|
128
|
+
})
|
|
129
|
+
// Mais recentes primeiro (ISO 8601 compara lexicograficamente).
|
|
130
|
+
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Finding + caminho absoluto. O cliente monta o link `vscode://file/...` e não
|
|
136
|
+
* conhece o cwd do servidor — o caminho relativo do relatório não basta.
|
|
137
|
+
*/
|
|
138
|
+
function toClientFinding(finding: FindingState): FindingState & { absFile: string } {
|
|
139
|
+
return { ...finding, absFile: resolve(process.cwd(), finding.file) };
|
|
108
140
|
}
|
|
109
141
|
|
|
110
142
|
function runDetail(dir: string, slug: string): unknown {
|
|
@@ -138,7 +170,7 @@ function findingsFor(dir: string, slug: string, params: URLSearchParams): unknow
|
|
|
138
170
|
};
|
|
139
171
|
return {
|
|
140
172
|
branch: state.branch,
|
|
141
|
-
findings: filterFindings(state, filter),
|
|
173
|
+
findings: filterFindings(state, filter).map(toClientFinding),
|
|
142
174
|
summary: summarize(state),
|
|
143
175
|
};
|
|
144
176
|
}
|
|
@@ -158,7 +190,7 @@ async function updateFinding(
|
|
|
158
190
|
reorder(state);
|
|
159
191
|
store.write(state);
|
|
160
192
|
pingFindingsStatus(dir, state);
|
|
161
|
-
return json({ finding });
|
|
193
|
+
return json({ finding: toClientFinding(finding) });
|
|
162
194
|
}
|
|
163
195
|
|
|
164
196
|
function syncRun(dir: string, slug: string): Response {
|
|
@@ -170,6 +202,46 @@ function syncRun(dir: string, slug: string): Response {
|
|
|
170
202
|
return json({ result, summary: summarize(state) });
|
|
171
203
|
}
|
|
172
204
|
|
|
205
|
+
/** Prévia do envio da revisão: candidatos + disponibilidade da PR (via gh). */
|
|
206
|
+
function submitPreview(dir: string, slug: string, ghRunner: GhRunner): Response {
|
|
207
|
+
const state = loadState(dir, slug);
|
|
208
|
+
const target = resolvePrTarget(ghRunner, state.branch || slug);
|
|
209
|
+
return json(buildSubmitPreview(state, target));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Envia os findings selecionados como comentários na PR. Fail-open: sem PR/gh
|
|
214
|
+
* devolve `{ available:false, reason }` (a UI só informa). Persiste o vínculo
|
|
215
|
+
* (`finding.github`) no `state.json` e atualiza o status da run.
|
|
216
|
+
*/
|
|
217
|
+
async function submitRun(
|
|
218
|
+
dir: string,
|
|
219
|
+
slug: string,
|
|
220
|
+
ghRunner: GhRunner,
|
|
221
|
+
request: Request,
|
|
222
|
+
): Promise<Response> {
|
|
223
|
+
const state = loadState(dir, slug);
|
|
224
|
+
const body = (await request.json().catch(() => ({}))) as SubmitSelection;
|
|
225
|
+
const selection: SubmitSelection = {
|
|
226
|
+
severities: body.severities,
|
|
227
|
+
execStates: body.execStates,
|
|
228
|
+
includeSent: body.includeSent,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const target = resolvePrTarget(ghRunner, state.branch || slug);
|
|
232
|
+
if (!target.ok) return json({ available: false, reason: target.reason });
|
|
233
|
+
|
|
234
|
+
const outcome = submitFindings({
|
|
235
|
+
state,
|
|
236
|
+
target: target.target,
|
|
237
|
+
runner: ghRunner,
|
|
238
|
+
selection,
|
|
239
|
+
});
|
|
240
|
+
new FindingsStateStore(join(dir, STATE_FILE), slug).write(state);
|
|
241
|
+
pingFindingsStatus(dir, state);
|
|
242
|
+
return json({ available: true, target: target.target, outcome });
|
|
243
|
+
}
|
|
244
|
+
|
|
173
245
|
function serveArtifact(dir: string, name: string): Response {
|
|
174
246
|
if (!/\.(json|md)$/i.test(name)) return notFound("tipo de artefato não servido");
|
|
175
247
|
const content = readRunFile(dir, name);
|
|
@@ -196,7 +268,11 @@ async function serveLogo(): Promise<Response> {
|
|
|
196
268
|
});
|
|
197
269
|
}
|
|
198
270
|
|
|
199
|
-
async function route(
|
|
271
|
+
async function route(
|
|
272
|
+
request: Request,
|
|
273
|
+
artifactDir: string,
|
|
274
|
+
ghRunner: GhRunner,
|
|
275
|
+
): Promise<Response> {
|
|
200
276
|
const url = new URL(request.url);
|
|
201
277
|
const path = url.pathname;
|
|
202
278
|
|
|
@@ -226,6 +302,12 @@ async function route(request: Request, artifactDir: string): Promise<Response> {
|
|
|
226
302
|
return json(findingsFor(dir, slug, url.searchParams));
|
|
227
303
|
}
|
|
228
304
|
if (rest === "/sync" && request.method === "POST") return syncRun(dir, slug);
|
|
305
|
+
if (rest === "/submit-preview" && request.method === "GET") {
|
|
306
|
+
return submitPreview(dir, slug, ghRunner);
|
|
307
|
+
}
|
|
308
|
+
if (rest === "/submit" && request.method === "POST") {
|
|
309
|
+
return submitRun(dir, slug, ghRunner, request);
|
|
310
|
+
}
|
|
229
311
|
|
|
230
312
|
const findingMatch = rest.match(/^\/findings\/([^/]+)$/);
|
|
231
313
|
if (findingMatch) {
|
|
@@ -234,7 +316,7 @@ async function route(request: Request, artifactDir: string): Promise<Response> {
|
|
|
234
316
|
const state = loadState(dir, slug);
|
|
235
317
|
const finding = state.findings[id];
|
|
236
318
|
return finding
|
|
237
|
-
? json({ finding })
|
|
319
|
+
? json({ finding: toClientFinding(finding) })
|
|
238
320
|
: notFound(`finding '${id}' não encontrado`);
|
|
239
321
|
}
|
|
240
322
|
if (request.method === "PATCH" || request.method === "POST") {
|
|
@@ -250,14 +332,19 @@ async function route(request: Request, artifactDir: string): Promise<Response> {
|
|
|
250
332
|
return notFound();
|
|
251
333
|
}
|
|
252
334
|
|
|
335
|
+
/** Dependências injetáveis do servidor (testes). */
|
|
336
|
+
export type ServerDeps = { ghRunner?: GhRunner };
|
|
337
|
+
|
|
253
338
|
/** Cria o handler de requisições (testável sem abrir porta). */
|
|
254
339
|
export function createRequestHandler(
|
|
255
340
|
config: HoundsmanConfig,
|
|
341
|
+
deps: ServerDeps = {},
|
|
256
342
|
): (request: Request) => Promise<Response> {
|
|
257
343
|
const artifactDir = resolveArtifactDir(config);
|
|
344
|
+
const ghRunner = deps.ghRunner ?? defaultGhRunner;
|
|
258
345
|
return async (request) => {
|
|
259
346
|
try {
|
|
260
|
-
return await route(request, artifactDir);
|
|
347
|
+
return await route(request, artifactDir, ghRunner);
|
|
261
348
|
} catch (error) {
|
|
262
349
|
return json(
|
|
263
350
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
@@ -271,6 +358,8 @@ export type ServerOptions = {
|
|
|
271
358
|
config: HoundsmanConfig;
|
|
272
359
|
port?: number;
|
|
273
360
|
host?: string;
|
|
361
|
+
/** Runner do `gh` (injetável nos testes). */
|
|
362
|
+
ghRunner?: GhRunner;
|
|
274
363
|
};
|
|
275
364
|
|
|
276
365
|
/** Sobe o servidor e devolve a instância + URL. */
|
|
@@ -280,7 +369,7 @@ export function startServer(options: ServerOptions): { server: BunServer; url: s
|
|
|
280
369
|
const server = Bun.serve({
|
|
281
370
|
hostname: host,
|
|
282
371
|
port,
|
|
283
|
-
fetch: createRequestHandler(options.config),
|
|
372
|
+
fetch: createRequestHandler(options.config, { ghRunner: options.ghRunner }),
|
|
284
373
|
});
|
|
285
374
|
return { server, url: `http://${host}:${server.port}` };
|
|
286
375
|
}
|
|
@@ -290,70 +379,240 @@ export function startServer(options: ServerOptions): { server: BunServer; url: s
|
|
|
290
379
|
// ---------------------------------------------------------------------------
|
|
291
380
|
|
|
292
381
|
const STYLES = `
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
.
|
|
316
|
-
.
|
|
317
|
-
|
|
318
|
-
.
|
|
319
|
-
.
|
|
320
|
-
.
|
|
321
|
-
.
|
|
322
|
-
.
|
|
323
|
-
.
|
|
324
|
-
.
|
|
325
|
-
|
|
326
|
-
.
|
|
327
|
-
.
|
|
328
|
-
.
|
|
329
|
-
.
|
|
330
|
-
.
|
|
331
|
-
.
|
|
332
|
-
.
|
|
333
|
-
.
|
|
334
|
-
.
|
|
335
|
-
.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
.
|
|
339
|
-
.
|
|
340
|
-
.
|
|
341
|
-
.
|
|
342
|
-
.
|
|
343
|
-
.
|
|
344
|
-
.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
.
|
|
348
|
-
.
|
|
349
|
-
|
|
350
|
-
.
|
|
351
|
-
.btn
|
|
352
|
-
.
|
|
353
|
-
.
|
|
354
|
-
.
|
|
355
|
-
.
|
|
382
|
+
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
|
383
|
+
|
|
384
|
+
:root {
|
|
385
|
+
color-scheme: dark;
|
|
386
|
+
--bg:#0a0a0c; --surface:#101014; --surface-2:#17171c; --card:#131318;
|
|
387
|
+
--border:rgba(255,255,255,.07); --border-strong:rgba(255,255,255,.14);
|
|
388
|
+
--fg:#ece9e4; --muted:#9d978e; --muted-2:#6d6862;
|
|
389
|
+
--accent:#f0a52c; --accent-strong:#ffc247; --accent-soft:rgba(240,165,44,.07); --accent-line:rgba(240,165,44,.25);
|
|
390
|
+
--blocking:#f2595e; --attention:#eda13a; --suggestion:#5aa4f0; --nit:#8d8a85; --success:#45c07a;
|
|
391
|
+
--radius:16px; --radius-lg:18px;
|
|
392
|
+
--font-sans:"Poppins","Space Grotesk","Geist Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
393
|
+
--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
394
|
+
}
|
|
395
|
+
* { box-sizing:border-box; }
|
|
396
|
+
html, body { height:100%; }
|
|
397
|
+
body { margin:0; font-size:14px; background:var(--bg); color:var(--fg); font-family:var(--font-sans); -webkit-font-smoothing:antialiased; }
|
|
398
|
+
body::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
|
|
399
|
+
background:radial-gradient(820px 420px at 8% -12%, rgba(240,165,44,.08), transparent 62%); }
|
|
400
|
+
code, pre, .mono { font-family:var(--font-mono); }
|
|
401
|
+
::-webkit-scrollbar { width:10px; height:10px; }
|
|
402
|
+
::-webkit-scrollbar-thumb { background:rgba(255,255,255,.1); border:2px solid transparent; border-radius:999px; background-clip:padding-box; }
|
|
403
|
+
::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.18); background-clip:padding-box; }
|
|
404
|
+
.app { position:relative; z-index:1; display:flex; height:100vh; overflow:hidden; }
|
|
405
|
+
.sidebar { width:292px; flex:0 0 292px; display:flex; flex-direction:column; gap:14px; padding:18px 14px;
|
|
406
|
+
background:var(--surface); border-right:1px solid var(--border); overflow-y:auto; }
|
|
407
|
+
.brand { display:flex; align-items:center; gap:12px; }
|
|
408
|
+
.brand .logo { width:42px; height:42px; object-fit:contain; border-radius:12px; background:var(--surface-2); border:1px solid var(--border); }
|
|
409
|
+
.sidebar h1 { font-size:15.5px; font-weight:700; letter-spacing:-.01em; margin:0; }
|
|
410
|
+
.sub { color:var(--muted); font-size:11.5px; margin:0; }
|
|
411
|
+
.section-label { margin:4px 0 0; font-size:10px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
|
|
412
|
+
.runs { display:flex; flex-direction:column; gap:6px; }
|
|
413
|
+
.run { display:flex; flex-direction:column; gap:8px; text-align:left; cursor:pointer; font:inherit; color:inherit; padding:11px 13px;
|
|
414
|
+
background:transparent; border:1px solid transparent; border-radius:var(--radius); transition:background .15s ease, border-color .15s ease; }
|
|
415
|
+
.run:hover { background:var(--surface-2); border-color:var(--border); }
|
|
416
|
+
.run.active { background:var(--accent-soft); border-color:var(--accent-line); }
|
|
417
|
+
.run-top { display:flex; align-items:flex-start; gap:8px; }
|
|
418
|
+
.run-icon { flex:none; margin-top:2px; color:var(--muted-2); }
|
|
419
|
+
.run.active .run-icon { color:var(--accent); }
|
|
420
|
+
.run-branch { font-size:13.5px; font-weight:600; line-height:1.35; word-break:break-word; }
|
|
421
|
+
.run-meta { display:flex; align-items:center; gap:10px; font-family:var(--font-mono); font-size:11px; color:var(--muted); }
|
|
422
|
+
.run-when { display:inline-flex; align-items:center; gap:6px; }
|
|
423
|
+
.run-count { margin-left:auto; color:var(--muted-2); }
|
|
424
|
+
.run-badge { align-self:flex-start; display:inline-flex; align-items:center; gap:6px; padding:4px 9px; border-radius:999px;
|
|
425
|
+
font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
|
|
426
|
+
color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); }
|
|
427
|
+
.run-status { font-size:10px; font-weight:600; letter-spacing:.07em; text-transform:uppercase; color:var(--muted-2); }
|
|
428
|
+
.status-reviewing, .status-fixing { color:var(--accent); }
|
|
429
|
+
.status-review_done { color:var(--suggestion); }
|
|
430
|
+
.status-fix_done { color:var(--success); }
|
|
431
|
+
.status-timeout { color:var(--blocking); }
|
|
432
|
+
.main { flex:1; min-width:0; display:flex; flex-direction:column; overflow-y:auto; }
|
|
433
|
+
.topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px;
|
|
434
|
+
padding:13px 24px; border-bottom:1px solid var(--border); background:rgba(16,16,20,.74);
|
|
435
|
+
backdrop-filter:blur(16px) saturate(140%); -webkit-backdrop-filter:blur(16px) saturate(140%); }
|
|
436
|
+
.segmented { display:inline-flex; gap:4px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
|
|
437
|
+
.seg-btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:12.5px; font-weight:600; cursor:pointer;
|
|
438
|
+
color:var(--muted); background:transparent; border:1px solid transparent; border-radius:999px; padding:7px 16px;
|
|
439
|
+
transition:color .15s ease, background .15s ease; }
|
|
440
|
+
.seg-btn:hover { color:var(--fg); }
|
|
441
|
+
.seg-btn.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); box-shadow:0 4px 14px rgba(240,165,44,.24); }
|
|
442
|
+
.topbar-right { display:flex; align-items:center; gap:14px; }
|
|
443
|
+
.auto-refresh { display:inline-flex; align-items:center; gap:8px; white-space:nowrap; font-family:var(--font-mono); font-size:11.5px; color:var(--muted); }
|
|
444
|
+
.dot { width:8px; height:8px; border-radius:50%; background:var(--success); box-shadow:0 0 0 3px rgba(69,192,122,.14); }
|
|
445
|
+
.dot.busy { background:var(--accent); box-shadow:0 0 0 3px rgba(240,165,44,.18); animation:pulse 1.1s ease-in-out infinite; }
|
|
446
|
+
.btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:12.5px; font-weight:500; cursor:pointer; color:var(--fg);
|
|
447
|
+
padding:8px 15px; background:var(--surface-2); border:1px solid var(--border-strong); border-radius:999px;
|
|
448
|
+
transition:background .15s ease, border-color .15s ease, color .15s ease; }
|
|
449
|
+
.btn:hover { background:#202027; border-color:var(--accent-line); }
|
|
450
|
+
.btn:disabled { opacity:.55; cursor:not-allowed; }
|
|
451
|
+
.icon.spin { animation:spin .8s linear infinite; }
|
|
452
|
+
.content { width:100%; max-width:1180px; margin:0 auto; padding:22px 24px 64px; }
|
|
453
|
+
.page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }
|
|
454
|
+
.page-header-text { min-width:0; }
|
|
455
|
+
.page-header-actions { display:flex; align-items:center; gap:10px; flex:none; }
|
|
456
|
+
.btn.submit-review { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); border-color:transparent; box-shadow:0 4px 14px rgba(240,165,44,.22); }
|
|
457
|
+
.btn.submit-review:hover { background:linear-gradient(180deg, var(--accent-strong), var(--accent)); border-color:transparent; filter:brightness(1.07); }
|
|
458
|
+
.page-title { margin:0 0 8px; font-size:26px; font-weight:700; letter-spacing:-.02em; word-break:break-word; }
|
|
459
|
+
.page-sub { margin:0; max-width:78ch; color:var(--muted); font-size:12.5px; line-height:1.55; }
|
|
460
|
+
.page-sub code { color:var(--fg); background:rgba(255,255,255,.06); border-radius:6px; padding:1px 6px; font-size:11.5px; }
|
|
461
|
+
.error { color:#ff8b8f; font-size:12.5px; }
|
|
356
462
|
.empty { color:var(--muted); font-size:13px; }
|
|
463
|
+
.kpis { display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:12px; margin:20px 0 18px; }
|
|
464
|
+
.kpi { display:flex; flex-direction:column; gap:8px; padding:15px 16px; background:var(--card);
|
|
465
|
+
border:1px solid var(--border); border-radius:var(--radius-lg); }
|
|
466
|
+
.kpi-label { font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted-2); }
|
|
467
|
+
.kpi-value { font-size:30px; font-weight:700; line-height:1; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
|
|
468
|
+
.kpi-hint { font-family:var(--font-mono); font-size:11px; color:var(--muted-2); }
|
|
469
|
+
.kpi-execute .kpi-value { color:var(--accent); }
|
|
470
|
+
.kpi-ignore .kpi-value { color:var(--muted); }
|
|
471
|
+
.kpi-done .kpi-value { color:var(--success); }
|
|
472
|
+
.progress { margin:0 0 22px; }
|
|
473
|
+
.progress-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; font-size:11.5px; color:var(--muted); }
|
|
474
|
+
.progress-head .pct { color:var(--fg); font-family:var(--font-mono); }
|
|
475
|
+
.progress-track { height:8px; border-radius:999px; background:var(--surface-2); border:1px solid var(--border); overflow:hidden; }
|
|
476
|
+
.progress-fill { display:block; height:100%; border-radius:999px; background:linear-gradient(90deg, #2c8f57, var(--success)); transition:width .35s ease; }
|
|
477
|
+
.filters { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-bottom:20px; }
|
|
478
|
+
.search { display:flex; align-items:center; gap:8px; flex:1 1 240px; max-width:330px; padding:8px 14px; color:var(--muted);
|
|
479
|
+
background:var(--surface-2); border:1px solid var(--border-strong); border-radius:999px; }
|
|
480
|
+
.search:focus-within { color:var(--accent); border-color:var(--accent-line); }
|
|
481
|
+
.search input { flex:1; min-width:0; font:inherit; font-size:12.5px; color:var(--fg); background:transparent; border:0; outline:0; }
|
|
482
|
+
.search input::placeholder { color:var(--muted-2); }
|
|
483
|
+
.search-clear { padding:0 2px; font-size:15px; line-height:1; color:var(--muted); background:transparent; border:0; cursor:pointer; }
|
|
484
|
+
.chips { display:inline-flex; gap:3px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
|
|
485
|
+
.chip { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted);
|
|
486
|
+
padding:6px 13px; background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
|
|
487
|
+
.chip:hover { color:var(--fg); }
|
|
488
|
+
.chip.active { color:#14100a; background:#e9e5df!important; }
|
|
489
|
+
.sev-group { margin-bottom:24px; }
|
|
490
|
+
.sev-head { display:flex; align-items:center; gap:8px; margin:0 0 10px; font-size:11px; font-weight:700;
|
|
491
|
+
letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
|
|
492
|
+
.sev-head .n { font-family:var(--font-mono); letter-spacing:0; }
|
|
493
|
+
.sev-group.sev-blocking .sev-head { color:var(--blocking); }
|
|
494
|
+
.sev-group.sev-attention .sev-head { color:var(--attention); }
|
|
495
|
+
.sev-group.sev-suggestion .sev-head { color:var(--suggestion); }
|
|
496
|
+
.sev-group.sev-nit .sev-head { color:var(--nit); }
|
|
497
|
+
.card { position:relative; margin-bottom:20px; overflow:hidden; background:var(--card);
|
|
498
|
+
border:1px solid var(--border); border-left:4px solid var(--border-strong); border-radius:var(--radius-lg);
|
|
499
|
+
transition:background .15s ease, border-color .15s ease, opacity .2s ease; }
|
|
500
|
+
.card:hover { background:#16161b; }
|
|
501
|
+
.card.sev-blocking { border-left-color:var(--blocking); }
|
|
502
|
+
.card.sev-attention { border-left-color:var(--attention); }
|
|
503
|
+
.card.sev-suggestion { border-left-color:var(--suggestion); }
|
|
504
|
+
.card.sev-nit { border-left-color:var(--nit); }
|
|
505
|
+
.card.absent { opacity:.5; }
|
|
506
|
+
.card.done { opacity:.6; }
|
|
507
|
+
.card-head { display:flex; align-items:center; gap:10px; padding:12px 14px 0; }
|
|
508
|
+
.chev { display:grid; place-items:center; flex:none; width:22px; height:22px; cursor:pointer; color:var(--muted-2);
|
|
509
|
+
background:transparent; border:1px solid transparent; border-radius:7px; }
|
|
510
|
+
.chev:hover { color:var(--fg); background:rgba(255,255,255,.06); }
|
|
511
|
+
.chev:focus-visible { color:var(--fg); border-color:var(--accent-line); outline:none; }
|
|
512
|
+
.chev svg { transition:transform .18s ease; }
|
|
513
|
+
.chev.open svg { transform:rotate(90deg); }
|
|
514
|
+
.sev-badge { display:inline-flex; align-items:center; flex:none; padding:3px 9px; border-radius:999px;
|
|
515
|
+
font-size:10px; letter-spacing:.08em; text-transform:uppercase; }
|
|
516
|
+
.card.sev-blocking .sev-badge { color:var(--blocking); background:rgba(242,89,94,.12); }
|
|
517
|
+
.card.sev-attention .sev-badge { color:var(--attention); background:rgba(237,161,58,.12); }
|
|
518
|
+
.card.sev-suggestion .sev-badge { color:var(--suggestion); background:rgba(90,164,240,.12); }
|
|
519
|
+
.card.sev-nit .sev-badge { color:var(--nit); background:rgba(255,255,255,.06); }
|
|
520
|
+
.loc { display:inline-flex; align-items:center; gap:7px; max-width: 406ch; overflow:hidden; white-space:break-spaces; text-overflow:ellipsis;
|
|
521
|
+
padding:4px 9px; font-family:var(--font-mono); font-size:11.5px; color:#aba7a1; text-decoration:none; }
|
|
522
|
+
.loc:hover { color:#fff; }
|
|
523
|
+
.loc .ext { flex:none; opacity:0; transition:opacity .15s ease; }
|
|
524
|
+
.loc:hover .ext { opacity:1; }
|
|
525
|
+
.loc.static { color:var(--muted); }
|
|
526
|
+
.count { padding:2px 7px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
|
|
527
|
+
background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
|
|
528
|
+
.spacer { flex:1; }
|
|
529
|
+
.done-btn { display:inline-flex; align-items:center; gap:7px; flex:none; font:inherit; font-size:12px; font-weight:600; cursor:pointer;
|
|
530
|
+
padding:7px 13px; color:var(--muted); background:transparent; border:1px solid var(--border-strong); border-radius:999px;
|
|
531
|
+
transition:color .15s ease, background .15s ease, border-color .15s ease; }
|
|
532
|
+
.done-btn:hover { color:var(--fg); border-color:rgba(69,192,122,.55); }
|
|
533
|
+
.done-btn:disabled { opacity:.55; cursor:not-allowed; }
|
|
534
|
+
.done-btn.is-done { color:var(--success); background:rgba(69,192,122,.14); border-color:rgba(69,192,122,.5); }
|
|
535
|
+
.card-title { margin:9px 0 0; padding:0 16px 20px; font-size:14px; font-weight:400; }
|
|
536
|
+
.card.done .card-title { color:#b7b7b7; text-decoration:line-through; text-decoration-color:rgba(255,255,255,.15); }
|
|
537
|
+
.card-body { display:flex; flex-direction:column; gap:12px; padding:20px 16px 15px; background:rgba(255,255,255,.012); border-top:1px solid var(--border); }
|
|
538
|
+
.meta-row { display:flex; flex-wrap:wrap; gap:14px; font-size:11.5px; color:var(--muted); }
|
|
539
|
+
.meta { display:inline-flex; align-items:center; gap:7px; }
|
|
540
|
+
.meta-k { font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
|
|
541
|
+
.meta code { padding:2px 6px; font-size:11.5px; color:var(--fg); background:rgba(255,255,255,.04); border:1px solid var(--border); border-radius:6px; }
|
|
542
|
+
.action-block { display:flex; gap:11px; padding:16px; background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:var(--radius); }
|
|
543
|
+
.action-icon { flex:none; display:inline-block; margin-right:5px; margin-bottom:-5px; color:var(--accent); }
|
|
544
|
+
.action-label { display:inline-block; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); margin-right:5px; }
|
|
545
|
+
.action-block p { margin:0; font-size:13px; color:#f0ece6; }
|
|
546
|
+
.decision-tags-block { display:flex; align-items:center; gap:8px; }
|
|
547
|
+
.evidence { margin:0; padding: 16px; white-space:break-spaces; font-size:12px; line-height:1.6; color:#cdc7bf;
|
|
548
|
+
background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
|
|
549
|
+
.tags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin-left: auto; }
|
|
550
|
+
.tags-label { display:inline-flex; align-items:center; gap:6px; font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
|
|
551
|
+
.tag { display:inline-flex; align-items:center; gap:6px; padding:3px 9px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
|
|
552
|
+
background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
|
|
553
|
+
.decisions { display:inline-flex; gap:3px; align-self:flex-start; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
|
|
554
|
+
.dec-btn { font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted); padding:6px 15px;
|
|
555
|
+
background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
|
|
556
|
+
.dec-btn:hover { color:var(--fg); }
|
|
557
|
+
.dec-btn:disabled { opacity:.55; cursor:not-allowed; }
|
|
558
|
+
.dec-btn.dec-execute.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); }
|
|
559
|
+
.dec-btn.dec-ignore.active { color:#ffd2d3; background:rgba(242,89,94,.18); box-shadow:inset 0 0 0 1px rgba(242,89,94,.45); }
|
|
560
|
+
.dec-btn.dec-pending.active { color:var(--fg); background:rgba(255,255,255,.08); box-shadow:inset 0 0 0 1px var(--border-strong); }
|
|
561
|
+
.comment { position:relative; }
|
|
562
|
+
.comment textarea { width:100%; min-height:58px; padding:11px 13px 26px; resize:vertical; font:inherit; font-size:12.5px; line-height:1.5;
|
|
563
|
+
color:var(--fg); background:var(--surface-2); border:1px solid var(--border-strong); border-radius:var(--radius); outline:none;
|
|
564
|
+
transition:border-color .15s ease, box-shadow .15s ease; }
|
|
565
|
+
.comment textarea::placeholder { color:var(--muted-2); }
|
|
566
|
+
.comment textarea:focus { border-color:var(--accent-line); box-shadow:0 0 0 3px rgba(240,165,44,.12); }
|
|
567
|
+
.comment-status { position:absolute; right:12px; bottom:9px; display:inline-flex; align-items:center; gap:6px;
|
|
568
|
+
font-family:var(--font-mono); font-size:11px; color:var(--muted); }
|
|
569
|
+
.comment-status.saved { color:var(--success); }
|
|
570
|
+
.artifacts { display:flex; flex-direction:column; }
|
|
571
|
+
.artifact-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:12px; }
|
|
572
|
+
.artifact-tabs .chip { border:1px solid var(--border); background:var(--surface-2); }
|
|
573
|
+
.artifact-desc { margin:0 0 12px; color:var(--muted); font-size:12.5px; line-height:1.55; }
|
|
574
|
+
.artifact-view { margin:0; padding:14px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
|
|
575
|
+
background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
|
|
576
|
+
.sent-tag { display:inline-flex; align-items:center; gap:5px; padding:2px 8px; font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
|
|
577
|
+
color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); border-radius:999px; text-decoration:none; }
|
|
578
|
+
.sent-tag:hover { color:#fff; }
|
|
579
|
+
.modal-layer { position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:24px; }
|
|
580
|
+
.modal-backdrop { position:absolute; inset:0; width:100%; padding:0; background:rgba(5,5,7,.66); border:0; cursor:default; backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); }
|
|
581
|
+
.modal { position:relative; width:min(640px, 100%); max-height:min(78vh, 780px); display:flex; flex-direction:column; overflow:hidden;
|
|
582
|
+
background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:0 24px 70px rgba(0,0,0,.5); }
|
|
583
|
+
.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px 14px; border-bottom:1px solid var(--border); }
|
|
584
|
+
.modal-title { margin:0; font-size:17px; font-weight:700; letter-spacing:-.01em; }
|
|
585
|
+
.modal-sub { margin:5px 0 0; font-size:12px; color:var(--muted); line-height:1.5; }
|
|
586
|
+
.modal-close { flex:none; display:grid; place-items:center; width:30px; height:30px; font:inherit; font-size:19px; line-height:1; color:var(--muted); background:transparent; border:1px solid var(--border); border-radius:9px; cursor:pointer; }
|
|
587
|
+
.modal-close:hover { color:var(--fg); border-color:var(--border-strong); }
|
|
588
|
+
.modal-body { display:flex; flex-direction:column; gap:16px; padding:16px 20px; overflow-y:auto; }
|
|
589
|
+
.modal-foot { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 20px; background:var(--surface-2); border-top:1px solid var(--border); }
|
|
590
|
+
.submit-field { display:flex; flex-direction:column; gap:8px; }
|
|
591
|
+
.submit-label { font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted-2); }
|
|
592
|
+
.submit-summary { padding:13px 15px; font-size:12.5px; color:var(--fg); background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:var(--radius); }
|
|
593
|
+
.submit-summary b { font-family:var(--font-mono); }
|
|
594
|
+
.submit-list { display:flex; flex-direction:column; gap:6px; max-height:240px; overflow-y:auto; padding-right:2px; }
|
|
595
|
+
.submit-item { display:flex; align-items:center; gap:10px; padding:8px 11px; font-size:12px; background:var(--surface-2); border:1px solid var(--border); border-radius:10px; }
|
|
596
|
+
.submit-item.sent { opacity:.7; }
|
|
597
|
+
.submit-item .rule { flex:1; min-width:0; color:var(--muted); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
598
|
+
.submit-empty { color:var(--muted); font-size:12.5px; }
|
|
599
|
+
.submit-field .chips { flex-wrap:wrap; }
|
|
600
|
+
.submit-check { display:inline-flex; align-items:center; gap:8px; font-size:12.5px; color:var(--muted); cursor:pointer; }
|
|
601
|
+
.submit-check input { accent-color:var(--accent); }
|
|
602
|
+
.submit-actions { display:flex; align-items:center; gap:8px; }
|
|
603
|
+
.sev-badge.sev-blocking { color:var(--blocking); background:rgba(242,89,94,.12); }
|
|
604
|
+
.sev-badge.sev-attention { color:var(--attention); background:rgba(237,161,58,.12); }
|
|
605
|
+
.sev-badge.sev-suggestion { color:var(--suggestion); background:rgba(90,164,240,.12); }
|
|
606
|
+
.sev-badge.sev-nit { color:var(--nit); background:rgba(255,255,255,.06); }
|
|
607
|
+
@keyframes pulse { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:.4; transform:scale(.8); } }
|
|
608
|
+
@keyframes spin { to { transform:rotate(360deg); } }
|
|
609
|
+
@media (max-width:980px) { .kpis { grid-template-columns:repeat(2, minmax(0,1fr)); } }
|
|
610
|
+
@media (max-width:820px) {
|
|
611
|
+
.sidebar { width:232px; flex-basis:232px; }
|
|
612
|
+
.topbar { padding:12px 16px; }
|
|
613
|
+
.content { padding:18px 16px 56px; }
|
|
614
|
+
.auto-refresh { display:none; }
|
|
615
|
+
}
|
|
357
616
|
`;
|
|
358
617
|
|
|
359
618
|
/** Id de build por processo — muda a URL do bundle a cada reinício do servidor. */
|
|
@@ -365,7 +624,12 @@ function renderShell(): string {
|
|
|
365
624
|
<head>
|
|
366
625
|
<meta charset="utf-8" />
|
|
367
626
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
627
|
+
<meta name="color-scheme" content="dark" />
|
|
368
628
|
<title>Houndsman — Revisões</title>
|
|
629
|
+
<!-- Fontes de CDN: se estiver offline, o CSS cai para a pilha de sistema. -->
|
|
630
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
631
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
632
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" />
|
|
369
633
|
<style>${STYLES}</style>
|
|
370
634
|
</head>
|
|
371
635
|
<body>
|
|
File without changes
|