Skip to content

Latest commit

 

History

History
261 lines (226 loc) · 15.3 KB

PLAN.md

File metadata and controls

261 lines (226 loc) · 15.3 KB

ServiceManager Frontend — Implementation Plan

Angular dashboard (Angular Material) for the .NET ServiceManager backend. Backend: http://localhost:5193/ — Swagger: ServiceManager-swagger.json. Status: planned — implementation starts tomorrow.

Decisions (agreed)

Topic Decision
UI variant C — Service cards + SSE live job monitor
Refresh strategy Manual only (initial load + Refresh button + re-fetch after every action)
Theme Dark by default, toggleable to light (Material 3, class-based)
Job result colors done → green; failed → red; canceled/timeout/dependency/skipped → amber
Unit test framework Jasmine (ng new default) + Material test harnesses
Antiforgery No impact — .NET 9/10 only enforces antiforgery on minimal API endpoints that bind form data; all backend POSTs are body-less (verified against MS docs)
CORS Backend CustomCorsPolicy active (app.UseCors in pipeline); dev origins http://localhost:4200 / https://localhost:4200 in Cors:AllowedOrigins

API reference (from swagger + backend DTOs, JSON camelCase)

Base: /api/services

Endpoint Response
GET / ServiceStatusDto[]
GET /{name} ServiceStatusDto, or 404 ErrorDto
POST /{name}/start, POST /{name}/stop Blocks until job ends (timeout 600 s) → 200 JobResultDto / 500 JobFailed / 504 JobTimeout / 403·404·409·500·502 ErrorDto
POST /start-all, POST /stop-all Fail-fast sequence → 200 SequenceSuccessDto / 500·504 SequenceFailureDto
POST /{name}/monitor-start, POST /{name}/monitor-stop SSE stream (text/event-stream)
ServiceStatusDto { name, description, loadState, activeState, subState,
                   unitFileState, fragmentPath, jobId?: number|null,
                   jobPath?: string|null, stateChangedUtc?: string|null /* ISO "O" */ }
JobResultDto     { jobId, jobPath, result }
ErrorDto         { error, errorName, message }
SequenceSuccessDto  { completed: string[] }
SequenceFailureDto  { failedService, result?: string|null, completed: string[] }

Note: SequenceFailureDto.result may be a systemd job result or a D-Bus error name (e.g. org.freedesktop.systemd1.UnitMasked) — tone mappers need an unknown-value fallback.

SSE events (one JSON data object each, stream closes after last frame):

job-enqueued   { unit, jobId, jobPath }
state-changed  { loadState?, activeState?, subState?, unitFileState?, jobId? }   // * times
job-completed  { jobId, result }
error          { message }

Known quirk (documented in backend): a state-changed frame may legitimately precede job-enqueued (systemd can emit the first PropertiesChanged before the method reply). job-completed is always last.

Status semantics: job results done = success; failed, canceled, timeout, dependency, skipped = not success.

Unit states (systemd): active/inactive/activating/deactivating/failed/unknown.

Phase 1 — Scaffold

  • Angular 22 (CLI 22.1.8, Node 24), ng new: scss, no SSR, zoneless, Jasmine, --skip-git.
  • frontend/ is non-empty (.git, swagger, .opencode) → scaffold into a temp dir, move contents into frontend/, preserve git repo and the swagger file.
  • Add @angular/material + @angular/cdk (Angular-22-matching versions) + Material Icons.
  • src/app/core/env.ts: API_BASE = 'http://localhost:5193/api'.
  • Pin dev server port to 4200 (ng serve --port 4200); backend CORS origins depend on it.

Phase 1 — Implementation Notes

  • Angular 22's ng new defaults to vitest; used --test-runner=karma to keep the Jasmine decision.
  • ng new --directory interpreted the path relative to CWD (scaffold landed in frontend/tmp/...); moved in place, no harm done.
  • npm 11.19 gates package install scripts: approved esbuild, lmdb, msgpackr-extract, @parcel/watcher via npm install-scripts approve <pkg> — required for the Angular builder to work.
  • Installed: @angular/material@22.1.6, @angular/cdk@22.1.6, material-icons@1.13.14 (font @imported in src/styles.scss, verified bundled into dist).
  • Port 4200 pinned in angular.json serve options and the npm start script (ng serve --port 4200); verified live (HTTP 200 on localhost:4200).
  • ng build (production) passes.
  • Committed as root commit 6882ba1.

Phase 2 — Dashboard core (variant C foundation)

src/app/
  core/
    env.ts
    api/
      types.ts           ServiceStatus, JobResult, SequenceSuccess, SequenceFailure,
                         ErrorDto, SseJobEnqueued/StateChanged/JobCompleted/Error
      services-api.ts    injectable: list(), get(name), start(name), stop(name),
                         startAll(), stopAll(); typed error extraction (ErrorDto)
    status.ts            pure mappers:
                         unitStateTone(status) → 'success'|'danger'|'warning'|'neutral'|'progress'
                         jobResultTone(result) → 'success'|'danger'|'warning'; unknown values → 'warning'
                          (D-Bus error names in SequenceFailureDto.result fall here)
    theme/               M3 dark theme (default) + light, class-based toggle (document.documentElement)
  features/
    dashboard/
      dashboard.component      toolbar + card grid + loading/empty/error states
      service-card.component
  app.component              shell

Toolbar: app title · theme toggle icon · Refresh · Start All · Stop All.

Card contents:

  • Service name + description (secondary line)
  • Status badge with tone: active→success, failed→danger, activating/deactivating→progress (spinner + pulsing card border), inactive→neutral, other→neutral/warning; text shows activeState/subState
  • jobId present → "Job #n" chip + spinner, Start/Stop disabled
  • Last changed: relative time from stateChangedUtc (displayed in local time)
  • Actions: Start / Stop (Material buttons, icon+label)

Behavior:

  • List via resource() from @angular/core; re-fetch on Refresh and after every action completes.
  • ErrorDto → MatSnackBar with message (errorName as context).
  • Service names: list name values carry the .service suffix (e.g. example-sleep.service) — use the returned name verbatim in all start/stop/monitor calls; never strip or re-append the suffix.

Phase 2 — Implementation Notes

  • resource() API changed in Angular 22: the Observable-based request option is gone; resource() now takes a Promise/stream loader. For HttpClient observables the idiomatic form is rxResource() from @angular/core/rxjs-interop (stream: () => api.list()). Deviation from the plan's "resource() from @angular/core" wording — same resource API, RxJS flavor. Ref API in 22.1: .value()/.hasValue()/.isLoading()/.error()/.reload().
  • Material 22 theming: new API (mat.define-theme / mat.theme, M2 palettes like mat.$blue-palette). Components now bundle their own structural CSS; the global stylesheet only emits system tokens via mat.theme(). Dark/light is a single theme with theme-type: color-scheme — every color token compiles to light-dark(light, dark) and follows color-scheme on <html> (dark by default, html.theme-light { color-scheme: light; } flips it). ThemeService toggles theme-dark/theme-light classes on document.documentElement, persists to localStorage['sm-theme']; index.html sets the class pre-paint to avoid a flash.
  • App-level tone colors live in :root as --sm-tone-* (also light-dark()).
  • Singletons use the v22 @Service() decorator; cards use input()/output().
  • Shell stayed as generated app.ts (2025 naming) rendering <app-dashboard />; provideHttpClient() added to app.config.ts.
  • Bundle: initial ~454 kB raw / ~106 kB transfer (under the 500 kB warning budget).
  • Verified: production build, karma test (1/1), and a live headless-Chrome DOM dump against the running backend — card renders with badge--neutral "inactive / dead", Start/Stop buttons, dark theme default.
  • Rework (user feedback): service-card initially used plain <div>s inside mat-card — mat-card adds no padding of its own, so the layout was off. Rebuilt onto the predefined sections: mat-card-header (mat-card-title/mat-card-subtitle), mat-card-content (status badge + job/last-changed row), mat-card-actions, appearance="outlined", role="group". Process fix recorded in AGENTS.md/STATE.md: always consult .opencode/skills/angular-material/references/<component>.md before writing mat-* markup.

Phase 3 — SSE live job monitor (variant C)

  • src/app/core/sse/monitor-stream.ts:
    • Endpoints are POST → native EventSource can't be used.
    • fetch(POST) + response.body.getReader(); manual SSE frame parser (buffer until \n\n, parse event: / data: lines, JSON.parse payload).
    • Exposes AsyncIterable<SseEvent> (or push to a signal); AbortController to disconnect. Disconnecting does NOT cancel the job — it continues server-side (state this in UI copy).
  • features/job-monitor/monitor-panel.component (side drawer):
    • Header: service name + "starting…/stopping…"
    • Live state line per state-changed (tone-colored)
    • Collapsible event log (timestamped frames)
    • Terminal state: job-completed → green done / red failed / amber others; error frame → red + message
    • Must tolerate state-changed before job-enqueued; timeout arrives as an error event ("Timed out after Ns…"), not a distinct frame
    • On close → re-fetch service list
  • Wiring: card Start/Stop → open monitor → stream → close on completion.

Phase 3 — Implementation Notes

  • Transport (core/sse/monitor-stream.ts): POST SSE via fetch + response.body.getReader() (no native EventSource). openMonitorStream(url) returns { events: AsyncGenerator<SseEvent>, abort(), aborted }; an AbortController backs disconnect. parseSseFrame() is a pure, exported helper (buffer-to-\n\n loop in readMonitorEvents calls it) so it can be unit-tested with synthetic/partial frames. Non-2xx / no-body responses yield a synthetic error frame carrying the backend ErrorDto.message (or an HTTP-status fallback), so the panel always has a terminal error to render.
  • Panel (features/job-monitor/monitor-panel.component): a mat-sidenav (position="end", mode="over" — changed from the plan's drawer push so opening the monitor does not reflow the card grid) rendered inside mat-sidenav-container; the dashboard keeps a session signal ({service, action}) and viewChild.required<MatSidenav> to open/close. The panel consumes the AsyncIterable in a for await loop (started in ngAfterViewInit, aborted in ngOnDestroy). Signals: status (running/completed/error), result, resultTone, jobId, liveState (tone from activeStateTone), and a timestamped log of every frame.
  • Terminal handling: job-completed → tone via jobResultTone (green done, red failed, amber other) and an auto-close timer (2500 ms) so the result is visible before the drawer closes and the list re-fetches. error frame → red + message, no auto-close (user dismisses). A stream that ends still running (empty body, dropped connection) is coerced to an error state with an explanatory message.
  • Ordering quirk handled for real: observed a state-changed frame before job-enqueued and a trailing state-changed with jobId:0 after completion — the panel tolerates both (state line renders from any state-changed; jobId chip only once known).
  • UI copy: "Closing this panel does not cancel the job — it keeps running in the background." (plan requirement; disconnect ≠ cancel).
  • Card actions get a busy input; while a monitor session is open, all card Start/Stop and the toolbar Start All / Stop All are disabled (one job monitored at a time).
  • Deviation: Start All / Stop All keep the existing blocking endpoints + snackbar (the progress-dialog sub-item was dropped from the plan before implementation).
  • Bundle: initial grew ~454 kB → 531 kB raw (119 kB transfer), now over the 500 kB warning budget (1 MB error still clear). Cause: MatSidenavModule + MatExpansionModule pulled in eagerly. Candidate for Phase 4: lazy-load the monitor feature (loadComponent/@defer) if the warning matters.
  • Verified live (backend :5193, 1 s artificial SSE delay, Restart=no): start example-sleep-2 → live inactive/dead → active/running → green done, drawer auto-closes, card flips to active; stop example-sleep-3 → deactivating/stop-sigterm → inactive/dead → done; start already-active example-sleep → done (observed state-changed-before-enqueued); start missing example-missing → empty stream → red error state. Production build + karma pass.

Phase 4 — Polish & verification

  • Loading skeletons, empty state, backend-down error state.
  • Accessibility: aria-live on status changes, button labels, focus states.
  • Jasmine + TestBed tests:
    • status.ts tone mappers (all states + all job results)
    • SSE parser (feed synthetic frames incl. ordering quirk, partial chunks)
    • services-api error mapping (ErrorDto from non-2xx)
    • service-card and monitor-panel with Material test harnesses
  • ng lint, ng test, ng build.
  • Live pass against backend: list renders → start → live stream → green done → stop → start-all sequence → failure coloring (e.g. start an already-active service → failed → red).
  • README.md in frontend/: run steps, CORS note (dev origins configured in backend Cors:AllowedOrigins; production needs the production origin added there or same-origin serving of the built app), production serving note.

Phase 4 — Implementation Notes

  • Closed as-is on 2026-09-20 (explicit user decision; no new code in this phase). Loading/empty/error+Retry states and the a11y basics (aria-live, button labels, sidenav focus capture in over mode) had already landed incrementally in Phases 2–3.
  • Live verification was done incrementally instead of as one pass: the Phase 3 pass (start → live stream → green done, stop, missing unit, already-active start) plus the CDP pass in f3f4abf (backdrop click / Esc / in-panel Close all clear the monitor session, re-enable the buttons and re-fetch the list).
  • Deferred as known debt (listed in STATE.md "Known debt"): unit tests beyond the smoke test, lint setup (ng add @angular-eslint), README.md, the Start All / Stop All failure live check, bundle lazy-load of the monitor panel, Start All/Stop All in-button spinner, periodic "Changed Xm ago" tick, loading skeleton.

Risks / notes

  • CORS: resolved — app.UseCors("CustomCorsPolicy") added to the backend pipeline with real dev origins; verified live (preflight + GET return correct Access-Control-Allow-Origin headers).
  • Job timeout is 600 s: single-service ops are comfortable via the monitor; the blocking sequence endpoints are the one place a request can sit up to 10 min.
  • The angular-cli MCP list_projects tool currently returns a schema error (MCP bug) — non-blocking; drive ng CLI directly and use local Angular skills (.opencode/skills/angular-developer, angular-material).
  • Backend internals are out of scope; swagger + DTOs are sufficient for the frontend.