* feat(system): read-only health check and truthful overview counts POST /prompt_manager/system/health runs the health check off-loop as a read registry job of kind "health" (one at a time, beside a scan, not cancellable, no file outcomes) and keeps the last report; GET returns it or null. The check reads one snapshot on its own mode=ro connection, closed in finally: integrity_check(100), foreign_key_check, stored hashes against utils.hashing, duplicate pair rows, the legacy consistency queries (now shared with check_consistency), and image links whose file is gone, counted only under readable sources. An unavailable source, or the absent Needs-review queue, makes its check unavailable and the report incomplete instead of producing a count. GET /prompt_manager/system/overview returns Library-eligible positive prompts and active negative variants, indexed images, the last health summary or null, backup null, the Needs-review count null, the effective sources with whether they were auto-detected, and the thumbnail plan's missing count. GET /prompt_manager/system/statistics lists runs and average rating per category of Library parents, with the /stats totals. * feat(system): native System route, drafts and shared review dialog - #/system/<section> renders the concept's System shell: section nav, settings layout and Save row with truthful status (D92); each section not delivered yet hands off to its legacy tools (D94). Overview's System card opens #/system/overview (Q12). - A module-level draft store saves browser, then host (expected_revision), then runtime; failed domains stay dirty, a stale revision is a conflict. - System row builders (setting, operation, section nav) on the scope. - openReviewDialog and backupNotice extracted from the scan review, which is now a thin caller with identical markup. - Overview images gain a "rated" count (images whose linked prompt is rated); the missing-files health check reports per source, so partial availability is visible. * feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. * test(system): coverage floors for the native Overview Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes. * fix(system): keep health reports per database and off the event loop - A restore that replaces the database forgets the stored health report, and a check that started before the swap is not kept (database generation stamped at start). - The Overview no longer walks every source for the missing-thumbnail count; the page reads /images/thumbnails/status on its own. - Image sources for the health check and the Overview resolve in the executor; a failure to start the check answers the coded envelope. - Any exception fails only its own check; missing files reads its rows in the snapshot and stats the files after it ended. - The read-only URI keeps Windows UNC paths (empty authority). - An integrity count at SQLite's limit is flagged as capped. * fix(system): thumbnail row read, tracked health job and guarded host save - The Overview's thumbnail row is its own read of the thumbnail status: "Checking thumbnails…" until it answers, unavailable when it fails. - The health job is tracked in the Activity store (new store.track) and followed through its event stream instead of polling every 150 ms. - A host save before a read returned a revision fails without a request; System reads the host values on mount and after a conflict. - "100+ problems found" when the integrity count is capped. - D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone. - The concept spec builds the health report from the concept's own rows and asserts the full unmapped path list; surface tops pin the 18px offset. - TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI and the new behavior. Coverage floors raised for the touched files. * fix(system): make two health tests portable to Windows - Windows forbids "?" in file names, so the URI-character open test uses "#", "%" and a space there. - ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of raising, so the bad-path test raises as POSIX does to reach the missing-files failure path on every platform.
208 lines
8.3 KiB
Python
208 lines
8.3 KiB
Python
"""System routes: the read-only health check, the Overview and usage statistics.
|
||
|
||
- ``POST /prompt_manager/system/health`` starts the health check
|
||
(``database/health.py``) off-loop as a ``read`` job of kind ``health``: one
|
||
at a time, beside a scan, not cancellable and with no file outcomes. The
|
||
API keeps the last report for this session; ``GET`` returns it or null.
|
||
A report describes the database it was taken from: replacing the database
|
||
(a restore) forgets it, and a check that started before the swap is not
|
||
kept.
|
||
- ``GET /prompt_manager/system/overview`` is the System › Overview aggregate.
|
||
Every value is real or null: there is no backup status until backups report
|
||
one, no health result until a check ran, and no Needs-review count until
|
||
the review queue exists. The missing-thumbnail count is not part of it:
|
||
the page reads ``/images/thumbnails/status``, which walks every source.
|
||
- ``GET /prompt_manager/system/statistics`` lists runs and ratings per
|
||
category, with the legacy ``/stats`` totals and the Overview's count of
|
||
rated images (images whose linked prompt is rated).
|
||
|
||
New routes answer errors with ``{success:false, code, error}``.
|
||
"""
|
||
|
||
import asyncio
|
||
import os
|
||
from collections import namedtuple
|
||
|
||
from aiohttp import web
|
||
|
||
from .job_routes import busy_response
|
||
from .jobs import HEALTH_JOB_KIND, JobBusy
|
||
|
||
HEALTH_JOB_TITLE = "Health check"
|
||
_HEALTH_STATUS_LINES = {
|
||
"passed": "Health check passed",
|
||
"issues": "Health check found issues",
|
||
"incomplete": "Health check incomplete",
|
||
}
|
||
|
||
|
||
# The last health report and the database generation it was taken from.
|
||
_StoredReport = namedtuple("_StoredReport", "generation report")
|
||
|
||
|
||
def _failure(message, status=500, code=None):
|
||
return web.json_response(
|
||
{"success": False, "code": code, "error": message}, status=status
|
||
)
|
||
|
||
|
||
def health_summary(report):
|
||
"""What the Overview shows of a report: when, the outcome, checks with issues."""
|
||
if report is None:
|
||
return None
|
||
return {
|
||
"checked_at": report["checked_at"],
|
||
"status": report["status"],
|
||
"issues": sum(1 for check in report["checks"] if check["status"] == "issues"),
|
||
}
|
||
|
||
|
||
class SystemRoutesMixin:
|
||
"""Mixin providing the /prompt_manager/system endpoints."""
|
||
|
||
def _register_system_routes(self, routes):
|
||
@routes.post("/prompt_manager/system/health")
|
||
async def start_health_check_route(request):
|
||
return await self.start_health_check(request)
|
||
|
||
@routes.get("/prompt_manager/system/health")
|
||
async def health_report_route(request):
|
||
return await self.get_health_report(request)
|
||
|
||
@routes.get("/prompt_manager/system/overview")
|
||
async def system_overview_route(request):
|
||
return await self.get_system_overview(request)
|
||
|
||
@routes.get("/prompt_manager/system/statistics")
|
||
async def system_statistics_route(request):
|
||
return await self.get_system_statistics(request)
|
||
|
||
# ── health ────────────────────────────────────────────────────────
|
||
|
||
def _health_roots(self):
|
||
"""The sources missing files are judged under.
|
||
|
||
The effective sources, plus every configured source that is not an
|
||
existing directory (an unmounted drive): the check then reports
|
||
missing files as unavailable instead of counting that source's links.
|
||
"""
|
||
from ..config import GalleryConfig
|
||
|
||
roots = [str(path) for path in self._get_all_output_dirs()]
|
||
for configured in GalleryConfig.MONITORING_DIRECTORIES:
|
||
try:
|
||
resolved = GalleryConfig.resolve_gallery_root(configured)
|
||
except (OSError, TypeError, ValueError):
|
||
continue
|
||
if not os.path.isdir(resolved):
|
||
roots.append(resolved)
|
||
return roots
|
||
|
||
def _database_replaced(self):
|
||
"""Forget what described the previous database (call after swapping ``db``)."""
|
||
self._db_generation += 1
|
||
self._health_report = None
|
||
|
||
def _current_health_report(self):
|
||
"""The last report, or None when none was kept for the current database."""
|
||
stored = self._health_report
|
||
generation = self._db_generation
|
||
if isinstance(stored, _StoredReport) and stored.generation == generation:
|
||
return stored.report
|
||
return None
|
||
|
||
async def start_health_check(self, request):
|
||
"""Start the health check job (202), or 409 naming the running check."""
|
||
generation = self._db_generation
|
||
db = self.db
|
||
try:
|
||
roots = await self._run_in_executor(self._health_roots)
|
||
except Exception:
|
||
self.logger.exception("Health check could not resolve the image sources")
|
||
return _failure("Failed to start the health check")
|
||
|
||
async def run(job):
|
||
job.update(status_line="Checking the database and image links")
|
||
report = await self._run_in_executor(db.run_health_checks, roots)
|
||
if generation == self._db_generation:
|
||
self._health_report = _StoredReport(generation, report)
|
||
else:
|
||
self.logger.info("Health report dropped: the database was replaced")
|
||
job.update(progress=100, status_line=_HEALTH_STATUS_LINES[report["status"]])
|
||
|
||
try:
|
||
job = self._jobs.start(
|
||
HEALTH_JOB_KIND,
|
||
HEALTH_JOB_TITLE,
|
||
admission="read",
|
||
cancellable=False,
|
||
scope={},
|
||
runner=run,
|
||
)
|
||
except JobBusy as busy:
|
||
return busy_response(busy)
|
||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||
|
||
async def get_health_report(self, request):
|
||
return web.json_response(
|
||
{"success": True, "report": self._current_health_report()}
|
||
)
|
||
|
||
# ── overview ──────────────────────────────────────────────────────
|
||
|
||
async def get_system_overview(self, request):
|
||
from ..config import GalleryConfig
|
||
|
||
try:
|
||
(output_dirs, auto_detected), counts = await asyncio.gather(
|
||
self._run_in_executor(self._output_dirs_with_origin),
|
||
self._run_in_executor(self.db.library_counts),
|
||
)
|
||
except Exception:
|
||
self.logger.exception("System overview failed")
|
||
return _failure("Failed to read the overview")
|
||
return web.json_response(
|
||
{
|
||
"success": True,
|
||
"library": {
|
||
"positive_prompts": counts["positive_prompts"],
|
||
"negative_variants": counts["negative_variants"],
|
||
},
|
||
"images": {
|
||
"indexed": counts["images"],
|
||
"rated": counts["rated_images"],
|
||
},
|
||
"health": health_summary(self._current_health_report()),
|
||
"backup": None, # no verified backup status exists yet (S8)
|
||
"needs_review": {"pending": None}, # no review queue yet (Q6)
|
||
"sources": {
|
||
"configured": len(GalleryConfig.MONITORING_DIRECTORIES),
|
||
"effective": len(output_dirs),
|
||
"auto_detected": auto_detected,
|
||
},
|
||
},
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
|
||
# ── statistics ────────────────────────────────────────────────────
|
||
|
||
async def get_system_statistics(self, request):
|
||
try:
|
||
by_category, totals, counts = await asyncio.gather(
|
||
self._run_in_executor(self.db.usage_by_category),
|
||
self._run_in_executor(self.db.get_statistics),
|
||
self._run_in_executor(self.db.library_counts),
|
||
)
|
||
except Exception:
|
||
self.logger.exception("System statistics failed")
|
||
return _failure("Failed to read statistics")
|
||
return web.json_response(
|
||
{
|
||
"success": True,
|
||
"by_category": by_category,
|
||
"totals": totals,
|
||
"rated_images": counts["rated_images"],
|
||
},
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|