@payconductor/houndsman 0.1.4 → 0.3.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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/cli/arg-parser.ts +8 -0
  3. package/src/cli/commands/command.ts +23 -0
  4. package/src/cli/commands/context-command.ts +38 -0
  5. package/src/cli/commands/doctor-command.ts +55 -0
  6. package/src/cli/commands/findings-command.ts +28 -0
  7. package/src/cli/commands/help-command.ts +13 -0
  8. package/src/cli/commands/registry.ts +34 -0
  9. package/src/cli/commands/review-command.ts +22 -0
  10. package/src/cli/commands/server-command.ts +37 -0
  11. package/src/cli/commands/setup-command.ts +34 -0
  12. package/src/cli/commands/watchdog-command.ts +34 -0
  13. package/src/cli/help.ts +43 -0
  14. package/src/cli/index.ts +62 -260
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +163 -147
  17. package/src/core/commands/review.ts +18 -35
  18. package/src/core/commands/scaffold.ts +174 -64
  19. package/src/core/commands/setup.ts +16 -11
  20. package/src/core/commands/update.ts +7 -7
  21. package/src/core/commands/watchdog.ts +8 -80
  22. package/src/core/config.ts +9 -19
  23. package/src/core/defaults.ts +20 -0
  24. package/src/core/diff-source.ts +1 -13
  25. package/src/core/doc-discoverer.ts +25 -15
  26. package/src/core/domain/budget.ts +42 -0
  27. package/src/core/domain/classification.ts +94 -0
  28. package/src/core/domain/diff.ts +53 -0
  29. package/src/core/domain/watchdog.ts +96 -0
  30. package/src/core/engine/carryover.ts +67 -0
  31. package/src/core/engine/houndsman-engine.ts +51 -0
  32. package/src/core/engine/review-scope.ts +95 -0
  33. package/src/core/engine/review-session.ts +51 -0
  34. package/src/core/file-classifier.ts +11 -67
  35. package/src/core/findings-state.ts +189 -38
  36. package/src/core/git-revision.ts +14 -40
  37. package/src/core/git-runner.ts +46 -0
  38. package/src/core/github-review.ts +423 -0
  39. package/src/core/orchestrator.ts +116 -337
  40. package/src/core/paths.ts +9 -0
  41. package/src/core/pr-context.ts +28 -54
  42. package/src/core/project-config.ts +57 -0
  43. package/src/core/renderers/batch-presenter.ts +40 -0
  44. package/src/core/renderers/brief-renderer.ts +23 -0
  45. package/src/core/renderers/findings-presenter.ts +74 -0
  46. package/src/core/renderers/manifest-writer.ts +112 -0
  47. package/src/core/renderers/report-renderer.ts +66 -0
  48. package/src/core/renderers/verify-presenter.ts +50 -0
  49. package/src/core/renderers/watchdog-presenter.ts +100 -0
  50. package/src/core/report-builder.ts +12 -7
  51. package/src/core/run-layout.ts +108 -0
  52. package/src/core/run-status.ts +6 -11
  53. package/src/core/services/clock.ts +12 -0
  54. package/src/core/services/file-classifier.ts +34 -0
  55. package/src/core/services/fix-batch-builder.ts +13 -0
  56. package/src/core/services/gh-client.ts +54 -0
  57. package/src/core/services/git-repository.ts +123 -0
  58. package/src/core/services/project-files.ts +79 -0
  59. package/src/core/services/verifier.ts +58 -0
  60. package/src/core/verify.ts +2 -1
  61. package/src/core/watchdog-registry.ts +58 -16
  62. package/src/index.ts +31 -1
  63. package/src/server/client/App.tsx +174 -49
  64. package/src/server/client/ArtifactsPanel.tsx +2 -2
  65. package/src/server/client/AutoRefresh.tsx +38 -0
  66. package/src/server/client/Filters.tsx +72 -53
  67. package/src/server/client/FindingCard.tsx +212 -71
  68. package/src/server/client/FindingsList.tsx +8 -6
  69. package/src/server/client/RunList.tsx +30 -7
  70. package/src/server/client/SubmitModal.tsx +418 -0
  71. package/src/server/client/SummaryBar.tsx +65 -12
  72. package/src/server/client/constants.ts +9 -12
  73. package/src/server/client/format.ts +40 -0
  74. package/src/server/client/icons.tsx +143 -0
  75. package/src/server/client/types.ts +69 -0
  76. package/src/server/index.ts +316 -222
  77. package/src/server/run-repository.ts +291 -0
  78. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  79. package/src/tests/baseline.test.ts +513 -0
  80. package/src/tests/cli.test.ts +167 -0
  81. package/src/tests/domain.test.ts +328 -0
  82. package/src/tests/github-review.test.ts +320 -0
  83. package/src/tests/infrastructure.test.ts +210 -0
  84. package/src/tests/server.test.ts +117 -0
@@ -1,18 +1,70 @@
1
1
  /**
2
- * Acesso tipado aos watchdogs de um `HoundsmanConfig` (biblioteca).
2
+ * Registry de watchdogs (biblioteca) — acesso tipado ao `HoundsmanConfig`.
3
3
  *
4
- * O motor não conhece ids concretos — tudo chega via `config`.
4
+ * O motor não conhece ids concretos: o registry resolve definições, contexto e
5
+ * casamento de arquivos por domínio. As funções antigas continuam como
6
+ * adaptadores para a API pública.
5
7
  */
6
8
 
7
9
  import type { HoundsmanConfig } from "./config";
10
+ import { Watchdog, type WatchdogContext } from "./domain/watchdog";
8
11
  import type { WatchdogDefinition } from "./types";
9
12
 
13
+ /** Coleção tipada de watchdogs do projeto. */
14
+ export class WatchdogRegistry<TId extends string = string> {
15
+ private readonly config: HoundsmanConfig<TId>;
16
+
17
+ constructor(config: HoundsmanConfig<TId>) {
18
+ this.config = config;
19
+ }
20
+
21
+ /** Ids na ordem de declaração. */
22
+ get ids(): readonly TId[] {
23
+ return this.config.watchdogIds;
24
+ }
25
+
26
+ /** Definição de um watchdog (id string em runtime). */
27
+ get(watchdogId: string): WatchdogDefinition {
28
+ return this.config.watchdogs[watchdogId as TId];
29
+ }
30
+
31
+ /** Busca null-safe por id. */
32
+ find(watchdogId: string): Watchdog | null {
33
+ const definition = this.config.watchdogs[watchdogId as TId];
34
+ return definition ? new Watchdog(definition) : null;
35
+ }
36
+
37
+ /** Nome legível (default: o próprio id). */
38
+ nameOf(watchdogId: string): string {
39
+ return this.find(watchdogId)?.name ?? watchdogId;
40
+ }
41
+
42
+ /** Contexto essencial (sem regras). */
43
+ context(watchdogId: string): WatchdogContext {
44
+ return new Watchdog(this.get(watchdogId)).context();
45
+ }
46
+
47
+ /** Ids dos watchdogs que cobrem o arquivo. */
48
+ matching(filePath: string, content?: string): TId[] {
49
+ return this.config.watchdogIds.filter((id) =>
50
+ new Watchdog(this.config.watchdogs[id]).matches(filePath, content),
51
+ );
52
+ }
53
+
54
+ /** Todos os watchdogs (na ordem de declaração). */
55
+ all(): Watchdog[] {
56
+ return this.config.watchdogIds.map(
57
+ (id) => new Watchdog(this.config.watchdogs[id]),
58
+ );
59
+ }
60
+ }
61
+
10
62
  /** Acesso tipado a um watchdog do projeto (id string em runtime). */
11
63
  export function getWatchdog<TId extends string>(
12
64
  config: HoundsmanConfig<TId>,
13
65
  watchdogId: string,
14
66
  ): WatchdogDefinition {
15
- return config.watchdogs[watchdogId as TId];
67
+ return new WatchdogRegistry(config).get(watchdogId);
16
68
  }
17
69
 
18
70
  /** Busca null-safe por id string (runtime). */
@@ -20,23 +72,13 @@ export function findWatchdog<TId extends string>(
20
72
  config: HoundsmanConfig<TId>,
21
73
  watchdogId: string,
22
74
  ): WatchdogDefinition | null {
23
- return config.watchdogs[watchdogId as TId] ?? null;
75
+ return new WatchdogRegistry(config).find(watchdogId)?.definition ?? null;
24
76
  }
25
77
 
26
78
  /** Contexto "essencial" do watchdog — SEM regras (docs/UI/inspeção). */
27
79
  export function getWatchdogContext<TId extends string>(
28
80
  config: HoundsmanConfig<TId>,
29
81
  watchdogId: string,
30
- ) {
31
- const { id, name, description, scope, reviewFocus, exclude, contextFiles } =
32
- getWatchdog(config, watchdogId);
33
- return {
34
- id,
35
- name,
36
- description,
37
- scope,
38
- reviewFocus,
39
- exclude,
40
- contextFiles,
41
- };
82
+ ): WatchdogContext {
83
+ return new WatchdogRegistry(config).context(watchdogId);
42
84
  }
package/src/index.ts CHANGED
@@ -5,10 +5,12 @@
5
5
  * (`houndsman.config.ts`): watchdogs, tipos e helpers de autoria.
6
6
  */
7
7
 
8
+ export { ArgParser } from "./core/arg-parser";
8
9
  export { assetPath, OUTPUT_FORMAT_FILE } from "./core/assets";
9
10
  export { defaultRenderBrief, PROCEDURE, renderBrief } from "./core/brief";
10
11
  export type { FindingsCommandResult } from "./core/commands/findings";
11
12
  export { runFindingsCommand, runVerifyCommand } from "./core/commands/findings";
13
+ export { ProjectScaffolder } from "./core/commands/scaffold";
12
14
  export type {
13
15
  HoundsmanBudgets,
14
16
  HoundsmanConcurrency,
@@ -36,6 +38,14 @@ export {
36
38
  parseDiffIndex,
37
39
  parseNumstat,
38
40
  } from "./core/diff-source";
41
+ export { DocDiscoverer } from "./core/doc-discoverer";
42
+ export { Classification } from "./core/domain/classification";
43
+ export { Diff } from "./core/domain/diff";
44
+ export { Watchdog } from "./core/domain/watchdog";
45
+ export { buildCarryover, mergeCarryover } from "./core/engine/carryover";
46
+ export { HoundsmanEngine } from "./core/engine/houndsman-engine";
47
+ export { ReviewScopeResolver } from "./core/engine/review-scope";
48
+ export { ReviewSession } from "./core/engine/review-session";
39
49
  export {
40
50
  churnOf,
41
51
  classifyEntries,
@@ -81,6 +91,9 @@ export {
81
91
  FINDING_DECISIONS,
82
92
  FINDING_EXEC_STATES,
83
93
  FINDINGS_STATE_VERSION,
94
+ Finding,
95
+ FindingsRepository,
96
+ FindingsState,
84
97
  FindingsStateStore,
85
98
  fileReader,
86
99
  filterFindings,
@@ -91,6 +104,7 @@ export {
91
104
  summarize,
92
105
  syncState,
93
106
  } from "./core/findings-state";
107
+ export type { GitResult, GitRunner } from "./core/git-runner";
94
108
  export {
95
109
  ARTIFACT_DIR,
96
110
  CORE_DIR,
@@ -100,9 +114,23 @@ export {
100
114
  relFromRoot,
101
115
  STATE_FILE,
102
116
  } from "./core/paths";
117
+ export { ProjectConfig } from "./core/project-config";
103
118
  export { checkRegistry } from "./core/registry-check";
119
+ export { BriefRenderer } from "./core/renderers/brief-renderer";
120
+ export { ManifestWriter } from "./core/renderers/manifest-writer";
121
+ export { ReportRenderer } from "./core/renderers/report-renderer";
104
122
  export type { DomainReport } from "./core/report-builder";
105
123
  export { renderDomainReport } from "./core/report-builder";
124
+ export { RunLayout } from "./core/run-layout";
125
+ export type { Clock } from "./core/services/clock";
126
+ export { systemClock } from "./core/services/clock";
127
+ export { FileClassifier } from "./core/services/file-classifier";
128
+ export { FixBatchBuilder } from "./core/services/fix-batch-builder";
129
+ export type { GhResult, GhRunner } from "./core/services/gh-client";
130
+ export { GhClient } from "./core/services/gh-client";
131
+ export { GitRepository } from "./core/services/git-repository";
132
+ export { ProjectFiles } from "./core/services/project-files";
133
+ export { Verifier } from "./core/services/verifier";
106
134
  export type {
107
135
  ChurnMap,
108
136
  ClassifyRule,
@@ -127,6 +155,8 @@ export {
127
155
  findWatchdog,
128
156
  getWatchdog,
129
157
  getWatchdogContext,
158
+ WatchdogRegistry,
130
159
  } from "./core/watchdog-registry";
131
160
  export type { ServerOptions } from "./server";
132
- export { createRequestHandler, startServer } from "./server";
161
+ export { createRequestHandler, HoundsmanServer, startServer } from "./server";
162
+ export { RunRepository } from "./server/run-repository";
@@ -1,20 +1,37 @@
1
1
  /**
2
- * App: layout do servidor — sidebar de runs, abas (Findings/Artefatos), legenda
3
- * da aba ativa e os painéis. Concentra o estado e as chamadas à API.
2
+ * App: layout do servidor — sidebar de execuções, header fixo (abas segmentadas,
3
+ * auto-refresh e reancoragem), KPIs, filtros e os painéis de findings/artefatos.
4
+ * Concentra o estado e as chamadas à API.
4
5
  */
5
6
 
6
- import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
7
8
  import { ArtifactsPanel } from "./ArtifactsPanel";
9
+ import { AutoRefresh } from "./AutoRefresh";
8
10
  import { api, computeSummary } from "./api";
9
- import { TAB_CAPTIONS } from "./constants";
11
+ import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./constants";
10
12
  import { Filters, type FiltersState } from "./Filters";
11
13
  import { FindingsList } from "./FindingsList";
14
+ import { Layers, ListIcon, Refresh, Send } from "./icons";
12
15
  import { RunList } from "./RunList";
16
+ import { SubmitModal } from "./SubmitModal";
13
17
  import { SummaryBar } from "./SummaryBar";
14
18
  import type { Finding, FindingsResponse, RunSummary } from "./types";
15
19
 
16
20
  type Tab = "findings" | "artifacts";
17
21
 
22
+ const TABS: { id: Tab; label: string; hint: string }[] = [
23
+ {
24
+ id: "findings",
25
+ label: "Findings",
26
+ hint: "Achados da revisão: triagem e decisões por finding",
27
+ },
28
+ {
29
+ id: "artifacts",
30
+ label: "Artefatos",
31
+ hint: "Arquivos brutos gerados pela revisão (manifesto, classificação, relatório…)",
32
+ },
33
+ ];
34
+
18
35
  export function App() {
19
36
  const [runs, setRuns] = useState<RunSummary[]>([]);
20
37
  const [slug, setSlug] = useState<string | null>(null);
@@ -27,6 +44,23 @@ export function App() {
27
44
  showAbsent: false,
28
45
  });
29
46
  const [error, setError] = useState("");
47
+ const [lastSync, setLastSync] = useState(() => Date.now());
48
+ const [refreshing, setRefreshing] = useState(false);
49
+ const [syncing, setSyncing] = useState(false);
50
+ const [submitOpen, setSubmitOpen] = useState(false);
51
+ /** Refs espelhando estado volátil — o polling não deve recriar o intervalo. */
52
+ const lastSyncRef = useRef(lastSync);
53
+ const slugRef = useRef<string | null>(null);
54
+ const refreshingRef = useRef(false);
55
+
56
+ useEffect(() => {
57
+ slugRef.current = slug;
58
+ }, [slug]);
59
+
60
+ const markSynced = useCallback(() => {
61
+ lastSyncRef.current = Date.now();
62
+ setLastSync(lastSyncRef.current);
63
+ }, []);
30
64
 
31
65
  const reloadRuns = useCallback(async () => {
32
66
  try {
@@ -47,6 +81,31 @@ export function App() {
47
81
  }
48
82
  }, []);
49
83
 
84
+ /** Revalidação leve (auto-refresh): runs + findings da run selecionada. */
85
+ const refresh = useCallback(async () => {
86
+ if (refreshingRef.current) return;
87
+ refreshingRef.current = true;
88
+ setRefreshing(true);
89
+ try {
90
+ const { runs: list } = await api<{ runs: RunSummary[] }>("/api/runs");
91
+ setRuns(list);
92
+ const target = slugRef.current ?? list[0]?.slug ?? null;
93
+ if (target) {
94
+ setSlug((current) => current ?? target);
95
+ setData(
96
+ await api<FindingsResponse>(`/api/runs/${target}/findings?all=1`),
97
+ );
98
+ }
99
+ setError("");
100
+ } catch (err) {
101
+ setError(String(err));
102
+ } finally {
103
+ refreshingRef.current = false;
104
+ setRefreshing(false);
105
+ markSynced();
106
+ }
107
+ }, [markSynced]);
108
+
50
109
  useEffect(() => {
51
110
  reloadRuns();
52
111
  }, [reloadRuns]);
@@ -55,6 +114,16 @@ export function App() {
55
114
  if (slug) loadFindings(slug);
56
115
  }, [slug, loadFindings]);
57
116
 
117
+ // Auto-refresh: só o badge mostra o tempo decorrido; a lista só refaz o fetch
118
+ // quando o intervalo estourou (e a aba está visível).
119
+ useEffect(() => {
120
+ const id = setInterval(() => {
121
+ if (document.hidden || refreshingRef.current) return;
122
+ if (Date.now() - lastSyncRef.current >= AUTO_REFRESH_MS) void refresh();
123
+ }, 1000);
124
+ return () => clearInterval(id);
125
+ }, [refresh]);
126
+
58
127
  const onUpdated = useCallback(
59
128
  (updated: Finding) => {
60
129
  setData((current) =>
@@ -74,10 +143,22 @@ export function App() {
74
143
 
75
144
  const sync = useCallback(async () => {
76
145
  if (!slug) return;
77
- await api(`/api/runs/${slug}/sync`, { method: "POST" });
78
- await loadFindings(slug);
79
- await reloadRuns();
80
- }, [slug, loadFindings, reloadRuns]);
146
+ setSyncing(true);
147
+ try {
148
+ await api(`/api/runs/${slug}/sync`, { method: "POST" });
149
+ await loadFindings(slug);
150
+ await reloadRuns();
151
+ markSynced();
152
+ } finally {
153
+ setSyncing(false);
154
+ }
155
+ }, [slug, loadFindings, reloadRuns, markSynced]);
156
+
157
+ /** Após enviar a revisão: a triagem ganhou o vínculo remoto — recarrega. */
158
+ const afterSubmit = useCallback(() => {
159
+ if (slugRef.current) void loadFindings(slugRef.current);
160
+ void reloadRuns();
161
+ }, [loadFindings, reloadRuns]);
81
162
 
82
163
  const findings = useMemo(() => {
83
164
  const needle = filters.query.trim().toLowerCase();
@@ -102,56 +183,100 @@ export function App() {
102
183
 
103
184
  const summary = useMemo(() => computeSummary(data?.findings ?? []), [data]);
104
185
 
186
+ const branch =
187
+ data?.branch ?? runs.find((run) => run.slug === slug)?.branch ?? slug ?? "";
188
+
105
189
  return (
106
190
  <div class="app">
107
191
  <RunList runs={runs} slug={slug} onSelect={setSlug} />
108
192
  <main class="main">
109
193
  <header class="topbar">
110
- <div class="tabs">
111
- <button
112
- type="button"
113
- title="Achados da revisão: triagem e decisões por finding"
114
- class={`btn tab${tab === "findings" ? " active" : ""}`}
115
- onClick={() => setTab("findings")}
116
- >
117
- Findings
118
- </button>
119
- <button
120
- type="button"
121
- title="Arquivos brutos gerados pela revisão (manifesto, classificação, relatório…)"
122
- class={`btn tab${tab === "artifacts" ? " active" : ""}`}
123
- onClick={() => setTab("artifacts")}
124
- >
125
- Artefatos
126
- </button>
194
+ <div class="segmented">
195
+ {TABS.map((item) => (
196
+ <button
197
+ type="button"
198
+ key={item.id}
199
+ title={item.hint}
200
+ class={`seg-btn${tab === item.id ? " active" : ""}`}
201
+ onClick={() => setTab(item.id)}
202
+ >
203
+ {item.id === "findings" ? (
204
+ <ListIcon size={15} />
205
+ ) : (
206
+ <Layers size={15} />
207
+ )}
208
+ {item.label}
209
+ </button>
210
+ ))}
211
+ </div>
212
+ <div class="topbar-right">
213
+ <AutoRefresh
214
+ lastSync={lastSync}
215
+ refreshing={refreshing || syncing}
216
+ />
217
+ {slug && (
218
+ <button
219
+ type="button"
220
+ title="Reancorar: recalcula a linha de cada finding lendo o arquivo atual — use após um auto-fix que deslocou as linhas"
221
+ class="btn sync"
222
+ disabled={syncing}
223
+ onClick={sync}
224
+ >
225
+ <Refresh
226
+ size={15}
227
+ class={`icon${syncing ? " spin" : ""}`}
228
+ />
229
+ {syncing ? "Reancorando…" : "Reancorar"}
230
+ </button>
231
+ )}
127
232
  </div>
128
- {slug && (
129
- <button
130
- type="button"
131
- title="Reancorar: recalcula a linha de cada finding lendo o arquivo atual — use após um auto-fix que deslocou as linhas"
132
- class="btn sync"
133
- onClick={sync}
134
- >
135
- ↻ Reancorar (sync)
136
- </button>
137
- )}
138
233
  </header>
139
234
 
140
- <p class="caption">{TAB_CAPTIONS[tab]}</p>
141
- {error && <p class="error">{error}</p>}
142
-
143
- {slug && tab === "findings" && data && (
144
- <>
145
- <SummaryBar summary={summary} />
146
- <Filters value={filters} onChange={setFilters} />
147
- <FindingsList
148
- slug={slug}
149
- findings={findings}
150
- onUpdated={onUpdated}
151
- />
152
- </>
235
+ <div class="content">
236
+ <div class="page-header">
237
+ <div class="page-header-text">
238
+ {slug && <h1 class="page-title">{branch}</h1>}
239
+ <p class="page-sub">{TAB_CAPTIONS[tab]}</p>
240
+ </div>
241
+ {slug && tab === "findings" && data && (
242
+ <div class="page-header-actions">
243
+ <button
244
+ type="button"
245
+ class="btn submit-review"
246
+ title="Publicar os findings selecionados como comentários na PR (via gh)"
247
+ onClick={() => setSubmitOpen(true)}
248
+ >
249
+ <Send size={15} />
250
+ Enviar revisão
251
+ </button>
252
+ </div>
253
+ )}
254
+ </div>
255
+ {error && <p class="error">{error}</p>}
256
+
257
+ {slug && tab === "findings" && data && (
258
+ <>
259
+ <SummaryBar summary={summary} />
260
+ <Filters value={filters} onChange={setFilters} />
261
+ <FindingsList
262
+ slug={slug}
263
+ findings={findings}
264
+ onUpdated={onUpdated}
265
+ />
266
+ </>
267
+ )}
268
+ {slug && tab === "artifacts" && <ArtifactsPanel slug={slug} />}
269
+ {!slug && !error && <p class="empty">Nenhuma execução para triar.</p>}
270
+ </div>
271
+ {slug && (
272
+ <SubmitModal
273
+ slug={slug}
274
+ branch={branch}
275
+ open={submitOpen}
276
+ onClose={() => setSubmitOpen(false)}
277
+ onSubmitted={afterSubmit}
278
+ />
153
279
  )}
154
- {slug && tab === "artifacts" && <ArtifactsPanel slug={slug} />}
155
280
  </main>
156
281
  </div>
157
282
  );
@@ -25,12 +25,12 @@ export function ArtifactsPanel({ slug }: { slug: string }) {
25
25
 
26
26
  return (
27
27
  <div class="artifacts">
28
- <div class="tabs">
28
+ <div class="artifact-tabs">
29
29
  {ARTIFACTS.map((artifact) => (
30
30
  <button
31
31
  type="button"
32
32
  title={ARTIFACT_INFO[artifact]}
33
- class={`btn tab${artifact === name ? " active" : ""}`}
33
+ class={`chip${artifact === name ? " active" : ""}`}
34
34
  onClick={() => setName(artifact)}
35
35
  >
36
36
  {artifact}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Indicador de auto-refresh: ponto de pulso (verde estável / âmbar pulsando
3
+ * durante a atualização) + tempo decorrido desde a última sincronização.
4
+ *
5
+ * O tique de 1s vive aqui — a lista de findings não re-renderiza por causa dele.
6
+ */
7
+
8
+ import { useEffect, useState } from "preact/hooks";
9
+ import { formatElapsed } from "./format";
10
+
11
+ export function AutoRefresh({
12
+ lastSync,
13
+ refreshing,
14
+ }: {
15
+ lastSync: number;
16
+ refreshing: boolean;
17
+ }) {
18
+ const [now, setNow] = useState(() => Date.now());
19
+
20
+ useEffect(() => {
21
+ const id = setInterval(() => setNow(Date.now()), 1000);
22
+ return () => clearInterval(id);
23
+ }, []);
24
+
25
+ const seconds = Math.max(0, Math.floor((now - lastSync) / 1000));
26
+
27
+ return (
28
+ <span
29
+ class="auto-refresh"
30
+ title="A lista é revalidada automaticamente enquanto esta página estiver aberta"
31
+ >
32
+ <span class={`dot${refreshing ? " busy" : ""}`} />
33
+ {refreshing
34
+ ? "Auto-refresh · atualizando…"
35
+ : `Auto-refresh · ${formatElapsed(seconds)}`}
36
+ </span>
37
+ );
38
+ }
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Barra de filtros dos findings: busca, decisão, severidade e "mostrar ausentes".
2
+ * Barra de filtros dos findings: busca, chips de severidade, chips de decisão e
3
+ * o toggle "ausentes".
3
4
  */
4
5
 
5
- import { DECISION_LABELS, DECISIONS, SEVERITY_ORDER } from "./constants";
6
+ import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS, SEVERITY_ORDER } from "./constants";
7
+ import { Search, Watch } from "./icons";
6
8
  import type { Decision, Severity } from "./types";
7
9
 
8
10
  export type FiltersState = {
@@ -23,61 +25,78 @@ export function Filters({
23
25
 
24
26
  return (
25
27
  <div class="filters">
26
- <input
27
- title="Filtrar por arquivo, regra ou mensagem"
28
- placeholder="Buscar arquivo, regra ou mensagem…"
29
- value={value.query}
30
- onInput={(event) =>
31
- patch({ query: (event.target as HTMLInputElement).value })
32
- }
33
- />
34
- <select
35
- title="Filtrar por decisão de triagem"
36
- value={value.decision}
37
- onChange={(event) =>
38
- patch({
39
- decision: (event.target as HTMLSelectElement).value as
40
- | "all"
41
- | Decision,
42
- })
43
- }
44
- >
45
- <option value="all">Todas as decisões</option>
46
- {DECISIONS.map((decision) => (
47
- <option value={decision}>{DECISION_LABELS[decision]}</option>
48
- ))}
49
- </select>
50
- <select
51
- title="Filtrar por severidade"
52
- value={value.severity}
53
- onChange={(event) =>
54
- patch({
55
- severity: (event.target as HTMLSelectElement).value as
56
- | "all"
57
- | Severity,
58
- })
59
- }
60
- >
61
- <option value="all">Todas as severidades</option>
62
- {SEVERITY_ORDER.map((severity) => (
63
- <option value={severity}>{severity}</option>
64
- ))}
65
- </select>
66
- <label
67
- class="toggle"
68
- title="Incluir findings que sumiram do último relatório"
69
- >
28
+ <label class="search" title="Filtrar por arquivo, regra ou mensagem">
29
+ <Search size={15} />
70
30
  <input
71
- type="checkbox"
72
- checked={value.showAbsent}
73
- onChange={(event) =>
74
- patch({
75
- showAbsent: (event.target as HTMLInputElement).checked,
76
- })
31
+ type="text"
32
+ placeholder="Buscar arquivo, regra ou mensagem…"
33
+ value={value.query}
34
+ onInput={(event) =>
35
+ patch({ query: (event.target as HTMLInputElement).value })
77
36
  }
78
37
  />
79
- mostrar ausentes
38
+ {value.query && (
39
+ <button
40
+ type="button"
41
+ class="search-clear"
42
+ title="Limpar busca"
43
+ onClick={() => patch({ query: "" })}
44
+ >
45
+ ×
46
+ </button>
47
+ )}
80
48
  </label>
49
+
50
+ <div class="chips">
51
+ <button
52
+ type="button"
53
+ title="Todas as severidades"
54
+ class={`chip${value.severity === "all" ? " active" : ""}`}
55
+ onClick={() => patch({ severity: "all" })}
56
+ >
57
+ Todas
58
+ </button>
59
+ {SEVERITY_ORDER.map((severity) => (
60
+ <button
61
+ type="button"
62
+ title={`Somente severidade ${severity}`}
63
+ class={`chip sev-${severity}${value.severity === severity ? " active" : ""}`}
64
+ onClick={() => patch({ severity })}
65
+ >
66
+ {SEVERITY_LABELS[severity]}
67
+ </button>
68
+ ))}
69
+ </div>
70
+
71
+ <div class="chips">
72
+ <button
73
+ type="button"
74
+ title="Todas as decisões"
75
+ class={`chip${value.decision === "all" ? " active" : ""}`}
76
+ onClick={() => patch({ decision: "all" })}
77
+ >
78
+ Todas
79
+ </button>
80
+ {DECISIONS.map((decision) => (
81
+ <button
82
+ type="button"
83
+ title={`Somente ${DECISION_LABELS[decision]}`}
84
+ class={`chip dec-${decision}${value.decision === decision ? " active" : ""}`}
85
+ onClick={() => patch({ decision })}
86
+ >
87
+ {DECISION_LABELS[decision]}
88
+ </button>
89
+ ))}
90
+ </div>
91
+
92
+ <button
93
+ type="button"
94
+ title="Incluir findings que sumiram do último relatório"
95
+ class={`chip${value.showAbsent ? " active" : ""}`}
96
+ onClick={() => patch({ showAbsent: !value.showAbsent })}
97
+ >
98
+ <Watch size={13} /> Ausentes
99
+ </button>
81
100
  </div>
82
101
  );
83
102
  }