mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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:
parent
b5263eb5cd
commit
b23cb69a66
29 changed files with 1619 additions and 392 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '保存に失敗しました',
|
||||
|
|
|
|||
|
|
@ -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: '저장 실패',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '保存失败',
|
||||
|
|
|
|||
|
|
@ -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: '儲存失敗',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
2
static/assets/index-BYzbe9TT.css
Normal file
2
static/assets/index-BYzbe9TT.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue