Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e786c1f22 | ||
|
|
4efb1a1b03 | ||
|
|
3876809a83 | ||
|
|
e9fe12d1fd | ||
|
|
bceffef856 | ||
|
|
ecde59be32 | ||
|
|
b37924545c | ||
|
|
a54a2b6f82 | ||
|
|
67bfc1ceb1 | ||
|
|
1e0eaca35e | ||
|
|
7cbd871ab7 | ||
|
|
6b41350e0c | ||
|
|
533abe7cdb | ||
|
|
e454ed94fd | ||
|
|
ffb3e6d5e1 | ||
|
|
b9305fdee9 | ||
|
|
3481f98bcc | ||
|
|
e280e9072c | ||
|
|
d235b91d15 | ||
|
|
b2c093d2d6 | ||
|
|
8da1256ffa | ||
|
|
2609b4ce59 | ||
|
|
80751a5d74 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+7
-1636
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+56
-27
@@ -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();
|
||||
if (!register) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener(PRIMARY_ACTION_EVENT, listener);
|
||||
return () => window.removeEventListener(PRIMARY_ACTION_EVENT, listener);
|
||||
}, [routeId]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user