cadence-diff 0.1.0__py3-none-any.whl

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 (78) hide show
  1. cadence_diff-0.1.0.dist-info/METADATA +245 -0
  2. cadence_diff-0.1.0.dist-info/RECORD +78 -0
  3. cadence_diff-0.1.0.dist-info/WHEEL +4 -0
  4. cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
  5. cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
  6. qc_tool/__init__.py +3 -0
  7. qc_tool/__main__.py +6 -0
  8. qc_tool/attestation.py +209 -0
  9. qc_tool/availability.py +273 -0
  10. qc_tool/cli.py +769 -0
  11. qc_tool/config/__init__.py +1 -0
  12. qc_tool/config/lint.py +480 -0
  13. qc_tool/config/profile.py +207 -0
  14. qc_tool/coverage.py +35 -0
  15. qc_tool/crosscheck/__init__.py +1 -0
  16. qc_tool/crosscheck/numbers.py +146 -0
  17. qc_tool/crosscheck/package.py +154 -0
  18. qc_tool/crosscheck/trace.py +374 -0
  19. qc_tool/engine.py +978 -0
  20. qc_tool/excel/__init__.py +1 -0
  21. qc_tool/excel/align.py +452 -0
  22. qc_tool/excel/charts.py +931 -0
  23. qc_tool/excel/context.py +106 -0
  24. qc_tool/excel/controls.py +266 -0
  25. qc_tool/excel/dependency.py +252 -0
  26. qc_tool/excel/diff_structure.py +326 -0
  27. qc_tool/excel/diff_values.py +327 -0
  28. qc_tool/excel/formulas.py +516 -0
  29. qc_tool/excel/interaction.py +548 -0
  30. qc_tool/excel/periods.py +117 -0
  31. qc_tool/excel/preflight.py +615 -0
  32. qc_tool/excel/references.py +436 -0
  33. qc_tool/excel/regions.py +201 -0
  34. qc_tool/findings.py +220 -0
  35. qc_tool/fingerprint.py +369 -0
  36. qc_tool/fingerprint.schema.json +15 -0
  37. qc_tool/history/__init__.py +1 -0
  38. qc_tool/history/store.py +287 -0
  39. qc_tool/io/__init__.py +1 -0
  40. qc_tool/io/decrypt.py +64 -0
  41. qc_tool/io/excel_formula.py +256 -0
  42. qc_tool/io/excel_formula_worker.py +280 -0
  43. qc_tool/io/formula_enrichment.py +95 -0
  44. qc_tool/io/libreoffice_formula.py +232 -0
  45. qc_tool/io/loader.py +973 -0
  46. qc_tool/io/model.py +356 -0
  47. qc_tool/io/ooxml_chart.py +530 -0
  48. qc_tool/io/ooxml_interaction.py +400 -0
  49. qc_tool/io/ooxml_worksheet.py +405 -0
  50. qc_tool/io/xlsb_formula.py +313 -0
  51. qc_tool/package-sanitize.schema.json +31 -0
  52. qc_tool/package_sanitize.py +255 -0
  53. qc_tool/ppt/__init__.py +1 -0
  54. qc_tool/ppt/chart_xml.py +422 -0
  55. qc_tool/ppt/diff.py +120 -0
  56. qc_tool/ppt/element_diff.py +878 -0
  57. qc_tool/ppt/element_match.py +317 -0
  58. qc_tool/ppt/extract.py +212 -0
  59. qc_tool/ppt/match.py +137 -0
  60. qc_tool/ppt/model.py +132 -0
  61. qc_tool/ppt/preflight.py +307 -0
  62. qc_tool/privacy.py +392 -0
  63. qc_tool/progress.py +78 -0
  64. qc_tool/report/__init__.py +1 -0
  65. qc_tool/report/excel_report.py +205 -0
  66. qc_tool/report/findings.schema.json +32 -0
  67. qc_tool/report/html_report.py +38 -0
  68. qc_tool/report/json_report.py +55 -0
  69. qc_tool/report/templates/report.html.j2 +117 -0
  70. qc_tool/sanitize.py +598 -0
  71. qc_tool/security.py +34 -0
  72. qc_tool/server_config.py +86 -0
  73. qc_tool/triage/__init__.py +1 -0
  74. qc_tool/triage/rules.py +211 -0
  75. qc_tool/ui/__init__.py +1 -0
  76. qc_tool/ui/app.py +1261 -0
  77. qc_tool/ui/guide.py +452 -0
  78. qc_tool/ui/theme.py +505 -0
qc_tool/ui/app.py ADDED
@@ -0,0 +1,1261 @@
1
+ """NiceGUI local web application for the QC tool.
2
+
3
+ The UI stays thin: uploads, profile selection, passwords, one button.
4
+ All comparison logic lives in `qc_tool.engine`; `perform_run` wraps a run
5
+ with report writing and history recording and is used by both the UI and
6
+ the tests. Everything runs on localhost; sources are read-only. The
7
+ visual language lives in `qc_tool.ui.theme`.
8
+ """
9
+
10
+ import asyncio
11
+ import datetime as dt
12
+ import logging
13
+ import re
14
+ import secrets
15
+ import shutil
16
+ import tempfile
17
+ import threading
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+ from nicegui import app, events, run, ui
23
+
24
+ from qc_tool.config.profile import (
25
+ CrosscheckMapping,
26
+ DeliverableProfile,
27
+ default_profile,
28
+ load_profile,
29
+ save_profile,
30
+ )
31
+ from qc_tool.coverage import QCRunMode
32
+ from qc_tool.crosscheck.trace import MappingSuggestion, SuggestedSource
33
+ from qc_tool.engine import FindingsDelta, QCRunResult, compare_findings, run_qc
34
+ from qc_tool.findings import Severity
35
+ from qc_tool.history.store import RunHistory, sha256_file
36
+ from qc_tool.io.decrypt import InvalidPasswordError, PasswordRequiredError
37
+ from qc_tool.progress import (
38
+ CancellationToken,
39
+ ProgressCallback,
40
+ ProgressEvent,
41
+ RunCancelled,
42
+ RunPhase,
43
+ check_cancelled,
44
+ report_progress,
45
+ )
46
+ from qc_tool.report.excel_report import write_excel_report
47
+ from qc_tool.report.html_report import write_html_report
48
+ from qc_tool.security import private_directory, private_file, secure_managed_tree
49
+ from qc_tool.server_config import (
50
+ NetworkMode,
51
+ lan_config_matches,
52
+ local_config,
53
+ save_server_config,
54
+ )
55
+ from qc_tool.ui.guide import render_guide
56
+ from qc_tool.ui.theme import FINDINGS_BODY_SLOT, page_frame, section
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # accept large workbooks locally
61
+ _PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$")
62
+
63
+ ROLES = ("baseline_excel", "current_excel", "baseline_ppt", "current_ppt")
64
+ ROLE_LABELS = {
65
+ "baseline_excel": "Baseline — Excel workbook",
66
+ "current_excel": "Current — Excel workbook",
67
+ "baseline_ppt": "Baseline — PowerPoint deck",
68
+ "current_ppt": "Current — PowerPoint deck",
69
+ }
70
+ ROLE_ACCEPT = {
71
+ "baseline_excel": ".xlsx,.xlsm,.xlsb",
72
+ "current_excel": ".xlsx,.xlsm,.xlsb",
73
+ "baseline_ppt": ".pptx",
74
+ "current_ppt": ".pptx",
75
+ }
76
+ MODE_LABELS = {
77
+ QCRunMode.CURRENT_FILE_PREFLIGHT: "Current-file preflight",
78
+ QCRunMode.CYCLE_COMPARISON: "Cycle comparison",
79
+ QCRunMode.FINAL_PACKAGE: "Final-package QC",
80
+ }
81
+ PHASE_LABELS = {
82
+ RunPhase.PREPARING: "Preparing run",
83
+ RunPhase.LOADING_BASELINE_EXCEL: "Loading baseline Excel",
84
+ RunPhase.LOADING_CURRENT_EXCEL: "Loading current Excel",
85
+ RunPhase.LOADING_BASELINE_POWERPOINT: "Loading baseline PowerPoint",
86
+ RunPhase.LOADING_CURRENT_POWERPOINT: "Loading current PowerPoint",
87
+ RunPhase.ANALYZING_EXCEL: "Analyzing Excel",
88
+ RunPhase.ANALYZING_POWERPOINT: "Analyzing PowerPoint",
89
+ RunPhase.CROSSCHECKING: "Cross-checking package",
90
+ RunPhase.WRITING_REPORTS: "Writing reports",
91
+ RunPhase.RECORDING_HISTORY: "Recording history",
92
+ RunPhase.COMPLETE: "Complete",
93
+ }
94
+
95
+
96
+ def _download_handler(path: str | Path):
97
+ """Zero-arg click handler (NiceGUI passes event args to 1-arg lambdas)."""
98
+
99
+ def handler() -> None:
100
+ ui.download(str(path))
101
+
102
+ return handler
103
+
104
+
105
+ @dataclass(slots=True)
106
+ class RunArtifacts:
107
+ run_id: int
108
+ result: QCRunResult
109
+ report_paths: dict[str, Path]
110
+ rerun_of: int | None = None
111
+ delta: FindingsDelta | None = None
112
+
113
+
114
+ @dataclass(slots=True)
115
+ class SessionState:
116
+ files: dict[str, Path] = field(default_factory=dict)
117
+ passwords: dict[str, str] = field(default_factory=dict) # role -> password
118
+ profile_name: str = "default"
119
+ mode: QCRunMode = QCRunMode.CYCLE_COMPARISON
120
+ allow_large_workbooks: bool = False
121
+ rerun_of: int | None = None
122
+ rerun_required: frozenset[str] = frozenset() # roles the previous run used
123
+
124
+
125
+ # --- pure helpers (unit-testable without a browser) -------------------------
126
+
127
+
128
+ def _safe_upload_name(raw_name: str) -> str:
129
+ """Return a browser-supplied basename that cannot escape managed storage."""
130
+ name = Path(raw_name.replace("\\", "/")).name.strip()
131
+ if not name or name in {".", ".."}:
132
+ raise ValueError("uploaded file has no usable filename")
133
+ return name
134
+
135
+
136
+ def _profile_path(profiles_dir: Path, name: str) -> Path:
137
+ """Resolve a validated profile name inside the managed profile directory."""
138
+ if not _PROFILE_NAME_RE.fullmatch(name) or name in {".", ".."}:
139
+ raise ValueError(
140
+ "profile name must start with a letter or number and contain only "
141
+ "letters, numbers, spaces, dots, dashes, or underscores"
142
+ )
143
+ return profiles_dir / f"{name}.yaml"
144
+
145
+
146
+ def _storage_secret(work_dir: Path) -> str:
147
+ """Load or create the private signing secret used by NiceGUI storage."""
148
+ secret_path = work_dir / ".nicegui-storage-secret"
149
+ if secret_path.exists():
150
+ secret = secret_path.read_text(encoding="utf-8").strip()
151
+ if secret:
152
+ return secret
153
+ private_directory(work_dir)
154
+ secret = secrets.token_urlsafe(32)
155
+ secret_path.write_text(secret, encoding="utf-8")
156
+ secret_path.chmod(0o600)
157
+ return secret
158
+
159
+
160
+ def list_profiles(profiles_dir: Path) -> list[str]:
161
+ private_directory(profiles_dir)
162
+ return ["default", *sorted(p.stem for p in profiles_dir.glob("*.yaml"))]
163
+
164
+
165
+ def load_profile_by_name(profiles_dir: Path, name: str) -> DeliverableProfile:
166
+ if name == "default":
167
+ return default_profile()
168
+ return load_profile(_profile_path(profiles_dir, name))
169
+
170
+
171
+ def _files_for_mode(
172
+ mode: QCRunMode, files: dict[str, Path]
173
+ ) -> dict[str, Path]:
174
+ """Validate and select only file roles used by one QC mode."""
175
+ if mode is QCRunMode.CYCLE_COMPARISON:
176
+ has_excel = {"baseline_excel", "current_excel"} <= files.keys()
177
+ has_ppt = {"baseline_ppt", "current_ppt"} <= files.keys()
178
+ if not has_excel and not has_ppt:
179
+ raise ValueError(
180
+ "Upload a baseline and current Excel pair and/or PowerPoint pair"
181
+ )
182
+ return {
183
+ role: path
184
+ for role, path in files.items()
185
+ if (has_excel and "excel" in role) or (has_ppt and "ppt" in role)
186
+ }
187
+ if mode is QCRunMode.CURRENT_FILE_PREFLIGHT:
188
+ selected = {
189
+ role: path
190
+ for role, path in files.items()
191
+ if role in {"current_excel", "current_ppt"}
192
+ }
193
+ if not selected:
194
+ raise ValueError("Upload a current Excel workbook and/or PowerPoint deck")
195
+ return selected
196
+ required = {"current_excel", "current_ppt"}
197
+ if not required <= files.keys():
198
+ raise ValueError("Upload both current Excel and current PowerPoint files")
199
+ return {role: files[role] for role in sorted(required)}
200
+
201
+
202
+ def persist_confirmed_mapping(
203
+ profiles_dir: Path,
204
+ profile_name: str,
205
+ suggestion: MappingSuggestion,
206
+ candidate: SuggestedSource,
207
+ ) -> CrosscheckMapping:
208
+ """Save one analyst-confirmed suggestion into a named profile."""
209
+ if profile_name == "default":
210
+ raise ValueError("create or select a named profile before confirming mappings")
211
+ profile = load_profile_by_name(profiles_dir, profile_name)
212
+ mapping = CrosscheckMapping(
213
+ slide=suggestion.slide,
214
+ line_skeleton=suggestion.line_skeleton,
215
+ figure_index=suggestion.figure_index,
216
+ label=(
217
+ " ".join(
218
+ part for part in (candidate.row_label, candidate.column_label) if part
219
+ )
220
+ or suggestion.line_skeleton
221
+ ),
222
+ source_sheet=candidate.sheet,
223
+ source_cell=candidate.cell,
224
+ )
225
+ identity = (mapping.slide, mapping.line_skeleton, mapping.figure_index)
226
+ profile.crosscheck.mappings = [
227
+ existing
228
+ for existing in profile.crosscheck.mappings
229
+ if (existing.slide, existing.line_skeleton, existing.figure_index) != identity
230
+ ]
231
+ profile.crosscheck.mappings.append(mapping)
232
+ save_profile(profile, _profile_path(profiles_dir, profile_name))
233
+ return mapping
234
+
235
+
236
+ def perform_run(
237
+ work_dir: Path,
238
+ files: dict[str, Path],
239
+ passwords: dict[str, str],
240
+ profile: DeliverableProfile,
241
+ *,
242
+ mode: QCRunMode = QCRunMode.CYCLE_COMPARISON,
243
+ rerun_of: int | None = None,
244
+ allow_large_workbooks: bool = False,
245
+ cancellation_token: CancellationToken | None = None,
246
+ on_progress: ProgressCallback | None = None,
247
+ ) -> RunArtifacts:
248
+ """Run QC, write both reports, and record the run in history."""
249
+ password_by_file = {
250
+ files[role].name: password
251
+ for role, password in passwords.items()
252
+ if role in files and password
253
+ }
254
+ result = run_qc(
255
+ baseline_excel=files.get("baseline_excel"),
256
+ current_excel=files.get("current_excel"),
257
+ baseline_ppt=files.get("baseline_ppt"),
258
+ current_ppt=files.get("current_ppt"),
259
+ profile=profile,
260
+ passwords=password_by_file,
261
+ mode=mode,
262
+ allow_large_workbooks=allow_large_workbooks,
263
+ cancellation_token=cancellation_token,
264
+ on_progress=on_progress,
265
+ )
266
+ check_cancelled(cancellation_token)
267
+ runs_dir = private_directory(work_dir / "runs")
268
+ stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%S.%fZ-")
269
+ run_dir = Path(tempfile.mkdtemp(prefix=stamp, dir=runs_dir))
270
+ private_directory(run_dir)
271
+ report_paths = {
272
+ "excel": run_dir / "qc_report.xlsx",
273
+ "html": run_dir / "qc_report.html",
274
+ }
275
+ recorded = False
276
+ try:
277
+ report_progress(on_progress, RunPhase.WRITING_REPORTS, total=2)
278
+ write_excel_report(result, report_paths["excel"])
279
+ check_cancelled(cancellation_token)
280
+ report_progress(
281
+ on_progress,
282
+ RunPhase.WRITING_REPORTS,
283
+ processed=1,
284
+ total=2,
285
+ )
286
+ write_html_report(result, report_paths["html"])
287
+ check_cancelled(cancellation_token)
288
+ report_progress(
289
+ on_progress,
290
+ RunPhase.WRITING_REPORTS,
291
+ processed=2,
292
+ total=2,
293
+ )
294
+
295
+ report_progress(on_progress, RunPhase.RECORDING_HISTORY, total=1)
296
+ history = RunHistory(work_dir / "history.sqlite3")
297
+ delta: FindingsDelta | None = None
298
+ if rerun_of is not None:
299
+ try:
300
+ previous = history.get_run(rerun_of)
301
+ delta = compare_findings(previous.findings, result.findings)
302
+ except KeyError:
303
+ logger.warning("re-QC referenced missing run %s", rerun_of)
304
+ rerun_of = None
305
+ file_hashes: dict[str, str] = {}
306
+ for role, path in files.items():
307
+ check_cancelled(cancellation_token)
308
+ file_hashes[role] = sha256_file(path)
309
+ check_cancelled(cancellation_token)
310
+ run_id = history.record_run(
311
+ result,
312
+ file_hashes=file_hashes,
313
+ report_paths={kind: str(path) for kind, path in report_paths.items()},
314
+ file_paths={role: str(path) for role, path in files.items()},
315
+ rerun_of=rerun_of,
316
+ )
317
+ recorded = True
318
+ report_progress(
319
+ on_progress,
320
+ RunPhase.RECORDING_HISTORY,
321
+ processed=1,
322
+ total=1,
323
+ )
324
+ report_progress(on_progress, RunPhase.COMPLETE, processed=1, total=1)
325
+ except BaseException:
326
+ if not recorded:
327
+ shutil.rmtree(run_dir, ignore_errors=True)
328
+ raise
329
+ return RunArtifacts(
330
+ run_id=run_id,
331
+ result=result,
332
+ report_paths=report_paths,
333
+ rerun_of=rerun_of,
334
+ delta=delta,
335
+ )
336
+
337
+
338
+ # --- pages -------------------------------------------------------------------
339
+
340
+
341
+ def _findings_rows(result: QCRunResult) -> list[dict[str, object]]:
342
+ return [
343
+ {
344
+ "id": f.finding_id,
345
+ "severity": (f.severity or Severity.WARNING).value,
346
+ "class": f.finding_class.value,
347
+ "where": f.sheet or f.slide or "",
348
+ "location": f.location or f.baseline_location or "",
349
+ "message": f.message,
350
+ "baseline": f.baseline_value or "",
351
+ "current": f.current_value or "",
352
+ "element": f.element or "",
353
+ "impacts": "; ".join(f.impacts),
354
+ "artifact": f.artifact,
355
+ "comment": f.analyst_comment,
356
+ "overridden": f.severity_overridden,
357
+ "root": f.root_cause_key,
358
+ "waiver": (
359
+ f"{f.waiver_reason} (expires {f.waiver_expires})"
360
+ if f.waiver_reason
361
+ else ""
362
+ ),
363
+ "bx": f.baseline_excerpt.model_dump() if f.baseline_excerpt else None,
364
+ "cx": f.current_excerpt.model_dump() if f.current_excerpt else None,
365
+ }
366
+ for f in result.findings
367
+ ]
368
+
369
+
370
+ _FINDINGS_COLUMNS = [
371
+ {"name": "expand", "label": "", "field": "expand"},
372
+ {"name": "id", "label": "ID", "field": "id", "sortable": True, "classes": "mono"},
373
+ {"name": "severity", "label": "Severity", "field": "severity", "sortable": True},
374
+ {"name": "class", "label": "Class", "field": "class", "sortable": True, "classes": "mono"},
375
+ {"name": "where", "label": "Sheet / Slide", "field": "where", "sortable": True},
376
+ {"name": "location", "label": "Location", "field": "location", "classes": "mono"},
377
+ {"name": "message", "label": "Message", "field": "message", "align": "left"},
378
+ ]
379
+
380
+
381
+ def _render_result_view(
382
+ result: QCRunResult,
383
+ report_paths: dict[str, Path],
384
+ heading: str,
385
+ *,
386
+ delta: FindingsDelta | None = None,
387
+ rerun_of: int | None = None,
388
+ history: RunHistory | None = None,
389
+ run_id: int | None = None,
390
+ profiles_dir: Path | None = None,
391
+ ) -> None:
392
+ """Shared results renderer: stat strip, disclosures, exports, table."""
393
+ all_rows = _findings_rows(result)
394
+ findings_by_id = {f.finding_id: f for f in result.findings}
395
+ section(heading)
396
+ stats_box = ui.element("div").classes("statstrip")
397
+
398
+ def render_stats() -> None:
399
+ stats_box.clear()
400
+ with stats_box:
401
+ for severity, count in result.counts.items():
402
+ with ui.column().classes(f"stat stat-{severity.value}"):
403
+ ui.label(str(count)).classes("n")
404
+ ui.label(severity.value).classes("l")
405
+ with ui.column().classes("stat"):
406
+ ui.label(str(result.verified_crosschecks)).classes("n")
407
+ ui.label("cross-checks ok").classes("l")
408
+
409
+ render_stats()
410
+ if delta is not None and rerun_of is not None:
411
+ with ui.element("div").classes("deltaline"):
412
+ ui.html(
413
+ f"vs run #{rerun_of} — "
414
+ f'<span class="good">{delta.resolved} resolved</span> · '
415
+ f'<span class="bad">{delta.new} new</span> · '
416
+ f"{delta.persisting} persisting"
417
+ )
418
+ for disclosure in result.disclosures:
419
+ ui.label(disclosure).classes("notecard")
420
+
421
+ section("Check coverage")
422
+ ui.link("Coverage guide →", "/guide#coverage").classes("guide-jump")
423
+ coverage_rows = [
424
+ {
425
+ "artifact": item.artifact,
426
+ "check": item.label,
427
+ "status": item.state.value,
428
+ "findings": item.findings,
429
+ "detail": item.detail,
430
+ }
431
+ for item in result.coverage
432
+ ]
433
+ ui.table(
434
+ columns=[
435
+ {"name": "artifact", "label": "Artifact", "field": "artifact"},
436
+ {"name": "check", "label": "Check", "field": "check"},
437
+ {"name": "status", "label": "Status", "field": "status"},
438
+ {"name": "findings", "label": "Findings", "field": "findings"},
439
+ {"name": "detail", "label": "Detail", "field": "detail"},
440
+ ],
441
+ rows=coverage_rows,
442
+ row_key="check",
443
+ pagination=20,
444
+ ).classes("coverage-table").props("flat dense hide-bottom")
445
+
446
+ if result.mapping_coverage is not None:
447
+ section("Mapping review")
448
+ ui.link("Mapping guide →", "/guide#mappings").classes("guide-jump")
449
+ mapping_stats = ui.element("div").classes("mappingstats")
450
+
451
+ def render_mapping_stats() -> None:
452
+ mapping_stats.clear()
453
+ mapping = result.mapping_coverage
454
+ if mapping is None:
455
+ return
456
+ with mapping_stats:
457
+ for label, value in (
458
+ ("eligible", mapping.eligible),
459
+ ("mapped", mapping.mapped),
460
+ ("verified", mapping.verified),
461
+ ("mismatched", mapping.mismatched),
462
+ ("unresolved", mapping.unresolved),
463
+ ("unmapped", mapping.unmapped),
464
+ ):
465
+ with ui.column().classes("mappingstat"):
466
+ ui.label(str(value)).classes("n")
467
+ ui.label(label).classes("l")
468
+
469
+ render_mapping_stats()
470
+ if result.profile_name == "default":
471
+ ui.label("Named profile required to save confirmed mappings.").classes(
472
+ "notecard"
473
+ )
474
+ suggestions_box = ui.column().classes("w-full gap-2")
475
+
476
+ def refresh_mapping_coverage_detail() -> None:
477
+ mapping = result.mapping_coverage
478
+ if mapping is None:
479
+ return
480
+ item = next(
481
+ (
482
+ entry
483
+ for entry in result.coverage
484
+ if entry.check_id == "excel-ppt-crosscheck"
485
+ ),
486
+ None,
487
+ )
488
+ if item is not None:
489
+ item.detail = (
490
+ f"{mapping.eligible} eligible; {mapping.mapped} mapped; "
491
+ f"{mapping.verified} verified; {mapping.mismatched} mismatched; "
492
+ f"{mapping.unresolved} unresolved; {mapping.unmapped} unmapped"
493
+ )
494
+
495
+ def render_suggestions() -> None:
496
+ suggestions_box.clear()
497
+ with suggestions_box:
498
+ if not result.mapping_suggestions:
499
+ ui.label("All eligible figures are mapped.").classes("lede")
500
+ return
501
+ for suggestion in result.mapping_suggestions:
502
+ title = (
503
+ f"{suggestion.slide} · {suggestion.figure_raw} · "
504
+ f"{suggestion.line_skeleton}"
505
+ )
506
+ with ui.expansion(title).classes("mappingitem"):
507
+ ui.label(suggestion.line).classes("mappingcontext")
508
+ if not suggestion.candidates:
509
+ ui.label("No source candidate found.").classes("lede")
510
+ continue
511
+ for candidate in suggestion.candidates:
512
+ with ui.row().classes("candidate-row"):
513
+ ui.label(f"{candidate.sheet}!{candidate.cell}").classes(
514
+ "candidate-ref"
515
+ )
516
+ ui.label(str(candidate.value)).classes("candidate-value")
517
+ ui.label(
518
+ "display match"
519
+ if candidate.display_match
520
+ else "near match"
521
+ ).classes(
522
+ "candidate-match"
523
+ if candidate.display_match
524
+ else "candidate-near"
525
+ )
526
+
527
+ def confirm_handler(
528
+ suggestion: MappingSuggestion = suggestion,
529
+ candidate: SuggestedSource = candidate,
530
+ ):
531
+ def confirm() -> None:
532
+ if profiles_dir is None:
533
+ ui.notify(
534
+ "Profile storage is unavailable",
535
+ type="negative",
536
+ )
537
+ return
538
+ try:
539
+ persist_confirmed_mapping(
540
+ profiles_dir,
541
+ result.profile_name,
542
+ suggestion,
543
+ candidate,
544
+ )
545
+ except Exception as exc:
546
+ ui.notify(str(exc), type="warning")
547
+ return
548
+ key = (
549
+ suggestion.slide,
550
+ suggestion.line_skeleton,
551
+ suggestion.figure_index,
552
+ )
553
+ result.mapping_suggestions = [
554
+ item
555
+ for item in result.mapping_suggestions
556
+ if (
557
+ item.slide,
558
+ item.line_skeleton,
559
+ item.figure_index,
560
+ )
561
+ != key
562
+ ]
563
+ mapping = result.mapping_coverage
564
+ if mapping is not None:
565
+ mapping.mapped += 1
566
+ mapping.unmapped = max(0, mapping.unmapped - 1)
567
+ if candidate.display_match:
568
+ mapping.verified += 1
569
+ else:
570
+ mapping.mismatched += 1
571
+ result.verified_crosschecks = mapping.verified
572
+ refresh_mapping_coverage_detail()
573
+ if history is not None and run_id is not None:
574
+ history.update_mapping_review(
575
+ run_id,
576
+ coverage=mapping,
577
+ suggestions=result.mapping_suggestions,
578
+ check_coverage=result.coverage,
579
+ )
580
+ render_stats()
581
+ render_mapping_stats()
582
+ render_suggestions()
583
+ ui.notify(
584
+ f"Mapped {suggestion.figure_raw} to "
585
+ f"{candidate.sheet}!{candidate.cell}"
586
+ )
587
+
588
+ return confirm
589
+
590
+ button = ui.button(
591
+ "Confirm", on_click=confirm_handler()
592
+ ).classes("ghostbtn").props("no-caps flat dense")
593
+ if result.profile_name == "default":
594
+ button.disable()
595
+
596
+ render_suggestions()
597
+
598
+ with ui.row().classes("items-center gap-3 mt-3 w-full"):
599
+ exports = (
600
+ ("excel", "Export Excel (.xlsx)", write_excel_report),
601
+ ("html", "Export HTML", write_html_report),
602
+ )
603
+ for kind, label, writer in exports:
604
+ path = report_paths.get(kind)
605
+ if path is None:
606
+ continue
607
+
608
+ def export(writer=writer, path=path) -> None:
609
+ # Regenerate from the current (annotated) state before download.
610
+ writer(result, path)
611
+ ui.download(str(path))
612
+
613
+ ui.button(label, on_click=export).classes("ghostbtn").props("no-caps flat")
614
+ ui.label("exports are for sharing — findings are fully viewable below").classes(
615
+ "text-xs text-gray-500"
616
+ )
617
+ severity_filter = (
618
+ ui.select(
619
+ [s.value for s in Severity],
620
+ value=[s.value for s in Severity if s is not Severity.EXPECTED],
621
+ multiple=True,
622
+ label="Show severities",
623
+ )
624
+ .classes("w-72 ml-auto")
625
+ .props("outlined dense use-chips")
626
+ )
627
+
628
+ visible = set(severity_filter.value or [])
629
+ table = (
630
+ ui.table(
631
+ columns=_FINDINGS_COLUMNS,
632
+ rows=[r for r in all_rows if r["severity"] in visible],
633
+ row_key="id",
634
+ pagination=25,
635
+ )
636
+ .classes("findings-table")
637
+ .props("flat dense")
638
+ )
639
+ table.add_slot("body", FINDINGS_BODY_SLOT)
640
+
641
+ def refilter() -> None:
642
+ selected = set(severity_filter.value or [])
643
+ table.rows = [r for r in all_rows if r["severity"] in selected]
644
+
645
+ severity_filter.on_value_change(refilter)
646
+
647
+ def on_severity(e: events.GenericEventArguments) -> None:
648
+ finding_id, value = e.args["id"], e.args["value"]
649
+ finding = findings_by_id.get(finding_id)
650
+ if finding is None:
651
+ return
652
+ finding.severity = Severity(value)
653
+ finding.severity_overridden = True
654
+ for row in all_rows:
655
+ if row["id"] == finding_id:
656
+ row["severity"] = value
657
+ row["overridden"] = True
658
+ if history is not None and run_id is not None:
659
+ history.set_annotation(
660
+ run_id, finding_id, severity=value, comment=finding.analyst_comment
661
+ )
662
+ render_stats()
663
+ ui.notify(f"{finding_id}: severity set to {value} (analyst override)")
664
+
665
+ def on_note(e: events.GenericEventArguments) -> None:
666
+ finding_id, value = e.args["id"], str(e.args["value"] or "")
667
+ finding = findings_by_id.get(finding_id)
668
+ if finding is None or finding.analyst_comment == value:
669
+ return
670
+ finding.analyst_comment = value
671
+ for row in all_rows:
672
+ if row["id"] == finding_id:
673
+ row["comment"] = value
674
+ if history is not None and run_id is not None:
675
+ history.set_annotation(
676
+ run_id,
677
+ finding_id,
678
+ severity=finding.severity.value
679
+ if finding.severity_overridden and finding.severity
680
+ else None,
681
+ comment=value,
682
+ )
683
+ ui.notify(f"{finding_id}: comment saved")
684
+
685
+ table.on("sev", on_severity)
686
+ table.on("note", on_note)
687
+
688
+
689
+ def _render_results(container: ui.element, artifacts: RunArtifacts, work_dir: Path) -> None:
690
+ container.clear()
691
+ with container:
692
+ _render_result_view(
693
+ artifacts.result,
694
+ artifacts.report_paths,
695
+ f"4 · Results — run #{artifacts.run_id}",
696
+ delta=artifacts.delta,
697
+ rerun_of=artifacts.rerun_of,
698
+ history=RunHistory(work_dir / "history.sqlite3"),
699
+ run_id=artifacts.run_id,
700
+ profiles_dir=work_dir / "profiles",
701
+ )
702
+ # Results render below the upload/config sections; bring them into view.
703
+ ui.run_javascript(
704
+ f"document.getElementById('{container.html_id}')"
705
+ "?.scrollIntoView({behavior: 'smooth', block: 'start'})"
706
+ )
707
+
708
+
709
+ def create_pages(
710
+ work_dir: Path,
711
+ *,
712
+ network_mode: NetworkMode = NetworkMode.LOCAL,
713
+ expires_at: dt.datetime | None = None,
714
+ ) -> None:
715
+ secure_managed_tree(work_dir)
716
+ uploads_dir = work_dir / "uploads"
717
+ profiles_dir = work_dir / "profiles"
718
+ private_directory(uploads_dir)
719
+ private_directory(profiles_dir)
720
+
721
+ @ui.page("/")
722
+ def main_page(rerun: int | None = None) -> None: # pyright: ignore[reportUnusedFunction]
723
+ state = SessionState()
724
+ file_states: dict[str, ui.label] = {}
725
+
726
+ rerun_record = None
727
+ if rerun is not None:
728
+ history = RunHistory(work_dir / "history.sqlite3")
729
+ try:
730
+ rerun_record = history.get_run(rerun)
731
+ state.rerun_of = rerun
732
+ state.mode = rerun_record.mode
733
+ except KeyError:
734
+ rerun_record = None
735
+
736
+ with page_frame(
737
+ "run",
738
+ network_mode=network_mode.value,
739
+ expires_at=expires_at.isoformat(timespec="seconds") if expires_at else None,
740
+ ):
741
+ ui.label(
742
+ "Run intrinsic current-file checks, compare reporting cycles, "
743
+ "or reconcile the final Excel and PowerPoint package. Files "
744
+ "are processed locally and never modified."
745
+ ).classes("lede")
746
+ rerun_banner_actions: ui.row | None = None
747
+ if rerun_record is not None:
748
+ with ui.element("div").classes("rerunbanner"), ui.row().classes(
749
+ "items-center gap-3 w-full no-wrap"
750
+ ):
751
+ ui.label(
752
+ f"Re-QC of run #{rerun_record.run_id} — files are"
753
+ " re-selected only after verifying they still match that"
754
+ " run. Anything missing or changed (e.g. renamed) must be"
755
+ " selected again below."
756
+ ).classes("flex-1")
757
+ rerun_banner_actions = ui.row().classes("no-wrap")
758
+
759
+ section("1 · QC mode")
760
+ ui.link("How to choose a mode →", "/guide#modes").classes("guide-jump")
761
+ mode_copy = ui.label().classes("modecopy")
762
+ upload_boxes: dict[str, ui.column] = {}
763
+
764
+ def update_mode_surface() -> None:
765
+ descriptions = {
766
+ QCRunMode.CURRENT_FILE_PREFLIGHT: (
767
+ "Intrinsic integrity checks on the latest Excel workbook "
768
+ "and/or PowerPoint deck."
769
+ ),
770
+ QCRunMode.CYCLE_COMPARISON: (
771
+ "Baseline-versus-current checks with expected cadence growth."
772
+ ),
773
+ QCRunMode.FINAL_PACKAGE: (
774
+ "Current Excel and PowerPoint preflight plus figure reconciliation."
775
+ ),
776
+ }
777
+ mode_copy.text = descriptions[state.mode]
778
+ for role, box in upload_boxes.items():
779
+ box.visible = (
780
+ state.mode is QCRunMode.CYCLE_COMPARISON
781
+ or role in {"current_excel", "current_ppt"}
782
+ )
783
+
784
+ def on_mode_change(e: events.ValueChangeEventArguments) -> None:
785
+ state.mode = QCRunMode(e.value)
786
+ update_mode_surface()
787
+
788
+ mode_select = ui.toggle(
789
+ {mode.value: label for mode, label in MODE_LABELS.items()},
790
+ value=state.mode.value,
791
+ on_change=on_mode_change,
792
+ ).classes("mode-select").props("no-caps spread")
793
+ if rerun_record is not None:
794
+ mode_select.disable()
795
+ update_mode_surface()
796
+
797
+ section("2 · Files")
798
+ with ui.element("div").classes("upgrid"):
799
+ for role in ROLES:
800
+
801
+ async def handle_upload(
802
+ e: events.UploadEventArguments, role: str = role
803
+ ) -> None:
804
+ try:
805
+ filename = _safe_upload_name(e.file.name)
806
+ except ValueError as exc:
807
+ ui.notify(str(exc), type="negative")
808
+ return
809
+ target = uploads_dir / role / filename
810
+ private_directory(target.parent)
811
+ await e.file.save(target)
812
+ private_file(target)
813
+ state.files[role] = target
814
+ size_kb = max(1, e.file.size() // 1024)
815
+ file_states[role].text = f"{e.file.name} · {size_kb:,} KB"
816
+ file_states[role].classes(add="ok", remove="err")
817
+
818
+ with ui.column().classes("gap-0") as upload_box:
819
+ upload_boxes[role] = upload_box
820
+ ui.label(ROLE_LABELS[role]).classes("rolelabel")
821
+ ui.upload(
822
+ label=ROLE_ACCEPT[role],
823
+ auto_upload=True,
824
+ max_file_size=MAX_UPLOAD_BYTES,
825
+ on_upload=handle_upload,
826
+ ).props(f'accept="{ROLE_ACCEPT[role]}" flat')
827
+ file_states[role] = ui.label("no file selected").classes(
828
+ "filestate"
829
+ )
830
+ update_mode_surface()
831
+
832
+ if rerun_record is not None:
833
+ state.rerun_required = frozenset(rerun_record.file_paths)
834
+ for role, stored in rerun_record.file_paths.items():
835
+ if role not in file_states:
836
+ continue
837
+ stored_path = Path(stored)
838
+ display_name = rerun_record.files.get(role, stored_path.name)
839
+ if not stored_path.exists():
840
+ file_states[role].text = (
841
+ f"file not found: {display_name} — select the file again"
842
+ )
843
+ file_states[role].classes(add="err")
844
+ continue
845
+ expected_hash = rerun_record.file_hashes.get(role)
846
+ if expected_hash and sha256_file(stored_path) != expected_hash:
847
+ file_states[role].text = (
848
+ f"{stored_path.name} changed since run "
849
+ f"#{rerun_record.run_id} — select the file again"
850
+ )
851
+ file_states[role].classes(add="err")
852
+ continue
853
+ state.files[role] = stored_path
854
+ file_states[role].text = (
855
+ f"{stored_path.name} · verified, reused from run "
856
+ f"#{rerun_record.run_id}"
857
+ )
858
+ file_states[role].classes(add="ok")
859
+
860
+ with (
861
+ ui.expansion("Passwords — only needed for encrypted files").classes(
862
+ "w-full mt-4"
863
+ ),
864
+ ui.row().classes("gap-4 flex-wrap p-2"),
865
+ ):
866
+ for role in ROLES:
867
+ ui.input(
868
+ ROLE_LABELS[role],
869
+ password=True,
870
+ password_toggle_button=True,
871
+ on_change=lambda e, role=role: state.passwords.__setitem__(
872
+ role, e.value or ""
873
+ ),
874
+ ).classes("w-72").props("outlined dense")
875
+
876
+ section("3 · Configuration")
877
+ ui.link("Profiles and controls guide →", "/guide#profiles").classes(
878
+ "guide-jump"
879
+ )
880
+ with ui.row().classes("items-end gap-3 w-full"):
881
+ profile_options = list_profiles(profiles_dir)
882
+ initial_profile = "default"
883
+ if rerun_record is not None and rerun_record.profile in profile_options:
884
+ initial_profile = rerun_record.profile
885
+ state.profile_name = initial_profile
886
+ profile_select = (
887
+ ui.select(
888
+ profile_options,
889
+ value=initial_profile,
890
+ label="Deliverable profile",
891
+ on_change=lambda e: setattr(state, "profile_name", e.value),
892
+ )
893
+ .classes("w-64")
894
+ .props("outlined dense")
895
+ )
896
+
897
+ async def edit_profile() -> None:
898
+ name = state.profile_name
899
+ if name == "default":
900
+ ui.notify("Create a named profile first", type="warning")
901
+ return
902
+ path = _profile_path(profiles_dir, name)
903
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
904
+ with ui.dialog() as dialog, ui.card().classes("w-[42rem]"):
905
+ ui.label(f"Profile: {name}").classes("font-bold")
906
+ editor = (
907
+ ui.textarea(value=content)
908
+ .classes("w-full")
909
+ .style("font-family: var(--font-mono); font-size: 0.8rem")
910
+ .props("rows=20 outlined")
911
+ )
912
+
913
+ def save() -> None:
914
+ try:
915
+ profile = DeliverableProfile.model_validate(
916
+ yaml.safe_load(editor.value or "") or {}
917
+ )
918
+ except Exception as exc: # surfaced to the analyst
919
+ ui.notify(f"Invalid profile: {exc}", type="negative")
920
+ return
921
+ save_profile(profile, path)
922
+ ui.notify("Profile saved")
923
+ dialog.close()
924
+
925
+ with ui.row():
926
+ ui.button("Save", on_click=save).classes("runbtn").props(
927
+ "no-caps"
928
+ )
929
+ ui.button("Cancel", on_click=dialog.close).props(
930
+ "flat no-caps"
931
+ )
932
+ dialog.open()
933
+
934
+ ui.button("Edit profile", on_click=edit_profile).classes(
935
+ "ghostbtn"
936
+ ).props("no-caps flat")
937
+
938
+ new_profile_name = (
939
+ ui.input("New profile name").classes("w-48").props("outlined dense")
940
+ )
941
+
942
+ def create_profile() -> None:
943
+ name = (new_profile_name.value or "").strip()
944
+ if not name or name == "default":
945
+ ui.notify("Choose a non-empty, non-default name", type="warning")
946
+ return
947
+ try:
948
+ path = _profile_path(profiles_dir, name)
949
+ except ValueError as exc:
950
+ ui.notify(str(exc), type="warning")
951
+ return
952
+ save_profile(DeliverableProfile(name=name), path)
953
+ profile_select.options = list_profiles(profiles_dir)
954
+ profile_select.value = name
955
+ profile_select.update()
956
+ ui.notify(f"Profile {name!r} created")
957
+
958
+ ui.button("Create", on_click=create_profile).classes("ghostbtn").props(
959
+ "no-caps flat"
960
+ )
961
+
962
+ spinner = ui.spinner(size="1.6rem").classes("ml-auto")
963
+ spinner.visible = False
964
+
965
+ ui.checkbox(
966
+ "Override large-workbook refusal",
967
+ value=False,
968
+ on_change=lambda e: setattr(
969
+ state,
970
+ "allow_large_workbooks",
971
+ bool(e.value),
972
+ ),
973
+ ).tooltip(
974
+ "Process workbooks above local safety limits; workload coverage is degraded"
975
+ )
976
+
977
+ progress_label = ui.label("").classes("text-sm min-w-52")
978
+ progress_label.visible = False
979
+ progress_lock = threading.Lock()
980
+ latest_progress: dict[str, ProgressEvent | None] = {"event": None}
981
+ active_token: dict[str, CancellationToken | None] = {"token": None}
982
+
983
+ def cancel_active_run() -> None:
984
+ token = active_token["token"]
985
+ if token is None:
986
+ return
987
+ token.cancel()
988
+ cancel_button.disable()
989
+ progress_label.set_text("Cancelling at the next safe boundary")
990
+
991
+ cancel_button = ui.button(
992
+ "Cancel",
993
+ icon="stop_circle",
994
+ on_click=cancel_active_run,
995
+ ).classes("ghostbtn").props("flat no-caps")
996
+ cancel_button.visible = False
997
+
998
+ def refresh_progress() -> None:
999
+ with progress_lock:
1000
+ event = latest_progress["event"]
1001
+ if event is None or active_token["token"] is None:
1002
+ return
1003
+ label = PHASE_LABELS[event.phase]
1004
+ counts = (
1005
+ f" ({event.processed}/{event.total})"
1006
+ if event.total
1007
+ else ""
1008
+ )
1009
+ detail = f" · {event.detail}" if event.detail else ""
1010
+ progress_label.set_text(f"{label}{counts}{detail}")
1011
+
1012
+ ui.timer(0.25, refresh_progress)
1013
+
1014
+ async def start_run() -> None:
1015
+ if state.rerun_of is not None:
1016
+ missing = state.rerun_required - state.files.keys()
1017
+ if missing:
1018
+ names = ", ".join(
1019
+ ROLE_LABELS[role] for role in sorted(missing)
1020
+ )
1021
+ ui.notify(
1022
+ f"Re-QC of run #{state.rerun_of} is blocked — "
1023
+ f"select these files again: {names}",
1024
+ type="negative",
1025
+ )
1026
+ return
1027
+ try:
1028
+ files = _files_for_mode(state.mode, state.files)
1029
+ except ValueError as exc:
1030
+ ui.notify(str(exc), type="warning")
1031
+ return
1032
+ try:
1033
+ profile = load_profile_by_name(profiles_dir, state.profile_name)
1034
+ except Exception as exc:
1035
+ ui.notify(f"Profile failed to load: {exc}", type="negative")
1036
+ return
1037
+ spinner.visible = True
1038
+ run_button.disable()
1039
+ cancellation_token = CancellationToken()
1040
+ active_token["token"] = cancellation_token
1041
+ with progress_lock:
1042
+ latest_progress["event"] = None
1043
+ progress_label.set_text("Preparing run")
1044
+ progress_label.visible = True
1045
+ cancel_button.enable()
1046
+ cancel_button.visible = True
1047
+
1048
+ def receive_progress(event: ProgressEvent) -> None:
1049
+ with progress_lock:
1050
+ latest_progress["event"] = event
1051
+
1052
+ try:
1053
+ artifacts = await run.io_bound(
1054
+ perform_run,
1055
+ work_dir,
1056
+ files,
1057
+ dict(state.passwords),
1058
+ profile,
1059
+ mode=state.mode,
1060
+ rerun_of=state.rerun_of,
1061
+ allow_large_workbooks=state.allow_large_workbooks,
1062
+ cancellation_token=cancellation_token,
1063
+ on_progress=receive_progress,
1064
+ )
1065
+ except RunCancelled:
1066
+ ui.notify(
1067
+ "Run cancelled; no successful run was recorded",
1068
+ type="warning",
1069
+ )
1070
+ return
1071
+ except PasswordRequiredError as exc:
1072
+ ui.notify(f"{exc} — set it under Passwords", type="negative")
1073
+ return
1074
+ except InvalidPasswordError as exc:
1075
+ ui.notify(str(exc), type="negative")
1076
+ return
1077
+ except Exception as exc: # analyst-facing failure, never a crash
1078
+ logger.exception("QC run failed")
1079
+ ui.notify(f"QC run failed: {exc}", type="negative")
1080
+ return
1081
+ finally:
1082
+ active_token["token"] = None
1083
+ spinner.visible = False
1084
+ run_button.enable()
1085
+ progress_label.visible = False
1086
+ cancel_button.visible = False
1087
+ if artifacts is None: # run.io_bound is typed Optional
1088
+ ui.notify("QC run returned no result", type="negative")
1089
+ return
1090
+ ui.notify(f"Run #{artifacts.run_id} complete")
1091
+ _render_results(results, artifacts, work_dir)
1092
+
1093
+ run_button = ui.button("Run QC", on_click=start_run).classes(
1094
+ "runbtn"
1095
+ ).props("no-caps")
1096
+
1097
+ if rerun_banner_actions is not None:
1098
+ with rerun_banner_actions:
1099
+ ui.button("Run QC now", on_click=start_run).classes("runbtn").props(
1100
+ "no-caps dense"
1101
+ )
1102
+
1103
+ results = ui.column().classes("w-full")
1104
+
1105
+ @ui.page("/guide")
1106
+ def guide_page() -> None: # pyright: ignore[reportUnusedFunction]
1107
+ with page_frame(
1108
+ "guide",
1109
+ network_mode=network_mode.value,
1110
+ expires_at=expires_at.isoformat(timespec="seconds") if expires_at else None,
1111
+ ):
1112
+ render_guide()
1113
+
1114
+ @ui.page("/history")
1115
+ def history_page() -> None: # pyright: ignore[reportUnusedFunction]
1116
+ with page_frame(
1117
+ "history",
1118
+ network_mode=network_mode.value,
1119
+ expires_at=expires_at.isoformat(timespec="seconds") if expires_at else None,
1120
+ ):
1121
+ section("Run history")
1122
+ history = RunHistory(work_dir / "history.sqlite3")
1123
+ runs = history.list_runs()
1124
+ if not runs:
1125
+ ui.label("No runs recorded yet.").classes("lede")
1126
+ return
1127
+ for record in runs:
1128
+ with ui.card().classes("runcard"):
1129
+ ui.label(
1130
+ f"#{record.run_id} — "
1131
+ f"{record.started_at.isoformat(timespec='seconds')}"
1132
+ f" — {MODE_LABELS[record.mode]} — profile {record.profile!r}"
1133
+ ).classes("runhead")
1134
+ ui.label(
1135
+ " | ".join(f"{r}: {n}" for r, n in record.files.items())
1136
+ ).classes("runmeta")
1137
+ with ui.element("div").classes("runcounts"):
1138
+ for kind, count in record.counts.items():
1139
+ with ui.row().classes("items-center gap-1 no-wrap"):
1140
+ ui.element("span").classes(f"sevdot sev-{kind}")
1141
+ ui.label(f"{kind} {count}")
1142
+ with ui.row().classes("gap-2 mt-1"):
1143
+ run_id = record.run_id
1144
+ ui.button(
1145
+ "View findings",
1146
+ on_click=lambda run_id=run_id: ui.navigate.to(
1147
+ f"/runs/{run_id}"
1148
+ ),
1149
+ ).classes("runbtn").props("no-caps dense")
1150
+ ui.button(
1151
+ "Re-QC",
1152
+ on_click=lambda run_id=run_id: ui.navigate.to(
1153
+ f"/?rerun={run_id}"
1154
+ ),
1155
+ ).classes("ghostbtn").props("no-caps flat dense")
1156
+ for kind, path in record.report_paths.items():
1157
+ if Path(path).exists():
1158
+ ui.button(
1159
+ f"Export {kind}",
1160
+ on_click=_download_handler(path),
1161
+ ).classes("ghostbtn").props("no-caps flat dense")
1162
+
1163
+ @ui.page("/runs/{run_id}")
1164
+ def run_detail_page(run_id: int) -> None: # pyright: ignore[reportUnusedFunction]
1165
+ with page_frame(
1166
+ "history",
1167
+ network_mode=network_mode.value,
1168
+ expires_at=expires_at.isoformat(timespec="seconds") if expires_at else None,
1169
+ ):
1170
+ history = RunHistory(work_dir / "history.sqlite3")
1171
+ try:
1172
+ record = history.get_run(run_id)
1173
+ except KeyError:
1174
+ section("Run not found")
1175
+ ui.label(
1176
+ f"No QC run with id {run_id} exists in the history store."
1177
+ ).classes("lede")
1178
+ return
1179
+ result = QCRunResult(
1180
+ profile_name=record.profile,
1181
+ mode=record.mode,
1182
+ files=record.files,
1183
+ findings=record.findings,
1184
+ disclosures=record.disclosures,
1185
+ coverage=record.coverage,
1186
+ mapping_coverage=record.mapping_coverage,
1187
+ mapping_suggestions=record.mapping_suggestions,
1188
+ verified_crosschecks=record.verified_crosschecks,
1189
+ )
1190
+ heading = (
1191
+ f"Run #{record.run_id} — "
1192
+ f"{record.started_at.isoformat(timespec='seconds')} — "
1193
+ f"{MODE_LABELS[record.mode]} — profile {record.profile!r}"
1194
+ )
1195
+ delta = None
1196
+ if record.rerun_of is not None:
1197
+ heading += f" — re-QC of #{record.rerun_of}"
1198
+ try:
1199
+ previous = history.get_run(record.rerun_of)
1200
+ delta = compare_findings(previous.findings, record.findings)
1201
+ except KeyError:
1202
+ delta = None
1203
+ report_paths = {kind: Path(path) for kind, path in record.report_paths.items()}
1204
+ _render_result_view(
1205
+ result,
1206
+ report_paths,
1207
+ heading,
1208
+ delta=delta,
1209
+ rerun_of=record.rerun_of,
1210
+ history=history,
1211
+ run_id=record.run_id,
1212
+ profiles_dir=work_dir / "profiles",
1213
+ )
1214
+ with ui.row().classes("items-center gap-3 mt-2"):
1215
+ ui.label(
1216
+ " | ".join(f"{r}: {n}" for r, n in record.files.items())
1217
+ ).classes("runmeta")
1218
+ ui.button(
1219
+ "Re-QC this run",
1220
+ on_click=lambda: ui.navigate.to(f"/?rerun={record.run_id}"),
1221
+ ).classes("ghostbtn").props("no-caps flat dense")
1222
+
1223
+
1224
+ def run_app(
1225
+ work_dir: Path,
1226
+ *,
1227
+ port: int = 8080,
1228
+ host: str = "127.0.0.1",
1229
+ network_mode: NetworkMode = NetworkMode.LOCAL,
1230
+ expires_at: dt.datetime | None = None,
1231
+ ) -> None:
1232
+ create_pages(work_dir, network_mode=network_mode, expires_at=expires_at)
1233
+ if network_mode is NetworkMode.LAN:
1234
+ if expires_at is None:
1235
+ raise ValueError("LAN mode requires an expiry timestamp")
1236
+
1237
+ async def expire_network_access() -> None:
1238
+ while True:
1239
+ remaining = (expires_at - dt.datetime.now(dt.UTC)).total_seconds()
1240
+ if remaining <= 0:
1241
+ save_server_config(work_dir, local_config())
1242
+ logger.warning("temporary LAN exposure expired; shutting down server")
1243
+ app.shutdown()
1244
+ return
1245
+ await asyncio.sleep(min(2.0, remaining))
1246
+ if not lan_config_matches(work_dir, expires_at):
1247
+ logger.warning(
1248
+ "LAN exposure config changed or expired; shutting down server"
1249
+ )
1250
+ app.shutdown()
1251
+ return
1252
+
1253
+ app.on_startup(lambda: asyncio.create_task(expire_network_access()))
1254
+ ui.run(
1255
+ title="QC Tool",
1256
+ host=host,
1257
+ port=port,
1258
+ reload=False,
1259
+ show=True,
1260
+ storage_secret=_storage_secret(work_dir),
1261
+ )