From d77e3673dc6fe6502fc8fc16d98956d14373a169 Mon Sep 17 00:00:00 2001 From: chodeus Date: Mon, 7 Sep 2026 14:07:19 +0800 Subject: [PATCH] chore: frontend-c slice of main for CodeRabbit review [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files taken verbatim from main. Review target only — never merge. --- .coderabbit.yaml | 180 + frontend/src/hooks/useDebounce.js | 23 + frontend/src/hooks/useDocumentTitle.js | 55 + frontend/src/hooks/useEscapeKey.js | 46 + frontend/src/hooks/useFocusTrap.js | 145 + frontend/src/hooks/useInstancesData.js | 104 + frontend/src/hooks/useLogContent.js | 124 + frontend/src/hooks/useLogFiles.js | 75 + frontend/src/hooks/useLogModules.js | 59 + frontend/src/hooks/useLogPolling.js | 49 + frontend/src/hooks/useModuleEvents.js | 126 + frontend/src/hooks/useModuleExecution.js | 220 + frontend/src/hooks/useModuleSchema.js | 106 + frontend/src/hooks/useRecentSearches.js | 44 + frontend/src/hooks/useSearchPageDetection.js | 42 + frontend/src/hooks/useStreamToken.js | 30 + .../src/hooks/useUnsavedChangesWarning.js | 66 + frontend/src/main.jsx | 20 + frontend/src/pages/DashboardPage.jsx | 1078 +++ frontend/src/pages/LoginPage.jsx | 222 + frontend/src/pages/Logs.jsx | 219 + frontend/src/pages/SetupWizardPage.jsx | 741 ++ frontend/src/pages/dev/AccordionTestPage.jsx | 619 ++ frontend/src/pages/dev/ApiTestPage.jsx | 412 + .../src/pages/dev/ArrayObjectFieldPage.jsx | 416 + .../pages/dev/ButtonPrimitivesTestPage.jsx | 510 ++ .../src/pages/dev/CardPrimitivesTestPage.jsx | 673 ++ frontend/src/pages/dev/ErrorTestPage.jsx | 289 + frontend/src/pages/dev/FieldTestPage.jsx | 871 ++ frontend/src/pages/dev/FormCompoundsTest.jsx | 331 + frontend/src/pages/dev/LogPerformance.jsx | 21 + frontend/src/pages/dev/ModalsTestPage.jsx | 795 ++ frontend/src/pages/dev/SettingsMockPage.jsx | 359 + frontend/src/pages/dev/SpinnerTestPage.jsx | 375 + .../src/pages/dev/StatsPrimitivesTestPage.jsx | 308 + .../src/pages/dev/ToolbarCompoundTest.jsx | 467 ++ frontend/src/pages/dev/ToolbarTestPage.jsx | 179 + frontend/src/pages/media/LabelarrPage.jsx | 659 ++ frontend/src/pages/media/MediaManagePage.jsx | 1478 ++++ frontend/src/pages/media/MediaSearchPage.jsx | 500 ++ frontend/src/pages/media/MediaStatsPage.jsx | 496 ++ .../src/pages/poster/BorderPreviewPage.jsx | 795 ++ frontend/src/pages/poster/Cl2kMakerPage.jsx | 7187 +++++++++++++++++ .../src/pages/poster/Cl2kMakerPage.test.jsx | 88 + .../pages/poster/PosterAssetsSearchPage.jsx | 1169 +++ .../src/pages/poster/PosterCleanarrPage.jsx | 1967 +++++ .../pages/poster/PosterCleanarrPage.test.jsx | 258 + .../pages/poster/PosterGDriveSearchPage.jsx | 354 + .../src/pages/poster/PosterHealReviewPage.jsx | 348 + frontend/src/pages/poster/PosterStatsPage.jsx | 759 ++ .../src/pages/poster/UnmatchedAssetsPage.jsx | 2329 ++++++ .../pages/poster/UnmatchedAssetsPage.test.jsx | 370 + .../pages/settings/GeneralSettingsPage.jsx | 489 ++ frontend/src/pages/settings/InstancesPage.jsx | 897 ++ frontend/src/pages/settings/JobsPage.jsx | 714 ++ .../src/pages/settings/NotificationsPage.jsx | 824 ++ frontend/src/pages/settings/SchedulePage.jsx | 331 + .../src/pages/settings/SystemSettingsPage.jsx | 576 ++ frontend/src/pages/settings/WebhooksPage.jsx | 719 ++ .../settings/modules/ModuleSettingsPage.jsx | 721 ++ .../pages/settings/modules/ModulesHubPage.jsx | 205 + frontend/src/test/setup.js | 59 + frontend/src/utils/api/border_replacerr.js | 91 + frontend/src/utils/api/cl2k_maker.js | 242 + frontend/src/utils/api/config.js | 73 + frontend/src/utils/api/core.js | 504 ++ frontend/src/utils/api/core.test.js | 343 + frontend/src/utils/api/index.js | 97 + frontend/src/utils/api/instances.js | 247 + frontend/src/utils/api/jobs.js | 92 + frontend/src/utils/api/labelarr.js | 18 + frontend/src/utils/api/logs.js | 131 + frontend/src/utils/api/media.js | 303 + frontend/src/utils/api/modules.js | 154 + frontend/src/utils/api/nestarr.js | 46 + frontend/src/utils/api/notifications.js | 66 + frontend/src/utils/api/posterSelfHeal.js | 15 + frontend/src/utils/api/posterSelfHeal.test.js | 32 + frontend/src/utils/api/posters.js | 700 ++ .../api/postersCacheInvalidation.test.js | 30 + frontend/src/utils/api/schedule.js | 60 + frontend/src/utils/api/streamAuth.js | 222 + frontend/src/utils/api/system.js | 220 + frontend/src/utils/api/webhooks.js | 87 + .../src/utils/checkTailwindClasses.test.js | 62 + frontend/src/utils/clipboard.js | 64 + frontend/src/utils/constants/constants.js | 18 + .../constants/general_settings_schema.js | 177 + .../src/utils/constants/instance_schema.js | 36 + .../utils/constants/notifications_schema.js | 91 + .../src/utils/constants/settings_schema.js | 1926 +++++ frontend/src/utils/datetime.js | 42 + frontend/src/utils/download.js | 26 + frontend/src/utils/download.test.js | 70 + frontend/src/utils/forms/FormRenderer.jsx | 281 + frontend/src/utils/forms/conditionalFields.js | 278 + .../src/utils/forms/conditionalFields.test.js | 66 + frontend/src/utils/forms/index.js | 7 + frontend/src/utils/forms/schemaUtils.js | 434 + frontend/src/utils/forms/secretInput.js | 8 + frontend/src/utils/forms/validation.js | 332 + frontend/src/utils/gdrivePresets.js | 77 + frontend/src/utils/positioning.js | 141 + frontend/src/utils/posterCanvas.js | 135 + frontend/src/utils/posterRequest.js | 47 + frontend/src/utils/schedule.js | 316 + frontend/src/utils/schemaAdapter.js | 237 + frontend/src/utils/theme.js | 460 ++ frontend/src/utils/tools.js | 25 + frontend/src/utils/touchDetection.js | 102 + frontend/stylelint.config.js | 46 + frontend/vite.config.js | 42 + frontend/vitest.config.js | 22 + 113 files changed, 44032 insertions(+) create mode 100644 .coderabbit.yaml create mode 100755 frontend/src/hooks/useDebounce.js create mode 100644 frontend/src/hooks/useDocumentTitle.js create mode 100755 frontend/src/hooks/useEscapeKey.js create mode 100755 frontend/src/hooks/useFocusTrap.js create mode 100755 frontend/src/hooks/useInstancesData.js create mode 100755 frontend/src/hooks/useLogContent.js create mode 100755 frontend/src/hooks/useLogFiles.js create mode 100755 frontend/src/hooks/useLogModules.js create mode 100755 frontend/src/hooks/useLogPolling.js create mode 100644 frontend/src/hooks/useModuleEvents.js create mode 100755 frontend/src/hooks/useModuleExecution.js create mode 100644 frontend/src/hooks/useModuleSchema.js create mode 100644 frontend/src/hooks/useRecentSearches.js create mode 100755 frontend/src/hooks/useSearchPageDetection.js create mode 100644 frontend/src/hooks/useStreamToken.js create mode 100644 frontend/src/hooks/useUnsavedChangesWarning.js create mode 100755 frontend/src/main.jsx create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100755 frontend/src/pages/Logs.jsx create mode 100644 frontend/src/pages/SetupWizardPage.jsx create mode 100755 frontend/src/pages/dev/AccordionTestPage.jsx create mode 100755 frontend/src/pages/dev/ApiTestPage.jsx create mode 100755 frontend/src/pages/dev/ArrayObjectFieldPage.jsx create mode 100755 frontend/src/pages/dev/ButtonPrimitivesTestPage.jsx create mode 100755 frontend/src/pages/dev/CardPrimitivesTestPage.jsx create mode 100755 frontend/src/pages/dev/ErrorTestPage.jsx create mode 100755 frontend/src/pages/dev/FieldTestPage.jsx create mode 100755 frontend/src/pages/dev/FormCompoundsTest.jsx create mode 100755 frontend/src/pages/dev/LogPerformance.jsx create mode 100755 frontend/src/pages/dev/ModalsTestPage.jsx create mode 100755 frontend/src/pages/dev/SettingsMockPage.jsx create mode 100755 frontend/src/pages/dev/SpinnerTestPage.jsx create mode 100755 frontend/src/pages/dev/StatsPrimitivesTestPage.jsx create mode 100755 frontend/src/pages/dev/ToolbarCompoundTest.jsx create mode 100755 frontend/src/pages/dev/ToolbarTestPage.jsx create mode 100644 frontend/src/pages/media/LabelarrPage.jsx create mode 100644 frontend/src/pages/media/MediaManagePage.jsx create mode 100644 frontend/src/pages/media/MediaSearchPage.jsx create mode 100644 frontend/src/pages/media/MediaStatsPage.jsx create mode 100644 frontend/src/pages/poster/BorderPreviewPage.jsx create mode 100644 frontend/src/pages/poster/Cl2kMakerPage.jsx create mode 100644 frontend/src/pages/poster/Cl2kMakerPage.test.jsx create mode 100644 frontend/src/pages/poster/PosterAssetsSearchPage.jsx create mode 100644 frontend/src/pages/poster/PosterCleanarrPage.jsx create mode 100644 frontend/src/pages/poster/PosterCleanarrPage.test.jsx create mode 100644 frontend/src/pages/poster/PosterGDriveSearchPage.jsx create mode 100644 frontend/src/pages/poster/PosterHealReviewPage.jsx create mode 100644 frontend/src/pages/poster/PosterStatsPage.jsx create mode 100644 frontend/src/pages/poster/UnmatchedAssetsPage.jsx create mode 100644 frontend/src/pages/poster/UnmatchedAssetsPage.test.jsx create mode 100755 frontend/src/pages/settings/GeneralSettingsPage.jsx create mode 100755 frontend/src/pages/settings/InstancesPage.jsx create mode 100644 frontend/src/pages/settings/JobsPage.jsx create mode 100755 frontend/src/pages/settings/NotificationsPage.jsx create mode 100755 frontend/src/pages/settings/SchedulePage.jsx create mode 100644 frontend/src/pages/settings/SystemSettingsPage.jsx create mode 100644 frontend/src/pages/settings/WebhooksPage.jsx create mode 100755 frontend/src/pages/settings/modules/ModuleSettingsPage.jsx create mode 100644 frontend/src/pages/settings/modules/ModulesHubPage.jsx create mode 100644 frontend/src/test/setup.js create mode 100644 frontend/src/utils/api/border_replacerr.js create mode 100644 frontend/src/utils/api/cl2k_maker.js create mode 100755 frontend/src/utils/api/config.js create mode 100755 frontend/src/utils/api/core.js create mode 100644 frontend/src/utils/api/core.test.js create mode 100755 frontend/src/utils/api/index.js create mode 100755 frontend/src/utils/api/instances.js create mode 100755 frontend/src/utils/api/jobs.js create mode 100644 frontend/src/utils/api/labelarr.js create mode 100755 frontend/src/utils/api/logs.js create mode 100755 frontend/src/utils/api/media.js create mode 100755 frontend/src/utils/api/modules.js create mode 100644 frontend/src/utils/api/nestarr.js create mode 100755 frontend/src/utils/api/notifications.js create mode 100644 frontend/src/utils/api/posterSelfHeal.js create mode 100644 frontend/src/utils/api/posterSelfHeal.test.js create mode 100755 frontend/src/utils/api/posters.js create mode 100644 frontend/src/utils/api/postersCacheInvalidation.test.js create mode 100755 frontend/src/utils/api/schedule.js create mode 100644 frontend/src/utils/api/streamAuth.js create mode 100755 frontend/src/utils/api/system.js create mode 100644 frontend/src/utils/api/webhooks.js create mode 100644 frontend/src/utils/checkTailwindClasses.test.js create mode 100644 frontend/src/utils/clipboard.js create mode 100755 frontend/src/utils/constants/constants.js create mode 100755 frontend/src/utils/constants/general_settings_schema.js create mode 100755 frontend/src/utils/constants/instance_schema.js create mode 100755 frontend/src/utils/constants/notifications_schema.js create mode 100755 frontend/src/utils/constants/settings_schema.js create mode 100644 frontend/src/utils/datetime.js create mode 100644 frontend/src/utils/download.js create mode 100644 frontend/src/utils/download.test.js create mode 100755 frontend/src/utils/forms/FormRenderer.jsx create mode 100755 frontend/src/utils/forms/conditionalFields.js create mode 100644 frontend/src/utils/forms/conditionalFields.test.js create mode 100755 frontend/src/utils/forms/index.js create mode 100755 frontend/src/utils/forms/schemaUtils.js create mode 100644 frontend/src/utils/forms/secretInput.js create mode 100755 frontend/src/utils/forms/validation.js create mode 100644 frontend/src/utils/gdrivePresets.js create mode 100755 frontend/src/utils/positioning.js create mode 100755 frontend/src/utils/posterCanvas.js create mode 100644 frontend/src/utils/posterRequest.js create mode 100755 frontend/src/utils/schedule.js create mode 100644 frontend/src/utils/schemaAdapter.js create mode 100755 frontend/src/utils/theme.js create mode 100755 frontend/src/utils/tools.js create mode 100755 frontend/src/utils/touchDetection.js create mode 100755 frontend/stylelint.config.js create mode 100755 frontend/vite.config.js create mode 100644 frontend/vitest.config.js diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..c29e0df9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,180 @@ +# .coderabbit.yaml — CHUB (Chodeus' Media Script Hub) +# FastAPI + SQLite Python backend (main.py, backend/) + React 19 / Vite / Tailwind v4 frontend (frontend/). +# One branch: main. Everything PRs into it (#577 retired develop). +language: "en-US" + +reviews: + # assertive: chill passed a diff clean that assertive flagged with two Major findings (2026-09-03); the "**" rule keeps style nits out + profile: "assertive" + request_changes_workflow: false + pre_merge_checks: + # off: the 80% docstring-coverage warning contradicts the one-line-docstring rule under "**" + docstrings: + mode: "off" + title: + mode: warning + requirements: |- + Conventional Commits, `type(scope)?: subject`. release-please reads the + squash-merge title to pick the next version: feat bumps minor, fix bumps + patch, a `!` after the type or a BREAKING CHANGE footer bumps major, and + chore/ci/test/build are hidden from the changelog and never release on + their own. Flag a title whose type understates the diff (a behaviour + change labelled chore or ci ships unversioned) or overstates it. + auto_review: + enabled: true + drafts: false + # No base_branches: main is the default branch and is always auto-reviewed. + # The old "^develop$" entry matched nothing once #577 retired that branch. + + path_filters: + - "!**/*.db" + - "!**/*.png" + - "!assets/**" + - "!docs/**" + - "!wikiscreens/**" + - "!design_handoff*/**" + - "!logs/**" + - "!refs/**" + - "!templates/**" + - "!frontend/dist/**" + - "!**/node_modules/**" + - "!**/__pycache__/**" + - "!frontend/package-lock.json" + - "!CHANGELOG.md" + + path_instructions: + - path: "backend/**/*.py" + instructions: |- + CHUB Python backend review rules — flag any of these failure modes: + - FAIL CLOSED: any auth / webhook-secret / API-key / config-load / DNS / DB guard that returns None/empty, skips, or calls the next handler on error MUST deny (401/403/503), never pass through. `if config and not allowed(): deny` is a bug — it skips the check when config is falsy. An `except ...: config = None` branch must reject the request, not continue with defaults. + - Read LIVE config each pass: long-lived threads, schedulers, job processors and request handlers must re-read config every iteration/request, not capture a config object at startup — config is REPLACED (not mutated) on reload, so a snapshot goes stale. + - No long-lived token in a URL: image/SSE/EventSource/webhook URLs that can't send an Authorization header must use a short-lived, scope-limited token (see create_stream_token / STREAM_SCOPE), never the full session JWT or an admin token. + - Don't cache a transient failure as a negative result: distinguish genuine not-found (cacheable) from network/5xx/timeout/rate-limit/breaker-open (never cache) — a blip must not suppress a valid answer later. + - Redact secrets on EVERY read path (per-module, per-section, per-instance, export, diagnostics), not just GET /config. Make redaction STRUCTURAL — mask by sensitive leaf-key name — so a newly added secret field is covered automatically, not per-endpoint. + - Confirm the recovery before the destructive step: delete-then-refetch / delete-then-research must guarantee the recovery runs even if the confirm times out, or a transient failure becomes permanent loss. + - Destructive filesystem ops (unlink/rmtree/rm -rf): re-confine the RESOLVED physical target (os.path.realpath) and re-assert it is inside the allowed root before deleting — a string-only under-root/".." check is defeated by a symlinked path component. When two delete branches exist (soft vs hard, file vs tree), diff their guards: a check present on one branch and missing on its sibling is the bug. An empty "in-use"/"keep" set means a FAILED READ, not "delete everything" — fail closed. + - Validate the NORMALIZED path for allowlist/traversal checks (../ collapse defeats string-only checks). + - Comments: navigational/instructional only (1-2 line what/gotcha), no why/history essays; match existing density. + - path: "main.py" + instructions: |- + HTTP entrypoint / middleware wiring: + - FAIL CLOSED auth: any authentication / webhook-secret / API-key middleware that can't load its config or secret must return 401/503 — never `except ...: await call_next(request)`, which silently disables auth for the whole API when config is unparseable. + - Read live config per request; do not snapshot a config object at import/startup that goes stale when config is replaced on reload. + - Never mount an endpoint that accepts a long-lived session JWT in the query string; URL-embedded auth must be the short-lived, stream-scoped token only. + - path: "backend/util/database/**/*.py" + instructions: |- + SQLite cache/data layer: + - Escape % and _ in SQL LIKE patterns with an explicit ESCAPE clause — especially delete-by-prefix / clear-by-prefix — or a value containing % or _ wildcard-matches sibling rows and deletes/returns too much. + - Invalidate the LIST and SEARCH caches on mutation, not just the single item: a delete/patch/insert of /x/{id} must also drop the /x list cache and any search-result cache, or lists show stale or deleted rows. + - Don't persist a transient failure (network/5xx/timeout/rate-limit) as a negative/empty cache entry — only cache genuine not-found; a blip must not suppress a valid value on later reads. + - Parameterize every query; never string-format user/config values into SQL. + - path: "backend/util/logger.py" + instructions: |- + Log redaction: + - Redaction must run at the formatter on the FULLY RENDERED line (msg, args AND the exc_info traceback), on every handler — HTTP-client exceptions embed secret-bearing URLs in the traceback. + - Cover URL PATH-SEGMENT secrets, not just query params: e.g. ?X-Plex-Token=, ?apikey=, /passthrough/, bearer tokens. A regex that only masks query strings misses path-embedded secrets. + - Mask by sensitive key name structurally so new secret fields are redacted automatically. + - path: "backend/api/posters/**/*.py" + instructions: |- + Poster / GDrive endpoints — high-stakes local-delete path: + - /gdrive/delete-local must authorize by gdrive_list MEMBERSHIP (realpath-match against a currently-configured gdrive_list entry), NOT is_path_allowed — is_path_allowed keys off roots that exist on disk and would wrongly refuse (and skip the row purge for) a drive whose folder was already deleted. Do NOT add an is_path_allowed gate to this handler. + - Fail closed: if config can't be loaded, return 503 and delete nothing. + - Re-confine the RESOLVED path (os.path.realpath) and re-assert membership before any unlink/rmtree; reject a resolve to filesystem root. A string-only ".." check is defeated by a symlinked component. + - Invalidate the poster LIST + search caches after a delete/mutation, not just the touched row. + - path: "frontend/src/**/*.{js,jsx}" + instructions: |- + React 19 / Vite frontend: + - 0 and "" are FALSY: use ?? / Number.isFinite / explicit null checks (not ||) for any value where 0 or "" is legitimate (counts, offsets, timeouts, indices, ratings) — `value || fallback` silently replaces a real 0. + - useEffect/useMemo/useCallback deps: follow exhaustive-deps — include EVERY reactive value the effect/callback reads. To control excess re-runs, memoize the object/callback at its source (useMemo/useCallback) or depend on a stable identity field (e.g. item.id) ONLY when that field fully determines the work; never drop a value the body reads just to silence a re-run (that is a stale-closure bug). A non-memoized object/callback gets a new identity every parent render so `[obj]`/`[onDone]` re-runs then — a memoized one does not. + - Clean up on unmount: clear timers/intervals, abort fetches, close EventSource/WebSocket and stop polling loops in the effect's cleanup return. + - Never embed the full session JWT in an /EventSource/link URL — use the short-lived stream token (useStreamToken) for URL-embedded auth. + + - path: "deploy/docker/**" + instructions: |- + Container build — every layer ships in the published image: + - A recursive `chown -R` / `chmod -R` over a tree a previous COPY wrote re-materialises EVERY file in a new layer, duplicating the tree in the image (45MB of /app here). Set ownership with `COPY --chown=uid:gid`; when a later RUN creates files, it must chown them inside that same RUN, never in a fresh layer. Prefer a build-time assertion over a tree-wide chmod. + - Pin base images by digest (FROM image:tag@sha256:...) and pin apt/pip/npm versions — an unpinned tag silently changes the runtime between builds. + - Never bake a secret into ARG/ENV or into a RUN's argv: both persist in image history, and /proc//cmdline is world-readable in-container. Pass credentials as runtime env instead. + - Don't pipe a remote script into a shell (curl ... | sh). Fetch it, verify against a published checksum, then run it. + - Multi-stage: the runtime stage must not inherit compilers, headers or -dev packages from the builder. Copy artefacts, not toolchains. + - A path added or renamed here must stay in step with the paths filters in .github/workflows/* — otherwise the image silently stops being built and validated on PRs that touch it. + - path: "requirements*.txt" + instructions: |- + Python dependency manifest: + - An extra ([composite], [all], [security]) pulls that extra's whole transitive set. Confirm the code path needing it still EXISTS: deleting a feature leaves its dependency behind. psd-tools[composite] carried scipy + scikit-image (107MB) for a PSD-flatten path that was removed three days after the extra was added, and survived 2.5 months. + - A pin whose comment says it exists only to lift a transitive floor out of a CVE is a smell — check whether the consumer is still reachable at all, and drop the dependency rather than maintain the pin. + - Removing a feature must remove its dependency in the same change: flag a PR deleting a module, route or config field that touches no manifest. + - Runtime vs dev: a test-only or build-only package must not land in the manifest the runtime image installs. + - path: "tests/**/*.py" + instructions: |- + Test suite: + - No side-effecting call inside an assert — python -O strips assert statements, taking the call with them. Bind to a local first, then assert on the local. + - A guard that cannot fail is worse than no guard. If an assertion is reachable only when some list is non-empty, or keys off a symbol that may be renamed, it passes vacuously. Pair it with a control that fails when the defect is injected. + - A static/AST check over source must resolve helper calls, not only direct ones: keying on a direct call name misses a handler that reaches the behaviour through a helper, which is exactly the case such a check exists to catch. + - Assert the observable contract (resulting state), not incidental ordering or a mock's call count, unless the call count IS the invariant. + - path: ".github/workflows/**" + instructions: |- + CI, CodeQL, dep-audit and the GHCR publish: + - A gate that stops blocking (a lint/CVE/test step made non-blocking or + continue-on-error) must have every downstream step that relied on it + audited in the same change. dep-audit runs with fail-on-vuln: true — + flag anything that softens that. + - A job listed in `needs:` that FAILS makes the dependent job SKIP, and + GitHub reports a skipped required check as success — that is why + frontend-tests is deliberately not in docker-validate's needs. Flag a + `needs:` edit that reintroduces the trap. + - Enumerate allowed states positively (== 'success' || == 'skipped'), + never by negation — a third state slips through `!= 'failure'`. + - Untrusted input (base SHA, PR title, branch name) reaches `run:` only + via `env:`, never `${{ }}` interpolation — the gdrive-preset-moves job + is the pattern. + - The push trigger must not be self-referential, or a workflow edit + republishes :latest. Pin third-party actions by commit SHA. + - path: "{scripts/start.sh,build_frontend.sh}" + instructions: |- + Container entrypoint and frontend staging: + - start.sh fails closed on the OUTCOME: the runuser write probe on + CONFIG_DIR must stay a hard exit 1, /app is never chowned, and the + chmod -R 777 path stays behind CHUB_LEGACY_CHMOD=1. Flag a change that + makes the probe advisory or chowns /app again. + - Rootless (id -u != 0) overrides PUID/PGID from the real uid/gid; keep + that, never pretend. + - build_frontend.sh rm -rf's templates/{assets,icons,img,posters} before + copying — a missing posters dir must stay a hard exit 1, and the copy + targets must remain inside templates/. + - Quote every path expansion; never echo a secret, including under set -x. + - path: "deploy/unraid/chub.xml" + instructions: |- + Published Unraid CA template: stays bridge (the personal + network belongs only in the user's my-*.xml), the port stays 8000, and + every (PUID/PGID/UMASK/TZ, /config, /kometa, /media, /data, + /plex) must match scripts/start.sh, the Dockerfile and README. Flag a + Mask="true" omission on any new credential-shaped variable. + - path: "**" + instructions: |- + Applies to every file, whatever the language: + - Comments are navigational or instructional only and capped at 1-2 lines: + what a non-obvious block does, or the gotcha it guards. No why/history + essays, no before/after narrative, no restating the code, no section + banners. Docstrings: one line unless the signature genuinely cannot + carry it. Never ask for a docstring to be added; a missing docstring + is not a finding. + - The 1-2 line cap wins even inside an already heavily-commented file — + matching local density is not licence to exceed it. + - A comment, docstring, help text, column header, log message or UI label + that disagrees with what the code does is a correctness finding, not a + stale-comment nit: report it and say which side is wrong. + - Do not flag stylistic preferences that are consistent across the file + (naming, quoting, import order, formatting, line length). Report + correctness, security, data-loss and the classes named above. + + tools: + # ruff + eslint left to CI (codeql-lint.yml runs Ruff, ESLint, stylelint, + # prettier, CodeQL). Every CodeRabbit tool defaults ON, so they must be + # switched off explicitly or they run here too and double-post CI findings. + ruff: + enabled: false + eslint: + enabled: false + gitleaks: + enabled: true diff --git a/frontend/src/hooks/useDebounce.js b/frontend/src/hooks/useDebounce.js new file mode 100755 index 00000000..434bb3dd --- /dev/null +++ b/frontend/src/hooks/useDebounce.js @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * Hook for debouncing values + */ +const useDebounce = (value, delay) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // Cleanup timeout if value changes before delay completes + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; + +export { useDebounce }; diff --git a/frontend/src/hooks/useDocumentTitle.js b/frontend/src/hooks/useDocumentTitle.js new file mode 100644 index 00000000..cdd371c1 --- /dev/null +++ b/frontend/src/hooks/useDocumentTitle.js @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router'; +import { NAV_TITLES } from '../components/navSections.js'; + +/** + * Map of canonical pathname → user-facing page name. Kept in sync with the + * Breadcrumbs route map so the browser tab and breadcrumb trail speak the + * same language. Core routes only — extension routes fall through to + * NAV_TITLES, which is derived from the nav tree they already register into. + */ +const ROUTE_TITLES = { + '/login': 'Sign in', + '/setup': 'Setup', + '/dashboard': 'Dashboard', + '/media/search': 'Library Search', + '/media/manage': 'Library Management', + '/media/statistics': 'Library Statistics', + '/media/labelarr': 'Label Sync', + '/poster/search/assets': 'Assets Search', + '/poster/search/gdrive': 'GDrive Sources', + '/poster/border-replacerr': 'Border Replacerr', + '/poster/cleanarr': 'Poster Cleanarr', + '/poster/manage': 'Poster Cleanarr', // legacy redirect target + '/poster/unmatched': 'Unmatched Assets', + '/poster/statistics': 'Poster Statistics', + '/settings': 'Settings', + '/settings/general': 'General Settings', + '/settings/modules': 'Modules', + '/settings/instances': 'Instances', + '/settings/schedule': 'Schedule', + '/settings/jobs': 'Jobs', + '/settings/notifications': 'Notifications', + '/settings/webhooks': 'Webhooks', + '/settings/system': 'System', + '/logs': 'Logs', +}; + +const SUFFIX = 'CHUB'; + +/** + * Hook that keeps `document.title` in sync with the current route so the + * browser tab actually tells the user which page they're on. Without this + * every tab just reads "CHUB · Media Manager" and tab-switching is + * useless. + * + * Drop it into Layout once — don't sprinkle it per page. + */ +export function useDocumentTitle() { + const { pathname } = useLocation(); + + useEffect(() => { + const label = ROUTE_TITLES[pathname] ?? NAV_TITLES[pathname]; + document.title = label ? `${label} · ${SUFFIX}` : SUFFIX; + }, [pathname]); +} diff --git a/frontend/src/hooks/useEscapeKey.js b/frontend/src/hooks/useEscapeKey.js new file mode 100755 index 00000000..a1b29370 --- /dev/null +++ b/frontend/src/hooks/useEscapeKey.js @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; + +/** + * useEscapeKey - Enhanced ESC key handling for modals and overlays + * + * Manages ESC key behavior for dismissible components: + * - Calls callback when ESC key pressed + * - Only active when isActive=true + * - Proper cleanup on unmount or deactivation + * - Multiple modals support (last modal wins - last mounted handler executes first) + * - Event listener added at document level for global scope + * + * @example + * const handleClose = () => setIsOpen(false); + * useEscapeKey(handleClose, isOpen); + * + * @param {Function} onEscape - Callback function to execute when ESC is pressed + * @param {boolean} isActive - Whether ESC key handling is currently active + * @returns {void} + */ +export const useEscapeKey = (onEscape, isActive) => { + useEffect(() => { + if (!isActive) return; + + /** + * Handle ESC key press + * @param {KeyboardEvent} event - Keyboard event + */ + const handleEscape = event => { + if (event.key === 'Escape') { + onEscape(); + } + }; + + // Add event listener at document level + // Last mounted modal will handle ESC first (event propagation) + document.addEventListener('keydown', handleEscape); + + // Cleanup: remove event listener + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [onEscape, isActive]); +}; + +export default useEscapeKey; diff --git a/frontend/src/hooks/useFocusTrap.js b/frontend/src/hooks/useFocusTrap.js new file mode 100755 index 00000000..717604ce --- /dev/null +++ b/frontend/src/hooks/useFocusTrap.js @@ -0,0 +1,145 @@ +import { useEffect, useRef } from 'react'; + +/** + * Focusable element selector query + * Includes all interactive elements that can receive keyboard focus + */ +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(', '); + +/** + * useFocusTrap - Trap keyboard focus within a container element + * + * Manages focus behavior for modal dialogs and other overlay components: + * - Stores original focused element for restoration on deactivation + * - Focuses first focusable element when activated + * - TAB cycles forward through focusable elements (wraps to beginning) + * - Shift+TAB cycles backward through focusable elements (wraps to end) + * - Restores focus to original element when deactivated + * - Handles dynamic content with MutationObserver + * + * @example + * const containerRef = useRef(null); + * useFocusTrap(containerRef, isModalOpen); + * + * return ( + *
+ * + * + * + *
+ * ); + * + * @param {React.RefObject} containerRef - Reference to container element to trap focus within + * @param {boolean} isActive - Whether focus trap is currently active + * @returns {void} + */ +export const useFocusTrap = (containerRef, isActive) => { + const previousFocusRef = useRef(null); + const observerRef = useRef(null); + + useEffect(() => { + if (!isActive || !containerRef.current) return; + + const container = containerRef.current; + + // Store currently focused element for restoration + previousFocusRef.current = document.activeElement; + + /** + * Get all currently focusable elements within container + * @returns {HTMLElement[]} Array of focusable elements + */ + const getFocusableElements = () => { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)); + }; + + // Focus first focusable element on activation + const focusableElements = getFocusableElements(); + if (focusableElements.length > 0) { + focusableElements[0].focus(); + } + + /** + * Handle TAB and Shift+TAB key navigation + * Cycles through focusable elements with wrapping + * @param {KeyboardEvent} event - Keyboard event + */ + const handleKeyDown = event => { + if (event.key !== 'Tab') return; + + const focusableElements = getFocusableElements(); + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement; + + // Shift+TAB: cycle backward + if (event.shiftKey) { + if (activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } + // TAB: cycle forward + else { + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + }; + + /** + * Handle dynamic content changes + * Refocuses container if active element is removed + */ + const handleMutation = () => { + const focusableElements = getFocusableElements(); + const activeElement = document.activeElement; + + // If focused element was removed, focus first available element + if (!container.contains(activeElement) && focusableElements.length > 0) { + focusableElements[0].focus(); + } + }; + + // Set up MutationObserver for dynamic content + observerRef.current = new MutationObserver(handleMutation); + observerRef.current.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['disabled', 'tabindex'], + }); + + // Add keyboard event listener + container.addEventListener('keydown', handleKeyDown); + + // Cleanup function + return () => { + container.removeEventListener('keydown', handleKeyDown); + + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + // Restore focus to original element + if (previousFocusRef.current && document.body.contains(previousFocusRef.current)) { + previousFocusRef.current.focus(); + } + + previousFocusRef.current = null; + }; + }, [containerRef, isActive]); +}; + +export default useFocusTrap; diff --git a/frontend/src/hooks/useInstancesData.js b/frontend/src/hooks/useInstancesData.js new file mode 100755 index 00000000..73f6e8da --- /dev/null +++ b/frontend/src/hooks/useInstancesData.js @@ -0,0 +1,104 @@ +/** + * Instance Data Management Hook + * Provides centralized access to instances API data with caching and helper functions + */ + +import { useCallback } from 'react'; +import { useApiData } from './useApiData'; +import { configAPI } from '../utils/api/config'; +import { + generateInstanceOptions, + getInstanceType as getInstanceTypeUtil, +} from '../utils/forms/conditionalFields'; + +/** + * Hook for managing instance data with API integration + * @returns {Object} Instance data and helper functions + */ +export const useInstancesData = () => { + const { + data: instancesResponse, + isLoading, + error, + } = useApiData({ + apiFunction: () => configAPI.fetchSection('instances'), + options: { + retryAttempts: 2, + cacheKey: 'instances_data', + cacheTTL: 300000, // 5 minutes + showErrorToast: true, + successMessage: null, // Don't show success toast for background data loading + }, + }); + + // Extract instances data from API response - config API nests it under data.instances + const instancesData = instancesResponse?.data?.instances; + + /** + * Get dropdown options for specific allowed instance types + * @param {Array} allowedTypes - Array of allowed service types (e.g. ['radarr', 'sonarr']) + * @returns {Array} Dropdown options array + */ + const getInstanceOptions = useCallback( + (allowedTypes = []) => { + return generateInstanceOptions(instancesData, allowedTypes); + }, + [instancesData] + ); + + /** + * Get instance type for a specific instance name + * @param {string} instanceName - Instance name to look up + * @returns {string|null} Instance type (radarr, sonarr, plex) or null + */ + const getInstanceType = useCallback( + instanceName => { + return getInstanceTypeUtil(instanceName, instancesData); + }, + [instancesData] + ); + + /** + * Check if instances data is ready for use + * @returns {boolean} True if data is loaded and available + */ + const isInstancesReady = useCallback(() => { + return !isLoading && !error && !!instancesData; + }, [isLoading, error, instancesData]); + + /** + * Get all available service types + * @returns {Array} Array of service type strings + */ + const getAvailableServiceTypes = useCallback(() => { + if (!instancesData) return []; + return Object.keys(instancesData); + }, [instancesData]); + + /** + * Get instances for a specific service type + * @param {string} serviceType - Service type (radarr, sonarr, plex) + * @returns {Object} Instance objects for the service type + */ + const getInstancesForServiceType = useCallback( + serviceType => { + if (!instancesData || !serviceType) return {}; + return instancesData[serviceType] || {}; + }, + [instancesData] + ); + + return { + // Raw data + instancesData, + isLoading, + error, + + // Helper functions + getInstanceOptions, + getInstanceType, + isInstancesReady, + getAvailableServiceTypes, + getInstancesForServiceType, + }; +}; diff --git a/frontend/src/hooks/useLogContent.js b/frontend/src/hooks/useLogContent.js new file mode 100755 index 00000000..57a8dbc4 --- /dev/null +++ b/frontend/src/hooks/useLogContent.js @@ -0,0 +1,124 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { logsAPI } from '../utils/api/logs.js'; + +// Initial load and poll refresh MUST pass the same tail, or the view shrinks +// on the first poll. +const LOG_TAIL_LINES = 5000; + +/** + * useLogContent - Fetch log file content + * + * Fetches log content when module and file are selected, and provides + * a manual refresh function for polling. In-flight fetches are cancelled + * on unmount or when the selected module/file changes, and overlapping + * refresh calls are skipped so slow fetches don't pile up. + * + * @param {string} selectedModule - Currently selected module + * @param {string} selectedLogFile - Currently selected log file + * @returns {Object} Log content state + * @property {string} logText - Log file content + * @property {boolean} loading - Loading state + * @property {Error|null} error - Error state + * @property {Function} refresh - Manual refresh function (returns Promise) + * @property {{current: boolean}} inFlightRef - Ref that's true while a fetch is running + */ +export function useLogContent(selectedModule, selectedLogFile) { + const [logText, setLogText] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Tracks whether a fetch is currently in flight. The polling hook reads + // this to skip ticks that would overlap with an ongoing request. + const inFlightRef = useRef(false); + const abortRef = useRef(null); + + // Reset loading/error state at render time when the key changes. This is + // the "adjusting state when a prop changes" pattern from the React docs — + // conditional setState during render, not a setState-in-effect. + const currentKey = + selectedModule && selectedLogFile ? `${selectedModule}|${selectedLogFile}` : ''; + const [lastKey, setLastKey] = useState(currentKey); + if (lastKey !== currentKey) { + setLastKey(currentKey); + setError(null); + if (!currentKey) { + setLogText(''); + setLoading(false); + } else { + setLoading(true); + } + } + + useEffect(() => { + if (!selectedModule || !selectedLogFile) return; + + const controller = new AbortController(); + abortRef.current = controller; + inFlightRef.current = true; + + logsAPI + .fetchLogContent(selectedModule, selectedLogFile, controller.signal, LOG_TAIL_LINES) + .then(content => { + if (!controller.signal.aborted) setLogText(content); + }) + .catch(err => { + if (err?.name === 'AbortError') return; + console.error('Failed to load log content:', err); + setError(err); + setLogText(''); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + if (abortRef.current === controller) { + abortRef.current = null; + inFlightRef.current = false; + } + }); + + return () => { + controller.abort(); + if (abortRef.current === controller) { + abortRef.current = null; + inFlightRef.current = false; + } + }; + }, [selectedModule, selectedLogFile]); + + // Manual refresh function for polling. Skips if a fetch is already running + // so slow log reads don't stack up when the poll interval fires repeatedly. + const refresh = useCallback(async () => { + if (!selectedModule || !selectedLogFile) return; + if (inFlightRef.current) return; + + const controller = new AbortController(); + abortRef.current = controller; + inFlightRef.current = true; + + try { + const content = await logsAPI.fetchLogContent( + selectedModule, + selectedLogFile, + controller.signal, + LOG_TAIL_LINES + ); + if (!controller.signal.aborted) setLogText(content); + } catch (err) { + if (err?.name === 'AbortError') return; + // Silent failure during polling - don't show toast spam + console.error('Failed to refresh log content:', err); + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + inFlightRef.current = false; + } + } + }, [selectedModule, selectedLogFile]); + + return { + logText, + loading, + error, + refresh, + inFlightRef, + }; +} diff --git a/frontend/src/hooks/useLogFiles.js b/frontend/src/hooks/useLogFiles.js new file mode 100755 index 00000000..882235f7 --- /dev/null +++ b/frontend/src/hooks/useLogFiles.js @@ -0,0 +1,75 @@ +import { useState, useEffect } from 'react'; +import { logsAPI } from '../utils/api/logs.js'; + +/** + * useLogFiles - Fetch log files for selected module + * + * Fetches log files when module selected and auto-selects the default + * file ({moduleName}.log preferred, otherwise first file). + * + * @param {string} selectedModule - Currently selected module + * @returns {Object} Log files state + * @property {Array} logFiles - Available log files + * @property {string} selectedLogFile - Auto-selected default file + * @property {Function} setSelectedLogFile - Manual file selection + * @property {boolean} loading - Loading state + * @property {Error|null} error - Error state + */ +export function useLogFiles(selectedModule) { + const [logFiles, setLogFiles] = useState([]); + const [selectedLogFile, setSelectedLogFile] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + async function loadFiles() { + if (!selectedModule) { + setLogFiles([]); + setSelectedLogFile(''); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const files = await logsAPI.fetchLogFiles(selectedModule); + + if (!isMounted) return; + + setLogFiles(files); + + // Auto-select default file: prefer {module}.log, otherwise first file + const defaultLog = files.find(f => f === `${selectedModule}.log`) || files[0] || ''; + setSelectedLogFile(defaultLog); + } catch (err) { + if (!isMounted) return; + console.error('Failed to fetch log files:', err); + setError(err); + setLogFiles([]); + setSelectedLogFile(''); + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + loadFiles(); + + return () => { + isMounted = false; + }; + }, [selectedModule]); + + return { + logFiles, + selectedLogFile, + setSelectedLogFile, + loading, + error, + }; +} diff --git a/frontend/src/hooks/useLogModules.js b/frontend/src/hooks/useLogModules.js new file mode 100755 index 00000000..87333e6a --- /dev/null +++ b/frontend/src/hooks/useLogModules.js @@ -0,0 +1,59 @@ +import { useState, useEffect } from 'react'; +import { logsAPI } from '../utils/api/logs.js'; +import { moduleOrder } from '../utils/constants/constants.js'; + +/** + * useLogModules - Fetch and order log modules + * + * Fetches available log modules on mount and orders them according to + * the moduleOrder constant, with any additional modules appended. + * + * @returns {Object} Module state + * @property {Array} modules - Ordered module list + * @property {boolean} loading - Loading state + * @property {Error|null} error - Error state + */ +export function useLogModules() { + const [modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + async function loadModules() { + try { + setLoading(true); + setError(null); + + const availableModules = await logsAPI.fetchLogModules(); + + if (!isMounted) return; + + // Order modules by moduleOrder constant + const ordered = (moduleOrder || []) + .filter(m => availableModules.includes(m)) + .concat(availableModules.filter(m => !(moduleOrder || []).includes(m))); + + setModules(ordered); + } catch (err) { + if (!isMounted) return; + console.error('Failed to fetch log modules:', err); + setError(err); + setModules([]); + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + loadModules(); + + return () => { + isMounted = false; + }; + }, []); + + return { modules, loading, error }; +} diff --git a/frontend/src/hooks/useLogPolling.js b/frontend/src/hooks/useLogPolling.js new file mode 100755 index 00000000..8b22e2d7 --- /dev/null +++ b/frontend/src/hooks/useLogPolling.js @@ -0,0 +1,49 @@ +import { useEffect, useRef } from 'react'; + +/** + * useLogPolling - Auto-refresh log content + * + * Polls the refresh callback every 5 seconds when both module and file + * are selected. Clears interval on unmount or when selections change. + * Skips ticks while a fetch is already in flight so slow log reads don't + * stack up. + * + * @param {string} selectedModule - Currently selected module + * @param {string} selectedLogFile - Currently selected log file + * @param {Function} refreshCallback - Function to call every interval + * @param {{current: boolean}} [inFlightRef] - Optional ref to skip overlapping polls + * @returns {void} + */ +export function useLogPolling(selectedModule, selectedLogFile, refreshCallback, inFlightRef) { + const intervalRef = useRef(null); + + useEffect(() => { + // Only poll when both module and file selected + if (!selectedModule || !selectedLogFile || !refreshCallback) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + // Clear existing interval + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + // Start polling every 5 seconds + intervalRef.current = setInterval(() => { + if (inFlightRef?.current) return; + refreshCallback(); + }, 5000); + + // Cleanup on unmount or when dependencies change + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [selectedModule, selectedLogFile, refreshCallback, inFlightRef]); +} diff --git a/frontend/src/hooks/useModuleEvents.js b/frontend/src/hooks/useModuleEvents.js new file mode 100644 index 00000000..6b3629dc --- /dev/null +++ b/frontend/src/hooks/useModuleEvents.js @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +import { ensureStreamToken } from '../utils/api/streamAuth.js'; + +/** + * useModuleEvents - SSE-based hook for real-time module status updates + * + * Subscribes to the /api/modules/events SSE endpoint for push-based + * status updates. Falls back to polling if SSE connection fails. + * + * NOTE: EventSource cannot send custom headers, so the auth token is + * passed as a query parameter (?token=...) instead. + * + * @param {Object} options + * @param {Function} [options.onStatusChange] - Callback when a module status changes + * @param {boolean} [options.enabled=true] - Whether to connect + * @returns {Object} { states, isConnected } + */ +export function useModuleEvents({ onStatusChange, enabled = true } = {}) { + const [states, setStates] = useState({}); + // Internal "socket is live" flag. Exposed `isConnected` is derived + // (false whenever disabled) so we never need to setState-in-effect on disable. + const [hasOpenSocket, setHasOpenSocket] = useState(false); + const isConnected = enabled && hasOpenSocket; + + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const onStatusChangeRef = useRef(onStatusChange); + const connectRef = useRef(null); + + // Keep callback ref current without re-triggering effect + useEffect(() => { + onStatusChangeRef.current = onStatusChange; + }, [onStatusChange]); + + const connect = useCallback(async () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + // EventSource can't send Authorization headers — pass a short-lived, + // scope-limited stream token via query param, not the session JWT. + let sseUrl = '/api/modules/events'; + try { + const token = await ensureStreamToken(); + if (token) { + sseUrl += `?token=${encodeURIComponent(token)}`; + } + } catch { + // token unavailable — connect without (middleware will 401) + } + + const es = new EventSource(sseUrl); + eventSourceRef.current = es; + + es.onopen = () => { + setHasOpenSocket(true); + // Clear any pending reconnect + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + + es.onmessage = event => { + try { + const data = JSON.parse(event.data); + if (data.event === 'status_change' && data.module) { + setStates(prev => ({ + ...prev, + [data.module]: data, + })); + if (onStatusChangeRef.current) { + onStatusChangeRef.current(data); + } + } + } catch { + // Ignore parse errors (keepalive comments, etc.) + } + }; + + es.onerror = () => { + setHasOpenSocket(false); + es.close(); + eventSourceRef.current = null; + + // Reconnect after 5 seconds via ref — avoids use-before-define. + reconnectTimeoutRef.current = setTimeout(() => { + if (enabled && connectRef.current) connectRef.current(); + }, 5000); + }; + }, [enabled]); + + // Mirror connect into a ref so the reconnect timer can call it without + // forward-referencing the binding. + useEffect(() => { + connectRef.current = connect; + }, [connect]); + + useEffect(() => { + if (!enabled) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + // isConnected is already false via derivation; no setState needed. + return; + } + + connect(); + + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + }; + }, [enabled, connect]); + + return { states, isConnected }; +} + +export default useModuleEvents; diff --git a/frontend/src/hooks/useModuleExecution.js b/frontend/src/hooks/useModuleExecution.js new file mode 100755 index 00000000..a0f5c1e8 --- /dev/null +++ b/frontend/src/hooks/useModuleExecution.js @@ -0,0 +1,220 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useToast } from '../contexts/ToastContext.jsx'; +import { modulesAPI } from '../utils/api/modules.js'; + +/** + * Module execution hook for running and monitoring CHUB modules + * + * Features: + * - Execute modules with loading state management + * - Real-time status polling for running modules + * - Automatic polling start/stop based on activity + * - Exponential backoff on consecutive errors + * - Toast notifications for all operations + * + * @returns {Object} Module execution state and actions + */ +export const useModuleExecution = () => { + const [runningModules, setRunningModules] = useState(new Set()); + const [runStates, setRunStates] = useState({}); + + // `polling` is derived: we poll iff at least one module is running. + const polling = runningModules.size > 0; + + const toast = useToast(); + const pollingIntervalRef = useRef(null); + const isMountedRef = useRef(true); + const consecutiveErrorsRef = useRef(0); + const currentIntervalRef = useRef(2000); + const loadRunStatesRef = useRef(null); + const runningModulesRef = useRef(runningModules); + + const BASE_INTERVAL = 2000; + const MAX_INTERVAL = 30000; + + /** + * Restart polling with a new interval (ref-stable, no hook deps) + */ + const restartPollingWithInterval = useCallback(interval => { + currentIntervalRef.current = interval; + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = setInterval(() => { + if (isMountedRef.current && loadRunStatesRef.current) { + loadRunStatesRef.current(); + } + }, interval); + } + }, []); + + /** + * Load run states from API with error backoff. + * Uses explicit promise chains (not async/await) so all setState calls live + * inside .then/.catch callbacks — satisfies react-hooks/set-state-in-effect + * when this is invoked from an effect body. + */ + const loadRunStates = useCallback( + () => + modulesAPI + .fetchRunStates({ useCache: false }) + .then(data => { + if (!isMountedRef.current) return; + const newStates = data?.data || {}; + setRunStates(newStates); + + // Detect modules that finished running + const currentRunning = runningModulesRef.current; + if (currentRunning.size > 0) { + const finished = []; + for (const modKey of currentRunning) { + const modState = newStates[modKey]; + if (!modState || modState.status !== 'running') { + finished.push(modKey); + if (modState?.status === 'success') { + toast.success(`${modKey} completed successfully`); + } else if (modState?.status === 'error') { + toast.error(`${modKey} failed`); + } + } + } + if (finished.length > 0) { + setRunningModules(prev => { + const newSet = new Set(prev); + finished.forEach(k => newSet.delete(k)); + return newSet; + }); + } + } + + // Reset backoff on success + if (consecutiveErrorsRef.current > 0) { + consecutiveErrorsRef.current = 0; + restartPollingWithInterval(BASE_INTERVAL); + } + }) + .catch(error => { + if (isMountedRef.current) { + consecutiveErrorsRef.current += 1; + // Exponential backoff: 2s, 4s, 8s, 16s, 30s max + const newInterval = Math.min( + BASE_INTERVAL * Math.pow(2, consecutiveErrorsRef.current), + MAX_INTERVAL + ); + if (newInterval !== currentIntervalRef.current) { + restartPollingWithInterval(newInterval); + } + } + console.error('Failed to load run states:', error); + }), + [restartPollingWithInterval, toast] + ); + + // Keep refs in sync for interval callbacks (in effects to avoid writes during render) + useEffect(() => { + loadRunStatesRef.current = loadRunStates; + }, [loadRunStates]); + useEffect(() => { + runningModulesRef.current = runningModules; + }, [runningModules]); + + /** + * Start polling for status updates + */ + const startPolling = useCallback(() => { + if (pollingIntervalRef.current) return; + + consecutiveErrorsRef.current = 0; + currentIntervalRef.current = BASE_INTERVAL; + pollingIntervalRef.current = setInterval(() => { + if (isMountedRef.current && loadRunStatesRef.current) { + loadRunStatesRef.current(); + } + }, BASE_INTERVAL); + }, []); + + /** + * Stop polling + */ + const stopPolling = useCallback(() => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + consecutiveErrorsRef.current = 0; + currentIntervalRef.current = BASE_INTERVAL; + }, []); + + /** + * Execute module + * @param {string} moduleKey - Module key to execute + */ + const executeModule = useCallback( + async moduleKey => { + try { + setRunningModules(prev => new Set([...prev, moduleKey])); + startPolling(); + + toast.info(`Starting ${moduleKey}...`); + + const result = await modulesAPI.runModule(moduleKey); + + if (!result.success) { + toast.error(`${moduleKey} failed: ${result.message || 'Unknown error'}`); + setRunningModules(prev => { + const newSet = new Set(prev); + newSet.delete(moduleKey); + return newSet; + }); + } + // On success, module stays in runningModules — polling will detect completion + } catch (error) { + console.error(`Failed to run ${moduleKey}:`, error); + toast.error(`Failed to run ${moduleKey}: ${error.message}`); + if (isMountedRef.current) { + setRunningModules(prev => { + const newSet = new Set(prev); + newSet.delete(moduleKey); + return newSet; + }); + } + } + }, + [toast, startPolling] + ); + + // Initial load and cleanup + useEffect(() => { + loadRunStates(); + return () => { + isMountedRef.current = false; + stopPolling(); + }; + }, [loadRunStates, stopPolling]); + + // Auto start/stop polling based on running modules. Triggers off either + // a locally-initiated run (runningModules Set) OR an externally-started + // run that shows up in runStates — otherwise the page would idle while + // a job triggered from Dashboard/Jobs page is in flight. + const externalRunning = Object.values(runStates).some(s => s?.status === 'running'); + useEffect(() => { + if (runningModules.size > 0 || externalRunning) { + startPolling(); + } else { + stopPolling(); + } + }, [runningModules.size, externalRunning, startPolling, stopPolling]); + + return { + runningModules, + runStates, + polling, + executeModule, + refreshData: loadRunStates, + // Reflect cross-page truth: a job started elsewhere (Dashboard, Jobs page, + // schedule fire) shows up in runStates with status='running' via the + // backend overlay, even though this hook's local Set is empty. + isRunning: moduleKey => + runningModules.has(moduleKey) || runStates[moduleKey]?.status === 'running', + getRunState: moduleKey => runStates[moduleKey] || null, + }; +}; diff --git a/frontend/src/hooks/useModuleSchema.js b/frontend/src/hooks/useModuleSchema.js new file mode 100644 index 00000000..a7317ff2 --- /dev/null +++ b/frontend/src/hooks/useModuleSchema.js @@ -0,0 +1,106 @@ +import { useState, useEffect, useCallback } from 'react'; +import { modulesAPI } from '../utils/api/modules.js'; +import { adaptModuleSchema, mergeSchemas } from '../utils/schemaAdapter.js'; +import { SETTINGS_SCHEMA } from '../utils/constants/settings_schema.js'; +import { withExtensionConfigModuleKeys } from '../extensions/index.js'; + +/** + * Known config module keys that have Pydantic models on the backend. + * Matches the fields in ChubConfig (backend/util/config.py). + */ +const CORE_CONFIG_MODULE_KEYS = [ + 'tmdb', + 'fanart', + 'sync_gdrive', + 'poster_renamerr', + 'asset_renamerr', + 'border_replacerr', + 'upgradinatorr', + 'renameinatorr', + 'nohl', + 'labelarr', + 'health_checkarr', + 'jduparr', + 'nestarr', + 'poster_cleanarr', + 'plex_maintenance', + 'unmatched_assets', +]; + +// Extension config keys spliced in (src/extensions) — identity on main. +const CONFIG_MODULE_KEYS = withExtensionConfigModuleKeys(CORE_CONFIG_MODULE_KEYS); + +const MODULE_SETTINGS_SCHEMA = SETTINGS_SCHEMA.filter(schema => + CONFIG_MODULE_KEYS.includes(schema.key) +); + +/** + * Hook that fetches module schemas from the backend and merges them + * with the static SETTINGS_SCHEMA as a fallback. + * + * @returns {{ schemas: Array, loading: boolean, error: string|null }} + */ +// Sort the static fallback up-front so the initial render uses the same +// canonical order as the eventual backend merge. +const SORTED_SETTINGS_SCHEMA = (() => { + const keyOrder = new Map(CONFIG_MODULE_KEYS.map((k, i) => [k, i])); + return [...MODULE_SETTINGS_SCHEMA].sort((a, b) => { + const idxA = keyOrder.get(a.key) ?? Infinity; + const idxB = keyOrder.get(b.key) ?? Infinity; + return idxA - idxB; + }); +})(); + +export function useModuleSchema() { + const [schemas, setSchemas] = useState(SORTED_SETTINGS_SCHEMA); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Core fetch logic returned as a promise so all setState happens in .then/.catch + // callbacks (not synchronously in any effect body). + const fetchSchemas = useCallback( + () => + Promise.allSettled( + CONFIG_MODULE_KEYS.map(key => + modulesAPI + .getModuleSchema(key) + .then(res => ({ key, schema: res?.data?.schema })) + ) + ) + .then(results => { + const backendSchemas = results + .filter(r => r.status === 'fulfilled' && r.value.schema) + .map(r => adaptModuleSchema(r.value.key, r.value.schema)); + + // Re-sort to match CONFIG_MODULE_KEYS order (Promise.allSettled + // resolves in arbitrary order which causes modules to jump around) + const keyOrder = new Map(CONFIG_MODULE_KEYS.map((k, i) => [k, i])); + backendSchemas.sort((a, b) => { + const idxA = keyOrder.get(a.key) ?? Infinity; + const idxB = keyOrder.get(b.key) ?? Infinity; + return idxA - idxB; + }); + + if (backendSchemas.length > 0) { + setSchemas( + mergeSchemas(backendSchemas, MODULE_SETTINGS_SCHEMA, CONFIG_MODULE_KEYS) + ); + } + // If all fetches failed, we keep the static fallback + }) + .catch(e => { + setError(e.message); + // Keep static schemas as fallback + }) + .finally(() => { + setLoading(false); + }), + [] + ); + + useEffect(() => { + fetchSchemas(); + }, [fetchSchemas]); + + return { schemas, loading, error, refetch: fetchSchemas }; +} diff --git a/frontend/src/hooks/useRecentSearches.js b/frontend/src/hooks/useRecentSearches.js new file mode 100644 index 00000000..3fad5357 --- /dev/null +++ b/frontend/src/hooks/useRecentSearches.js @@ -0,0 +1,44 @@ +import { useState, useCallback } from 'react'; + +const STORAGE_KEY = 'chub_recent_searches'; +const MAX_RECENT = 8; + +/** + * Hook for managing recent search history in localStorage + * @returns {{ recentSearches: string[], addSearch: (term: string) => void, clearSearches: () => void }} + */ +export function useRecentSearches() { + const [recentSearches, setRecentSearches] = useState(() => { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + } catch { + return []; + } + }); + + const addSearch = useCallback(term => { + if (!term || !term.trim()) return; + const trimmed = term.trim(); + setRecentSearches(prev => { + const filtered = prev.filter(s => s !== trimmed); + const next = [trimmed, ...filtered].slice(0, MAX_RECENT); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Ignore storage errors + } + return next; + }); + }, []); + + const clearSearches = useCallback(() => { + setRecentSearches([]); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore storage errors + } + }, []); + + return { recentSearches, addSearch, clearSearches }; +} diff --git a/frontend/src/hooks/useSearchPageDetection.js b/frontend/src/hooks/useSearchPageDetection.js new file mode 100755 index 00000000..7d82f73c --- /dev/null +++ b/frontend/src/hooks/useSearchPageDetection.js @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router'; + +/** + * Hook to detect if the current page is a search page + */ +const useSearchPageDetection = () => { + const location = useLocation(); + + const searchPageData = useMemo(() => { + const path = location.pathname; + + // Check for media search page + if (path === '/media/search') { + return { + isSearchPage: true, + searchPageType: 'media', + searchSubtype: null, + }; + } + + // Check for poster search pages + if (path === '/poster/search/assets') { + return { + isSearchPage: true, + searchPageType: 'posters', + searchSubtype: 'assets', + }; + } + + // Not a search page + return { + isSearchPage: false, + searchPageType: null, + searchSubtype: null, + }; + }, [location.pathname]); + + return searchPageData; +}; + +export default useSearchPageDetection; diff --git a/frontend/src/hooks/useStreamToken.js b/frontend/src/hooks/useStreamToken.js new file mode 100644 index 00000000..37c4f634 --- /dev/null +++ b/frontend/src/hooks/useStreamToken.js @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; +import { + subscribeStreamToken, + streamTokenSnapshot, + ensureStreamToken, +} from '../utils/api/streamAuth.js'; + +/** + * Subscribe a component to the current stream token. Ensures the token is + * fetched, and re-renders the component whenever it's minted/refreshed — so any + * `` it builds (via getThumbnailUrl / getPosterUrl / getPreviewUrl) + * gets rebuilt WITH the token instead of rendering token-less (401) during the + * initial fetch race or a refresh. Call it once in any page that renders a + * poster/thumbnail grid; the return value can be ignored. + */ +export function useStreamToken() { + // Re-render on EVERY stream-auth change, not just token-value changes: when + // auth is not configured the token stays '' permanently, so tracking the + // token value alone would never re-render the grid to swap the blank + // placeholder for a real (token-less) image URL. A tick forces the re-render + // once the auth state resolves. + const [, setTick] = useState(0); + useEffect(() => { + ensureStreamToken(); + return subscribeStreamToken(() => setTick(t => t + 1)); + }, []); + return streamTokenSnapshot(); +} + +export default useStreamToken; diff --git a/frontend/src/hooks/useUnsavedChangesWarning.js b/frontend/src/hooks/useUnsavedChangesWarning.js new file mode 100644 index 00000000..5407c56d --- /dev/null +++ b/frontend/src/hooks/useUnsavedChangesWarning.js @@ -0,0 +1,66 @@ +import { useEffect } from 'react'; + +const DEFAULT_MESSAGE = 'You have unsaved changes. Leave this page and discard them?'; + +const isPlainLeftClick = event => + event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; + +const sameDocumentUrl = href => { + try { + const nextUrl = new URL(href, window.location.href); + const currentUrl = new URL(window.location.href); + + return ( + nextUrl.origin === currentUrl.origin && + nextUrl.pathname === currentUrl.pathname && + nextUrl.search === currentUrl.search && + nextUrl.hash === currentUrl.hash + ); + } catch { + return false; + } +}; + +/** + * Warn before discarding unsaved form changes. + * + * Handles browser unloads plus normal in-app anchor navigation. The app uses + * BrowserRouter rather than a data router, so React Router's useBlocker is not + * available without a larger router migration. + */ +export function useUnsavedChangesWarning(isDirty, message = DEFAULT_MESSAGE) { + useEffect(() => { + if (!isDirty) return undefined; + + const handleBeforeUnload = event => { + event.preventDefault(); + event.returnValue = ''; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isDirty]); + + useEffect(() => { + if (!isDirty) return undefined; + + const handleDocumentClick = event => { + if (event.defaultPrevented || !isPlainLeftClick(event)) return; + + const anchor = event.target.closest?.('a[href]'); + if (!anchor || anchor.target || anchor.hasAttribute('download')) return; + if (sameDocumentUrl(anchor.href)) return; + + const nextUrl = new URL(anchor.href, window.location.href); + if (nextUrl.origin !== window.location.origin) return; + + if (!window.confirm(message)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + document.addEventListener('click', handleDocumentClick, true); + return () => document.removeEventListener('click', handleDocumentClick, true); + }, [isDirty, message]); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100755 index 00000000..932f4602 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; + +// Import CHUB Design System CSS +import './css/tailwind.css'; +import './css/index.css'; + +/** + * Main application entry point + * Using React 19 createRoot API + */ +const container = document.getElementById('root'); +const root = createRoot(container); + +root.render( + + + +); diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 00000000..46afe723 --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -0,0 +1,1078 @@ +import React, { useMemo, useEffect, useRef, useCallback, useState } from 'react'; +import { Link } from 'react-router'; +import { useApiData } from '../hooks/useApiData'; +import { useModuleEvents } from '../hooks/useModuleEvents'; +import { modulesAPI } from '../utils/api/modules'; +import { jobsAPI } from '../utils/api/jobs'; +import { systemAPI } from '../utils/api/system'; +import { scheduleAPI } from '../utils/api/schedule'; +import { instancesAPI } from '../utils/api/instances'; +import { postersAPI } from '../utils/api/posters'; +import { configAPI } from '../utils/api/config'; +import { Button } from '../components/ui'; +import { Modal } from '../components/modals/Modal'; +import { Skeleton } from '../components/ui'; +import Dropdown from '../components/ui/Dropdown.jsx'; +import { useToast } from '../contexts/ToastContext.jsx'; +import { humanize } from '../utils/tools.js'; +import { formatDateTime } from '../utils/datetime.js'; +import { + scheduleToNextFire, + scheduleToHuman, + formatTimeUntil, + formatTimeAgo, +} from '../utils/schedule.js'; + +const UPCOMING_LIMIT = 5; + +// Status-dot colour + soft ring glow per run state. rgba glows aren't +// expressible as theme tokens, so the presentational hexes are inlined here +// (they mirror the redesign palette). +const DOT = { + success: ['#6cbc66', 'rgba(108,188,102,.16)'], + running: ['#53e8f0', 'rgba(83,232,240,.18)'], + error: ['#fd355c', 'rgba(253,53,92,.18)'], + pending: ['#ffc944', 'rgba(255,201,68,.16)'], + idle: ['#564f8a', 'rgba(86,79,138,0)'], +}; + +// 7px status dot with a soft ring (box-shadow). status keys match DOT. +const StatusDot = ({ status = 'idle', size = 7 }) => { + const [color, glow] = DOT[status] || DOT.idle; + return ( +