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

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

Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
  native <select> elements are replaced with a bambu-themed FilterDropdown
  (button trigger, floating menu, optgroup-style headers for the Target
  picker, hover + selected states with a check mark, closes on outside
  click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
  reference is stable when the data is stable. Fixes the
  react-hooks/exhaustive-deps warning where the inline fallback returned
  a fresh empty array every render, invalidating both downstream useMemo
  caches (target-options + filtered-pipelines list).
This commit is contained in:
maziggy
2026-06-28 11:18:18 +02:00
parent b5263eb5cd
commit b23cb69a66
29 changed files with 1619 additions and 392 deletions
@@ -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',