fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish

Administrators system group sync
- Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
  every permission. Upgrades previously only got what one-off backfill blocks
  in seed_default_groups() explicitly listed (library:purge, archives:purge,
  the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
  enum member added without a matching block silently stayed missing on
  existing admin rows. The most recent gap was printer_sensor_history:read
  (Sensor History charts returned 403 for upgraded admins).
- seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
  startup: append every Permission value that isn't already on the row.
  Additive only -- hand-added custom permissions are preserved.
- The pure-admin one-off backfills (library:purge / archives:purge block,
  the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
  branch of the pipeline backfill) are retired since the sync subsumes
  them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
  orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
  cross-group adders) are untouched.
- Tests: test_administrators_printer_sensor_history_read_backfilled
  (regression for the reported gap),
  test_administrators_sync_covers_every_current_permission (generic
  invariant -- any future new permission lands on admin without needing
  a one-off test), test_administrators_sync_is_additive_only (custom
  permissions preserved). 12/12 backfill-migration + 102/102 broader
  permission tests green; ruff clean.

Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
  native <select> elements are replaced with a bambu-themed FilterDropdown
  (button trigger, floating menu, optgroup-style headers for the Target
  picker, hover + selected states with a check mark, closes on outside
  click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
  reference is stable when the data is stable. Fixes the
  react-hooks/exhaustive-deps warning where the inline fallback returned
  a fresh empty array every render, invalidating both downstream useMemo
  caches (target-options + filtered-pipelines list).
This commit is contained in:
maziggy
2026-06-28 11:18:18 +02:00
parent b5263eb5cd
commit b23cb69a66
29 changed files with 1619 additions and 392 deletions
+106 -6
View File
@@ -30,7 +30,7 @@ from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import desc, func, select
from sqlalchemy import delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -284,6 +284,16 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
printer_name = p.name if p else None
live_job_status = _compute_job_status(job.status, queue_entry)
# If the job WAS dispatched (had a queue_entry_id) but the entry has
# since been deleted from the queue page, the user's intent was
# cancellation. Otherwise the run would stay forever showing as
# ``queued`` because the persisted job.status hasn't been updated.
if (
job.queue_entry_id is not None
and queue_entry is None
and live_job_status not in ("completed", "failed", "cancelled")
):
live_job_status = "cancelled"
job_live_statuses.append(live_job_status)
job_responses.append(
PipelineJobResponse(
@@ -465,6 +475,16 @@ def _make_orchestration_callable(
logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
return {}
# Honour a cancel that landed between ``POST /run`` returning and
# this background task starting. If the run was cancelled while
# still in ``queued`` we must NOT flip it back to ``slicing`` —
# the operator's intent was to stop, and overwriting status here
# was the bug that left runs stuck at ``dispatching`` after a
# user-side cancel (#1425 PR C bug report).
if run.status == "cancelled":
logger.info("pipeline_run %d was cancelled before slicing started", run_id)
return {}
run.status = "slicing"
run.started_at = datetime.now(timezone.utc)
await session.commit()
@@ -512,6 +532,17 @@ def _make_orchestration_callable(
run.sliced_library_file_id = slice_response.library_file_id
# Re-check cancellation: the slice can take minutes, and the
# operator may have hit Cancel during that window. Refresh from
# the DB rather than trusting our in-memory `run` (the cancel
# route writes via a separate session). When cancelled, don't
# enqueue print queue items — that's the whole point of cancel.
await session.refresh(run)
if run.status == "cancelled":
logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
await session.commit()
return slice_response.model_dump()
# PR C: enqueue N copies per the picked assignment strategy.
assignments = await _pick_assignments(session, pipeline, copies)
@@ -542,9 +573,40 @@ def _make_orchestration_callable(
job.queue_entry_id = queue_item.id
job.assigned_printer_id = printer_id # may be None for max_parallel
job.status = "queued"
# Don't write job.status yet — final cancellation check below
# may flip it to 'cancelled' instead. dispatched_at is fine to
# set unconditionally since the orchestration actually got here.
job.dispatched_at = datetime.now(timezone.utc)
# Final cancellation check before committing 'dispatching'. The
# cancel route writes via a separate session so we have to refresh
# to see the latest. If the cancel landed in this narrow window —
# AFTER the post-slice refresh but BEFORE this commit — the queue
# entries we just created would otherwise pick up and print. Mark
# them + the per-copy jobs cancelled so the user's intent sticks.
await session.refresh(run)
if run.status == "cancelled":
logger.info(
"pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
run_id,
len(jobs),
)
for job in jobs:
if job.queue_entry_id:
qe = (
await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
).scalar_one_or_none()
if qe is not None and qe.status in ("pending", "queued"):
qe.status = "cancelled"
if job.status not in ("completed", "failed", "cancelled"):
job.status = "cancelled"
job.completed_at = datetime.now(timezone.utc)
await session.commit()
await _publish_run_event(session, run)
return slice_response.model_dump()
for job in jobs:
job.status = "queued"
run.status = "dispatching"
await session.commit()
await _publish_run_event(session, run)
@@ -720,13 +782,17 @@ async def list_all_runs(
offset: int = 0,
pipeline_id: int | None = None,
status: str | None = None,
target_printer_id: int | None = None,
target_model_class: str | None = None,
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
db: AsyncSession = Depends(get_db),
):
"""Dashboard list. Newest first; filters on pipeline_id + status. The
`status` filter matches the persisted snapshot, not the live roll-up —
in-progress runs may appear under `dispatching` until the next state
transition writes through."""
"""Dashboard list. Newest first; filters on pipeline_id + status +
target_printer_id + target_model_class. The ``status`` filter matches
the persisted snapshot, not the live roll-up — in-progress runs may
appear under ``dispatching`` until the next state transition writes
through. ``target_*`` filters JOIN to the pipeline so runs whose
pipeline currently points at the printer / class are returned."""
limit = max(1, min(limit, 100))
offset = max(0, offset)
@@ -738,6 +804,15 @@ async def list_all_runs(
if status:
stmt = stmt.where(PipelineRun.status == status)
count_stmt = count_stmt.where(PipelineRun.status == status)
if target_printer_id is not None or target_model_class is not None:
stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
if target_printer_id is not None:
stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
if target_model_class is not None:
stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
total = (await db.execute(count_stmt)).scalar() or 0
@@ -748,6 +823,31 @@ async def list_all_runs(
)
_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
@pipeline_run_router.post("/clear")
async def clear_terminal_runs(
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
db: AsyncSession = Depends(get_db),
):
"""Delete every terminal pipeline run (completed / failed / cancelled /
partial_failure). In-flight runs (queued / slicing / dispatching /
in_progress) are preserved — clearing those mid-flight would lose the
operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
relationship; the linked PrintQueueItem rows stay (they have their own
lifecycle on the queue page)."""
# Count first so the response can report how many got cleared. Done
# under the same session/transaction as the delete so the numbers can't
# drift if another caller races in.
count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
n = (await db.execute(count_stmt)).scalar() or 0
if n > 0:
await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
await db.commit()
return {"deleted": n}
@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
async def get_run(
run_id: int,