* feat(system): share browser display defaults The workspace reads and writes the legacy gallery's gallerySettings object through preferences.mjs: workspaceLayout and workspaceDensity join the shared keys, a write re-reads the stored object and writes only the changed keys, denied storage or a full quota keeps the change for this tab and says so, and storage events report changes from other pages. The legacy gallery Save keeps keys it does not edit, and the viewer reads its video defaults through the shared module. * feat(system): Appearance defaults used by Images and the viewer System > Appearance is the concept's Browsing & display surface on real values: the browser rows edit this browser's display defaults through the shared gallerySettings object, and Open from ComfyUI and the node search auto-hide save to the server settings with their revision. A new Images visit starts from the default layout and size (the current view and saved views keep theirs); metadata off waits for Load metadata; cache off re-reads the shown file; paths off hides the Path row; the viewer reads the video defaults. D97 adds card details, collapsed viewer details and Reset display defaults; storage refused says Saved for this tab only. * fix(system): flag changes made while System was closed and keep embedded galleries fixed Final review fixes for System S3 (Appearance): - I1: the on-demand metadata deviation is D124 (TESTING.md, inspector markers); TESTING.md names the spec that pins it, since no concept comparison renders that state. - I2: System checks the display defaults for conflicts on mount, not only on storage events, and a browser write refuses a draft another page overtook, so a draft is never saved over another tab's value without the conflict status first. New two-tab spec. - I3: the layout and size defaults start the Images page only; embedded galleries (a prompt history's Shared gallery and dialogs) keep Grid/Comfortable. - M1: host rows whose /settings read failed say "Server settings could not be read." - M2: a rejected timeout shows the "Some changes were not saved" status. - M3: the browser loads on mount and on storage events never reject unhandled. - M4: the next accepted write stores the tab-only changes and clears the overlay. - M5: cache off never re-reads a source-changed selection, and refresh() never loads a deferred one. - M6: the System fixture refuses a config.json outside its temp root and restores it with os.replace. - M7: the inspection read counts wait on a request barrier, not frames. - M8 recorded as an open saved-views question in TESTING.md. * fix(system): answer a failed System read only after its other reads finish CI (py3.10 on ubuntu) crashed with a segfault inside SQLite in test_statistics_failure_is_an_error_envelope: the statistics and overview routes used asyncio.gather without return_exceptions, so the 500 answered on the first failure while get_statistics still ran in the executor, and the test tore the database down under it. Both routes now await every read before raising the first failure (failing before, passing after: TestFailedReadsSettleFirst). The frontend coverage merge failed on workspace/activity.mjs branches (86.3 < 87): no test asserted Activity's empty and failed-read states, so their branch was only reached in passing, depending on timing. A spec now asserts both.
221 lines
8.7 KiB
Python
221 lines
8.7 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")
|
||
|
||
|
||
async def _gather_settled(*reads):
|
||
"""Await every read, then raise the first failure.
|
||
|
||
Unlike a plain gather, a failure never answers while the other executor
|
||
reads still run against the database.
|
||
"""
|
||
results = await asyncio.gather(*reads, return_exceptions=True)
|
||
for result in results:
|
||
if isinstance(result, BaseException):
|
||
raise result
|
||
return results
|
||
|
||
|
||
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 _gather_settled(
|
||
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 _gather_settled(
|
||
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"},
|
||
)
|