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

File diff suppressed because one or more lines are too long

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,

View file

@ -3345,7 +3345,7 @@ async def seed_default_groups():
from sqlalchemy import select
from backend.app.core.permissions import DEFAULT_GROUPS
from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
from backend.app.models.group import Group
from backend.app.models.user import User
@ -3498,63 +3498,31 @@ async def seed_default_groups():
group.permissions = perms
await session.commit()
# Backfill library:purge + archives:purge for the Administrators group
# on existing installs. Both permissions were added after Administrators
# was first seeded, so upgrading users miss them even though the default
# config (ALL_PERMISSIONS) includes them for fresh installs.
# Backfill: sync the Administrators system group to ALL_PERMISSIONS.
# Administrators' contract is full access to every feature — fresh
# installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
# = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
# whatever permission set existed when they were first seeded, so a
# newly-added Permission enum member silently leaves admins gated out
# of the feature it controls.
#
# Generalises the previous one-off admin backfills (library:purge,
# archives:purge, the OWN/ALL read-flag set + legacy read flags,
# orca_cloud:auth, printer_sensor_history:read, …): every current
# Permission enum value is appended to the admin group if missing.
# Additive only — never removes a permission an operator added by
# hand. Run AFTER the legacy-rename migration above so the renamed
# OWN/ALL variants land in the group before the sync sees them.
result = await session.execute(select(Group).where(Group.name == "Administrators"))
admin_group = result.scalar_one_or_none()
if admin_group and admin_group.permissions is not None:
perms = list(admin_group.permissions)
added = False
for new_perm in ("library:purge", "archives:purge"):
for new_perm in ALL_PERMISSIONS:
if new_perm not in perms:
perms.append(new_perm)
added = True
logger.info("Added %s to Administrators group (backfill)", new_perm)
if added:
admin_group.permissions = perms
await session.commit()
# Backfill the read flag set for the Administrators group on existing
# installs (maziggy/bambuddy-security #2). Two layers:
#
# (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
# these via ALL_PERMISSIONS; upgrades need the explicit backfill
# so admin's permission set matches a fresh install's.
#
# (b) Legacy `archives:read` / `library:read` / `queue:read`. The
# frontend still gates download / preview UI on these LEGACY
# strings (see ArchivesPage / FileManagerPage), so admin needs
# them retained even though the new API uses the OWN/ALL split.
# The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
# read flags for admin — this backfill ensures they're present
# even if they were stripped by hand or by an older migration.
#
# Also includes orca_cloud:auth for parity with fresh-install
# behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
# admin role that's been customised since seed still has it).
result = await session.execute(select(Group).where(Group.name == "Administrators"))
admin_group = result.scalar_one_or_none()
if admin_group and admin_group.permissions is not None:
perms = list(admin_group.permissions)
added = False
for new_perm in (
"archives:read",
"archives:read_own",
"archives:read_all",
"library:read",
"library:read_own",
"library:read_all",
"queue:read",
"queue:read_own",
"queue:read_all",
"orca_cloud:auth",
):
if new_perm not in perms:
perms.append(new_perm)
added = True
logger.info("Added %s to Administrators group (backfill)", new_perm)
logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
if added:
admin_group.permissions = perms
await session.commit()
@ -3613,25 +3581,18 @@ async def seed_default_groups():
group.permissions = perms
await session.commit()
# Backfill pipeline permissions (#1425). Pipelines were added after
# initial seeding, so existing groups need them appended:
# - Administrators: all three (matches fresh-install ALL_PERMISSIONS)
# Backfill pipeline permissions (#1425) for non-admin groups.
# Administrators is handled by the ALL_PERMISSIONS sync above.
# - Operators: all three (matches fresh-install DEFAULT_GROUPS)
# - Viewers + any group with library:read_own or settings:read:
# - Any other group with library:read_own or settings:read:
# pipelines:read only
result = await session.execute(select(Group))
for group in result.scalars().all():
if not group.permissions:
if not group.permissions or group.name == "Administrators":
continue
perms = list(group.permissions)
changed = False
if group.name == "Administrators":
for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
if new_perm not in perms:
perms.append(new_perm)
changed = True
logger.info("Added %s to Administrators group (backfill)", new_perm)
elif group.name == "Operators":
if group.name == "Operators":
for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
if new_perm not in perms:
perms.append(new_perm)

View file

@ -730,6 +730,173 @@ class TestPipelineC:
assert body["parent_run_id"] == parent.id
class TestPolishFollowUp:
"""Polish-pass fixes: dashboard target filters, clear endpoint, and the
deleted-queue-entry cancelled rollup behaviour."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dashboard_filters_by_target_printer(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
printer_a = await printer_factory()
printer_b = await printer_factory()
pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
src = await library_file_factory()
for pipe in (pipe_a, pipe_a, pipe_b):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="completed",
)
)
await db_session.commit()
resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dashboard_filters_by_target_model_class(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
await printer_factory(model="X1C")
await printer_factory(model="P1S")
# Two pipelines, one class-targeting X1C, one P1S.
pipe_x = await pipeline_factory()
await async_client.put(
f"/api/v1/slicer-pipelines/{pipe_x['id']}",
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
)
pipe_p = await pipeline_factory()
await async_client.put(
f"/api/v1/slicer-pipelines/{pipe_p['id']}",
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
)
src = await library_file_factory()
for pipe in (pipe_x, pipe_p, pipe_p):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="completed",
)
)
await db_session.commit()
resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert all(r["target_model_class"] == "P1S" for r in body["runs"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_clear_endpoint_deletes_terminal_runs_only(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
printer = await printer_factory()
pipe = await pipeline_factory(target_printer_id=printer.id)
src = await library_file_factory()
for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status=status,
)
)
await db_session.commit()
resp = await async_client.post("/api/v1/pipeline-runs/clear")
assert resp.status_code == 200, resp.text
assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
# The in-flight rows survive.
survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
assert survivors["total"] == 2
assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleted_queue_entry_rolls_up_as_cancelled(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
"""When the queue entry that a PipelineJob is linked to gets deleted
from the print-queue page, the job's live status should roll up to
``cancelled`` so the run doesn't sit forever showing ``queued`` /
``dispatching``."""
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
printer = await printer_factory()
pipe = await pipeline_factory(target_printer_id=printer.id)
src = await library_file_factory()
# Simulate the state PR C leaves a successful dispatch in: run is
# 'dispatching' and the job has a queue_entry_id pointing at a
# PrintQueueItem that no longer exists.
run = PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="dispatching",
)
db_session.add(run)
await db_session.flush()
db_session.add(
PipelineJob(
pipeline_run_id=run.id,
copy_index=0,
queue_entry_id=999999, # Doesn't exist — simulates manual delete from queue.
assigned_printer_id=printer.id,
status="queued",
)
)
await db_session.commit()
await db_session.refresh(run)
resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
assert resp.status_code == 200, resp.text
body = resp.json()
# Job rolled up to cancelled because the queue entry is gone.
assert body["jobs"][0]["status"] == "cancelled"
# Run also rolls up — all jobs cancelled → run reads as cancelled.
assert body["status"] == "cancelled"
class TestCancelTerminal:
@pytest.mark.asyncio
@pytest.mark.integration

View file

@ -218,6 +218,63 @@ class TestReadPermissionMigration:
perms = await _get_perms("Operators")
assert "orca_cloud:auth" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_printer_sensor_history_read_backfilled(self, async_client: AsyncClient):
"""Admin without `printer_sensor_history:read` (older custom edit or
a DB seeded before that permission existed) gets it backfilled
regression for the gap maziggy hit on a live install where the
per-permission admin backfills missed it."""
await seed_default_groups()
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = [p for p in (grp.permissions or []) if p != "printer_sensor_history:read"]
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
assert "printer_sensor_history:read" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_sync_covers_every_current_permission(self, async_client: AsyncClient):
"""Generic invariant: ALL_PERMISSIONS sync ensures every Permission
enum value is present on the Administrators group, no matter what
was stripped pre-backfill. Catches every future "new permission
missing on upgrade" regression without needing a one-off test."""
from backend.app.core.permissions import ALL_PERMISSIONS
await seed_default_groups()
# Wipe the admin group's permission list entirely and force the sync
# to put everything back.
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = []
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
missing = [p for p in ALL_PERMISSIONS if p not in perms]
assert not missing, f"Administrators missing permissions after backfill: {missing}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_sync_is_additive_only(self, async_client: AsyncClient):
"""The sync block must never remove a permission an operator added by
hand only add missing entries from ALL_PERMISSIONS."""
await seed_default_groups()
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = [*(grp.permissions or []), "custom:plugin_permission"]
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
assert "custom:plugin_permission" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):

View file

@ -119,6 +119,7 @@ function isAlwaysAllowedIdentical(value) {
if (/^v?\d+(\.\d+)+/.test(value)) return true; // version-like
if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return true; // hex color
if (/^\{\{[^}]+\}\}$/.test(value)) return true; // pure placeholder
if (/^\{\{[^}]+\}\}([\s/\-–·,]+\{\{[^}]+\}\})+$/.test(value)) return true; // placeholders joined by punctuation only ({{a}} / {{b}})
if (/^[0-9a-fA-F]{6}$/.test(value)) return true; // bare hex color
if (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return true; // email
if (/^https?:\/\//.test(value)) return true; // URL
@ -148,6 +149,7 @@ const DE_COGNATES = [
'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
'Pipeline', 'Pipelines', 'Filament {{n}}', // #1425 — Slicer Pipelines (DE)
'parallel', // #1425 PR C polish — "parallel" is the same word in German
'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
@ -182,6 +184,7 @@ const FR_COGNATES = [
'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
'Pipeline', 'Pipelines', 'Filament {{n}}', // #1425 — Slicer Pipelines (FR)
'Copies', '{{n}} copies', 'max {{n}}', // #1425 PR C — French uses these forms verbatim
'round robin', // borrowed English term used as-is in French tech contexts
'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@ -222,6 +225,7 @@ const IT_COGNATES = [
'Email', // common loanword in Italian, used verbatim in UI labels
'Pipeline', 'slicing', // #1425 — Slicer Pipelines (cognate in IT)
'max {{n}}', // #1425 PR C — same form in Italian (max + number)
'round robin', // borrowed English term used as-is in Italian tech contexts
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
@ -266,6 +270,7 @@ const PT_BR_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Pipeline', 'Pipelines', // #1425 — Slicer Pipelines (PT-BR)
'round robin', // borrowed English term used as-is in Portuguese tech contexts
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@ -339,6 +344,7 @@ const ES_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Pipeline', 'Pipelines', // #1425 — Slicer Pipelines (ES)
'round robin', // borrowed English term used as-is in Spanish tech contexts
'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',

View file

@ -5,7 +5,6 @@ import { Layout } from './components/Layout';
import { PrintersPage } from './pages/PrintersPage';
import { ArchivesPage } from './pages/ArchivesPage';
import { QueuePage } from './pages/QueuePage';
import { PipelineRunsPage } from './pages/PipelineRunsPage';
import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { ProfilesPage } from './pages/ProfilesPage';
@ -198,7 +197,10 @@ function App() {
<Route index element={<PrintersPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="queue" element={<QueuePage />} />
<Route path="pipelines/runs" element={<PermissionRoute permission="pipelines:read"><PipelineRunsPage /></PermissionRoute>} />
{/* Slicer Pipelines (#1425) Pipelines tab lives on the
Print Queue page (Queue + History + Timeline +
Pipelines). Old standalone URL redirects. */}
<Route path="pipelines/runs" element={<Navigate to="/queue?tab=pipelines" replace />} />
<Route path="stats" element={<StatsPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="maintenance" element={<MaintenancePage />} />

View file

@ -9,7 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PipelineRunsPage } from '../../pages/PipelineRunsPage';
import { PipelineRunsView } from '../../pages/PipelineRunsPage';
import { api, type PipelineRun } from '../../api/client';
vi.mock('../../api/client', () => ({
@ -88,7 +88,7 @@ function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
};
}
describe('PipelineRunsPage', () => {
describe('PipelineRunsView', () => {
beforeEach(() => {
vi.clearAllMocks();
mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
@ -96,7 +96,7 @@ describe('PipelineRunsPage', () => {
});
it('renders empty state when no runs exist', async () => {
render(<PipelineRunsPage />);
render(<PipelineRunsView />);
await waitFor(() => {
expect(screen.getByText(/No pipeline runs yet/i)).toBeInTheDocument();
});
@ -104,7 +104,7 @@ describe('PipelineRunsPage', () => {
it('lists runs and surfaces the pipeline name + status chip', async () => {
mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
render(<PipelineRunsPage />);
render(<PipelineRunsView />);
await waitFor(() => {
expect(screen.getByText(/Production Batch/i)).toBeInTheDocument();
// Status chip uses the i18n key.
@ -115,7 +115,7 @@ describe('PipelineRunsPage', () => {
it('shows a Cancel button on in-flight runs and fires the mutation', async () => {
mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
mockApi.cancelPipelineRun.mockResolvedValue(makeRun({ status: 'cancelled' }));
render(<PipelineRunsPage />);
render(<PipelineRunsView />);
const user = userEvent.setup();
await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Cancel/i }));
@ -135,7 +135,7 @@ describe('PipelineRunsPage', () => {
total: 1,
});
mockApi.retryFailedPipelineRun.mockResolvedValue(makeRun({ id: 2, parent_run_id: 1, copies: 2 }));
render(<PipelineRunsPage />);
render(<PipelineRunsView />);
const user = userEvent.setup();
await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Retry failed/i }));
@ -144,7 +144,7 @@ describe('PipelineRunsPage', () => {
it('expands the row to show per-copy jobs', async () => {
mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
render(<PipelineRunsPage />);
render(<PipelineRunsView />);
const user = userEvent.setup();
await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Expand/i }));

View file

@ -303,7 +303,7 @@ describe('SettingsPage', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'pipelineRuns', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
);
});
@ -345,7 +345,7 @@ describe('SettingsPage', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'pipelineRuns', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
);
const settingsRow = screen.getAllByText('Settings')
@ -410,7 +410,6 @@ describe('SettingsPage', () => {
'inventory',
'archives',
'queue',
'pipelineRuns',
'projects',
'files',
'makerworld',

View file

@ -6390,17 +6390,26 @@ export const api = {
offset?: number;
pipelineId?: number;
status?: string;
targetPrinterId?: number;
targetModelClass?: string;
} = {}) => {
const search = new URLSearchParams();
if (params.limit) search.set('limit', String(params.limit));
if (params.offset) search.set('offset', String(params.offset));
if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
if (params.status) search.set('status', params.status);
if (params.targetPrinterId) search.set('target_printer_id', String(params.targetPrinterId));
if (params.targetModelClass) search.set('target_model_class', params.targetModelClass);
const q = search.toString();
return request<PipelineRunListResponse>(
`/pipeline-runs${q ? '?' + q : ''}`,
);
},
// Clear terminal pipeline runs (#1425 PR C polish). Deletes all runs in
// a terminal state (completed/failed/cancelled/partial_failure); in-flight
// runs are preserved.
clearTerminalPipelineRuns: () =>
request<{ deleted: number }>('/pipeline-runs/clear', { method: 'POST' }),
getPipelineRun: (runId: number) =>
request<PipelineRun>(`/pipeline-runs/${runId}`),
cancelPipelineRun: (runId: number) =>

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Workflow, type LucideIcon } from 'lucide-react';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@ -42,7 +42,6 @@ export const defaultNavItems: NavItem[] = [
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
{ id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
{ id: 'pipelineRuns', to: '/pipelines/runs', icon: Workflow, labelKey: 'nav.pipelineRuns' },
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
{ id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Trash2, Workflow, X } from 'lucide-react';
import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Search, Trash2, Workflow, X } from 'lucide-react';
import {
api,
type PipelineRun,
@ -62,14 +62,25 @@ export function SlicerPipelinesPanel() {
description,
target_printer_id,
target_kind,
target_model_class,
fanout_strategy,
}: {
id: number;
name?: string;
description?: string | null;
target_printer_id?: number | null;
target_kind?: 'specific_printer' | 'printer_class';
target_model_class?: string | null;
fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
}) =>
api.updateSlicerPipeline(id, { name, description, target_printer_id, target_kind }),
api.updateSlicerPipeline(id, {
name,
description,
target_printer_id,
target_kind,
target_model_class,
fanout_strategy,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
@ -90,7 +101,62 @@ export function SlicerPipelinesPanel() {
},
});
const pipelines = list?.pipelines ?? [];
// Panel-level search + filter (#1425 PR C polish). Filters by pipeline name
// (case-insensitive substring) and by target — the dropdown lists every
// distinct target in use across the saved pipelines so operators can jump
// straight to "show me everything for X1C #2" or "everything for the H2D
// class". State is local — list is small enough that re-rendering on every
// keystroke is fine.
const [searchTerm, setSearchTerm] = useState('');
// Encoded target filter value: '' = all, 'none' = no target set,
// 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
const [targetFilter, setTargetFilter] = useState<string>('');
const allPipelines = useMemo(() => list?.pipelines ?? [], [list?.pipelines]);
// Build the dropdown's options from the targets actually in use. Only
// printers / classes that at least one pipeline points at appear — keeps
// the dropdown short and meaningful for installs with many printers but
// few pipelines.
const targetOptions = useMemo(() => {
const printerIds = new Set<number>();
const classes = new Set<string>();
let anyWithoutTarget = false;
for (const p of allPipelines) {
if (p.target_kind === 'printer_class' && p.target_model_class) {
classes.add(p.target_model_class);
} else if (p.target_printer_id) {
printerIds.add(p.target_printer_id);
} else {
anyWithoutTarget = true;
}
}
return {
printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)),
classes: Array.from(classes).sort(),
anyWithoutTarget,
};
}, [allPipelines, printers]);
const pipelines = useMemo(() => {
const term = searchTerm.trim().toLowerCase();
return allPipelines.filter((p) => {
if (term && !p.name.toLowerCase().includes(term)) return false;
if (targetFilter === 'none') {
const hasTarget = p.target_kind === 'printer_class'
? !!p.target_model_class
: p.target_printer_id !== null;
if (hasTarget) return false;
} else if (targetFilter.startsWith('p:')) {
const wantId = parseInt(targetFilter.slice(2), 10);
if (p.target_kind === 'printer_class' || p.target_printer_id !== wantId) return false;
} else if (targetFilter.startsWith('c:')) {
const wantClass = targetFilter.slice(2);
if (p.target_kind !== 'printer_class' || p.target_model_class !== wantClass) return false;
}
return true;
});
}, [allPipelines, searchTerm, targetFilter]);
return (
<Card>
@ -118,7 +184,65 @@ export function SlicerPipelinesPanel() {
{t('settings.pipelines.loadError', 'Could not load pipelines.')}
</div>
)}
{!isLoading && !error && pipelines.length === 0 && (
{/* Search + target-type filter. Only render when there are pipelines
to filter; the empty-state hint reads better without controls. */}
{!isLoading && !error && allPipelines.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="relative flex-1 min-w-[12rem]">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray pointer-events-none" />
<input
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
aria-label={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
className="w-full pl-7 pr-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
/>
</div>
<select
value={targetFilter}
onChange={(e) => setTargetFilter(e.target.value)}
aria-label={t('settings.pipelines.filterTarget', 'Filter by target')}
className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
>
<option value="">
{t('settings.pipelines.filter.all', 'All targets')}
</option>
{targetOptions.printers.length > 0 && (
<optgroup label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}>
{targetOptions.printers.map((p) => (
<option key={`p-${p.id}`} value={`p:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
{targetOptions.classes.length > 0 && (
<optgroup label={t('settings.pipelines.field.targetKindClass', 'Printer class')}>
{targetOptions.classes.map((c) => (
<option key={`c-${c}`} value={`c:${c}`}>
{t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: c })}
</option>
))}
</optgroup>
)}
{targetOptions.anyWithoutTarget && (
<option value="none">
{t('settings.pipelines.filter.noTarget', 'No target set')}
</option>
)}
</select>
{(searchTerm || targetFilter) && (
<span className="text-xs text-bambu-gray">
{t('settings.pipelines.filter.count', '{{shown}} / {{total}}', {
shown: pipelines.length,
total: allPipelines.length,
})}
</span>
)}
</div>
)}
{!isLoading && !error && allPipelines.length === 0 && (
<div className="text-sm text-bambu-gray space-y-2">
<p>{t('settings.pipelines.empty.title', 'No pipelines yet.')}</p>
<p>
@ -129,6 +253,11 @@ export function SlicerPipelinesPanel() {
</p>
</div>
)}
{!isLoading && !error && allPipelines.length > 0 && pipelines.length === 0 && (
<p className="text-sm text-bambu-gray">
{t('settings.pipelines.filter.noMatches', 'No pipelines match the current filters.')}
</p>
)}
{!isLoading && !error && pipelines.length > 0 && (
<div className="space-y-2">
{pipelines.map((p) => (
@ -215,6 +344,19 @@ function PipelineRow({
const printerName = resolveName(presets, 'printer', pipeline.printer_preset);
const processName = resolveName(presets, 'process', pipeline.process_preset);
const filamentResolutions = pipeline.filament_presets.map((f) => resolveName(presets, 'filament', f));
// Collapse identical filaments into a single "All N slots" line — most
// production pipelines load the same filament into every AMS slot, and
// listing the same line three times is just noise. Compares raw preset
// refs (source + id) rather than resolved names so the dedup is correct
// even when ``presets`` hasn't loaded yet.
const filamentsAllIdentical =
pipeline.filament_presets.length > 1 &&
pipeline.filament_presets.every(
(f) =>
f.source === pipeline.filament_presets[0].source &&
f.id === pipeline.filament_presets[0].id,
);
const hasStaleRef =
presets !== undefined &&
(printerName === null || processName === null || filamentResolutions.some((n) => n === null));
@ -381,7 +523,42 @@ function PipelineRow({
</div>
) : (
<>
<h4 className="text-sm font-medium text-white truncate">{pipeline.name}</h4>
{/* Header: name + inline target chip (PR C polish). The target
context specific printer name OR class+strategy is the
thing the operator most needs to read at a glance, so it
rides up here next to the title instead of buried below. */}
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<h4 className="text-sm font-medium text-white truncate">{pipeline.name}</h4>
<span
className={`text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1 ${
needsTarget
? 'bg-amber-500/15 text-amber-400'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}
>
<PrinterIcon className="w-3 h-3" />
{needsTarget ? (
t('settings.pipelines.noTargetHint', 'Set a target printer to run this')
) : isClassTargeting ? (
<>
{t('library.runWithPipeline.classTarget', 'Any {{model}}', {
model: pipeline.target_model_class,
})}
{pipeline.fanout_strategy && (
<span className="text-bambu-gray/60">
{' · '}
{t(
`settings.pipelines.field.fanoutShort.${pipeline.fanout_strategy}`,
pipeline.fanout_strategy,
)}
</span>
)}
</>
) : (
targetPrinter?.name ?? ''
)}
</span>
</div>
{pipeline.description && (
<p className="text-xs text-bambu-gray mt-0.5">{pipeline.description}</p>
)}
@ -430,60 +607,68 @@ function PipelineRow({
</div>
{!editing && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-1 text-xs">
<PresetLine
label={t('settings.pipelines.slot.printer', 'Printer')}
ref={pipeline.printer_preset}
name={printerName}
/>
<PresetLine
label={t('settings.pipelines.slot.process', 'Process')}
ref={pipeline.process_preset}
name={processName}
/>
{pipeline.filament_presets.map((f, i) => (
<PresetLine
key={i}
label={
pipeline.filament_presets.length > 1
? t('settings.pipelines.slot.filamentN', 'Filament {{n}}', { n: i + 1 })
: t('settings.pipelines.slot.filament', 'Filament')
}
ref={f}
name={filamentResolutions[i]}
/>
))}
{pipeline.bed_type && (
<div className="text-bambu-gray">
<span className="font-medium text-bambu-gray/80">
{t('settings.pipelines.slot.bed', 'Bed')}:
</span>{' '}
<span className="text-white">{pipeline.bed_type}</span>
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 text-xs">
{/* Profiles group — printer / process / bed. These travel together
because they describe the slicer profile bundle that produces a
single gcode. The full preset name (including the BambuStudio
``@BBL <model>`` suffix) is shown verbatim so the user can match
it 1:1 against what they see in the slicer. */}
<div className="space-y-0.5">
<div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
{t('settings.pipelines.group.profiles', 'Profiles')}
</div>
)}
<div className="text-bambu-gray flex items-center gap-1">
<PrinterIcon className="w-3 h-3" />
<span className="font-medium text-bambu-gray/80">
{isClassTargeting
? t('settings.pipelines.field.targetModelClass', 'Printer model')
: t('settings.pipelines.field.targetPrinter', 'Target printer')}
:
</span>{' '}
{isClassTargeting && pipeline.target_model_class ? (
<span className="text-white">
{pipeline.target_model_class}
{pipeline.fanout_strategy && (
<span className="text-bambu-gray/60">
{' '}· {t(`settings.pipelines.field.fanout.${pipeline.fanout_strategy}`, pipeline.fanout_strategy)}
</span>
)}
</span>
) : targetPrinter ? (
<span className="text-white">{targetPrinter.name}</span>
<PresetLine
label={t('settings.pipelines.slot.printer', 'Printer')}
ref={pipeline.printer_preset}
name={printerName}
/>
<PresetLine
label={t('settings.pipelines.slot.process', 'Process')}
ref={pipeline.process_preset}
name={processName}
/>
{pipeline.bed_type && (
<div className="text-bambu-gray">
<span className="font-medium text-bambu-gray/80">
{t('settings.pipelines.slot.bed', 'Bed')}:
</span>{' '}
<span className="text-white">{pipeline.bed_type}</span>
</div>
)}
</div>
{/* Filaments group one per AMS slot. When every slot is the same
filament (the common single-color production-batch case) we
collapse them into a single ``All 4 slots: PLA Basic`` line. */}
<div className="space-y-0.5">
<div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
{t('settings.pipelines.group.filaments', 'Filaments')}
{pipeline.filament_presets.length > 1 && (
<span className="text-bambu-gray/60 normal-case ml-1">
({pipeline.filament_presets.length})
</span>
)}
</div>
{filamentsAllIdentical ? (
<PresetLine
label={t('settings.pipelines.slot.filamentAll', 'All {{n}} slots', {
n: pipeline.filament_presets.length,
})}
ref={pipeline.filament_presets[0]}
name={filamentResolutions[0]}
/>
) : (
<span className="text-amber-400">
{t('settings.pipelines.noTargetHint', 'Set a target printer to run this')}
</span>
pipeline.filament_presets.map((f, i) => (
<PresetLine
key={i}
label={
pipeline.filament_presets.length > 1
? t('settings.pipelines.slot.filamentN', 'Filament {{n}}', { n: i + 1 })
: t('settings.pipelines.slot.filament', 'Filament')
}
ref={f}
name={filamentResolutions[i]}
/>
))
)}
</div>
</div>

View file

@ -4,7 +4,6 @@ export default {
printers: 'Drucker',
archives: 'Archiv',
queue: 'Druckwarteschlange',
pipelineRuns: 'Pipeline-Läufe',
stats: 'Statistiken',
profiles: 'Profile',
maintenance: 'Wartung',
@ -1055,8 +1054,16 @@ export default {
filter: {
pipeline: 'Pipeline',
status: 'Status',
target: 'Ziel',
all: 'Alle',
allPipelines: 'Alle Pipelines',
allStatus: 'Alle Status',
allTargets: 'Alle Ziele',
clear: 'Filter zurücksetzen',
noMatches: 'Keine Läufe entsprechen den aktuellen Filtern.',
},
totalCount_one: '{{n}} Lauf',
totalCount_other: '{{n}} Läufe',
copies: '{{n}} Kopien',
failedCount: '{{n}} fehlgeschlagen',
copyN: 'Kopie {{n}}',
@ -1068,7 +1075,13 @@ export default {
cancelFailed: 'Abbruch fehlgeschlagen',
retryStarted: 'Wiederholung gestartet',
retryFailed: 'Wiederholung fehlgeschlagen',
cleared: '{{n}} Läufe gelöscht',
clearFailed: 'Löschen fehlgeschlagen',
},
clearLog: 'Verlauf löschen',
clearConfirmTitle: 'Verlauf löschen?',
clearConfirmBody: 'Jeden abgeschlossenen, fehlgeschlagenen, abgebrochenen und teilweise fehlgeschlagenen Pipeline-Lauf löschen? Laufende Läufe bleiben erhalten. Dies kann nicht rückgängig gemacht werden.',
clearConfirmAction: 'Löschen',
jobStatus: {
pending: 'ausstehend',
awaiting_printer: 'wartet auf Drucker',
@ -1078,6 +1091,7 @@ export default {
failed: 'fehlgeschlagen',
cancelled: 'abgebrochen',
},
cancelledByUser: 'Vom Benutzer abgebrochen',
},
// Queue page
@ -1159,6 +1173,7 @@ export default {
queue: 'Warteschlange',
history: 'Verlauf',
timeline: 'Zeitachse',
pipelines: 'Druckabläufe',
},
layout: {
flatList: 'Liste',
@ -2624,6 +2639,11 @@ export default {
round_robin: 'Reihum — durch geeignete Drucker rotieren',
fill_one_first: 'Erst einen füllen — alle Kopien an einen Drucker binden',
},
fanoutShort: {
max_parallel: 'parallel',
round_robin: 'Reihum',
fill_one_first: 'einer zuerst',
},
},
action: {
save: 'Speichern',
@ -2636,8 +2656,22 @@ export default {
process: 'Prozess',
filament: 'Filament',
filamentN: 'Filament {{n}}',
filamentAll: 'Alle {{n}} Slots',
bed: 'Druckplatte',
},
group: {
profiles: 'Profile',
filaments: 'Filamente',
},
searchPlaceholder: 'Pipelines durchsuchen…',
filterTargetType: 'Nach Zielart filtern',
filterTarget: 'Nach Ziel filtern',
filter: {
all: 'Alle Ziele',
noTarget: 'Kein Ziel festgelegt',
count: '{{shown}} / {{total}}',
noMatches: 'Keine Pipelines entsprechen den aktuellen Filtern.',
},
toast: {
saved: 'Pipeline gespeichert',
saveFailed: 'Speichern fehlgeschlagen',

View file

@ -4,7 +4,6 @@ export default {
printers: 'Printers',
archives: 'Archives',
queue: 'Print Queue',
pipelineRuns: 'Pipeline Runs',
stats: 'Statistics',
profiles: 'Profiles',
maintenance: 'Maintenance',
@ -1064,20 +1063,35 @@ export default {
filter: {
pipeline: 'Pipeline',
status: 'Status',
target: 'Target',
all: 'All',
allPipelines: 'All pipelines',
allStatus: 'All statuses',
allTargets: 'All targets',
clear: 'Clear filters',
noMatches: 'No runs match the current filters.',
},
totalCount_one: '{{n}} run',
totalCount_other: '{{n}} runs',
copies: '{{n}} copies',
failedCount: '{{n}} failed',
copyN: 'Copy {{n}}',
retryFailed: 'Retry failed',
retryOf: 'retry of #{{n}}',
pagination: '{{start}}{{end}} of {{total}}',
cancelledByUser: 'Cancelled by user',
toast: {
cancelled: 'Run cancelled',
cancelFailed: 'Cancel failed',
retryStarted: 'Retry started',
retryFailed: 'Retry failed',
cleared: '{{n}} runs cleared',
clearFailed: 'Clear failed',
},
clearLog: 'Clear log',
clearConfirmTitle: 'Clear log?',
clearConfirmBody: 'Delete every completed, failed, cancelled, and partial-failure pipeline run? In-flight runs are kept. This cannot be undone.',
clearConfirmAction: 'Clear',
jobStatus: {
pending: 'pending',
awaiting_printer: 'awaiting printer',
@ -1169,6 +1183,7 @@ export default {
queue: 'Queue',
history: 'History',
timeline: 'Timeline',
pipelines: 'Pipelines',
},
// Layout toggle on the Queue tab — distinct from the sort dropdown
// (those control order; these control whether items render as one flat
@ -2647,6 +2662,14 @@ export default {
round_robin: 'Round robin — cycle through eligible printers',
fill_one_first: 'Fill one first — pin all copies to one printer',
},
// Short labels for the inline target chip on each pipeline card.
// The verbose ones above explain the strategy in the editor; the
// card just needs a compact reminder of which one is in use.
fanoutShort: {
max_parallel: 'parallel',
round_robin: 'round robin',
fill_one_first: 'fill one first',
},
},
action: {
save: 'Save',
@ -2659,8 +2682,24 @@ export default {
process: 'Process',
filament: 'Filament',
filamentN: 'Filament {{n}}',
filamentAll: 'All {{n}} slots',
bed: 'Bed',
},
// PR C polish — grouped sections in the card body, plus the
// panel-level search + filter row.
group: {
profiles: 'Profiles',
filaments: 'Filaments',
},
searchPlaceholder: 'Search pipelines…',
filterTargetType: 'Filter by target type',
filterTarget: 'Filter by target',
filter: {
all: 'All targets',
noTarget: 'No target set',
count: '{{shown}} / {{total}}',
noMatches: 'No pipelines match the current filters.',
},
toast: {
saved: 'Pipeline saved',
saveFailed: 'Save failed',

View file

@ -4,7 +4,6 @@ export default {
printers: 'Impresoras',
archives: 'Archivos',
queue: 'Cola de impresión',
pipelineRuns: 'Ejecuciones de pipeline',
stats: 'Estadísticas',
profiles: 'Perfiles',
maintenance: 'Mantenimiento',
@ -1055,8 +1054,16 @@ export default {
filter: {
pipeline: 'Pipeline',
status: 'Estado',
target: 'Destino',
all: 'Todas',
allPipelines: 'Todas las pipelines',
allStatus: 'Todos los estados',
allTargets: 'Todos los destinos',
clear: 'Limpiar filtros',
noMatches: 'Ninguna ejecución coincide con los filtros actuales.',
},
totalCount_one: '{{n}} ejecución',
totalCount_other: '{{n}} ejecuciones',
copies: '{{n}} copias',
failedCount: '{{n}} fallidas',
copyN: 'Copia {{n}}',
@ -1068,7 +1075,13 @@ export default {
cancelFailed: 'Cancelación fallida',
retryStarted: 'Reintento iniciado',
retryFailed: 'Reintento fallido',
cleared: '{{n}} ejecuciones eliminadas',
clearFailed: 'Error al borrar',
},
clearLog: 'Borrar historial',
clearConfirmTitle: '¿Borrar historial?',
clearConfirmBody: '¿Eliminar todas las ejecuciones de pipeline completadas, fallidas, canceladas y con fallos parciales? Las ejecuciones en curso se conservan. Esto no se puede deshacer.',
clearConfirmAction: 'Borrar',
jobStatus: {
pending: 'pendiente',
awaiting_printer: 'esperando impresora',
@ -1078,6 +1091,7 @@ export default {
failed: 'fallida',
cancelled: 'cancelada',
},
cancelledByUser: 'Cancelado por el usuario',
},
// Queue page
@ -1159,6 +1173,7 @@ export default {
queue: 'Cola',
history: 'Historial',
timeline: 'Cronología',
pipelines: 'Procesos',
},
layout: {
flatList: 'Lista',
@ -2627,6 +2642,11 @@ export default {
round_robin: 'Round robin — alternar entre impresoras elegibles',
fill_one_first: 'Llenar una primero — fijar todas las copias a una impresora',
},
fanoutShort: {
max_parallel: 'paralelo',
round_robin: 'round robin',
fill_one_first: 'primero uno',
},
},
action: {
save: 'Guardar',
@ -2639,8 +2659,22 @@ export default {
process: 'Proceso',
filament: 'Filamento',
filamentN: 'Filamento {{n}}',
filamentAll: 'Todos los {{n}} slots',
bed: 'Placa',
},
group: {
profiles: 'Perfiles',
filaments: 'Filamentos',
},
searchPlaceholder: 'Buscar pipelines…',
filterTargetType: 'Filtrar por tipo de destino',
filterTarget: 'Filtrar por destino',
filter: {
all: 'Todos los destinos',
noTarget: 'Sin destino',
count: '{{shown}} / {{total}}',
noMatches: 'Ninguna pipeline coincide con los filtros actuales.',
},
toast: {
saved: 'Pipeline guardada',
saveFailed: 'Error al guardar',

View file

@ -4,7 +4,6 @@ export default {
printers: 'Imprimantes',
archives: 'Archives',
queue: 'File d\'attente d\'impression',
pipelineRuns: 'Exécutions de pipeline',
stats: 'Statistiques',
profiles: 'Profils',
maintenance: 'Maintenance',
@ -1055,8 +1054,16 @@ export default {
filter: {
pipeline: 'Pipeline',
status: 'Statut',
target: 'Cible',
all: 'Toutes',
allPipelines: 'Tous les pipelines',
allStatus: 'Tous les statuts',
allTargets: 'Toutes les cibles',
clear: 'Effacer les filtres',
noMatches: 'Aucune exécution ne correspond aux filtres actuels.',
},
totalCount_one: '{{n}} exécution',
totalCount_other: '{{n}} exécutions',
copies: '{{n}} copies',
failedCount: '{{n}} échouées',
copyN: 'Copie {{n}}',
@ -1068,7 +1075,13 @@ export default {
cancelFailed: 'Annulation échouée',
retryStarted: 'Réessai démarré',
retryFailed: 'Réessai échoué',
cleared: '{{n}} exécutions effacées',
clearFailed: 'Échec de l\'effacement',
},
clearLog: 'Effacer le journal',
clearConfirmTitle: 'Effacer le journal ?',
clearConfirmBody: 'Supprimer toutes les exécutions de pipeline terminées, échouées, annulées et en échec partiel ? Les exécutions en cours sont conservées. Ceci ne peut pas être annulé.',
clearConfirmAction: 'Effacer',
jobStatus: {
pending: 'en attente',
awaiting_printer: 'attente imprimante',
@ -1078,6 +1091,7 @@ export default {
failed: 'échouée',
cancelled: 'annulée',
},
cancelledByUser: 'Annulé par l\'utilisateur',
},
// Queue page
@ -1159,6 +1173,7 @@ export default {
queue: 'File',
history: 'Historique',
timeline: 'Chronologie',
pipelines: 'Exécutions',
},
layout: {
flatList: 'Liste',
@ -2613,6 +2628,11 @@ export default {
round_robin: 'Round robin — alterner entre imprimantes éligibles',
fill_one_first: 'Remplir une d\'abord — épingler toutes les copies à une imprimante',
},
fanoutShort: {
max_parallel: 'parallèle',
round_robin: 'round robin',
fill_one_first: 'remplir une',
},
},
action: {
save: 'Enregistrer',
@ -2625,8 +2645,22 @@ export default {
process: 'Processus',
filament: 'Filament',
filamentN: 'Filament {{n}}',
filamentAll: 'Tous les {{n}} emplacements',
bed: 'Plateau',
},
group: {
profiles: 'Profils',
filaments: 'Filaments',
},
searchPlaceholder: 'Rechercher des pipelines…',
filterTargetType: 'Filtrer par type de cible',
filterTarget: 'Filtrer par cible',
filter: {
all: 'Toutes les cibles',
noTarget: 'Aucune cible',
count: '{{shown}} / {{total}}',
noMatches: 'Aucun pipeline ne correspond aux filtres actuels.',
},
toast: {
saved: 'Pipeline enregistré',
saveFailed: 'Échec de l\'enregistrement',

View file

@ -4,7 +4,6 @@ export default {
printers: 'Stampanti',
archives: 'Archivi',
queue: 'Coda di stampa',
pipelineRuns: 'Esecuzioni pipeline',
stats: 'Statistiche',
profiles: 'Profili',
maintenance: 'Manutenzione',
@ -1055,8 +1054,16 @@ export default {
filter: {
pipeline: 'Pipeline',
status: 'Stato',
target: 'Destinazione',
all: 'Tutte',
allPipelines: 'Tutte le pipeline',
allStatus: 'Tutti gli stati',
allTargets: 'Tutte le destinazioni',
clear: 'Pulisci filtri',
noMatches: 'Nessuna esecuzione corrisponde ai filtri attuali.',
},
totalCount_one: '{{n}} esecuzione',
totalCount_other: '{{n}} esecuzioni',
copies: '{{n}} copie',
failedCount: '{{n}} fallite',
copyN: 'Copia {{n}}',
@ -1068,7 +1075,13 @@ export default {
cancelFailed: 'Annullamento fallito',
retryStarted: 'Ritentativo avviato',
retryFailed: 'Ritentativo fallito',
cleared: '{{n}} esecuzioni eliminate',
clearFailed: 'Eliminazione fallita',
},
clearLog: 'Cancella cronologia',
clearConfirmTitle: 'Cancellare la cronologia?',
clearConfirmBody: 'Eliminare tutte le esecuzioni di pipeline completate, fallite, annullate e con fallimento parziale? Le esecuzioni in corso sono conservate. Questa operazione non può essere annullata.',
clearConfirmAction: 'Cancella',
jobStatus: {
pending: 'in attesa',
awaiting_printer: 'in attesa stampante',
@ -1078,6 +1091,7 @@ export default {
failed: 'fallita',
cancelled: 'annullata',
},
cancelledByUser: 'Annullato dall\'utente',
},
// Queue page
@ -1159,6 +1173,7 @@ export default {
queue: 'Coda',
history: 'Cronologia',
timeline: 'Linea temporale',
pipelines: 'Pipeline',
},
layout: {
flatList: 'Elenco',
@ -2612,6 +2627,11 @@ export default {
round_robin: 'Round robin — alterna tra stampanti idonee',
fill_one_first: 'Riempi una prima — assegna tutte le copie a una stampante',
},
fanoutShort: {
max_parallel: 'parallelo',
round_robin: 'round robin',
fill_one_first: 'prima una',
},
},
action: {
save: 'Salva',
@ -2624,8 +2644,22 @@ export default {
process: 'Processo',
filament: 'Filamento',
filamentN: 'Filamento {{n}}',
filamentAll: 'Tutti i {{n}} slot',
bed: 'Piatto',
},
group: {
profiles: 'Profili',
filaments: 'Filamenti',
},
searchPlaceholder: 'Cerca pipeline…',
filterTargetType: 'Filtra per tipo di destinazione',
filterTarget: 'Filtra per destinazione',
filter: {
all: 'Tutte le destinazioni',
noTarget: 'Nessuna destinazione',
count: '{{shown}} / {{total}}',
noMatches: 'Nessuna pipeline corrisponde ai filtri attuali.',
},
toast: {
saved: 'Pipeline salvata',
saveFailed: 'Salvataggio non riuscito',

View file

@ -4,7 +4,6 @@ export default {
printers: 'プリンター',
archives: 'アーカイブ',
queue: '印刷キュー',
pipelineRuns: 'パイプライン実行',
stats: '統計',
profiles: 'プロファイル',
maintenance: 'メンテナンス',
@ -1054,8 +1053,16 @@ export default {
filter: {
pipeline: 'パイプライン',
status: 'ステータス',
target: '対象',
all: 'すべて',
allPipelines: 'すべてのパイプライン',
allStatus: 'すべてのステータス',
allTargets: 'すべての対象',
clear: 'フィルターをクリア',
noMatches: '現在のフィルターに一致する実行はありません。',
},
totalCount_one: '{{n}} 件の実行',
totalCount_other: '{{n}} 件の実行',
copies: '{{n}} 部',
failedCount: '{{n}} 失敗',
copyN: 'コピー {{n}}',
@ -1067,7 +1074,13 @@ export default {
cancelFailed: 'キャンセルに失敗しました',
retryStarted: '再試行を開始しました',
retryFailed: '再試行に失敗しました',
cleared: '{{n}} 件の実行を削除しました',
clearFailed: '削除に失敗しました',
},
clearLog: 'ログをクリア',
clearConfirmTitle: 'ログをクリアしますか?',
clearConfirmBody: '完了、失敗、キャンセル、部分的失敗のすべてのパイプライン実行を削除しますか?実行中のものは残ります。元に戻せません。',
clearConfirmAction: 'クリア',
jobStatus: {
pending: '保留中',
awaiting_printer: 'プリンター待機中',
@ -1077,6 +1090,7 @@ export default {
failed: '失敗',
cancelled: 'キャンセル',
},
cancelledByUser: 'ユーザーによりキャンセル',
},
// Queue page
@ -1158,6 +1172,7 @@ export default {
queue: 'キュー',
history: '履歴',
timeline: 'タイムライン',
pipelines: 'パイプライン',
},
layout: {
flatList: 'リスト',
@ -2624,6 +2639,11 @@ export default {
round_robin: 'ラウンドロビン — 適格なプリンター間で循環',
fill_one_first: '1台ずつ埋める — すべてのコピーを1台に固定',
},
fanoutShort: {
max_parallel: '並列',
round_robin: 'ラウンドロビン',
fill_one_first: '1台ずつ',
},
},
action: {
save: '保存',
@ -2636,8 +2656,22 @@ export default {
process: 'プロセス',
filament: 'フィラメント',
filamentN: 'フィラメント {{n}}',
filamentAll: '{{n}} スロットすべて',
bed: 'ベッド',
},
group: {
profiles: 'プロファイル',
filaments: 'フィラメント',
},
searchPlaceholder: 'パイプラインを検索…',
filterTargetType: '対象種別でフィルター',
filterTarget: '対象でフィルター',
filter: {
all: 'すべての対象',
noTarget: '対象未設定',
count: '{{shown}} / {{total}}',
noMatches: '現在のフィルターに一致するパイプラインはありません。',
},
toast: {
saved: 'パイプラインを保存しました',
saveFailed: '保存に失敗しました',

View file

@ -3,7 +3,6 @@ export default {
printers: '프린터',
archives: '아카이브',
queue: '대기열',
pipelineRuns: '파이프라인 실행',
stats: '통계',
profiles: '프로필',
maintenance: '유지보수',
@ -1009,8 +1008,16 @@ export default {
filter: {
pipeline: '파이프라인',
status: '상태',
target: '대상',
all: '전체',
allPipelines: '모든 파이프라인',
allStatus: '모든 상태',
allTargets: '모든 대상',
clear: '필터 지우기',
noMatches: '현재 필터와 일치하는 실행이 없습니다.',
},
totalCount_one: '{{n}}회 실행',
totalCount_other: '{{n}}회 실행',
copies: '사본 {{n}}',
failedCount: '실패 {{n}}',
copyN: '사본 {{n}}',
@ -1022,7 +1029,13 @@ export default {
cancelFailed: '취소 실패',
retryStarted: '재시도 시작됨',
retryFailed: '재시도 실패',
cleared: '{{n}}회 실행 삭제됨',
clearFailed: '삭제 실패',
},
clearLog: '로그 지우기',
clearConfirmTitle: '로그를 지울까요?',
clearConfirmBody: '완료, 실패, 취소, 부분 실패한 모든 파이프라인 실행을 삭제합니까? 진행 중인 실행은 유지됩니다. 되돌릴 수 없습니다.',
clearConfirmAction: '지우기',
jobStatus: {
pending: '대기 중',
awaiting_printer: '프린터 대기 중',
@ -1032,6 +1045,7 @@ export default {
failed: '실패',
cancelled: '취소됨',
},
cancelledByUser: '사용자에 의해 취소됨',
},
queue: {
@ -1101,6 +1115,7 @@ export default {
queue: '큐',
history: '기록',
timeline: '타임라인',
pipelines: '파이프라인',
},
layout: {
flatList: '목록',
@ -2480,6 +2495,11 @@ export default {
round_robin: '라운드 로빈 — 적격 프린터를 순환',
fill_one_first: '하나 먼저 채우기 — 모든 사본을 한 프린터에 고정',
},
fanoutShort: {
max_parallel: '병렬',
round_robin: '라운드 로빈',
fill_one_first: '하나 먼저',
},
},
action: {
save: '저장',
@ -2492,8 +2512,22 @@ export default {
process: '프로세스',
filament: '필라멘트',
filamentN: '필라멘트 {{n}}',
filamentAll: '전체 {{n}} 슬롯',
bed: '베드',
},
group: {
profiles: '프로필',
filaments: '필라멘트',
},
searchPlaceholder: '파이프라인 검색…',
filterTargetType: '대상 유형으로 필터',
filterTarget: '대상으로 필터',
filter: {
all: '모든 대상',
noTarget: '대상 미설정',
count: '{{shown}} / {{total}}',
noMatches: '현재 필터와 일치하는 파이프라인이 없습니다.',
},
toast: {
saved: '파이프라인이 저장되었습니다',
saveFailed: '저장 실패',

View file

@ -4,7 +4,6 @@ export default {
printers: 'Impressoras',
archives: 'Arquivos',
queue: 'Fila de impressão',
pipelineRuns: 'Execuções de pipeline',
stats: 'Estatísticas',
profiles: 'Perfis',
maintenance: 'Manutenção',
@ -1055,8 +1054,16 @@ export default {
filter: {
pipeline: 'Pipeline',
status: 'Status',
target: 'Destino',
all: 'Todas',
allPipelines: 'Todas as pipelines',
allStatus: 'Todos os status',
allTargets: 'Todos os destinos',
clear: 'Limpar filtros',
noMatches: 'Nenhuma execução corresponde aos filtros atuais.',
},
totalCount_one: '{{n}} execução',
totalCount_other: '{{n}} execuções',
copies: '{{n}} cópias',
failedCount: '{{n}} falharam',
copyN: 'Cópia {{n}}',
@ -1068,7 +1075,13 @@ export default {
cancelFailed: 'Cancelamento falhou',
retryStarted: 'Nova tentativa iniciada',
retryFailed: 'Nova tentativa falhou',
cleared: '{{n}} execuções limpas',
clearFailed: 'Falha ao limpar',
},
clearLog: 'Limpar histórico',
clearConfirmTitle: 'Limpar histórico?',
clearConfirmBody: 'Excluir todas as execuções de pipeline concluídas, falhas, canceladas e com falha parcial? Execuções em andamento são mantidas. Isso não pode ser desfeito.',
clearConfirmAction: 'Limpar',
jobStatus: {
pending: 'pendente',
awaiting_printer: 'aguardando impressora',
@ -1078,6 +1091,7 @@ export default {
failed: 'falhou',
cancelled: 'cancelada',
},
cancelledByUser: 'Cancelado pelo usuário',
},
// Queue page
@ -1159,6 +1173,7 @@ export default {
queue: 'Fila',
history: 'Histórico',
timeline: 'Linha do tempo',
pipelines: 'Processos',
},
layout: {
flatList: 'Lista',
@ -2612,6 +2627,11 @@ export default {
round_robin: 'Round robin — alternar entre impressoras elegíveis',
fill_one_first: 'Encher uma primeiro — fixar todas as cópias em uma impressora',
},
fanoutShort: {
max_parallel: 'paralelo',
round_robin: 'round robin',
fill_one_first: 'primeiro um',
},
},
action: {
save: 'Salvar',
@ -2624,8 +2644,22 @@ export default {
process: 'Processo',
filament: 'Filamento',
filamentN: 'Filamento {{n}}',
filamentAll: 'Todos os {{n}} slots',
bed: 'Mesa',
},
group: {
profiles: 'Perfis',
filaments: 'Filamentos',
},
searchPlaceholder: 'Buscar pipelines…',
filterTargetType: 'Filtrar por tipo de destino',
filterTarget: 'Filtrar por destino',
filter: {
all: 'Todos os destinos',
noTarget: 'Sem destino',
count: '{{shown}} / {{total}}',
noMatches: 'Nenhuma pipeline corresponde aos filtros atuais.',
},
toast: {
saved: 'Pipeline salva',
saveFailed: 'Falha ao salvar',

View file

@ -4,7 +4,6 @@ export default {
printers: 'Yazıcılar',
archives: 'Arşivler',
queue: 'Baskı Kuyruğu',
pipelineRuns: 'Pipeline çalıştırmaları',
stats: 'İstatistikler',
profiles: 'Profiller',
maintenance: 'Bakım',
@ -1056,8 +1055,16 @@ export default {
filter: {
pipeline: 'Pipeline',
status: 'Durum',
target: 'Hedef',
all: 'Tümü',
allPipelines: 'Tüm pipeline\'lar',
allStatus: 'Tüm durumlar',
allTargets: 'Tüm hedefler',
clear: 'Filtreleri temizle',
noMatches: 'Mevcut filtrelere uyan çalıştırma yok.',
},
totalCount_one: '{{n}} çalıştırma',
totalCount_other: '{{n}} çalıştırma',
copies: '{{n}} kopya',
failedCount: '{{n}} başarısız',
copyN: 'Kopya {{n}}',
@ -1069,7 +1076,13 @@ export default {
cancelFailed: 'İptal başarısız',
retryStarted: 'Yeniden deneme başlatıldı',
retryFailed: 'Yeniden deneme başarısız',
cleared: '{{n}} çalıştırma temizlendi',
clearFailed: 'Temizleme başarısız',
},
clearLog: 'Geçmişi temizle',
clearConfirmTitle: 'Geçmiş temizlensin mi?',
clearConfirmBody: 'Tamamlanan, başarısız olan, iptal edilen ve kısmen başarısız tüm pipeline çalıştırmaları silinsin mi? Devam eden çalıştırmalar korunur. Bu geri alınamaz.',
clearConfirmAction: 'Temizle',
jobStatus: {
pending: 'beklemede',
awaiting_printer: 'yazıcı bekleniyor',
@ -1079,6 +1092,7 @@ export default {
failed: 'başarısız',
cancelled: 'iptal edildi',
},
cancelledByUser: 'Kullanıcı tarafından iptal edildi',
},
queue: {
@ -1159,6 +1173,7 @@ export default {
queue: 'Kuyruk',
history: 'Geçmiş',
timeline: 'Zaman çizelgesi',
pipelines: 'Pipeline\'lar',
},
layout: {
flatList: 'Liste',
@ -2628,6 +2643,11 @@ export default {
round_robin: 'Sırayla — uygun yazıcılar arasında döndür',
fill_one_first: 'Önce birini doldur — tüm kopyaları tek yazıcıya sabitle',
},
fanoutShort: {
max_parallel: 'paralel',
round_robin: 'sırayla',
fill_one_first: 'önce bir',
},
},
action: {
save: 'Kaydet',
@ -2640,8 +2660,22 @@ export default {
process: 'İşlem',
filament: 'Filament',
filamentN: 'Filament {{n}}',
filamentAll: 'Tüm {{n}} yuva',
bed: 'Tabla',
},
group: {
profiles: 'Profiller',
filaments: 'Filamentler',
},
searchPlaceholder: 'Pipeline\'larda ara…',
filterTargetType: 'Hedef türüne göre filtrele',
filterTarget: 'Hedefe göre filtrele',
filter: {
all: 'Tüm hedefler',
noTarget: 'Hedef belirlenmemiş',
count: '{{shown}} / {{total}}',
noMatches: 'Mevcut filtrelere uyan pipeline yok.',
},
toast: {
saved: 'Pipeline kaydedildi',
saveFailed: 'Kaydetme başarısız',

View file

@ -4,7 +4,6 @@ export default {
printers: '打印机',
archives: '归档',
queue: '打印队列',
pipelineRuns: '流水线运行',
stats: '统计',
profiles: '配置文件',
maintenance: '维护',
@ -1055,8 +1054,16 @@ export default {
filter: {
pipeline: '流水线',
status: '状态',
target: '目标',
all: '全部',
allPipelines: '全部流水线',
allStatus: '全部状态',
allTargets: '全部目标',
clear: '清除筛选',
noMatches: '没有运行匹配当前筛选条件。',
},
totalCount_one: '{{n}} 次运行',
totalCount_other: '{{n}} 次运行',
copies: '{{n}} 份',
failedCount: '{{n}} 失败',
copyN: '副本 {{n}}',
@ -1068,7 +1075,13 @@ export default {
cancelFailed: '取消失败',
retryStarted: '已开始重试',
retryFailed: '重试失败',
cleared: '已清除 {{n}} 次运行',
clearFailed: '清除失败',
},
clearLog: '清除日志',
clearConfirmTitle: '清除日志?',
clearConfirmBody: '删除所有已完成、失败、已取消和部分失败的流水线运行?进行中的运行将保留。此操作无法撤销。',
clearConfirmAction: '清除',
jobStatus: {
pending: '等待中',
awaiting_printer: '等待打印机',
@ -1078,6 +1091,7 @@ export default {
failed: '失败',
cancelled: '已取消',
},
cancelledByUser: '用户已取消',
},
// Queue page
@ -1159,6 +1173,7 @@ export default {
queue: '队列',
history: '历史',
timeline: '时间线',
pipelines: '流水线',
},
layout: {
flatList: '列表',
@ -2612,6 +2627,11 @@ export default {
round_robin: '轮询 — 在合格打印机间循环',
fill_one_first: '先填一台 — 将所有副本固定到一台打印机',
},
fanoutShort: {
max_parallel: '并行',
round_robin: '轮询',
fill_one_first: '先填一台',
},
},
action: {
save: '保存',
@ -2624,8 +2644,22 @@ export default {
process: '工艺',
filament: '耗材',
filamentN: '耗材 {{n}}',
filamentAll: '全部 {{n}} 个槽',
bed: '热床',
},
group: {
profiles: '配置文件',
filaments: '耗材',
},
searchPlaceholder: '搜索流水线…',
filterTargetType: '按目标类型筛选',
filterTarget: '按目标筛选',
filter: {
all: '全部目标',
noTarget: '未设置目标',
count: '{{shown}} / {{total}}',
noMatches: '没有流水线匹配当前筛选条件。',
},
toast: {
saved: '流水线已保存',
saveFailed: '保存失败',

View file

@ -4,7 +4,6 @@ export default {
printers: '印表機',
archives: '歸檔',
queue: '列印佇列',
pipelineRuns: '管線執行',
stats: '統計',
profiles: '設定檔案',
maintenance: '維護',
@ -1055,8 +1054,16 @@ export default {
filter: {
pipeline: '管線',
status: '狀態',
target: '目標',
all: '全部',
allPipelines: '全部管線',
allStatus: '全部狀態',
allTargets: '全部目標',
clear: '清除篩選',
noMatches: '沒有執行符合目前的篩選條件。',
},
totalCount_one: '{{n}} 次執行',
totalCount_other: '{{n}} 次執行',
copies: '{{n}} 份',
failedCount: '{{n}} 失敗',
copyN: '副本 {{n}}',
@ -1068,7 +1075,13 @@ export default {
cancelFailed: '取消失敗',
retryStarted: '已開始重試',
retryFailed: '重試失敗',
cleared: '已清除 {{n}} 次執行',
clearFailed: '清除失敗',
},
clearLog: '清除日誌',
clearConfirmTitle: '清除日誌?',
clearConfirmBody: '刪除所有已完成、失敗、已取消和部分失敗的管線執行?進行中的執行將保留。此操作無法復原。',
clearConfirmAction: '清除',
jobStatus: {
pending: '等待中',
awaiting_printer: '等待印表機',
@ -1078,6 +1091,7 @@ export default {
failed: '失敗',
cancelled: '已取消',
},
cancelledByUser: '使用者已取消',
},
// Queue page
@ -1159,6 +1173,7 @@ export default {
queue: '佇列',
history: '歷史',
timeline: '時間軸',
pipelines: '管線',
},
layout: {
flatList: '清單',
@ -2612,6 +2627,11 @@ export default {
round_robin: '輪替 — 在合格印表機間循環',
fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
},
fanoutShort: {
max_parallel: '並行',
round_robin: '輪替',
fill_one_first: '先填一台',
},
},
action: {
save: '儲存',
@ -2624,8 +2644,22 @@ export default {
process: '製程',
filament: '耗材',
filamentN: '耗材 {{n}}',
filamentAll: '全部 {{n}} 個槽',
bed: '熱床',
},
group: {
profiles: '設定檔',
filaments: '耗材',
},
searchPlaceholder: '搜尋管線…',
filterTargetType: '依目標類型篩選',
filterTarget: '依目標篩選',
filter: {
all: '全部目標',
noTarget: '未設定目標',
count: '{{shown}} / {{total}}',
noMatches: '沒有管線符合目前的篩選條件。',
},
toast: {
saved: '管線已儲存',
saveFailed: '儲存失敗',

View file

@ -1,18 +1,25 @@
import { useState } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Check,
ChevronDown,
ChevronRight,
FileText,
Filter,
Loader2,
Printer as PrinterIcon,
RefreshCw,
RotateCcw,
Trash2,
Workflow,
X,
} from 'lucide-react';
import { api, type PipelineRun, type SlicerPipeline } from '../api/client';
import { api, type PipelineRun, type Printer, type SlicerPipeline } from '../api/client';
import { useToast } from '../contexts/ToastContext';
type DropdownOption = { value: string; label: string; group?: string };
const STATUSES = [
'',
'queued',
@ -28,32 +35,61 @@ const STATUSES = [
const PAGE_LIMIT = 25;
// Dashboard for Slicer Pipeline runs (#1425 PR C).
// Lists every run across every pipeline with status + pipeline filters and
// pagination. Each row expands to show per-copy status; in-flight runs get a
// Cancel button, partial-failure runs get a Retry-failed button.
export function PipelineRunsPage() {
// Renders the content of the Pipelines tab on the Print Queue page. Lists
// every run across every pipeline with status + pipeline filters and
// pagination; each row expands to show per-copy status; in-flight runs get a
// Cancel button, partial-failure runs get a Retry-failed button. Designed to
// embed inside QueuePage's tab strip — no page wrapper, no top-level title
// (the tab strip provides both).
export function PipelineRunsView() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [statusFilter, setStatusFilter] = useState<string>('');
const [pipelineFilter, setPipelineFilter] = useState<number | null>(null);
// Target filter — same encoded shape as SlicerPipelinesPanel: '' = all,
// 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
const [targetFilter, setTargetFilter] = useState<string>('');
const [offset, setOffset] = useState(0);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [showClearConfirm, setShowClearConfirm] = useState(false);
const { data: pipelines } = useQuery({
queryKey: ['slicer-pipelines'],
queryFn: () => api.listSlicerPipelines(),
});
// Used to resolve target_printer_id → printer name on each row so
// specific-printer runs show "H2D #1" instead of just an id.
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
const printersById: Record<number, Printer> = (printers ?? []).reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<number, Printer>,
);
const targetPrinterId = targetFilter.startsWith('p:')
? parseInt(targetFilter.slice(2), 10)
: undefined;
const targetModelClass = targetFilter.startsWith('c:')
? targetFilter.slice(2)
: undefined;
const { data: runsList, isLoading } = useQuery({
queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset],
queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset, targetFilter],
queryFn: () =>
api.listAllPipelineRuns({
limit: PAGE_LIMIT,
offset,
pipelineId: pipelineFilter ?? undefined,
status: statusFilter || undefined,
targetPrinterId,
targetModelClass,
}),
refetchInterval: 15_000,
});
@ -78,6 +114,21 @@ export function PipelineRunsPage() {
showToast(err.message || t('pipelineRuns.toast.retryFailed', 'Retry failed'), 'error'),
});
const clearMutation = useMutation({
mutationFn: () => api.clearTerminalPipelineRuns(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
setShowClearConfirm(false);
setOffset(0);
showToast(
t('pipelineRuns.toast.cleared', '{{n}} runs cleared', { n: result.deleted }),
'success',
);
},
onError: (err: Error) =>
showToast(err.message || t('pipelineRuns.toast.clearFailed', 'Clear failed'), 'error'),
});
const runs = runsList?.runs ?? [];
const total = runsList?.total ?? 0;
const pipelinesById: Record<number, SlicerPipeline> = (pipelines?.pipelines ?? []).reduce(
@ -97,62 +148,180 @@ export function PipelineRunsPage() {
});
};
const hasFilter = !!(statusFilter || pipelineFilter !== null || targetFilter);
// Build the target dropdown's options from the pipelines actually in use.
// Only printers / classes that at least one saved pipeline points at appear
// — keeps the dropdown short and meaningful.
const targetOptions = (() => {
const printerIds = new Set<number>();
const classes = new Set<string>();
for (const p of pipelines?.pipelines ?? []) {
if (p.target_kind === 'printer_class' && p.target_model_class) {
classes.add(p.target_model_class);
} else if (p.target_printer_id) {
printerIds.add(p.target_printer_id);
}
}
return {
printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)),
classes: Array.from(classes).sort(),
};
})();
return (
<div className="px-4 py-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Workflow className="w-5 h-5 text-bambu-green" />
{t('pipelineRuns.title', 'Pipeline Runs')}
</h1>
<div>
{/* Filter row bare dropdowns with placeholder labels and an inline
refresh button. The previous version had ``Pipeline:`` / ``Status:``
labels next to dropdowns that already declared the same thing, so the
row read as repetitive. */}
<div className="flex flex-wrap items-center gap-2 mb-4 text-sm">
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] })}
aria-label={t('common.refresh', 'Refresh')}
className="p-2 text-bambu-gray hover:text-white"
title={t('common.refresh', 'Refresh')}
className="p-1.5 text-bambu-gray hover:text-white border border-bambu-dark-tertiary rounded"
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className="w-3.5 h-3.5" />
</button>
<FilterDropdown
icon={<Workflow className="w-3.5 h-3.5 text-bambu-gray" />}
ariaLabel={t('pipelineRuns.filter.pipeline', 'Pipeline')}
value={pipelineFilter === null ? '' : String(pipelineFilter)}
onChange={(v) => {
setOffset(0);
setPipelineFilter(v ? parseInt(v, 10) : null);
}}
options={[
{ value: '', label: t('pipelineRuns.filter.allPipelines', 'All pipelines') },
...((pipelines?.pipelines ?? []).map((p) => ({
value: String(p.id),
label: p.name,
})) as DropdownOption[]),
]}
/>
<FilterDropdown
icon={<Filter className="w-3.5 h-3.5 text-bambu-gray" />}
ariaLabel={t('pipelineRuns.filter.status', 'Status')}
value={statusFilter}
onChange={(v) => {
setOffset(0);
setStatusFilter(v);
}}
options={STATUSES.map((s) => ({
value: s,
label:
s === ''
? t('pipelineRuns.filter.allStatus', 'All statuses')
: t(`settings.pipelines.runs.status.${s}`, s),
}))}
/>
{/* Target filter: built from targets actually in use across saved
pipelines so the dropdown stays short. Mirrors the SlicerPipelinesPanel
target picker. */}
{(targetOptions.printers.length > 0 || targetOptions.classes.length > 0) && (
<FilterDropdown
icon={<PrinterIcon className="w-3.5 h-3.5 text-bambu-gray" />}
ariaLabel={t('pipelineRuns.filter.target', 'Target')}
value={targetFilter}
onChange={(v) => {
setOffset(0);
setTargetFilter(v);
}}
options={[
{ value: '', label: t('pipelineRuns.filter.allTargets', 'All targets') },
...targetOptions.printers.map((p) => ({
value: `p:${p.id}`,
label: p.name,
group: t('settings.pipelines.field.targetKindSpecific', 'Specific printer'),
})),
...targetOptions.classes.map((c) => ({
value: `c:${c}`,
label: t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: c }),
group: t('settings.pipelines.field.targetKindClass', 'Printer class'),
})),
]}
/>
)}
{hasFilter && (
<button
type="button"
onClick={() => {
setStatusFilter('');
setPipelineFilter(null);
setTargetFilter('');
setOffset(0);
}}
className="text-xs text-bambu-gray hover:text-white"
>
{t('pipelineRuns.filter.clear', 'Clear filters')}
</button>
)}
<div className="ml-auto flex items-center gap-2 text-xs text-bambu-gray">
{!isLoading && total > 0 && (
<span>{t('pipelineRuns.totalCount', '{{n}} run', { n: total, count: total })}</span>
)}
{/* Clear logs opens a confirmation modal; only enabled when there
are terminal runs that the endpoint could actually delete. */}
<button
type="button"
onClick={() => setShowClearConfirm(true)}
disabled={total === 0}
className="flex items-center gap-1 px-2 py-1 text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="w-3 h-3" />
{t('pipelineRuns.clearLog', 'Clear log')}
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-4 text-sm">
<label className="text-bambu-gray">
{t('pipelineRuns.filter.pipeline', 'Pipeline')}:{' '}
<select
value={pipelineFilter ?? ''}
onChange={(e) => {
setOffset(0);
setPipelineFilter(e.target.value ? parseInt(e.target.value, 10) : null);
}}
aria-label={t('pipelineRuns.filter.pipeline', 'Pipeline')}
className="ml-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs"
{/* Clear-log confirmation modal. Only deletes terminal runs (completed
/ failed / cancelled / partial_failure); in-flight runs are
preserved so an active batch isn't accidentally torched. */}
{showClearConfirm && (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
onClick={() => setShowClearConfirm(false)}
role="dialog"
aria-modal="true"
>
<div
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl w-full max-w-md p-4"
onClick={(e) => e.stopPropagation()}
>
<option value="">{t('pipelineRuns.filter.all', 'All')}</option>
{(pipelines?.pipelines ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="text-bambu-gray">
{t('pipelineRuns.filter.status', 'Status')}:{' '}
<select
value={statusFilter}
onChange={(e) => {
setOffset(0);
setStatusFilter(e.target.value);
}}
aria-label={t('pipelineRuns.filter.status', 'Status')}
className="ml-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs"
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s === '' ? t('pipelineRuns.filter.all', 'All') : t(`settings.pipelines.runs.status.${s}`, s)}
</option>
))}
</select>
</label>
</div>
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Trash2 className="w-4 h-4 text-red-400" />
{t('pipelineRuns.clearConfirmTitle', 'Clear log?')}
</h3>
<p className="text-sm text-bambu-gray mt-2">
{t(
'pipelineRuns.clearConfirmBody',
'Delete every completed, failed, cancelled, and partial-failure pipeline run? In-flight runs are kept. This cannot be undone.',
)}
</p>
<div className="flex items-center justify-end gap-2 mt-4">
<button
type="button"
onClick={() => setShowClearConfirm(false)}
disabled={clearMutation.isPending}
className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending}
className="px-3 py-1.5 text-sm bg-red-500 hover:bg-red-600 text-white rounded disabled:opacity-50 flex items-center gap-1"
>
{clearMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
{t('pipelineRuns.clearConfirmAction', 'Clear')}
</button>
</div>
</div>
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-bambu-gray">
@ -161,16 +330,21 @@ export function PipelineRunsPage() {
</div>
)}
{!isLoading && runs.length === 0 && (
<p className="text-sm text-bambu-gray">{t('pipelineRuns.empty', 'No pipeline runs yet.')}</p>
<p className="text-sm text-bambu-gray">
{hasFilter
? t('pipelineRuns.filter.noMatches', 'No runs match the current filters.')
: t('pipelineRuns.empty', 'No pipeline runs yet.')}
</p>
)}
{!isLoading && runs.length > 0 && (
<div className="space-y-2">
<div className="space-y-1.5">
{runs.map((run) => (
<RunRow
key={run.id}
run={run}
pipeline={run.pipeline_id ? pipelinesById[run.pipeline_id] : undefined}
printersById={printersById}
expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)}
onCancel={() => cancelMutation.mutate(run.id)}
@ -216,6 +390,7 @@ export function PipelineRunsPage() {
function RunRow({
run,
pipeline,
printersById,
expanded,
onToggle,
onCancel,
@ -225,6 +400,7 @@ function RunRow({
}: {
run: PipelineRun;
pipeline: SlicerPipeline | undefined;
printersById: Record<number, Printer>;
expanded: boolean;
onToggle: () => void;
onCancel: () => void;
@ -235,48 +411,85 @@ function RunRow({
const { t } = useTranslation();
const inFlight = ['queued', 'slicing', 'dispatching', 'in_progress'].includes(run.status);
const partial = run.status === 'partial_failure' || run.status === 'failed';
const userCancelled = run.status === 'cancelled' && run.error_message === 'Cancelled by user';
// Target chip — class targeting reads "Any X1C", specific-printer reads the
// printer's actual name (resolved from the printersById map). The chip is
// only rendered when we have a target to show; an unbound pipeline simply
// omits it.
const targetLabel = run.target_kind === 'printer_class' && run.target_model_class
? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: run.target_model_class })
: run.target_printer_id && printersById[run.target_printer_id]
? printersById[run.target_printer_id].name
: null;
return (
<div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40">
<div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={onToggle}
aria-label={expanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
aria-expanded={expanded}
className="text-bambu-gray hover:text-white"
className="text-bambu-gray hover:text-white flex-shrink-0"
>
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-white truncate">
#{run.id} · {pipeline?.name ?? run.pipeline_name ?? '—'}
{/* Top line: run number · pipeline name · status badge. The status
chip is bigger and more saturated than the previous version so
it actually pops at a glance finding "what failed" was hard
when all chips read the same washed-out grey. */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
<span className="font-medium text-white">
#{run.id}
</span>
<span className="text-white truncate">
{pipeline?.name ?? run.pipeline_name ?? '—'}
</span>
<RunStatusChip status={run.status} />
{targetLabel && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray flex items-center gap-1">
<PrinterIcon className="w-3 h-3" />
{targetLabel}
</span>
)}
{run.parent_run_id && (
<span className="text-xs text-bambu-gray/60">
({t('pipelineRuns.retryOf', 'retry of #{{n}}', { n: run.parent_run_id })})
<span className="text-xs text-bambu-gray/70 italic">
{t('pipelineRuns.retryOf', 'retry of #{{n}}', { n: run.parent_run_id })}
</span>
)}
</div>
<div className="text-xs text-bambu-gray mt-0.5">
{run.source_filename ?? '—'} · {new Date(run.created_at).toLocaleString()}
{/* Second line: source filename give it its own row so long
titles don't crowd the metadata. */}
{run.source_filename && (
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-bambu-gray min-w-0">
<FileText className="w-3 h-3 flex-shrink-0" />
<span className="truncate" title={run.source_filename}>
{run.source_filename}
</span>
</div>
)}
{/* Third line: timestamp + roll-up counts. Greyed out so the eye
jumps to the title + chip first. */}
<div className="mt-0.5 text-xs text-bambu-gray/70 flex flex-wrap items-center gap-x-2">
<span>{new Date(run.created_at).toLocaleString()}</span>
{run.copies > 1 && (
<>
{' '}· {t('pipelineRuns.copies', '{{n}} copies', { n: run.copies })}
<span className="text-bambu-gray/40">·</span>
<span>{t('pipelineRuns.copies', '{{n}} copies', { n: run.copies })}</span>
</>
)}
{(run.copies_completed > 0 || run.copies_failed > 0) && (
{(run.copies_completed > 0 || run.copies_failed > 0 || run.copies_cancelled > 0) && (
<>
{' '}·{' '}
<span className="text-bambu-green">
{run.copies_completed}
<span className="text-bambu-gray/40">·</span>
<span>
<span className="text-bambu-green">{run.copies_completed}</span>
/{run.copies}
</span>
/{run.copies}
{run.copies_failed > 0 && (
<>
{' '}·{' '}
<span className="text-bambu-gray/40">·</span>
<span className="text-red-400">
{t('pipelineRuns.failedCount', '{{n}} failed', { n: run.copies_failed })}
</span>
@ -286,14 +499,14 @@ function RunRow({
)}
</div>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-shrink-0">
{inFlight && (
<button
type="button"
onClick={onCancel}
disabled={cancelling}
aria-label={t('common.cancel', 'Cancel')}
className="text-xs px-2 py-1 text-red-400 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50 flex items-center gap-1"
className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50 flex items-center gap-1"
>
<X className="w-3 h-3" />
{t('common.cancel', 'Cancel')}
@ -305,7 +518,7 @@ function RunRow({
onClick={onRetry}
disabled={retrying}
aria-label={t('pipelineRuns.retryFailed', 'Retry failed')}
className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-dark-tertiary rounded disabled:opacity-50 flex items-center gap-1"
className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-green/10 rounded disabled:opacity-50 flex items-center gap-1"
>
<RotateCcw className="w-3 h-3" />
{t('pipelineRuns.retryFailed', 'Retry failed')}
@ -314,64 +527,189 @@ function RunRow({
</div>
</div>
{expanded && (
<div className="border-t border-bambu-dark-tertiary px-3 py-2 space-y-1">
{run.jobs.map((job) => (
<div key={job.id} className="flex items-center gap-2 text-xs text-bambu-gray">
<span className="text-bambu-gray/60 w-12">
{t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
</span>
<JobStatusChip status={job.status} />
{job.assigned_printer_name && (
<span className="text-white">{job.assigned_printer_name}</span>
)}
{job.error_message && (
<span className="text-red-400 truncate">{job.error_message}</span>
)}
</div>
))}
{run.error_message && (
<div className="text-xs text-red-400 mt-1">
<div className="border-t border-bambu-dark-tertiary px-3 py-2 bg-bambu-dark/30">
<div className="space-y-1.5">
{run.jobs.map((job) => (
<div key={job.id} className="flex items-center gap-2 text-xs">
<span className="text-bambu-gray/60 w-14 flex-shrink-0">
{t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
</span>
<JobStatusChip status={job.status} />
{job.assigned_printer_name && (
<span className="text-bambu-gray flex items-center gap-1 truncate">
<PrinterIcon className="w-3 h-3 text-bambu-gray/60" />
<span className="text-white truncate">{job.assigned_printer_name}</span>
</span>
)}
{job.error_message && (
<span className="text-red-400 truncate" title={job.error_message}>
{job.error_message}
</span>
)}
</div>
))}
</div>
{run.error_message && !userCancelled && (
<div className="text-xs text-red-400 mt-2 pt-2 border-t border-bambu-dark-tertiary">
{run.error_message}
</div>
)}
{userCancelled && (
<div className="text-xs text-bambu-gray/70 mt-2 pt-2 border-t border-bambu-dark-tertiary italic">
{t('pipelineRuns.cancelledByUser', 'Cancelled by user')}
</div>
)}
</div>
)}
</div>
);
}
// High-contrast badges on solid Tailwind palette colours. The previous
// version used ``bg-bambu-gray/25 text-bambu-gray`` etc. — same hue for
// background AND text — which made every chip look washed-out grey
// regardless of state. These use saturated 700-tier backgrounds with bright
// 100-tier text, so each state reads at a glance.
function RunStatusChip({ status }: { status: PipelineRun['status'] }) {
const { t } = useTranslation();
const colours: Record<PipelineRun['status'], string> = {
queued: 'bg-bambu-gray/20 text-bambu-gray',
slicing: 'bg-blue-500/20 text-blue-300',
dispatching: 'bg-blue-500/20 text-blue-300',
in_progress: 'bg-bambu-green/20 text-bambu-green',
completed: 'bg-bambu-green/20 text-bambu-green',
failed: 'bg-red-500/20 text-red-300',
partial_failure: 'bg-amber-500/20 text-amber-300',
cancelled: 'bg-bambu-gray/20 text-bambu-gray',
queued: 'bg-slate-700 text-slate-200',
slicing: 'bg-sky-700 text-sky-100',
dispatching: 'bg-sky-700 text-sky-100',
in_progress: 'bg-emerald-700 text-emerald-100',
completed: 'bg-emerald-700 text-emerald-100',
failed: 'bg-red-700 text-red-100',
partial_failure: 'bg-amber-700 text-amber-100',
cancelled: 'bg-rose-900 text-rose-200',
};
return (
<span className={`px-1.5 py-0.5 rounded text-[10px] uppercase ${colours[status]}`}>
<span className={`px-1.5 py-0.5 rounded text-[10px] uppercase font-semibold tracking-wide whitespace-nowrap ${colours[status]}`}>
{t(`settings.pipelines.runs.status.${status}`, status)}
</span>
);
}
// Custom dropdown — bambu-themed replacement for the native browser
// `<select>` element. Same value/onChange contract; supports optional
// `group` per option to render section headers (replaces <optgroup>).
// Closes on outside click and Escape.
function FilterDropdown({
value,
onChange,
options,
icon,
ariaLabel,
}: {
value: string;
onChange: (value: string) => void;
options: DropdownOption[];
icon: ReactNode;
ariaLabel: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
const selected = options.find((o) => o.value === value);
// Group consecutive options that share the same `group` so the menu can
// render one header per group (mirrors the <optgroup> shape).
const grouped: { group: string | undefined; options: DropdownOption[] }[] = [];
for (const opt of options) {
const last = grouped[grouped.length - 1];
if (last && last.group === opt.group) last.options.push(opt);
else grouped.push({ group: opt.group, options: [opt] });
}
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
className="flex items-center gap-1.5 px-2 py-1 border border-bambu-dark-tertiary rounded bg-bambu-dark/40 text-xs text-white hover:border-bambu-gray/60 focus:outline-none focus:ring-1 focus:ring-bambu-green/40"
>
{icon}
<span className="truncate max-w-[14rem]">{selected?.label ?? ''}</span>
<ChevronDown
className={`w-3 h-3 text-bambu-gray transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
role="listbox"
className="absolute left-0 top-full mt-1 z-30 min-w-full max-h-72 overflow-auto rounded border border-bambu-dark-tertiary bg-bambu-dark-secondary shadow-xl py-1"
>
{grouped.map((g, gi) => (
<div key={gi}>
{g.group && (
<div className="px-2 pt-1.5 pb-0.5 text-[10px] uppercase tracking-wider text-bambu-gray/60">
{g.group}
</div>
)}
{g.options.map((opt) => {
const isSelected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={`flex w-full items-center gap-1.5 text-left px-2 py-1 text-xs whitespace-nowrap ${
isSelected
? 'bg-bambu-dark-tertiary text-white'
: 'text-bambu-gray hover:bg-bambu-dark-tertiary/60 hover:text-white'
}`}
>
<Check
className={`w-3 h-3 flex-shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="truncate">{opt.label}</span>
</button>
);
})}
</div>
))}
</div>
)}
</div>
);
}
function JobStatusChip({ status }: { status: PipelineRun['jobs'][number]['status'] }) {
const { t } = useTranslation();
const colours: Record<PipelineRun['jobs'][number]['status'], string> = {
pending: 'text-bambu-gray',
awaiting_printer: 'text-blue-300',
queued: 'text-blue-300',
printing: 'text-bambu-green',
completed: 'text-bambu-green',
failed: 'text-red-400',
cancelled: 'text-bambu-gray',
pending: 'bg-slate-700 text-slate-200',
awaiting_printer: 'bg-sky-700 text-sky-100',
queued: 'bg-sky-700 text-sky-100',
printing: 'bg-emerald-700 text-emerald-100',
completed: 'bg-emerald-700 text-emerald-100',
failed: 'bg-red-700 text-red-100',
cancelled: 'bg-rose-900 text-rose-200',
};
return (
<span className={colours[status]}>
<span className={`px-1.5 py-0.5 rounded text-[10px] uppercase font-semibold tracking-wide ${colours[status]}`}>
{t(`pipelineRuns.jobStatus.${status}`, status)}
</span>
);

View file

@ -59,8 +59,10 @@ import {
Ungroup,
Ban,
PlayCircle,
Workflow,
} from 'lucide-react';
import { api, ApiError } from '../api/client';
import { PipelineRunsView } from './PipelineRunsPage';
import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
import { getBedTypeInfo } from '../utils/bedType';
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
@ -1236,9 +1238,16 @@ export function QueuePage() {
// History tab renders unconditionally so this no longer drives the UI.
// Tabbed page structure: Active queue stays as the main view; History
// and Timeline split off. Persists per-user via localStorage.
const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline'>(() => {
const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline' | 'pipelines'>(() => {
// URL deep-link wins so the legacy /pipelines/runs redirect lands on the
// right tab. localStorage holds the per-user last-selected fallback.
const search = new URLSearchParams(window.location.search);
const url = search.get('tab');
if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue') {
return url;
}
const saved = localStorage.getItem('queue.activeTab');
if (saved === 'history' || saved === 'timeline') return saved;
if (saved === 'history' || saved === 'timeline' || saved === 'pipelines') return saved;
return 'queue';
});
// Active-tab layout toggle. "position" = today's flat list; "printer"
@ -1917,6 +1926,10 @@ export function QueuePage() {
{ id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
{ id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
{ id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
// Slicer Pipelines dashboard (#1425 PR C). Lives here instead of
// its own sidebar entry so the Print Queue page is the single
// place an operator looks for "what's running / what ran".
{ id: 'pipelines' as const, label: t('queue.tabs.pipelines'), icon: Workflow, count: null as number | null },
]).map(({ id, label, icon: Icon, count }) => (
<button
key={id}
@ -1940,15 +1953,15 @@ export function QueuePage() {
))}
</div>
{/* Summary Stats */}
<QueueStatsBar
{/* Summary Stats — about the print queue, not pipelines. */}
{activeTab !== 'pipelines' && <QueueStatsBar
activeCount={activeItems.length}
pendingCount={pendingItems.length}
totalTime={totalQueueTime}
totalWeight={totalWeight}
historyCount={historyItems.length}
t={t}
/>
/>}
{/* #1818: Resume-after-failure banner. One row per printer whose queue
is gated by a prior failed/aborted print. Visible regardless of
@ -1987,7 +2000,10 @@ export function QueuePage() {
</div>
)}
{/* Filters */}
{/* Filters — about the print queue items (printer / status / location).
The Pipelines tab has its own pipeline + status filters inside the
dashboard, so this row is hidden when that tab is active. */}
{activeTab !== 'pipelines' && (
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
<select
className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
@ -2049,6 +2065,7 @@ export function QueuePage() {
</Button>
)}
</div>
)}
{/* Queue-tab controls: layout toggle (Position / Printer) + SJF.
Hidden on History/Timeline tabs since they don't apply. */}
@ -2095,7 +2112,11 @@ export function QueuePage() {
</div>
)}
{isLoading ? (
{/* Pipelines tab short-circuits before the queue-empty branch so the
dashboard renders even when the regular queue is empty. */}
{activeTab === 'pipelines' ? (
<PipelineRunsView />
) : isLoading ? (
<div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
) : queue?.length === 0 ? (
<Card className="p-12 text-center border-dashed">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CWnKKhV6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B002rfYt.css">
<script type="module" crossorigin src="/assets/index-ChnivgH9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BYzbe9TT.css">
</head>
<body>
<div id="root"></div>