Compare commits

..
Author SHA1 Message Date
timothyandOpenAI Codex 1e786c1f22 test(web): harden primary-action ownership regressions
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:36:16 +02:00
timothyandOpenAI Codex 4efb1a1b03 refactor(web): replace primary-action events with context
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:31:31 +02:00
timothyandOpenAI Codex 3876809a83 chore(web): clean up App imports after screen extraction
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m45s
Refs #246

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 21:51:42 +02:00
timothyandOpenAI Codex e9fe12d1fd refactor(web): extract Guide screen
Move Guide rendering, timer and clipping helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.

Refs #246

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 21:49:56 +02:00
timothyandOpenAI Codex bceffef856 refactor(web): extract Dashboard screen
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m19s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m57s
Move Dashboard rendering, health and on-air helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.

Refs #246

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 21:16:40 +02:00
timothy ecde59be32 docs(queue): record cheap-worker launch config (#356)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m44s
Merge the reviewed, green documentation update. Fixes #355.
2026-07-14 19:00:01 +00:00
timothyandOpenAI Codex b37924545c chore(ci): retry flaky Libraries screen test
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Retrigger CI after the existing Libraries scan-progress test failed once remotely but passed six consecutive local runs at the same documentation-only PR head.

Refs #355

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:51:59 +02:00
timothyandOpenAI Codex a54a2b6f82 chore(ci): retry runner setup for docs PR
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Retrigger CI after the migration job failed in Setup .NET while cleaning a missing runner-cache file, before any migration command executed.

Refs #355

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:46:01 +02:00
timothyandOpenAI Codex 67bfc1ceb1 docs(queue): record cheap-worker launch config
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Record the tested GPT-5.4-Mini low-effort worker command, current spawn-agent limitation, and unsupported legacy model trap so future sessions can route bounded work without rediscovery.

Refs #355

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:33:21 +02:00
timothyandOpenAI Codex 1e0eaca35e docs(queue): start tool-bearing selectors at low effort
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m20s
Clarify that enabled web_search makes a cheap worker tool-bearing even when the selector intends to use only Gitea, reserve minimal for tool-free supplied-evidence synthesis, and require hard evidence/output caps.

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:03:57 +02:00
timothyandOpenAI Codex 7cbd871ab7 test(spa): close remaining playout gate gaps
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m50s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m39s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m5s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m19s
Cover the None action matrix, required add inputs, file-edit blank/trim/payload/refresh/error behavior, and clearing a default deco to null.

Refs #245

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 19:22:41 +02:00
timothyandOpenAI Codex 6b41350e0c test(spa): complete playout kind coverage
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Exercise default-deco persistence and errors plus the complete action, add-dialog, and edit-field kind matrices identified by cold review.

Refs #245

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 19:12:56 +02:00
timothyandOpenAI Codex 533abe7cdb refactor(spa): extract playouts screen
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Move the Playouts UI, route wrapper, helpers, and detailed behavior tests into a colocated screen module while preserving App-level navigation coverage.

Refs #245

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 18:54:32 +02:00
timothy e454ed94fd Merge pull request 'docs(queue): enforce milestone-aware picker ranking' (#348) from codex/queue-picker-claim-comments into main
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m55s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m53s
2026-07-14 06:03:46 +00:00
timothy ffb3e6d5e1 Merge pull request 'fix(spa): clear dirty guard before saved navigation' (#349) from codex/344-dirty-guard into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-14 06:02:35 +00:00
timothyandOpenAI Codex b9305fdee9 fix(spa): preserve remote drafts during save
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Disable and gate remote connection draft inputs while a save owns the current revision. Exercise the real App-owned route transition after a successful save so the regression proves the editor unmounts without a dirty prompt.

Refs #344

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 00:06:29 +02:00
timothyandOpenAI Codex 3481f98bcc fix(spa): clear dirty guard before saved navigation
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fixes #344

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 23:47:43 +02:00
timothyandOpenAI Codex e280e9072c docs(queue): enumerate milestone candidates
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #347

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 23:31:04 +02:00
timothyandOpenAI Codex d235b91d15 docs(queue): preserve reviewer comment claims
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fixes #347

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 23:20:50 +02:00
timothyandOpenAI Codex b2c093d2d6 docs(e2e): record real media-source validation results
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 3m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m20s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Refs #333

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 22:51:52 +02:00
timothyandCodex 8da1256ffa docs(workflow): enforce session-wide cost routing
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 3m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Fixes #342

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 21:43:11 +02:00
timothyandCodex 2609b4ce59 fix: add cross-origin resource policy header
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m44s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Fixes #330

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 21:25:06 +02:00
timothyandCodex 80751a5d74 docs: require low-cost queue selection preflight
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 20:56:23 +02:00
19 changed files with 3907 additions and 2746 deletions
@@ -38,6 +38,19 @@ public class SecurityHeadersMiddlewareTests
context.Response.Headers["X-Frame-Options"].ToString().ShouldBe("DENY");
context.Response.Headers["Referrer-Policy"].ToString().ShouldBe("strict-origin-when-cross-origin");
context.Response.Headers["Permissions-Policy"].ToString().ShouldNotBeEmpty();
context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
}
[TestCase("/api/v1/channels")]
[TestCase("/artwork/x.jpg")]
[TestCase("/iptv/channels.m3u")]
[TestCase("/docs")]
[TestCase("/openapi/v1.json")]
public async Task Should_Set_Cross_Origin_Resource_Policy_On_Every_Response(string path)
{
HttpContext context = await Invoke(path);
context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
}
[Test]
@@ -74,6 +87,7 @@ public class SecurityHeadersMiddlewareTests
context.Response.Headers.ContainsKey("Content-Security-Policy").ShouldBeFalse($"CSP must not be set on {path}");
// The cheap baseline headers still apply everywhere.
context.Response.Headers["X-Content-Type-Options"].ToString().ShouldBe("nosniff");
context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
}
}
@@ -11,7 +11,8 @@ namespace ErsatzTV.Middleware;
/// <see cref="InvokeAsync" />. HSTS is intentionally left out: it is a deployment/TLS decision
/// that belongs with the go-live reverse-proxy posture, not this host middleware.
/// Baseline headers landed for #197; the CSP + Permissions-Policy were added for #319 (ZAP
/// baseline hardening).
/// baseline hardening). Cross-Origin-Resource-Policy was added for #330 to prevent browsers
/// from embedding responses through cross-origin no-cors requests.
/// </summary>
public class SecurityHeadersMiddleware(RequestDelegate next)
{
@@ -63,6 +64,7 @@ public class SecurityHeadersMiddleware(RequestDelegate next)
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
// The Scalar API-reference UI (/docs) and the OpenAPI document (/openapi) rely on inline
// bootstrap scripts/styles that a strict policy would break; hardening that admin surface is
+38
View File
@@ -54,6 +54,7 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor
- [2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)](#2026-07-11--channel-editor-bare-create-entry-point--external-logo-mutual-exclusion-212)
- [2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)](#2026-07-11--entitylocker-atomic-flags--single-owner-release-discipline-no-owner-tokens-231)
- [2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)](#2026-07-11--channels-screen-extraction-244-single-file-screen-no-sibling-helper-dir-epic-243-phase-1)
- [2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)](#2026-07-14--playouts-screen-extraction-245-screen-owned-route-wrapper-epic-243-phase-2)
- [2026-07-11 — Media-source management REST write API + SPA (#202)](#2026-07-11--media-source-management-rest-write-api--spa-202)
- [2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)](#2026-07-11--legacyspa-redirect-matcher-exact-map--ordered-segment-template-patterns-204)
- [2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)](#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206)
@@ -1771,3 +1772,40 @@ prod-copy migration smoke before live recreation. Tagging and promotion remain s
tag build's immutable `:<version>` image, then deploy manually. Daily auto-update is only a fallback,
so cut tags with enough runway before 03:00 to prevent an unscanned promotion. Refs #335 and
server-management#585/#589.
## 2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)
The authenticated #314 ZAP scan found that ErsatzTV's baseline response posture omitted
`Cross-Origin-Resource-Policy`. `SecurityHeadersMiddleware` now sends
`Cross-Origin-Resource-Policy: same-origin` on every response, including `/docs` and `/openapi`.
Those two paths remain exempt only from the strict CSP that would break Scalar's inline bootstrap;
CORP has no equivalent rendering conflict and belongs with the middleware's path-independent baseline
headers.
`same-origin` requires the browser request and response to share the exact scheme, host, and port. It
blocks cross-origin `no-cors` loads, so direct browser embedding of ErsatzTV artwork or media from an
alternate origin is deliberately unsupported. It does not reject an allowed CORS-mode API fetch, so the
explicit `Api:CorsAllowedOrigins` machine-client path continues to work. It is also not enforced by
server-side HTTP clients, so Jellyfin's `/iptv/*` requests are unaffected; same-origin SPA artwork and
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
or authentication. Refs #330 #319 #314.
## 2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)
Second bounded extraction under the App.tsx modularization epic (#243): the Playouts domain moved from
`web/src/App.tsx` into `web/src/screens/PlayoutsScreen.tsx`, including its loading/error/empty states,
dialogs, mutations, timeline/filter helpers, and the existing `PlayoutsRouteScreen`. The dedicated
`PlayoutScheduleEditors.tsx` modules remain separate. This is a pure structural move: no API, route, CSS,
or runtime behavior changed; `App.tsx` retains only the import and `<PlayoutsRouteScreen />` dispatch.
**The unguarded route wrapper moves with the screen.** Playouts owns two sibling sub-path editors and,
per `spa-conventions.md` §2, must keep its local pathname state plus `popstate` listener because App's
allow-sub-path route object is reference-stable. Colocating the wrapper keeps that screen-specific route
ownership beside the base screen while App-level tests retain the cross-route navigation assertions.
**Temporal mutation behavior stays verbatim.** `mutatingRef`, `runMutation`, and the explicit
`query.refresh()` after a 409 moved as one unit. The extraction deliberately does not add mount/current
guards to these pre-existing promise completions; changing those semantics belongs to a separate issue.
Detailed behavior tests now render `PlayoutsScreen` directly with a scoped fetch mock, including the
zero-playout Add Playout affordance, lock/409 handling, refresh/poll ownership, action and kind gates, and
dialog flows. Refs #245 #243.
+51 -3
View File
@@ -7,9 +7,11 @@ completion is tracked by follow-up issue [#333](https://gitea.tblindustries.be/t
The original live local E2E run (branch `feat/202-media-sources`, worktree
`/Users/timothy/etv-worktrees/202-int`) automated everything that a disposable local instance could
exercise (local library CRUD, scan, move-path, delete, dirty-guard, apiKey contract, remote-screen
render). The two flows below genuinely need a live Jellyfin/Emby server and a live Plex account and
have **not** been recorded as run. Exercise them against the homelab instances; do not mark #333 done
without actual results.
render). The two flows below genuinely need a live Jellyfin/Emby server and a live Plex account.
Results from the first homelab run on 2026-07-13 are recorded under each flow. Jellyfin validation is
complete. Plex account authorization and cleanup were exercised, but the test account returned no
eligible Plex servers, so server/library discovery and sync-preference persistence remain outstanding.
Do not mark #333 done until those remaining Plex steps are exercised against an account with a server.
## 1. Jellyfin/Emby real-server connect
@@ -27,6 +29,28 @@ without actual results.
7. Re-enter a **wrong** API key and confirm the UI surfaces a clear connection-failure state rather than
a silent success or an unhandled 500.
### Result — 2026-07-13: passed
- [x] Connected to the homelab Jellyfin server and confirmed the connected state and server address.
- [x] Confirmed the secure-key contract: `hasApiKey` help was visible, the replacement input was empty
and `type="password"`, and the raw API key was absent from the DOM and responses.
- [x] Toggled the Movies library off and back on. Both
`PUT /api/v1/media-sources/jellyfin/2/libraries` requests returned 200, and both states persisted
across reload. The original enabled state was restored.
- [x] Added `/etv-e2e-333/remote``/etv-e2e-333/local`; the path replacement persisted across
reload, then was removed and confirmed absent after another reload.
- [x] Launched Movies from the `/app/libraries` hub. `POST /api/v1/libraries/15/scan` returned 202, the
SPA showed **Scanning**, and **Last scan** advanced from 20:28 to 20:29. The rendered item count
remained 254 because the server contents did not change during the scan.
- [x] Submitted a deliberately wrong key. The connection request returned 422 Problem Details with
`Response status code does not indicate success: 401 (Unauthorized).`; the SPA showed that message
and stayed on the editor. The original key was then restored with a 200 response and the source
returned to its connected state.
The successful connection save also exposed a stale dirty-guard prompt during navigation. That
failure is captured separately in [#344](https://gitea.tblindustries.be/timothy/ersatztv/issues/344).
No unexpected page errors occurred; the only console error was the deliberately induced 422 request.
## 2. Plex interactive pin-flow auth
1. Go to `/app/libraries/plex`.
@@ -42,11 +66,35 @@ without actual results.
7. Sign out / de-authorize and confirm the SPA correctly returns to the "Not signed in" state without
leaving stale server/library data visible.
### Result — 2026-07-13: partial; Plex server prerequisite remains
- [x] Started a real plex.tv pin flow. The SPA opened the HTTPS authorization URL in a new tab and
displayed the waiting state while polling `GET /api/v1/media-sources/plex`.
- [x] Completed authorization. Without a manual refresh, the SPA transitioned from **Waiting for you
to authorize** at 20:35:13 UTC to a terminal state at 20:35:55 UTC. The API then reported
`isAuthorized: true` and `isLocked: false`.
- [ ] Server discovery returned `servers: []`. The bounded server log recorded successful Plex
authentication and no discovery exception, but this test account/deployment exposed no eligible
Plex server. Repeat with an owned, reachable Plex Media Server attached to the test account.
- [ ] With no discovered server, the library list, sync-preference toggle, and persistence checks could
not be exercised.
- [x] Removed the temporary authorization through
`POST /api/v1/media-sources/plex/sign-out` (204). The API returned to `isAuthorized: false`,
`isLocked: false`, `servers: []`, and the SPA showed **Not signed in** with no stale server rows.
The authorized/unlocked/zero-server state exposed a separate SPA defect: the pin status said
**Connected to Plex**, the Connection card said **Not signed in**, and the UI offered another sign-in
but no sign-out action. Cleanup therefore required the API. The exact request/state evidence and
root-cause analysis are captured in
[#345](https://gitea.tblindustries.be/timothy/ersatztv/issues/345).
## Notes for whoever runs this
- Use a throwaway/test Jellyfin or Emby API key if possible — the SPA's `hasApiKey` contract means the
UI never displays the key back, so you won't be able to visually re-confirm which key is active
beyond "a key is set."
- The Plex pin-flow is time-limited (plex.tv auth PINs expire) — don't leave the tab idle mid-flow.
- A Plex account authorization alone is insufficient for the remaining validation. The account must
expose at least one owned, reachable Plex Media Server so its libraries can be discovered and edited.
- If either flow 500s or the SPA console shows errors, capture the exact request/response (Network
tab) and file a separate bug linked from #333.
+73 -22
View File
@@ -11,7 +11,7 @@
## Orchestrator launch profile (operator-facing; do not paste)
As of 2026-07-13:
As of 2026-07-14:
- **Codex:** use **GPT-5.6 Sol, `ultra`** for the orchestrator. `ultra` is the Codex
multi-agent orchestration setting; if it is unavailable, use **GPT-5.6 Sol, `max` effort**
@@ -19,9 +19,16 @@ As of 2026-07-13:
- **Claude Code:** use **Opus 4.8, `xhigh` effort** (or **ultracode**, which is the Claude Code
orchestration preset built on `xhigh`). Reserve **Fable 5, `high` effort** for the frontier
escalations listed below; use `xhigh` there only when the decision remains genuinely difficult.
- **Queue preflight:** when the user has not named an issue, use the client's cheapest suitable
fast/small model at **low** effort. Queue selection is tool-bearing, so do not use `minimal`:
Codex validates the full enabled tool profile, and enabled `web_search` can reject `minimal` even
when the selector intends to call only Gitea MCP. Route it as a subagent when possible; otherwise
run a separate low-cost selector session and pass its compact selection packet to the orchestrator.
Never spend the orchestrator tier on queue ranking.
- If those names are unavailable, choose the client's strongest long-horizon coding/agentic model
at its high extended-reasoning setting. Do not weaken the orchestrator to a fast/mini model; use
smaller models only for bounded delegated slices when the client supports per-agent selection.
smaller models for bounded delegated slices, with a separate low-cost queue preflight when the
client cannot select a cheaper model per agent.
The kickoff below deliberately describes capability tiers instead of model names so it survives
model rollovers. The operator-facing recommendations above may be updated without changing the
@@ -39,16 +46,35 @@ assume a named tool, command, plugin, model-routing feature, or fork mechanism e
Route by capability when the client supports per-agent model selection: fast/small for bounded recon,
balanced for mechanical changes, and the strongest coding/agentic tier for judgment-heavy work.
Otherwise use the active model for every slice. Independent review MUST start from a cold, review-only
brief; prefer a different model family/client when one is available, otherwise use a fresh agent with
no implementation role.
Otherwise use the active model for every slice except the mandatory queue preflight below. Independent
review MUST start from a cold, review-only brief; prefer a different model family/client when one is
available, otherwise use a fresh agent with no implementation role.
**Queue selection is a fast/small slice, not orchestrator work, when routing exists.** Dispatch one
low-effort selector with only #237's body, last ~6 comments, live issue/milestone/label state, and the
reviewer-repo candidate list; ask it for a ranked proposal, then have the orchestrator confirm the winner
is still open before claiming. Do not load implementation docs or issue bodies into the selector. If the
client cannot route a cheaper model, perform this small live-state sort inline instead of spawning an
equally expensive agent; delegate only after the pick has a concrete task-specific slice.
**Low-cost routing applies throughout the session, not only to queue selection.** Before any batch of
bounded searches, inventories, log triage, URL/status sampling, or report drafting, dispatch the cheapest
suitable fast/small model. **If any tool is enabled or required, start at `low`; this includes enabled
`web_search` even when the prompt names only Gitea MCP. Reserve `minimal` for explicitly tool-free synthesis
over already-supplied bounded evidence.** Never retry a known tool-bearing profile at `minimal`. Strictly
cap its allowed files, queries, log window, result/evidence count, and output; instruct it to stop and return
partial evidence at a cap instead of broadening scope. The orchestrator receives a compact evidence packet
and performs only the minimum focused live recheck. Same-tier agents add parallelism, not cost savings,
while direct tool-only mechanical checks may stay inline. If a tool-bearing launch is rejected at `minimal`,
retry once with the same model and unchanged scope at `low`. If a cheap worker lacks a required tool, give it
already-collected bounded evidence for explicitly tool-free synthesis or report the limitation instead of
silently expanding orchestrator reconnaissance. Global Codex hook enforcement is tracked in
`timothy/server-management#592`; the wider Claude-hook port is tracked in `timothy/server-management#593`.
**When the user has not named an issue, queue selection is mechanical fast/small work, never orchestrator
work.** Dispatch exactly one selector on the cheapest suitable model at `low` effort. Give it only
#237's body, last ~6 comments, every OPEN milestone and its OPEN issues, OPEN `review`-labeled
issues, and the reviewer-repo candidate list plus each candidate's claim/deliverable comments; it
returns a ranked shortlist of at most three issue IDs with one-line rationales and live-state evidence. The
orchestrator receives only that compact packet, then performs a focused live recheck of the winner before
claiming. Do not load implementation docs or issue bodies into the selector. If the client cannot route a
cheaper subagent, run the selector in a separate low-cost session before starting or resuming the orchestrator
and pass in its packet. **Do not fall back to inline sorting or an equally expensive selector.** If no cheaper
route or session is available, pause and request the selector packet rather than consuming orchestrator tokens
on queue ranking. If the user names an issue, skip selection and only verify that issue's live claimability.
FIRST read `AGENTS.md` and `CLAUDE.md` when present, then `docs/README.md`, the convention docs it
indexes, and the Lessons below. Apply both client instruction files; where they differ, follow the
@@ -70,8 +96,10 @@ Everything else (claiming, worktrees, dispatching implementers, CI monitoring, p
bookkeeping, routine merges of green reviewed PRs with user consent) stays at your level.
Then work the queue:
1. Read the pinned tracker **ersatztv#237** — body = goal + ordered arc + session protocol — and
its **last ~6 session comments** (newest-first; the full comment payload is large, so stop at ~6).
1. Unless the user named an issue, dispatch or obtain the mandatory low-cost selector packet. The
**selector**, not the orchestrator, reads the pinned tracker **ersatztv#237** — body = goal + ordered
arc + session protocol — and its **last ~6 session comments** (newest-first; the full comment
payload is large, so stop at ~6).
**SOURCE OF TRUTH = live Gitea issue state, NEVER the prose.** The arc body carries ORDER + goal
only; a session comment's "Recommended next" is a forward *guess* written before the next session
acted. Both go stale the instant an item closes (especially under parallel sessions narrating each
@@ -79,26 +107,40 @@ Then work the queue:
any inline marker or a prior comment's "next": the **current gate = the lowest-numbered OPEN arc
item in #237's arc list**; its open children are the gate cluster (query them by the `review`
label). Cross-check every arc / "recommended" item's real open/closed state (issue **and**
milestone) before trusting it — do NOT hardcode which issue is the frontier; read it. ALSO list
milestone) before trusting it — do NOT hardcode which issue is the frontier; read it. Candidate
discovery MUST enumerate every OPEN issue in every OPEN milestone plus OPEN `review`-labeled
issues; do not limit the pool to tracker prose or recent comments. Treat an umbrella/epic as a
container rather than a pickup when #237 names its eligible children. ALSO list
open `ersatztv`-labeled issues in **timothy/adversarial-reviewer** — unclaimed audits there are
pickup candidates too (read-only, parallel-safe; see the tracker's "Pending adversarial reviews"
section). If the Gitea MCP is down, hit the REST API directly (use credentials supplied by the
environment or your global client instructions; never print them):
section). Reviewer audits are claimed by comment: a claim remains active until a later comment
explicitly releases or abandons it, and a posted audit/review deliverable is completed work even
when its issue stays open for implementer replies. If the Gitea MCP is down, hit the REST API
directly (use credentials supplied by the environment or your global client instructions; never
print them):
`curl -u <user>:<pass> http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/issues/237`.
**The authoritative pickup + ranking protocol lives in #237's "Session protocol" section — this is a summary; if the two ever disagree, #237 wins.**
2. Pick the highest-ranked OPEN, un-`in-progress` candidate (or the item the user names). **Rank by
labels, not just arc position** (labels are live state; prose is not): (1) arc order in #237, then
(2) **gate vs backlog** — an item marked gate by the `review` label OR membership in an open gate
2. The selector picks the highest-ranked OPEN, un-`in-progress` candidate and returns at most two
fallbacks. **Rank by labels, not just arc position** (labels are live state; prose is not):
(1) arc order in #237, then
(2) **milestone/review tier vs backlog** — an item with the `review` label OR membership in an OPEN
milestone outranks anything unmilestoned; then (3) **`priority:`** label — `high` > `medium` >
`low` within a tier. Pick order across the three pools: the **arc frontier** (lowest-numbered open
arc item) first; an unclaimed Blocker/High **priority-pickup** (`review` + `priority: high`, e.g.
#253) beats a *non-frontier* arc item; reviewer-repo **audits** are read-only and run in parallel.
**Confirm the pick is genuinely OPEN in Gitea first.** If the prose says "recommended next / now
The orchestrator then makes one focused Gitea read to confirm the proposed winner is still OPEN,
unclaimed, and not blocked by a closed milestone; if it changed, check the next supplied fallback.
Do not reread the full tracker or comments for selection. If the prose says "recommended next / now
unblocked" but the issue (or its milestone) is already CLOSED, it is done — skip it and fix the
stale line in your session comment. Prose lags live state; live state wins; milestone + `priority:`
labels decide gate-vs-backlog, not the prose. (This mirrors #237's Session-protocol ranking — #237 is canonical.)
labels decide gate-vs-backlog, not the prose. For an equal milestone/review tier and equal priority,
preserve #237's explicit eligible order, then use lowest issue number as the deterministic fallback;
never invent a fix-size, recency, or perceived-relevance tiebreaker. (This mirrors #237's
Session-protocol ranking — #237 is canonical.)
3. **Claim it**: add the `in-progress` label + a "claiming" comment on the issue(s);
reviewer-repo audits are claimed by comment only.
reviewer-repo audits are claimed by comment only. Treat that claim as live until a later comment
explicitly releases or abandons it, and exclude audits with a posted deliverable even while the
issue remains open for implementer replies.
4. Read the issue bodies (they carry the task context/evidence) and work the item under the
HARD CONSTRAINTS below.
5. Finish by following the session-end protocol in #237: run the **H12 qualification audit**
@@ -142,6 +184,15 @@ HARD CONSTRAINTS:
- READ docs/README.md → the convention docs FIRST; point recon/implementer agents at specific
doc sections. Only recon the task-specific delta.
- **Codex cheap-worker launch (tested 2026-07-14)**: the native `spawn_agent` interface currently
has no model/effort selector, so it provides parallelism but not cost savings. For bounded,
tool-bearing selector/recon work, launch a separate worker with
`codex exec --ephemeral --color never -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only
-C <repo> "<hard-capped scope contract>"`. GPT-5.4-Mini is the current supported small/cost-efficient
profile on this ChatGPT-authenticated host; the older `gpt-5.1-codex-mini` guess fails with HTTP 400.
Keep `low` whenever any shell/MCP/web tool is exposed; reserve `minimal` for explicitly tool-free
synthesis over already-supplied bounded evidence. If the model rolls over, inspect the visible entries
in `~/.codex/models_cache.json` instead of guessing names, then update this dated note once verified.
- Keep the MAIN checkout's `web/node_modules` fresh (`npm install` after pulling a PR that
adds a dep) — worktrees copy it, and a stale copy broke typecheck in a #198 worktree.
- **Playwright-MCP E2E: never open tabs/window.open for file-download endpoints** — curl them.
+21 -7
View File
@@ -33,13 +33,14 @@ for the base path and every sub-path under it. `App`'s state update is
`setActiveRoute(routeFromLocation())`; React's `useState` setter bails via `Object.is` when the new
value is reference-equal to the old one — so navigating from `/app/blocks` to `/app/blocks/42` (or
between `/app/blocks/42` and `/app/blocks/17`) **never re-invokes `ScreenContent`** at the `App`
level. See the comment block directly above `PlayoutsRouteScreen` in `App.tsx` (~line 3540) for the
canonical explanation, and its implementation (`useState(() => window.location.pathname)` +
level. See the comment block directly above `PlayoutsRouteScreen` in
`screens/PlayoutsScreen.tsx` for the canonical explanation, and its implementation
(`useState(() => window.location.pathname)` +
`useEffect` with a `popstate` listener local to the wrapper component) for the fix.
Exemplars of screens that already do this correctly: `BlocksScreen.tsx`, `TemplatesScreen.tsx`,
`DecosScreen.tsx`, `DecoTemplatesScreen.tsx`, and the `PlayoutsRouteScreen` wrapper in `App.tsx`
(which owns two sibling sub-paths, `/playouts/{id}/alternate-schedules` and
`DecosScreen.tsx`, `DecoTemplatesScreen.tsx`, and the `PlayoutsRouteScreen` wrapper colocated in
`PlayoutsScreen.tsx` (which owns two sibling sub-paths, `/playouts/{id}/alternate-schedules` and
`/playouts/{id}/templates`, dispatching internally via `parsePlayoutSubRoute`).
**Exception — a guarded sub-path route defers pathname ownership to App.** When a sub-path route's
@@ -251,7 +252,7 @@ is gone. The SPA seams now are:
- Every screen with meaningful logic gets a screen test; every API client module gets a
param-mapping / URL-building test (e.g. `logs.test.ts` next to `logs.ts`).
- `web/src/App.test.tsx` covers navigation + the route table, including regressions like the
sub-path bug in §2 (see the tests around `PlayoutsRouteScreen`, ~line 1682+, that click into
sub-path bug in §2 (see the tests around `PlayoutsRouteScreen` that click into
`/app/playouts/{id}/...` sub-paths and assert the correct sub-screen rendered).
- **Nav-label test-selector care**: `getByRole('link'/'button', { name: /Regex/ })` matches by
substring by default — a loose regex can match more than one nav item. Verified example: the
@@ -260,7 +261,8 @@ is gone. The SPA seams now are:
contain "System". Anchor (`^`/`$`) or use exact strings in `getByRole` name matchers whenever a
new label could be a substring of (or share a substring with) an existing one — check
`App.tsx`'s nav `label:` list for collisions before picking a new label.
- **Extracted-screen tests own their own fetch mock** (Schedules #207, Channels #244): when a
- **Extracted-screen tests own their own fetch mock** (Schedules #207, Channels #244, Playouts
#245): when a
screen is pulled out of `App.tsx` into `web/src/screens/<Name>Screen.tsx`, its colocated
`<Name>Screen.test.tsx` builds a **self-contained** `vi.spyOn(window, 'fetch')` mock scoped to
that screen's own endpoints (plus local `jsonResponse`/fixture-factory helpers) and renders the
@@ -269,7 +271,7 @@ is gone. The SPA seams now are:
screen (route to it, assert it mounted and hit its own endpoint) plus genuinely cross-cutting
shell concerns (route table, sub-path ownership §2, the unsaved-changes/popstate guard §8, the
TopBar action wiring). Detailed screen behavior lives in the screen's own test file. See
`ChannelsScreen.test.tsx` / `SchedulesScreen.test.tsx` for the shape.
`ChannelsScreen.test.tsx` / `SchedulesScreen.test.tsx` / `PlayoutsScreen.test.tsx` for the shape.
## 7. Verification gate — run before every commit touching `web/`
@@ -327,6 +329,18 @@ draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:
Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time.
- **Successful save + same-callback navigation:** `useDirtyGuard` returns a `markClean` callback for
the narrow case where a successful save queues the new draft/baseline and immediately calls
`navigateToPath(...)` in that same promise callback. React has not committed the clean render before
the synthetic `popstate`, so call `markClean()` after the durable save succeeds and immediately before
navigating. Still queue the real clean draft/baseline state; the callback only transfers the synchronous
guard truth across that one event boundary. Only do this while the successful request still owns the
current draft: disable or otherwise gate draft mutations for the entire in-flight save, or revision-check
the completion before marking clean. Otherwise the completion can discard edits made after the request
started. Do not replace the callback with a timer or a forced render. Screens that remain mounted after
saving do not need the callback.
## 9. Review checklist — temporal semantics
- **For every effect / timer / async completion, ask: *when* does it fire (mount, dependency change,
+76 -1044
View File
File diff suppressed because it is too large Load Diff
+7 -1636
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -8,11 +8,18 @@
// each keystroke. A single hook keeps the three editors (connection / libraries / path-replacements)
// consistent instead of copy-pasting the guard block into each.
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { registerNavigationGuard } from '../navigationGuard';
export function useDirtyGuard(dirty: boolean, prompt: string): void {
export function useDirtyGuard(dirty: boolean, prompt: string): () => void {
const dirtyRef = useRef(dirty);
// A successful save may navigate in the same promise callback that queues the clean baseline.
// React has not rendered that baseline yet, so let the caller transfer the guard synchronously
// before dispatching the synthetic popstate. The normal render still owns the durable state.
const markClean = useCallback(() => {
dirtyRef.current = false;
}, []);
// Keep the ref current for the synchronous guard predicate. Updated in an effect (committed before
// any user pop/nav gesture can fire) rather than during render.
useEffect(() => {
@@ -32,4 +39,6 @@ export function useDirtyGuard(dirty: boolean, prompt: string): void {
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
return markClean;
}
+152
View File
@@ -0,0 +1,152 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PrimaryActionProvider, usePrimaryAction, usePrimaryActionHandler } from './primaryAction';
function Registration({ routeId, handler }: { routeId: string; handler: () => void }) {
usePrimaryAction(routeId, handler);
return null;
}
function Action({ routeId }: { routeId: string }) {
const handler = usePrimaryActionHandler(routeId);
return (
<button type="button" disabled={!handler} onClick={handler}>
Run {routeId}
</button>
);
}
describe('primary actions', () => {
afterEach(() => {
cleanup();
});
it('invokes the active handler for a matching route', () => {
const handler = vi.fn();
render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={handler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(handler).toHaveBeenCalledOnce();
});
it('returns no handler for a nonmatching route', () => {
const handler = vi.fn();
render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={handler} />
<Action routeId="channels" />
</PrimaryActionProvider>
);
const action = screen.getByRole('button', { name: 'Run channels' });
expect(action).toBeDisabled();
fireEvent.click(action);
expect(handler).not.toHaveBeenCalled();
});
it('invokes the latest handler without replacing the registration', () => {
const firstHandler = vi.fn();
const latestHandler = vi.fn();
const view = render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={firstHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={latestHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(firstHandler).not.toHaveBeenCalled();
expect(latestHandler).toHaveBeenCalledOnce();
});
it("moves the same owner's registration to a new route and clears it on unmount", () => {
const handler = vi.fn();
const view = render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={handler} />
<Action routeId="schedules" />
<Action routeId="channels" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration routeId="channels" handler={handler} />
<Action routeId="schedules" />
<Action routeId="channels" />
</PrimaryActionProvider>
);
expect(screen.getByRole('button', { name: 'Run schedules' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Run channels' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Run channels' }));
expect(handler).toHaveBeenCalledOnce();
view.rerender(
<PrimaryActionProvider>
<Action routeId="schedules" />
<Action routeId="channels" />
</PrimaryActionProvider>
);
expect(screen.getByRole('button', { name: 'Run channels' })).toBeDisabled();
});
it('does not let an older owner rerender or cleanup reclaim a newer registration', () => {
const olderHandler = vi.fn();
const updatedOlderHandler = vi.fn();
const newerHandler = vi.fn();
const view = render(
<PrimaryActionProvider>
<Registration key="older" routeId="schedules" handler={olderHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration key="older" routeId="schedules" handler={olderHandler} />
<Registration key="newer" routeId="schedules" handler={newerHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration key="older" routeId="schedules" handler={updatedOlderHandler} />
<Registration key="newer" routeId="schedules" handler={newerHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(newerHandler).toHaveBeenCalledOnce();
view.rerender(
<PrimaryActionProvider>
<Registration key="newer" routeId="schedules" handler={newerHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(olderHandler).not.toHaveBeenCalled();
expect(updatedOlderHandler).not.toHaveBeenCalled();
expect(newerHandler).toHaveBeenCalledTimes(2);
});
it('lets screens render harmlessly without a provider', () => {
expect(() => render(<Registration routeId="schedules" handler={vi.fn()} />)).not.toThrow();
});
});
+57 -28
View File
@@ -1,39 +1,68 @@
import { useEffect, useRef } from 'react';
import {
createContext,
createElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
// The TopBar renders one "primary action" button per screen (top-right). Because the
// TopBar and the screens are decoupled (the TopBar has no reference to the active
// screen component), the click is delivered as a window CustomEvent keyed on the
// active route id; a screen opts in with `usePrimaryAction(routeId, handler)`.
//
// A screen that does NOT call usePrimaryAction gets NO button — App.tsx's route table
// declares an empty `primaryAction` for such screens and the TopBar suppresses the
// button (see issue #238: the old code rendered a dead button for every unwired route).
export const PRIMARY_ACTION_EVENT = 'ctv:primary-action';
type PrimaryActionHandler = () => void;
export function dispatchPrimaryAction(routeId: string): void {
window.dispatchEvent(new CustomEvent(PRIMARY_ACTION_EVENT, { detail: routeId }));
interface PrimaryActionRegistration {
owner: symbol;
routeId: string;
handler: PrimaryActionHandler;
}
/**
* Subscribe a screen to its TopBar primary-action button. The handler runs whenever the
* TopBar dispatches `ctv:primary-action` with a detail matching `routeId`. The latest
* handler is held in a ref so re-renders don't churn the window listener.
*/
export function usePrimaryAction(routeId: string, handler: () => void): void {
interface PrimaryActionContextValue {
registration: PrimaryActionRegistration | undefined;
register: (owner: symbol, routeId: string, handler: PrimaryActionHandler) => () => void;
}
const PrimaryActionContext = createContext<PrimaryActionContextValue | undefined>(undefined);
/** Owns the single primary-action registration shared by the shell and active screen. */
export function PrimaryActionProvider({ children }: { children: ReactNode }) {
const [registration, setRegistration] = useState<PrimaryActionRegistration>();
const register = useCallback((owner: symbol, routeId: string, handler: PrimaryActionHandler) => {
setRegistration({ owner, routeId, handler });
return () => {
setRegistration((current) => (current?.owner === owner ? undefined : current));
};
}, []);
const value = useMemo(() => ({ registration, register }), [register, registration]);
return createElement(PrimaryActionContext.Provider, { value }, children);
}
/** Register a screen's primary action. Rendering outside the provider is intentionally harmless. */
export function usePrimaryAction(routeId: string, handler: PrimaryActionHandler): void {
const handlerRef = useRef(handler);
// Keep the ref pointing at the latest handler without re-subscribing the window listener
// on every render. Updating a ref during render trips react-hooks/refs, so do it in an effect.
const [owner] = useState(() => Symbol('primary-action-owner'));
const register = useContext(PrimaryActionContext)?.register;
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const listener = (event: Event) => {
if ((event as CustomEvent<string>).detail === routeId) {
handlerRef.current();
}
};
window.addEventListener(PRIMARY_ACTION_EVENT, listener);
return () => window.removeEventListener(PRIMARY_ACTION_EVENT, listener);
}, [routeId]);
if (!register) {
return;
}
return register(owner, routeId, () => handlerRef.current());
}, [owner, register, routeId]);
}
/** Return the active handler only when its registration belongs to the requested route. */
export function usePrimaryActionHandler(routeId: string): PrimaryActionHandler | undefined {
const registration = useContext(PrimaryActionContext)?.registration;
return registration?.routeId === routeId ? registration.handler : undefined;
}
+282
View File
@@ -0,0 +1,282 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDashboardHealthQuery } from '../api';
import { DashboardScreen } from './DashboardScreen';
describe('DashboardScreen', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
beforeEach(() => {
mockDashboardApi();
});
it('renders dashboard cards without the retired placeholder sections', async () => {
renderDashboard();
expect(await screen.findByText('On air now')).toBeInTheDocument();
expect(screen.getByText('System health')).toBeInTheDocument();
expect(screen.queryByText('Recent activity')).not.toBeInTheDocument();
expect(screen.queryByText('Release notes')).not.toBeInTheDocument();
});
it('renders dashboard cards and stats from live API responses', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
id: 1,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
streamingMode: 'HLS Direct'
},
{
ffmpegProfile: 'MPEG-TS',
id: 2,
language: 'fr',
name: 'News 24',
number: '24',
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true,
nowPlaying: {
finishUtc: '2026-07-04T21:30:00Z',
startUtc: '2026-07-04T21:00:00Z',
title: 'Saturday Morning Cartoons'
}
},
{
channelId: 2,
channelNumber: '24',
onAir: false,
nowPlaying: null
}
],
health: [
{
detail: 'SQLite is reachable',
link: null,
status: 'pass',
title: 'Database'
},
{
detail: 'FFmpeg path is missing',
link: null,
status: 'warn',
title: 'FFmpeg'
}
],
mediaSources: [
{
connectionAddress: null,
id: 30,
kind: 'Local',
libraries: [
{ id: 31, kind: 'Movies', name: 'Movies' },
{ id: 32, kind: 'Shows', name: 'Shows' }
],
name: 'Local'
}
],
playouts: {
page: [
{
buildStatus: {
lastBuild: '2026-07-04T20:00:00Z',
message: null,
success: true
},
channelName: 'Retro Cartoons',
channelNumber: '5.1',
dailyRebuildTime: null,
id: 20,
scheduleKind: 'Classic',
scheduleName: 'Default Schedule'
}
],
totalCount: 1
}
});
renderDashboard();
expect(await screen.findByRole('heading', { name: 'On air now' })).toBeInTheDocument();
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByText('5.1')).toBeInTheDocument();
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
expect(screen.getByText('1 on air')).toBeInTheDocument();
expect(screen.getByText('1 warning')).toBeInTheDocument();
expect(screen.getAllByText('2')).toHaveLength(2);
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/v1/media-sources', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/v1/health', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/sessions', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/schedules', expect.any(Object));
});
it('shows the dashboard loading state while requests are pending', async () => {
vi.mocked(window.fetch).mockImplementation(() => new Promise<Response>(() => {}));
renderDashboard();
expect(await screen.findByText('Loading dashboard')).toBeInTheDocument();
});
it('shows an empty on-air state when no channel state is on air', async () => {
mockDashboardApi({
channels: [{ id: 1, name: 'Retro Cartoons', number: '5.1' }],
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: false, nowPlaying: null }]
});
renderDashboard();
expect(await screen.findByText('No on-air channels reported')).toBeInTheDocument();
});
it('shows failing health checks with error styling, distinct from neutral info checks', async () => {
mockDashboardApi({
health: [
{
detail: 'SQLite is reachable',
link: null,
status: 'pass',
title: 'Database'
},
{
detail: 'FFmpeg path is missing',
link: null,
status: 'fail',
title: 'FFmpeg'
},
{
detail: 'Scheduled maintenance window active',
link: null,
status: 'info',
title: 'Maintenance'
}
]
});
const { container } = renderDashboard();
expect(await screen.findByText('FFmpeg path is missing')).toBeInTheDocument();
expect(screen.getByText('1 failing')).toBeInTheDocument();
expect(container.querySelectorAll('.ctv-health-icon-error')).toHaveLength(1);
expect(container.querySelectorAll('.ctv-health-icon-idle').length).toBeGreaterThanOrEqual(1);
});
it('refreshes health on demand without polling it', async () => {
mockDashboardApi({
health: [
{
detail: 'All checks passed',
link: null,
status: 'pass',
title: 'System'
}
]
});
renderDashboard();
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
expect(fetchCount('/api/v1/health')).toBe(1);
fireEvent.click(screen.getByRole('button', { name: 'Refresh health' }));
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
expect(fetchCount('/api/v1/health')).toBe(2);
});
it('shows the API error detail when dashboard loading fails', async () => {
vi.mocked(window.fetch).mockImplementation(() =>
Promise.resolve(new Response(
JSON.stringify({
detail: 'API write key is invalid',
status: 401,
title: 'Unauthorized'
}),
{
headers: { 'Content-Type': 'application/json' },
status: 401
}
))
);
renderDashboard();
expect(await screen.findByText('API write key is invalid')).toBeInTheDocument();
});
});
function DashboardUnderTest() {
const healthState = useDashboardHealthQuery();
return <DashboardScreen healthState={healthState} />;
}
function renderDashboard() {
return render(<DashboardUnderTest />);
}
function mockDashboardApi({
channels = [],
channelStates = [],
health = [],
mediaSources = [],
playouts = { page: [], totalCount: 0 }
}: {
channels?: unknown[];
channelStates?: unknown[];
health?: unknown[];
mediaSources?: unknown[];
playouts?: unknown;
} = {}) {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
if (path === '/api/v1/channels') {
return Promise.resolve(jsonResponse(channels));
}
if (path === '/api/v1/channels/state') {
return Promise.resolve(jsonResponse(channelStates));
}
if (path === '/api/v1/media-sources') {
return Promise.resolve(jsonResponse(mediaSources));
}
if (path === '/api/v1/playouts') {
return Promise.resolve(jsonResponse(playouts));
}
if (path === '/api/v1/health') {
return Promise.resolve(jsonResponse(health));
}
return Promise.resolve(jsonResponse(null, 404));
});
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function fetchCount(path: string): number {
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
}
+301
View File
@@ -0,0 +1,301 @@
import type { ReactNode } from 'react';
import {
Check,
Info,
Library,
ListVideo,
Radio,
RefreshCw,
TriangleAlert,
Tv
} from 'lucide-react';
import {
Badge,
Button,
Card,
ChannelLogo,
ProgressBar,
Spinner,
Stat,
StatusDot
} from '../components';
import {
useDashboardQuery,
type DashboardChannel,
type DashboardChannelState,
type DashboardHealthQueryState
} from '../api';
type HealthStatus = 'error' | 'idle' | 'live' | 'ok' | 'warn';
function summarizeHealth(
healthState: DashboardHealthQueryState
): { label: string; status: HealthStatus } {
if (healthState.status === 'loading') {
return { label: 'Checking', status: 'idle' };
}
if (healthState.status === 'error') {
return { label: 'Health unavailable', status: 'error' };
}
const failedCount = healthState.checks.filter((check) => isErrorHealthStatus(check.status)).length;
const warningCount = healthState.checks.filter((check) => isWarningHealthStatus(check.status)).length;
if (failedCount > 0) {
return { label: `${failedCount} failing`, status: 'error' };
}
if (warningCount > 0) {
return { label: `${warningCount} warning${warningCount === 1 ? '' : 's'}`, status: 'warn' };
}
return { label: 'Healthy', status: 'ok' };
}
export function DashboardHealthSummary({ healthState }: { healthState: DashboardHealthQueryState }) {
const summary = summarizeHealth(healthState);
return <StatusDot status={summary.status} label={summary.label} />;
}
// The backend serializes health check status as exactly 'pass' | 'fail' | 'warn' | 'info'
// (see ErsatzTV.Application/Health/Mapper.cs GetStatus).
function isWarningHealthStatus(status: string): boolean {
return status.toLowerCase() === 'warn';
}
function isErrorHealthStatus(status: string): boolean {
return status.toLowerCase() === 'fail';
}
function isInfoHealthStatus(status: string): boolean {
return status.toLowerCase() === 'info';
}
function healthIconStatus(status: string): HealthStatus {
if (isErrorHealthStatus(status)) {
return 'error';
}
if (isWarningHealthStatus(status)) {
return 'warn';
}
if (isInfoHealthStatus(status)) {
return 'idle';
}
return 'ok';
}
function healthIcon(status: string): ReactNode {
const iconStatus = healthIconStatus(status);
if (iconStatus === 'error') {
return <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'warn') {
return <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'idle') {
return <Info aria-hidden="true" size={15} />;
}
return <Check aria-hidden="true" size={15} />;
}
function progressFromNowPlaying(nowPlaying: NonNullable<DashboardChannelState['nowPlaying']>): number | null {
const start = new Date(nowPlaying.startUtc).getTime();
const finish = new Date(nowPlaying.finishUtc).getTime();
const now = Date.now();
if (!Number.isFinite(start) || !Number.isFinite(finish) || finish <= start) {
return null;
}
return Math.round(((now - start) / (finish - start)) * 100);
}
function minutesUntil(value: string): number | null {
const finish = new Date(value).getTime();
if (!Number.isFinite(finish)) {
return null;
}
return Math.max(0, Math.ceil((finish - Date.now()) / 60000));
}
function OnAirCard({ channel, state }: { channel: DashboardChannel | null; state: DashboardChannelState }) {
const name = channel?.name ?? 'Unnamed channel';
const number = state.channelNumber || channel?.number || `${state.channelId}`;
const nowPlaying = state.nowPlaying;
const progress = nowPlaying ? progressFromNowPlaying(nowPlaying) : null;
const remaining = nowPlaying ? minutesUntil(nowPlaying.finishUtc) : null;
return (
<div className="ctv-onair-card">
<div className="ctv-onair-head">
<ChannelLogo name={name} size={34} />
<div>
<code>{number}</code>
<strong>{name}</strong>
</div>
<Badge tone="accent" dot>
On air
</Badge>
</div>
<p>{nowPlaying?.title ?? 'Now-playing data unavailable'}</p>
<ProgressBar value={progress} />
<div className="ctv-onair-meta">
<span>{progress == null ? 'Progress unavailable' : `${Math.max(0, Math.min(100, progress))}% elapsed`}</span>
<span>{remaining == null ? 'Finish unavailable' : `${remaining}m to next`}</span>
</div>
</div>
);
}
function DashboardLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading dashboard</span>
</div>
</Card>
);
}
function DashboardErrorState({ error }: { error: string }) {
return (
<Card title={<h2>Dashboard unavailable</h2>} subtitle="Live API request failed">
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{error}</strong>
</div>
</Card>
);
}
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
return (
<Card
title={<h2>System health</h2>}
subtitle="Backend health checks"
actions={
<Button
onClick={healthState.refresh}
loading={healthState.status === 'loading'}
startIcon={<RefreshCw aria-hidden="true" size={14} />}
variant="secondary"
>
Refresh health
</Button>
}
padded={false}
>
<div className="ctv-health-panel">
{healthState.status === 'loading' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Spinner size={15} tone="muted" />
</span>
<strong>Health checks</strong>
<span>Loading current health</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'error' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-error">
<TriangleAlert aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>{healthState.error}</span>
<StatusDot status="error" />
</div>
)}
{healthState.status === 'success' && healthState.checks.length === 0 && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Info aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>No health checks returned</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'success' && healthState.checks.map((check) => {
const rowStatus = healthIconStatus(check.status);
return (
<div className="ctv-health-row" key={check.title}>
<span className={`ctv-health-icon ctv-health-icon-${rowStatus}`}>{healthIcon(check.status)}</span>
<strong>{check.title}</strong>
<span>{check.detail}</span>
<StatusDot status={rowStatus} />
</div>
);
})}
</div>
<div className="ctv-health-summary">
<DashboardHealthSummary healthState={healthState} />
</div>
</Card>
);
}
export function DashboardScreen({
healthState
}: {
healthState: DashboardHealthQueryState;
}) {
const dashboardQuery = useDashboardQuery();
if (dashboardQuery.status === 'loading') {
return <DashboardLoadingState />;
}
if (dashboardQuery.status === 'error') {
return <DashboardErrorState error={dashboardQuery.error} />;
}
const { channels, channelStates, mediaSources, playouts } = dashboardQuery.data;
const channelsById = new Map(channels.map((channel) => [channel.id, channel]));
const onAirStates = channelStates.filter((state) => state.onAir).slice(0, 4);
const playoutCount = playouts.totalCount;
const libraryCount = mediaSources.reduce((count, source) => count + source.libraries.length, 0);
return (
<div className="ctv-screen-stack">
<section className="ctv-stat-row" aria-label="At a glance">
<Stat label="Channels" value={channels.length} icon={<Tv aria-hidden="true" size={15} />} />
<Stat label="Active playouts" value={playoutCount} icon={<ListVideo aria-hidden="true" size={15} />} />
<Stat label="On air" value={onAirStates.length} icon={<Radio aria-hidden="true" size={15} />} />
<Stat label="Libraries" value={libraryCount} icon={<Library aria-hidden="true" size={15} />} />
</section>
<section className="ctv-dashboard-grid">
<Card
title={<h2>On air now</h2>}
subtitle="Current programmes by channel"
actions={<Badge tone="accent" dot>{onAirStates.length} on air</Badge>}
>
<div className="ctv-onair-grid">
{onAirStates.length > 0 ? (
onAirStates.map((state) => (
<OnAirCard channel={channelsById.get(state.channelId) ?? null} state={state} key={state.channelId} />
))
) : (
<div className="ctv-dashboard-empty">No on-air channels reported</div>
)}
</div>
</Card>
<HealthPanel healthState={healthState} />
</section>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GuideScreen } from './GuideScreen';
describe('GuideScreen', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-05T20:30:00Z'));
});
it('renders the Guide screen from a bounded JSON guide window', async () => {
mockGuideApi({
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true,
nowPlaying: {
finishUtc: '2026-07-05T21:00:00Z',
startUtc: '2026-07-05T20:00:00Z',
title: 'Saturday Morning Cartoons - s01e01 - Pilot'
}
},
{ channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null }
],
guide: guideFixture()
});
const { container } = render(<GuideScreen />);
await flushGuideEffects();
expect(screen.getByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByText('News 24')).toBeInTheDocument();
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
expect(screen.getByText('Pilot')).toBeInTheDocument();
expect(screen.getAllByText('Kids').length).toBeGreaterThan(0);
expect(screen.getByText('Early Show')).toBeInTheDocument();
expect(screen.getByText('No programmes in this window')).toBeInTheDocument();
expect(screen.queryByText('Filler')).not.toBeInTheDocument();
expect(container.querySelectorAll('.ctv-epg-programme-live')).toHaveLength(1);
const guideCall = fetchCallsStartingWith('/api/v1/guide?')[0];
expect(guideCall).toBeDefined();
const guideUrl = new URL(guideCall, window.location.origin);
expect(guideUrl.searchParams.get('start')).toBe('2026-07-05T19:30:00.000Z');
expect(guideUrl.searchParams.get('end')).toBe('2026-07-06T08:30:00.000Z');
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object));
});
it('renders the Guide screen when an on-air channel omits nowPlaying', async () => {
mockGuideApi({
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true
}
],
guide: guideFixture()
});
render(<GuideScreen />);
await flushGuideEffects();
expect(screen.getByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
});
it('moves the Guide now marker on timer ticks without polling the guide endpoint', async () => {
mockGuideApi({ guide: guideFixture() });
const { container } = render(<GuideScreen />);
await flushGuideEffects();
expect(screen.getByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
const guideFetchesBeforeTick = fetchCallsStartingWith('/api/v1/guide?').length;
const marker = container.querySelector('.ctv-epg-now-marker') as HTMLElement;
expect(marker.style.left).toBe('508px');
vi.setSystemTime(new Date('2026-07-05T20:59:00Z'));
await act(async () => {
await vi.advanceTimersByTimeAsync(60000);
});
expect(marker.style.left).toBe('664px');
expect(fetchCallsStartingWith('/api/v1/guide?')).toHaveLength(guideFetchesBeforeTick);
});
it('fetches one Guide window per explicit navigation and jumps back to now', async () => {
mockGuideApi({ guide: guideFixture() });
render(<GuideScreen />);
await flushGuideEffects();
expect(screen.getByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
expect(fetchCallsStartingWith('/api/v1/guide?')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Next guide window' }));
await flushGuideEffects();
expect(fetchCallsStartingWith('/api/v1/guide?')).toHaveLength(2);
const nextUrl = new URL(fetchCallsStartingWith('/api/v1/guide?')[1], window.location.origin);
expect(nextUrl.searchParams.get('start')).toBe('2026-07-06T08:30:00.000Z');
expect(nextUrl.searchParams.get('end')).toBe('2026-07-06T21:30:00.000Z');
fireEvent.click(screen.getByRole('button', { name: 'Jump to now' }));
await flushGuideEffects();
expect(fetchCallsStartingWith('/api/v1/guide?')).toHaveLength(3);
const nowUrl = new URL(fetchCallsStartingWith('/api/v1/guide?')[2], window.location.origin);
expect(nowUrl.searchParams.get('start')).toBe('2026-07-05T19:30:00.000Z');
expect(nowUrl.searchParams.get('end')).toBe('2026-07-06T08:30:00.000Z');
});
it('shows Guide API errors and retries', async () => {
mockGuideApi({
guide: guideFixture(),
guideFailuresBeforeSuccess: 1
});
render(<GuideScreen />);
await flushGuideEffects();
expect(screen.getByText('Request failed with status 500')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await flushGuideEffects();
expect(screen.getByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
});
});
async function flushGuideEffects() {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
function mockGuideApi({
channelStates = [],
guide = guideFixture(),
guideFailuresBeforeSuccess = 0
}: {
channelStates?: unknown[];
guide?: unknown;
guideFailuresBeforeSuccess?: number;
} = {}) {
let remainingGuideFailures = guideFailuresBeforeSuccess;
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
if (path.startsWith('/api/v1/guide?')) {
if (remainingGuideFailures > 0) {
remainingGuideFailures -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
return Promise.resolve(jsonResponse(guide));
}
if (path === '/api/v1/channels/state') {
return Promise.resolve(jsonResponse(channelStates));
}
return Promise.resolve(jsonResponse(null, 404));
});
}
function fetchCallsStartingWith(prefix: string): string[] {
return vi.mocked(window.fetch).mock.calls
.map(([input]) => input.toString())
.filter((path) => path.startsWith(prefix));
}
function guideFixture(overrides: Record<string, unknown> = {}) {
return {
channels: [
{
name: 'Retro Cartoons',
number: '5.1',
programmes: [
{
category: 'Kids',
fillerKind: 'None',
start: '2026-07-05T19:00:00Z',
stop: '2026-07-05T20:00:00Z',
subTitle: null,
title: 'Early Show'
},
{
category: 'Kids',
fillerKind: 'None',
start: '2026-07-05T20:00:00Z',
stop: '2026-07-05T21:00:00Z',
subTitle: 'Pilot',
title: 'Saturday Morning Cartoons'
},
{
category: null,
fillerKind: 'None',
start: '2026-07-05T22:00:00Z',
stop: '2026-07-05T23:00:00Z',
subTitle: null,
title: 'Saturday Morning Cartoons'
},
{
category: null,
fillerKind: 'None',
start: '2026-07-06T08:00:00Z',
stop: '2026-07-06T09:00:00Z',
subTitle: null,
title: 'Window Edge Special'
}
]
},
{
name: 'News 24',
number: '24',
programmes: []
}
],
end: '2026-07-06T08:30:00Z',
start: '2026-07-05T19:30:00Z',
...overrides
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
+295
View File
@@ -0,0 +1,295 @@
import { useEffect, useState } from 'react';
import { Crosshair, RefreshCw, TriangleAlert } from 'lucide-react';
import {
defaultGuideWindowStart,
GUIDE_WINDOW_MS,
useGuideScreenQuery,
type ChannelState,
type ChannelGuideChannel,
type ChannelGuideProgramme
} from '../api';
import {
Badge,
Button,
Card,
ChannelLogo,
Select,
Spinner,
StatusDot
} from '../components';
const EPG_SLOT_MINUTES = 30;
const EPG_SLOT_WIDTH = 156;
const EPG_RAIL_WIDTH = 196;
const EPG_ROW_HEIGHT = 82;
const EPG_HEADER_HEIGHT = 34;
function GuideLoadingState() {
return (
<Card title={<h2>Loading guide</h2>} subtitle="Fetching a bounded JSON guide window and live channel state.">
<div className="ctv-dashboard-state">
<Spinner tone="muted" />
<span>Loading guide</span>
</div>
</Card>
);
}
function GuideErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Guide unavailable</h2>}
subtitle="The API returned an error while loading the JSON guide."
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
>
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
</Card>
);
}
export function GuideScreen() {
const query = useGuideScreenQuery();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const intervalId = window.setInterval(() => {
setNow(new Date());
}, 60000);
return () => {
window.clearInterval(intervalId);
};
}, []);
if (query.status === 'loading') {
return <GuideLoadingState />;
}
if (query.status === 'error') {
return <GuideErrorState error={query.error} refresh={query.refresh} />;
}
const channels = query.data.guide.channels;
const channelStatesByNumber = new Map(query.data.channelStates.map((state) => [state.channelNumber, state]));
const windowStart = new Date(query.data.guide.start);
const windowEnd = new Date(query.data.guide.end);
const slots = guideSlots(windowStart, windowEnd);
const totalWidth = EPG_RAIL_WIDTH + slots.length * EPG_SLOT_WIDTH;
const nowOffset = offsetPx(now, windowStart);
const nowInWindow = now >= windowStart && now <= windowEnd;
const moveWindow = (delta: number) => {
query.setWindowStart(new Date(query.windowStart.getTime() + delta * GUIDE_WINDOW_MS));
};
const jumpToNow = () => {
query.setWindowStart(defaultGuideWindowStart(now));
};
return (
<div className="ctv-epg-screen">
<section className="ctv-epg-toolbar" aria-label="Guide controls">
<div className="ctv-epg-filter">
<Select disabled label="Channel group" options={['All channels']} size="sm" value="All channels" />
</div>
<div className="ctv-epg-window">
<span>{formatGuideTime(windowStart)}</span>
<input
aria-label="Guide window position"
disabled
max="100"
min="0"
type="range"
value={nowInWindow ? Math.round((now.getTime() - windowStart.getTime()) / (windowEnd.getTime() - windowStart.getTime()) * 100) : 0}
readOnly
/>
<span>{formatGuideTime(windowEnd)}</span>
</div>
<Badge tone={nowInWindow ? 'accent' : 'neutral'} dot={nowInWindow}>Now {formatGuideTime(now)}</Badge>
<Button onClick={() => moveWindow(-1)} variant="secondary">Previous</Button>
<Button onClick={() => moveWindow(1)} variant="secondary">Next guide window</Button>
<Button onClick={jumpToNow} startIcon={<Crosshair aria-hidden="true" size={15} />} variant="primary">Jump to now</Button>
</section>
<section className="ctv-epg-grid-shell" aria-label="Guide timeline">
<div className="ctv-epg-scroll">
<div
aria-label="Channel guide"
className="ctv-epg-grid"
role="grid"
style={{ minWidth: totalWidth }}
>
<div className="ctv-epg-time-head" role="row" style={{ height: EPG_HEADER_HEIGHT }}>
<div className="ctv-epg-rail-head" style={{ width: EPG_RAIL_WIDTH }} />
{slots.map((slot) => (
<div className="ctv-epg-time-slot" key={slot.toISOString()} role="columnheader" style={{ width: EPG_SLOT_WIDTH }}>
{formatGuideTime(slot)}
</div>
))}
</div>
{nowInWindow && (
<div
aria-hidden="true"
className="ctv-epg-now-marker"
style={{
bottom: 0,
left: EPG_RAIL_WIDTH + nowOffset,
top: EPG_HEADER_HEIGHT
}}
>
<span />
</div>
)}
{channels.map((channel, index) => (
<GuideChannelRow
channel={channel}
channelState={channelStatesByNumber.get(channel.number) ?? null}
index={index}
key={channel.number}
windowEnd={windowEnd}
windowStart={windowStart}
/>
))}
</div>
</div>
</section>
</div>
);
}
function GuideChannelRow({
channel,
channelState,
index,
windowEnd,
windowStart
}: {
channel: ChannelGuideChannel;
channelState: ChannelState | null;
index: number;
windowEnd: Date;
windowStart: Date;
}) {
const visibleProgrammes = channel.programmes
.map((programme) => clipProgramme(programme, windowStart, windowEnd))
.filter((programme): programme is ClippedProgramme => programme !== null);
return (
<div className="ctv-epg-row" role="row" style={{ height: EPG_ROW_HEIGHT }}>
<div className="ctv-epg-channel-rail" role="rowheader" style={{ width: EPG_RAIL_WIDTH }}>
<code>{channel.number}</code>
<ChannelLogo name={channel.name} size={30} />
<span>{channel.name}</span>
{channelState?.onAir && <StatusDot status="live" size={7} />}
</div>
<div className={`ctv-epg-track${index % 2 ? ' ctv-epg-track-alt' : ''}`} role="gridcell">
{visibleProgrammes.length === 0 ? (
<span className="ctv-epg-empty">No programmes in this window</span>
) : visibleProgrammes.map((programme) => (
<GuideProgrammeBlock
key={`${programme.title}-${programme.start.getTime()}-${programme.stop.getTime()}`}
programme={programme}
channelState={channelState}
windowStart={windowStart}
/>
))}
</div>
</div>
);
}
interface ClippedProgramme {
category: null | string;
fillerKind: string;
start: Date;
stop: Date;
subTitle: null | string;
title: string;
visibleStart: Date;
visibleStop: Date;
}
function GuideProgrammeBlock({
channelState,
programme,
windowStart
}: {
channelState: ChannelState | null;
programme: ClippedProgramme;
windowStart: Date;
}) {
const filler = programme.fillerKind !== 'None';
const live = Boolean(channelState?.onAir && programmeMatchesNowPlaying(programme, channelState.nowPlaying));
const left = offsetPx(programme.visibleStart, windowStart) + 3;
const width = Math.max(24, offsetPx(programme.visibleStop, programme.visibleStart) - 6);
return (
<article
className={`ctv-epg-programme${live ? ' ctv-epg-programme-live' : ''}${filler ? ' ctv-epg-programme-filler' : ''}`}
style={{ left, width }}
title={`${programme.title}${programme.subTitle ? ` - ${programme.subTitle}` : ''}`}
>
<div className="ctv-epg-programme-title">
{live && <StatusDot status="live" size={6} />}
<strong>{programme.title}</strong>
</div>
{programme.subTitle && <span>{programme.subTitle}</span>}
{filler ? <Badge tone="neutral">Filler</Badge> : programme.category && <small>{programme.category}</small>}
</article>
);
}
function guideSlots(start: Date, end: Date): Date[] {
const slots: Date[] = [];
const slotMs = EPG_SLOT_MINUTES * 60 * 1000;
for (let value = start.getTime(); value < end.getTime(); value += slotMs) {
slots.push(new Date(value));
}
return slots;
}
function formatGuideTime(value: Date): string {
return value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function offsetPx(value: Date, start: Date): number {
return (value.getTime() - start.getTime()) / (EPG_SLOT_MINUTES * 60 * 1000) * EPG_SLOT_WIDTH;
}
function clipProgramme(programme: ChannelGuideProgramme, windowStart: Date, windowEnd: Date): ClippedProgramme | null {
const start = new Date(programme.start);
const stop = new Date(programme.stop);
if (Number.isNaN(start.getTime()) || Number.isNaN(stop.getTime()) || stop <= windowStart || start >= windowEnd) {
return null;
}
return {
category: programme.category,
fillerKind: programme.fillerKind,
start,
stop,
subTitle: programme.subTitle,
title: programme.title,
visibleStart: start < windowStart ? windowStart : start,
visibleStop: stop > windowEnd ? windowEnd : stop
};
}
function programmeMatchesNowPlaying(programme: ClippedProgramme, nowPlaying: ChannelState['nowPlaying'] | undefined): boolean {
if (!nowPlaying) {
return false;
}
const nowPlayingStart = new Date(nowPlaying.startUtc);
const nowPlayingFinish = new Date(nowPlaying.finishUtc);
return programme.start.getTime() === nowPlayingStart.getTime() &&
programme.stop.getTime() === nowPlayingFinish.getTime();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ function json(body: unknown, status = 200): Response {
interface Options {
connection: { address: string | null; hasApiKey: boolean };
saveResponse?: Promise<Response>;
saveStatus?: number;
saveBody?: unknown;
}
@@ -28,6 +29,9 @@ function installFetch(family: string, options: Options) {
}
if (url.endsWith(`/api/v1/media-sources/${family}/connection`) && method === 'PUT') {
putBodies.push(String(init?.body ?? ''));
if (options.saveResponse) {
return options.saveResponse;
}
const status = options.saveStatus ?? 200;
if (status >= 400) {
return Promise.resolve(json({ title: 'Invalid', detail: 'Connection failed' }, status));
@@ -82,6 +86,72 @@ describe('RemoteConnectionEditScreen', () => {
expect(body.apiKey).toBe(''); // blank = retain existing key server-side
});
it.each(['jellyfin', 'emby'] as const)(
'successful %s save navigates without triggering the dirty guard',
async (family) => {
const editPath = `/app/libraries/${family}/connection`;
window.history.replaceState(null, '', editPath);
installFetch(family, {
connection: { address: `http://${family}:8096`, hasApiKey: true },
saveBody: { address: `http://${family}:9000`, hasApiKey: true }
});
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const onPopState = () => {
// Mirror App's dirty-guard veto: a rejected synthetic pop restores the editor path.
if (!canLeaveCurrentScreen()) {
window.history.pushState(null, '', editPath);
}
};
window.addEventListener('popstate', onPopState);
try {
render(<RemoteConnectionEditScreen family={family} />);
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
fireEvent.change(address, { target: { value: `http://${family}:9000` } });
fireEvent.click(screen.getByRole('button', { name: 'Save connection' }));
await vi.waitFor(() => expect(window.location.pathname).toBe(`/app/libraries/${family}`));
expect(confirmSpy).not.toHaveBeenCalled();
} finally {
window.removeEventListener('popstate', onPopState);
}
}
);
it('blocks draft edits while a save is in flight so the completion cannot discard newer input', async () => {
let resolveSave: (response: Response) => void = () => {};
const saveResponse = new Promise<Response>((resolve) => {
resolveSave = resolve;
});
const { putBodies } = installFetch('jellyfin', {
connection: { address: 'http://jf:8096', hasApiKey: true },
saveResponse
});
render(<RemoteConnectionEditScreen family="jellyfin" />);
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
const apiKey = screen.getByPlaceholderText(/leave blank to keep/i) as HTMLInputElement;
fireEvent.change(address, { target: { value: 'http://jf:9000' } });
fireEvent.click(screen.getByRole('button', { name: 'Save connection' }));
await vi.waitFor(() => expect(putBodies).toHaveLength(1));
expect(address).toBeDisabled();
expect(apiKey).toBeDisabled();
// A synthetic event cannot bypass the same mutation gate used by the disabled controls.
fireEvent.change(address, { target: { value: 'http://jf:newer' } });
fireEvent.change(apiKey, { target: { value: 'newer-key' } });
expect(address.value).toBe('http://jf:9000');
expect(apiKey.value).toBe('');
resolveSave(json({ address: 'http://jf:9000', hasApiKey: true }));
await vi.waitFor(() => expect(window.location.pathname).toBe('/app/libraries/jellyfin'));
expect(putBodies).toHaveLength(1);
});
it('first connect (no stored key) requires an api key before Save enables', async () => {
installFetch('jellyfin', { connection: { address: '', hasApiKey: false } });
+14 -3
View File
@@ -84,7 +84,7 @@ export function RemoteConnectionEditScreen({ family }: { family: RemoteFamilyWit
const valid = addressValid && apiKeyValid;
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
useDirtyGuard(dirty, DIRTY_PROMPT);
const markDirtyGuardClean = useDirtyGuard(dirty, DIRTY_PROMPT);
if (loadError) {
return (
@@ -129,6 +129,7 @@ export function RemoteConnectionEditScreen({ family }: { family: RemoteFamilyWit
setSaved(next);
setLoaded({ hasApiKey: connection.hasApiKey });
setSaving(false);
markDirtyGuardClean();
navigateToPath(familyRoute(family));
})
.catch((error: unknown) => {
@@ -157,17 +158,27 @@ export function RemoteConnectionEditScreen({ family }: { family: RemoteFamilyWit
<Card title={<h3>Connection</h3>} subtitle={`${meta.label} server address and API key.`}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 480 }}>
<Input
disabled={saving}
label="Address"
value={draft.address}
onChange={(event) => setDraft((current) => ({ ...current, address: event.target.value }))}
onChange={(event) => {
if (!saving) {
setDraft((current) => ({ ...current, address: event.target.value }));
}
}}
placeholder="http://192.168.1.50:8096"
error={draft.address.trim() && !addressValid ? 'Enter a valid absolute http(s) URL' : null}
/>
<Input
disabled={saving}
label="API key"
type="password"
value={draft.apiKey}
onChange={(event) => setDraft((current) => ({ ...current, apiKey: event.target.value }))}
onChange={(event) => {
if (!saving) {
setDraft((current) => ({ ...current, apiKey: event.target.value }));
}
}}
placeholder={hasApiKey ? 'Key is set — leave blank to keep, or enter a new key' : 'Enter an API key'}
error={!apiKeyValid && draft.apiKey.length === 0 && !hasApiKey ? 'API key is required' : null}
/>