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.
- cadence_diff-0.1.0.dist-info/METADATA +245 -0
- cadence_diff-0.1.0.dist-info/RECORD +78 -0
- cadence_diff-0.1.0.dist-info/WHEEL +4 -0
- cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
- cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
- qc_tool/__init__.py +3 -0
- qc_tool/__main__.py +6 -0
- qc_tool/attestation.py +209 -0
- qc_tool/availability.py +273 -0
- qc_tool/cli.py +769 -0
- qc_tool/config/__init__.py +1 -0
- qc_tool/config/lint.py +480 -0
- qc_tool/config/profile.py +207 -0
- qc_tool/coverage.py +35 -0
- qc_tool/crosscheck/__init__.py +1 -0
- qc_tool/crosscheck/numbers.py +146 -0
- qc_tool/crosscheck/package.py +154 -0
- qc_tool/crosscheck/trace.py +374 -0
- qc_tool/engine.py +978 -0
- qc_tool/excel/__init__.py +1 -0
- qc_tool/excel/align.py +452 -0
- qc_tool/excel/charts.py +931 -0
- qc_tool/excel/context.py +106 -0
- qc_tool/excel/controls.py +266 -0
- qc_tool/excel/dependency.py +252 -0
- qc_tool/excel/diff_structure.py +326 -0
- qc_tool/excel/diff_values.py +327 -0
- qc_tool/excel/formulas.py +516 -0
- qc_tool/excel/interaction.py +548 -0
- qc_tool/excel/periods.py +117 -0
- qc_tool/excel/preflight.py +615 -0
- qc_tool/excel/references.py +436 -0
- qc_tool/excel/regions.py +201 -0
- qc_tool/findings.py +220 -0
- qc_tool/fingerprint.py +369 -0
- qc_tool/fingerprint.schema.json +15 -0
- qc_tool/history/__init__.py +1 -0
- qc_tool/history/store.py +287 -0
- qc_tool/io/__init__.py +1 -0
- qc_tool/io/decrypt.py +64 -0
- qc_tool/io/excel_formula.py +256 -0
- qc_tool/io/excel_formula_worker.py +280 -0
- qc_tool/io/formula_enrichment.py +95 -0
- qc_tool/io/libreoffice_formula.py +232 -0
- qc_tool/io/loader.py +973 -0
- qc_tool/io/model.py +356 -0
- qc_tool/io/ooxml_chart.py +530 -0
- qc_tool/io/ooxml_interaction.py +400 -0
- qc_tool/io/ooxml_worksheet.py +405 -0
- qc_tool/io/xlsb_formula.py +313 -0
- qc_tool/package-sanitize.schema.json +31 -0
- qc_tool/package_sanitize.py +255 -0
- qc_tool/ppt/__init__.py +1 -0
- qc_tool/ppt/chart_xml.py +422 -0
- qc_tool/ppt/diff.py +120 -0
- qc_tool/ppt/element_diff.py +878 -0
- qc_tool/ppt/element_match.py +317 -0
- qc_tool/ppt/extract.py +212 -0
- qc_tool/ppt/match.py +137 -0
- qc_tool/ppt/model.py +132 -0
- qc_tool/ppt/preflight.py +307 -0
- qc_tool/privacy.py +392 -0
- qc_tool/progress.py +78 -0
- qc_tool/report/__init__.py +1 -0
- qc_tool/report/excel_report.py +205 -0
- qc_tool/report/findings.schema.json +32 -0
- qc_tool/report/html_report.py +38 -0
- qc_tool/report/json_report.py +55 -0
- qc_tool/report/templates/report.html.j2 +117 -0
- qc_tool/sanitize.py +598 -0
- qc_tool/security.py +34 -0
- qc_tool/server_config.py +86 -0
- qc_tool/triage/__init__.py +1 -0
- qc_tool/triage/rules.py +211 -0
- qc_tool/ui/__init__.py +1 -0
- qc_tool/ui/app.py +1261 -0
- qc_tool/ui/guide.py +452 -0
- 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
|
+
)
|