Files
silo-server/web/src/pages/AdminRequests.tsx
T
2c714e4ef2 feat(requests): pluginize request fulfillment behind request_router.v1 (#104)
* docs: design spec for pluginizing requests fulfillment

Pluginize the requests fulfillment backend behind an agnostic
request_router.v1 capability (high seam: whole-request fulfiller).
Host keeps lifecycle/quota/policy/quality-governance and a generic
two-tier connection registry; plugins own routing+submission+status.
First plugin extracts multi-instance Sonarr/Radarr; Seerr follows in
a separate spec. Preserves autoscan reuse of arr connection rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for requests pluginization

Three-phase plan: (1) request_router.v1 SDK capability, (2) new
silo-plugin-requests-arr plugin extracting multi-instance Sonarr/Radarr,
(3) host refactor routing fulfillment through the plugin while keeping
quality governance, target records, and autoscan connection reuse host-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db): generalize request_integrations into a two-tier connection registry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): add generic connection fields to Integration + repo mapping

* feat(pluginhost): typed RequestRouter capability client + resolver

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin-backed RequestRouterProvider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): route fulfillment through RequestRouterProvider; host keeps quality governance

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): base auto-approve gate on router connection model

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): wire plugin-backed request router at both service sites

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(requests): remove in-host Sonarr/Radarr fulfillment code

* test(autoscan): lock request-integration reuse after connection generalization

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): plugin-driven request integration config form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): echo router connection fields in integration response

* fix(requests): retry dropped qualities, contain to one router installation, dedupe targets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): harden plugin trust boundary (validate targets, contain bad connections, media-type routing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): tighten auto-approve gate, restore default/4k validation, propagate config-encode error, drop itoa wrapper, test status/options translation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(requests): resolve integrations/settings/secrets once per reconcile cycle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): dedupe config helpers, preserve zero profile id, stabilize installation default, drop redundant options write

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for schema-driven plugin config form

Extends AdminFormDescriptor into a full form-description language (dynamic
options, multi-select, conditional visibility, sections, validation) + a
plugin Validate RPC, rendered by one reusable SchemaForm engine. Retires the
bespoke arr connection form and integrationOptionsFromRouter so any
request_router backend renders its config UI from manifest data with zero
host changes. Addresses code-review finding #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for schema-driven plugin config form

Six phases: SDK AdminFormDescriptor extensions + Validate RPC; reusable
SchemaForm renderer (refactor PluginConfigForm onto it); host Validate
plumbing + generic options + legacy-column derivation + retire
integrationOptionsFromRouter; requests admin page swap to SchemaForm with
per-plugin grouping; arr manifest enrichment + Validate impl; verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): extend plugin admin-form TS types (sections, conditions, validation, multi-select)

* feat(web): schema-form pure utils (show_when, validation, value coercion)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm renderer (controls, sections, show_when, dynamic options, errors)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): render PluginConfigForm via the shared SchemaForm engine

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): RequestRouter Validate client + provider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin Validate on save, generic options, derive legacy columns from plugin_config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): generic options response + 400 field_errors on plugin validation failure

* feat(web): generic request-integration options type + surface validation field_errors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): render request connections via SchemaForm; per-plugin grouping; retire bespoke arr form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): silent connection-options probe with inline failure status (no toast spam)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): serialize admin_form sections/show_when/dynamic_options/validation to the client

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): drop show_when-hidden fields from buildSchemaValues payload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass requester user id as int64 (no truncation)

* refactor(requests): drop legacy arr columns; plugin_config is sole source of truth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): backfill api key in plugin validate; centralize validation 400; drop duplicate host cross-field check; guard admin-form serializer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): refuse stored api key reuse when base_url changes (security hardening)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm regex-guard, default_value, type-driven coercion, validity callback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): connection-options latest-wins + narrowed deps + clear stale errors; auto-select; type-driven persist; reuse types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for silo-plugin-requests-seerr (request_router.v1 backend)

* docs: implementation plan for silo-plugin-requests-seerr

* docs(spec): FindExistingRequest uses /api/v1/request (carries request id)

* docs(spec): seerr hardening — id-recovery, 404 terminal, media-status, sort pin, single missing-tmdb message

* docs: design spec for shared plugin-platform SDK helpers (code-review #10)

* docs: plan for plugin-platform SDK helpers (#10) + spec fix (inline broker wiring, no import cycle)

* docs: design spec for typed 4K quality-tier signal (code-review #9)

* docs: implementation plan for typed 4K quality-tier signal (#9)

* feat(requests): stamp is4k per quality (host owns the 4K-tier fact)

* fix(requests): store capability sub-id, not the type, in request_integrations

request_integrations.capability_id carried the capability TYPE
("request_router.v1") instead of the capability sub-id ("arr"/"seerr").
The host resolves a router plugin via
requireCapability("request_router.v1", id), which keys on (type, id), so
storing the type resolved to no capability: every save/options/fulfill
500'd ("Request operation failed" / "no fulfillment backend configured")
in ~1ms, before the arr/Seerr API was ever contacted. The path was
internally split-brained (the fulfillment filter matched the type while
the dispatcher needed the sub-id), so it never worked end-to-end; the
unit tests hid it behind a fake provider that skips requireCapability.

Align capability_id with the scan_source/metadata convention (sub-id):
- validateInstance: require a non-empty sub-id; drop the default-to-type
  and the "!= request_router.v1" reject.
- resolveRouterConnections / integrationConfigured / unbound-guidance:
  match on a non-empty capability, not type equality.
- repository: persist capability_id verbatim (never default to the type).
- web AdminRequests: send the selected plugin's capability.id in both the
  options probe and the save payload (was a hardcoded type constant).
- migration 20260608131649: backfill capability_id from each bound
  installation's request_router.v1 capability and drop the column's
  misleading default. Unbound legacy rows are left for admin re-save.

Tests: validateInstance now requires the sub-id, and the selected sub-id
must reach the plugin Validate RPC (fakeRouterProvider records it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): polish request connection cards (grouped toggles + option loading states)

The schema-driven connection cards rendered each boolean as its own
bordered, double-labeled box and showed dynamic SELECTs (root folder,
quality profile, tags) as empty controls with a single "Loading options…"
line while the host probed the service.

- Toggles render as a cohesive settings list: consecutive switches collapse
  into one bordered, divided container; each row is toggle-first with the
  label + description hugging beside it (no stranded whitespace between a
  short label and its switch). Honors show_when, so conditional toggles
  still group correctly.
- Dynamic SELECT/MULTI_SELECT fields show a per-field spinner + shimmer
  skeleton while options load, and only when there's nothing to show yet —
  a background re-probe never flashes over the operator's current value.
- Sections get a softer surface and clearer titles; the card's enable
  switch is labeled Enabled/Disabled; the options-load failure is a proper
  inline alert with retry guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): treat "Any"/no-cap playback ceiling as 4K-allowed

allowedQualities decided whether to also request 2160p with
`CompareQuality(ceiling, PlaybackQuality4K) >= 0`. But an "Any" max
playback quality resolves to an empty ceiling ("no cap"), and in
qualityRank "" is the LOWEST rank (0) — so CompareQuality("", "2160p")
returns -1 and 4K was dropped. A requester with unlimited playback quality
only got a 1080p request, never the 4K one.

Use access.QualityAllowed(PlaybackQuality4K, ceiling), which already
encodes "empty ceiling == no cap == allows everything". Now:
- "" / "Any"  -> 1080p + 2160p
- "2160p"     -> 1080p + 2160p
- "1080p"     -> 1080p only
- resolver error still fails safe to the HD ceiling.

Tests: add an "any/no-cap ceiling adds 2160p" case; the unknown-quality,
status-coercion, dedup, and per-quality-idempotency submit tests now pin
an explicit HD ceiling (they relied on the old empty-default == HD-only
behavior and were not about 4K entitlement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for collapsible Library + anime gate/nesting (request card UI, Spec A)

Spec A of two for the request connection card UX: Library section becomes
collapsible/collapsed (auto-expanding on validation errors) and the anime
override fields move into a single gated section below Library instead of
popping out as a detached sibling card. Single-default enforcement is Spec B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for collapsible Library + anime gate/nesting (Spec A)

Task-by-task TDD plan: SchemaForm auto-expand-on-error + nested-field
affordance (silo-server), arr manifest regroup (collapsible Library, anime
gate section), then build/deploy/reinstall + manual verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-expand collapsible schema sections that have validation errors

SchemaFormSection now accepts a forceOpen prop; when any field in the section
has a mergedError (client validation or server error), the section expands
automatically so required-field setup can never be hidden behind a collapsed
accordion. The operator's manual toggle is preserved via a nullable userOpen
state that only takes effect when forceOpen is false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): indent show_when-revealed schema fields to read as nested

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for schema-driven single-default exclusivity enforcement (Spec B)

At most one connection per service_kind may be the HD default (is_default) or
4K default (is_default_4k). Generic exclusivity: a new AdminFormField
exclusive_group_field declares the rule, the plugin Validate enforces it
against host-supplied siblings (config only, no creds), and the admin UI
auto-clears conflicts as you toggle. Host stays plugin-agnostic. Forward-only;
no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-default exclusivity enforcement (Spec B)

Five TDD tasks across 3 repos: SDK proto (siblings + exclusive_group_field)
+ buf regen; arr Validate cross-sibling + manifest; host gathers siblings
(config-only) into Validate; frontend generic mutual-exclusion helper; then
re-vendor/rebuild/redeploy + plugininstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass sibling connections to plugin Validate for cross-connection rules

Adds siblings []ResolvedRouterConnection to RequestRouterProvider.Validate so
the plugin can enforce cross-connection invariants (e.g. one default per
service_kind) without the host resolving sibling credentials. The new
siblingConnections helper gathers other connections on the same installation,
carrying only ID + PluginConfig. Vendor updated to the Task 1 SDK version that
carries ValidateRequest.Siblings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): auto-clear mutually-exclusive defaults across request connection cards

Adds generic applyExclusivity helper and wires it into updateCardConfig so
turning on a field with exclusive_group_field proactively clears the same
field on sibling cards sharing the same group value, matching server-side
enforcement with a proactive UX.

* docs: design spec for single-flighting plugin client launch (cold-start herd fix)

Concurrent ensureClient calls for a cold installation each spawn a redundant
plugin process (Host.Start releases its lock during launch). Wrap ensureClient
in a per-installation singleflight.Group so concurrent first-use collapses to
one launch. Host-only fix; surfaced while testing the request-router feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-flighting plugin client launch

TDD: concurrency tests (herd collapses to one launch, warm-cache reuse,
distinct installations stay parallel, failed launch propagates) + the
singleflight wrapper around ensureClient; then rebuild/redeploy + verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): single-flight ensureClient to prevent cold-start launch herd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(requests): harden capability containment + dedupe eligibility; UI/migration cleanups

Addresses /code-review high findings on the previously-unreviewed commits:
- resolveRouterConnections contains fulfillment to the first chosen
  (installation, capability) and locks only after a connection's key resolves,
  so a plugin exposing >1 request_router capability never mixes connections and
  a skipped bad-key connection never pins the capability (+ test).
- extract eligibleRouterConnection, shared by resolveRouterConnections and
  integrationConfigured so the auto-approval gate and fulfillment filter can't
  drift.
- SchemaForm: shared FieldDescription helper (field/switch/section); key switch
  groups by position so a show_when reveal doesn't remount the group (focus loss).
- migration backfill uses a deterministic correlated subquery instead of a join
  cross-product when an installation exposes multiple request_router capabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for opt-in Seerr per-user requester mapping

Per-connection requester_mode (admin default | mapped). In mapped mode the host
pushes the requester email/username into the Fulfill descriptor and the seerr
plugin resolves/creates the matching Seerr user by email with operator-chosen
default permissions, attributing the request (and gating Seerr-side approval via
the auto-approve permission). Spans SDK (descriptor fields), host (extend
UserIdentityLookup with email + a requester resolver), and the seerr plugin
(Seerr user API + mapping). Fallback to admin on any failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for Seerr per-user requester mapping

Five TDD tasks across 3 repos: SDK descriptor fields (requester_email/username);
host resolves identity (UserIdentityLookup+email, RequesterIdentityResolver,
populate descriptor at both Fulfill sites); seerr config+user API (find/create
by email, exported PermissionBits); seerr Fulfill mapping + admin_form; then
re-vendor/rebuild/redeploy + plugininstall (installation 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: make Seerr unmapped-requester behavior a toggle (admin fallback | fail request)

Per user feedback: require_mapped_user switch (default off = admin fallback,
on = fail the request). Updates spec + plan Tasks 3/4 (config field, Fulfill
honoring the toggle via a mapFailed signal, a new test, and the manifest switch).

* feat(requests): resolve requester email/username into the Fulfill descriptor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for simplified Seerr mapped-user permissions

Reduce the 5 permission toggles to two (request_4k_all + auto_approve);
1080p always granted; remove manage_requests; 4K eligibility per-user from the
request's qualities (host-decided, same as arr) with a blanket override toggle.
Seerr-plugin-only; permission-only override (host still gates 4K requests).

* docs: implementation plan for simplified Seerr mapped-user permissions

Two tasks (seerr-plugin-only): replace the 5 perm toggles with request_4k_all +
auto_approve (1080p always; 4K from request qualities via userPermissions;
remove PermManageRequests/PermissionBits; manifest + json_schema), then rebuild
+ reinstall (installation 6). No host/SDK change.

* docs: design spec for host rebase onto main + #95 credential-model adoption

Per-commit rebase of our 68 request-router commits onto the force-pushed
origin/main (drops 188 patch-equivalent). At the credential-path conflicts, adopt
#95's inline secret.Cipher model: keep our plugin columns + #95's encrypt/decrypt
in repository.go; drop our SecretResolver and read in.APIKeyRef directly in
service.go; wire NewRepository(pool, dataCipher). #39-area conflicts take ours
(our pluginization supersedes it). Security review + SECRET_KEY deploy note.

* docs: implementation plan for host rebase + #95 credential adoption

Four tasks: (1) guided per-commit rebase onto origin/main, take-ours on
credential files so it builds; (2) TDD integration commit adopting #95's
secret.Cipher (encrypt/decrypt in repository.go, drop SecretResolver, read
APIKeyRef directly, wire NewRepository(pool, cipher)); (3) security review;
(4) pin published SDK v0.6.0, push fork, open host PR with SECRET_KEY deploy note.

* chore(rebase): restore scan-source service methods + temp requests-repo arity

Post-rebase conflict fixups: take-ours on internal/plugins/service.go dropped
origin's ScanSourceClientByPluginID (independent upstream capability) — restored.
mediarequests.NewRepository temporarily 1-arg to match our pre-#95 repo; Task 2
restores the cipher arg when adopting #95's at-rest credential model.

* feat(requests): adopt at-rest credential cipher (#95) for plugin api keys; drop SecretResolver

* build: pin published silo-plugin-sdk v0.6.0 (drop local replace)

* test(requests): guard at-rest cipher round-trip + empty-key auto-approval (code-review)

Max-effort code review of the #95 credential integration. Fixes the actionable
findings:
- TestEncryptAPIKeyRoundTripAndAAD: pins encryptAPIKey<->DecryptIfEncrypted
  inversion, the id-bound apiKeyAAD == secret.RowAAD(...) match (so #95's backfill
  rows decrypt), the blank-key "" sentinel, and row-bound AAD — the security-
  critical invariants had no automated guard (no DB harness for scanIntegration).
- TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured: pins that a keyless
  connection reads as unconfigured (request stays pending, never submitted), so
  integrationConfigured and resolveRouterConnections can't drift.
- Fix stale fulfillContext comment (referenced a resolved-API-key cache removed
  with SecretResolver).

Assessed-not-changed (documented): decrypt-error-fails-closed and failed-backfill
behaviors are origin/main #95 design we adopt; nil-cipher is unreachable in prod
and matches the codebase-wide no-guard pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build: drop stale machine-local SDK replace comment from go.mod

The replace directive was already removed when v0.6.0 was pinned (3410df7);
this leftover comment falsely claimed a local replace still existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): prettier-format schema-form utils to 100-col width

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop internal superpowers specs/plans from PR

These design specs and implementation plans are internal development
artifacts; keep them out of the upstream PR diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(metadata): exclude providers from content levels they don't declare

ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): isolate singleflight launch from leader ctx cancellation

The deduped ensureClient launch ran doEnsureClient under the leader caller's
ctx, so if that caller's request was canceled/timed out mid-launch the shared
plugin start was torn down and the error propagated to every waiter. Run the
launch under context.WithoutCancel so a single caller cannot cancel work the
other waiters depend on (values preserved for tracing/auth). (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): nil-guard request-router wiring

RequestRouterClient dereferenced a.Svc unconditionally and AttachRequestRouter
called SetRouterProvider even with nil deps, so a build without the plugin
service would panic instead of degrading. Guard both: the adapter returns a
controlled error and AttachRequestRouter no-ops, leaving fulfillment to fail
with the existing "no backend configured" path. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): correct value coercion + track capability sub-id in request form

- schemaForm: Boolean("false") was true; parse string booleans explicitly.
  array:num now coerces decimals ("1.5"), array:int stays integer-only.
- AdminRequests: track capability_id alongside installation_id (composite
  <Select> value) so a multi-capability installation resolves the exact
  backend; reset pluginConfig when the selected plugin changes so plugin A's
  keys never reach plugin B's options probe/save. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): address request-router review findings

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

* fix(requests): skip unconfigured 4k fulfillment targets

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-09 13:00:48 -04:00

1459 lines
50 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Link, useSearchParams } from "react-router";
import {
AlertTriangle,
Check,
Library,
Plug,
Plus,
RefreshCw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
X,
} from "lucide-react";
import type {
MediaRequest,
MediaRequestOutcome,
MediaRequestStatus,
PluginCapability,
PluginInstallation,
RequestApprovalMode,
RequestIntegration,
RequestIntegrationOptions,
RequestIntegrationValidationError,
RequestLimitMode,
RequestSettings,
RequestTarget,
RequestUserLimit,
} from "@/api/types";
import { SchemaForm } from "@/components/admin/plugins/SchemaForm";
import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useDebounce } from "@/hooks/useDebounce";
import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins";
import { useAdminUsers } from "@/hooks/queries/admin/users";
import {
useAdminMediaRequests,
useApproveMediaRequest,
useCreateRequestIntegration,
useDeclineMediaRequest,
useDeleteRequestIntegration,
useLoadRequestIntegrationOptions,
useRequestIntegrations,
useRequestSettings,
useRequestUserLimit,
useRetryMediaRequest,
useUpdateRequestIntegration,
useUpdateRequestSettings,
useUpdateRequestUserLimit,
} from "@/hooks/queries/useRequests";
import {
formatMediaType,
formatRequestDate,
formatRequestOutcome,
formatRequestStatus,
requestOutcomeBadgeVariant,
requestStatusBadgeVariant,
REQUEST_OUTCOMES,
REQUEST_STATUSES,
} from "@/lib/mediaRequests";
import { applyExclusivity } from "./requestExclusivity";
import { supportedMediaTypesForConfig } from "./requestIntegrationMediaTypes";
type StatusFilter = MediaRequestStatus | "all";
type OutcomeFilter = MediaRequestOutcome | "all";
const ADMIN_REQUEST_TABS = ["queue", "settings", "integrations", "overrides"] as const;
type AdminRequestTab = (typeof ADMIN_REQUEST_TABS)[number];
function normalizeAdminRequestTab(value: string | null): AdminRequestTab {
return ADMIN_REQUEST_TABS.includes(value as AdminRequestTab)
? (value as AdminRequestTab)
: "queue";
}
export default function AdminRequests() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = normalizeAdminRequestTab(searchParams.get("tab"));
function setActiveTab(value: string) {
const nextTab = normalizeAdminRequestTab(value);
const next = new URLSearchParams(searchParams);
if (nextTab === "queue") {
next.delete("tab");
} else {
next.set("tab", nextTab);
}
setSearchParams(next, { replace: true });
}
return (
<div className="space-y-6">
<div className="page-header">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-normal text-balance sm:text-4xl">
Requests
</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-6">
Review media requests, set limits, and manage Radarr or Sonarr routing.
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="gap-5">
<TabsList variant="line" className="border-border w-full justify-start border-b">
<TabsTrigger value="queue">Queue</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="integrations">Integrations</TabsTrigger>
<TabsTrigger value="overrides">User Overrides</TabsTrigger>
</TabsList>
<TabsContent value="queue">
<RequestQueueTab />
</TabsContent>
<TabsContent value="settings">
<RequestSettingsTab />
</TabsContent>
<TabsContent value="integrations">
<RequestIntegrationsTab />
</TabsContent>
<TabsContent value="overrides">
<UserOverridesTab />
</TabsContent>
</Tabs>
</div>
);
}
function RequestQueueTab() {
const [status, setStatus] = useState<StatusFilter>("all");
const [outcome, setOutcome] = useState<OutcomeFilter>("all");
const requests = useAdminMediaRequests({ status, outcome, limit: 100 });
const users = useAdminUsers();
const approve = useApproveMediaRequest();
const decline = useDeclineMediaRequest();
const retry = useRetryMediaRequest();
const [declineTarget, setDeclineTarget] = useState<MediaRequest | null>(null);
const [declineReason, setDeclineReason] = useState("");
const usernamesByID = useMemo(() => {
return new Map((users.data ?? []).map((user) => [user.id, user.username]));
}, [users.data]);
function handleDecline(request: MediaRequest) {
setDeclineTarget(request);
setDeclineReason("");
}
function confirmDecline() {
if (!declineTarget) return;
decline.mutate({ id: declineTarget.id, reason: declineReason });
setDeclineTarget(null);
setDeclineReason("");
}
return (
<div className="space-y-4">
<div className="border-border bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
<Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
<SelectTrigger className="w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REQUEST_STATUSES.map((value) => (
<SelectItem key={value} value={value}>
{value === "all" ? "All statuses" : formatRequestStatus(value)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={outcome} onValueChange={(value) => setOutcome(value as OutcomeFilter)}>
<SelectTrigger className="w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REQUEST_OUTCOMES.map((value) => (
<SelectItem key={value} value={value}>
{value === "all" ? "All outcomes" : formatRequestOutcome(value)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => requests.refetch()}
disabled={requests.isFetching}
>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
{requests.isLoading ? (
<RowsSkeleton />
) : requests.isError ? (
<EmptyPanel title="Requests failed" detail="The request queue could not be loaded." />
) : (
<div className="border-border bg-card overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Requested</TableHead>
<TableHead>Status</TableHead>
<TableHead>Outcome</TableHead>
<TableHead>Integration</TableHead>
<TableHead className="w-[240px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(requests.data ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
No requests match the current filters.
</TableCell>
</TableRow>
) : (
requests.data?.map((request) => (
<RequestQueueRow
key={request.id}
request={request}
requesterUsername={
request.requested_by_user_id
? usernamesByID.get(request.requested_by_user_id)
: undefined
}
approving={approve.isPending && approve.variables === request.id}
declining={decline.isPending && decline.variables?.id === request.id}
retrying={retry.isPending && retry.variables === request.id}
onApprove={() => approve.mutate(request.id)}
onDecline={() => handleDecline(request)}
onRetry={() => retry.mutate(request.id)}
/>
))
)}
</TableBody>
</Table>
</div>
)}
<Dialog
open={declineTarget !== null}
onOpenChange={(open) => {
if (!open) {
setDeclineTarget(null);
setDeclineReason("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Decline request</DialogTitle>
<DialogDescription>
{declineTarget
? `"${declineTarget.title}" will be marked declined. Add an optional note for the requester.`
: null}
</DialogDescription>
</DialogHeader>
<Label htmlFor="decline-reason" className="text-sm">
Reason (optional)
</Label>
<textarea
id="decline-reason"
className="border-input bg-background text-foreground focus-visible:ring-ring min-h-[88px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
value={declineReason}
onChange={(event) => setDeclineReason(event.target.value)}
placeholder="e.g. duplicate of an existing request"
/>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setDeclineTarget(null);
setDeclineReason("");
}}
>
Cancel
</Button>
<Button onClick={confirmDecline} disabled={decline.isPending}>
Decline
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function RequestQueueRow({
request,
requesterUsername,
approving,
declining,
retrying,
onApprove,
onDecline,
onRetry,
}: {
request: MediaRequest;
requesterUsername?: string;
approving: boolean;
declining: boolean;
retrying: boolean;
onApprove: () => void;
onDecline: () => void;
onRetry: () => void;
}) {
const canApprove = request.status === "pending" && request.outcome === "active";
const canDecline = request.status !== "completed" && request.outcome === "active";
const canRetry = request.outcome === "failed";
const requesterLabel = requesterUsername ?? `User ${request.requested_by_user_id}`;
const requestDetailHref = `/requests/${request.media_type}/${request.tmdb_id}`;
return (
<TableRow>
<TableCell>
<div className="min-w-[220px]">
<div className="flex flex-wrap items-center gap-2">
<Link to={requestDetailHref} className="font-medium hover:underline">
{request.title}
</Link>
<Badge variant="secondary">{formatMediaType(request.media_type)}</Badge>
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
{request.year ? <span>{request.year}</span> : null}
<span>TMDB {request.tmdb_id}</span>
{request.requested_by_user_id ? (
<Link
to={`/admin/users/${request.requested_by_user_id}`}
className="hover:text-foreground hover:underline"
>
{requesterLabel}
</Link>
) : null}
{request.library_content_id ? (
<Link
to={`/item/${encodeURIComponent(request.library_content_id)}`}
className="hover:text-foreground inline-flex items-center gap-1 hover:underline"
>
<Library className="h-3 w-3" />
Library
</Link>
) : null}
</div>
{request.last_error ? (
<p className="text-destructive mt-1 max-w-md text-xs">{request.last_error}</p>
) : null}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs">{formatRequestDate(request)}</TableCell>
<TableCell>
<Badge variant={requestStatusBadgeVariant(request.status)}>
{formatRequestStatus(request.status)}
</Badge>
</TableCell>
<TableCell>
<Badge variant={requestOutcomeBadgeVariant(request.outcome)}>
{formatRequestOutcome(request.outcome)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{request.targets?.length ? (
<div className="flex flex-col gap-1.5">
{request.is_anime ? (
<Badge variant="secondary" className="w-fit">
Anime
</Badge>
) : null}
{request.targets.map((target) => (
<RequestTargetBadge key={target.id} target={target} />
))}
</div>
) : (
"Not submitted"
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={onApprove}
disabled={!canApprove || approving}
>
<Check className="h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={onDecline}
disabled={!canDecline || declining}
>
<X className="h-4 w-4" />
Decline
</Button>
<Button size="sm" variant="outline" onClick={onRetry} disabled={!canRetry || retrying}>
<RefreshCw className="h-4 w-4" />
Retry
</Button>
</div>
</TableCell>
</TableRow>
);
}
function RequestTargetBadge({ target }: { target: RequestTarget }) {
const qualityLabel = target.quality === "2160p" ? "2160p" : "1080p";
const instanceLabel = target.instance_name || target.integration_kind || "Unknown";
const failed = target.status === "failed";
const statusLabel = target.status === "failed" ? "Failed" : formatRequestStatus(target.status);
return (
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">{qualityLabel}</Badge>
<span className="text-foreground">{instanceLabel}</span>
<Badge variant={failed ? "destructive" : "secondary"}>{statusLabel}</Badge>
{target.external_status ? <span>{target.external_status}</span> : null}
</div>
{failed && target.last_error ? (
<p className="text-destructive flex max-w-xs items-start gap-1">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>{target.last_error}</span>
</p>
) : null}
</div>
);
}
type SettingsFormState = {
requests_enabled: boolean;
global_max_requests: string;
global_window_days: string;
global_auto_approval_enabled: boolean;
force_dual_quality: boolean;
updated_at: string;
};
function RequestSettingsTab() {
const settings = useRequestSettings();
if (settings.isLoading) return <RowsSkeleton />;
if (settings.isError) {
return <EmptyPanel title="Settings failed" detail="Request settings could not be loaded." />;
}
if (!settings.data) {
return <EmptyPanel title="No settings" detail="Request settings are not available." />;
}
return <RequestSettingsForm key={settings.data.updated_at} settings={settings.data} />;
}
function RequestSettingsForm({ settings }: { settings: RequestSettings }) {
const updateSettings = useUpdateRequestSettings();
const [form, setForm] = useState<SettingsFormState>(() => ({
requests_enabled: settings.requests_enabled,
global_max_requests: String(settings.global_max_requests),
global_window_days: String(settings.global_window_days),
global_auto_approval_enabled: settings.global_auto_approval_enabled,
force_dual_quality: settings.force_dual_quality,
updated_at: settings.updated_at,
}));
function saveSettings() {
const payload: RequestSettings = {
requests_enabled: form.requests_enabled,
global_max_requests: Math.max(0, Number(form.global_max_requests) || 0),
global_window_days: Math.max(1, Number(form.global_window_days) || 1),
global_auto_approval_enabled: form.global_auto_approval_enabled,
force_dual_quality: form.force_dual_quality,
updated_at: form.updated_at,
};
updateSettings.mutate(payload);
}
return (
<div className="border-border bg-card max-w-3xl space-y-5 rounded-lg border p-5">
<div className="flex items-center gap-2">
<Settings2 className="text-primary h-4 w-4" />
<h2 className="text-lg font-semibold tracking-normal">Global Settings</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<SwitchField
label="Requests enabled"
checked={form.requests_enabled}
onCheckedChange={(checked) =>
setForm((current) => ({ ...current, requests_enabled: checked }))
}
/>
<SwitchField
label="Auto approval"
checked={form.global_auto_approval_enabled}
onCheckedChange={(checked) =>
setForm((current) => ({ ...current, global_auto_approval_enabled: checked }))
}
/>
<Field label="Max requests">
<Input
type="number"
min={0}
value={form.global_max_requests}
onChange={(event) =>
setForm((current) => ({ ...current, global_max_requests: event.target.value }))
}
/>
</Field>
<Field label="Window days">
<Input
type="number"
min={1}
value={form.global_window_days}
onChange={(event) =>
setForm((current) => ({ ...current, global_window_days: event.target.value }))
}
/>
</Field>
<div className="sm:col-span-2">
<SwitchField
label="Always fulfill in both 1080p and 4K"
description="Applies to all requests when both a Default HD and Default 4K instance exist, regardless of user role."
checked={form.force_dual_quality}
onCheckedChange={(checked) =>
setForm((current) => ({ ...current, force_dual_quality: checked }))
}
/>
</div>
</div>
<Button onClick={saveSettings} disabled={updateSettings.isPending}>
<Save className="h-4 w-4" />
Save Settings
</Button>
</div>
);
}
// Host chrome owned by Silo; everything arr-specific now lives in pluginConfig
// and is rendered by the plugin's connection descriptor via <SchemaForm>.
type IntegrationFormState = {
id: string;
name: string;
enabled: boolean;
base_url: string;
api_key_ref: string;
has_api_key: boolean;
// Selected installed plugin (which request-router plugin fulfills this connection).
// Empty string means "none selected"; the backend requires a non-empty value.
installation_id: string;
// The specific request_router.v1 capability sub-id on that installation. Tracked
// alongside installation_id because one installation can expose more than one
// capability, so installation_id alone can't identify the chosen backend.
capability_id: string;
};
// All request integrations are fulfilled by a plugin exposing this capability.
const REQUEST_ROUTER_CAPABILITY = "request_router.v1";
type RequestRouterInstallation = {
installationID: number;
pluginID: string;
capability: PluginCapability;
};
// requestRouterInstallations flattens installed plugins to one entry per
// request_router.v1 capability so the form can offer an installation selector.
function requestRouterInstallations(
installations: PluginInstallation[],
): RequestRouterInstallation[] {
const out: RequestRouterInstallation[] = [];
for (const installation of installations) {
for (const capability of installation.capabilities ?? []) {
if (
capability.type === REQUEST_ROUTER_CAPABILITY ||
capability.id === REQUEST_ROUTER_CAPABILITY
) {
out.push({
installationID: installation.id,
pluginID: installation.plugin_id,
capability,
});
}
}
}
return out;
}
function installationOptionLabel(entry: RequestRouterInstallation): string {
const name = entry.capability.display_name || entry.pluginID;
return `${name} (${entry.capability.id})`;
}
// installationOptionValue is the <Select> option value: it encodes both the
// installation and the capability sub-id so multiple capabilities on one
// installation stay distinct (installation_id alone would collide).
function installationOptionValue(entry: RequestRouterInstallation): string {
return `${entry.installationID}:${entry.capability.id}`;
}
function RequestIntegrationsTab() {
const integrations = useRequestIntegrations();
if (integrations.isLoading) return <RowsSkeleton />;
if (integrations.isError) {
return (
<EmptyPanel title="Integrations failed" detail="Request integrations could not be loaded." />
);
}
const list = integrations.data ?? [];
const integrationsKey =
list.length === 0
? "empty"
: list.map((integration) => `${integration.id}:${integration.updated_at ?? ""}`).join("|");
return <RequestIntegrationsForm key={integrationsKey} integrations={list} />;
}
type IntegrationCard = {
key: string;
form: IntegrationFormState;
pluginConfig: Record<string, unknown>;
source: RequestIntegration | null;
};
let integrationCardCounter = 0;
function nextCardKey(): string {
integrationCardCounter += 1;
return `card-${integrationCardCounter}`;
}
function integrationToForm(integration?: RequestIntegration): IntegrationFormState {
return {
id: integration?.id ?? "",
name: integration?.name ?? "",
enabled: integration?.enabled ?? true,
base_url: integration?.base_url ?? "",
api_key_ref: "",
has_api_key: integration?.has_api_key ?? false,
installation_id: integration?.installation_id ? String(integration.installation_id) : "",
capability_id: integration?.capability_id ?? "",
};
}
type ConnectionOptionsStatus = "idle" | "loading" | "error";
// useConnectionOptions debounces a ListConfigOptions probe keyed on the connection
// IDENTITY (base URL + key ref + installation + capability) — NOT the full
// plugin_config, so editing a quality profile / switch doesn't re-probe the arr
// API. It backs the dynamic SELECT options (root folders, quality profiles, tags)
// the descriptor declares. The probe is silent (no toasts); callers surface
// failures inline via `status`. A generation counter enforces latest-wins so a
// slow older probe can't overwrite a newer result, and options are cleared
// whenever a probe can't run.
function useConnectionOptions(
connectionID: string,
draft: {
base_url: string;
api_key_ref: string;
has_api_key: boolean;
installation_id?: number;
capability_id: string;
plugin_config: Record<string, unknown>;
},
) {
const load = useLoadRequestIntegrationOptions();
const [options, setOptions] = useState<RequestIntegrationOptions>({});
const [status, setStatus] = useState<ConnectionOptionsStatus>("idle");
// Latest-wins guard: each probe captures the generation it started with and
// only commits its result if no newer probe has begun since.
const genRef = useRef(0);
const canLoad =
draft.base_url.trim().length > 0 &&
Boolean(draft.installation_id) &&
draft.capability_id.trim().length > 0 &&
Boolean(draft.api_key_ref.trim() || draft.has_api_key);
// Options depend ONLY on the connection identity. plugin_config is still sent
// in the request body so the plugin can resolve options, but it is not part of
// the signature — see #2.
const sig = JSON.stringify({
u: draft.base_url,
k: draft.api_key_ref,
i: draft.installation_id,
c: draft.capability_id,
});
const debouncedSig = useDebounce(sig, 400);
// Snapshot the latest connection inputs so the debounced effect sends the
// current plugin_config without re-firing when only plugin_config changes.
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
if (!canLoad) {
// Bump the generation so any in-flight probe is invalidated, and clear any
// stale options from a previous/invalid connection so they never linger.
genRef.current += 1;
setOptions({});
setStatus("idle");
return;
}
const current = draftRef.current;
const gen = ++genRef.current;
setStatus("loading");
load
.mutateAsync({
id: connectionID || "new",
body: {
base_url: current.base_url,
api_key_ref: current.api_key_ref.trim() || undefined,
capability_id: current.capability_id,
installation_id: current.installation_id,
plugin_config: current.plugin_config,
},
})
.then((loaded) => {
if (gen === genRef.current) {
setOptions(loaded);
setStatus("idle");
}
})
.catch(() => {
if (gen === genRef.current) {
setOptions({});
setStatus("error");
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSig, canLoad]);
return { options, status };
}
function RequestIntegrationsForm({ integrations }: { integrations: RequestIntegration[] }) {
const installationsQuery = useAdminPluginInstallations();
const routerInstallations = useMemo(
() => requestRouterInstallations(installationsQuery.data ?? []),
[installationsQuery.data],
);
// When exactly one request-router plugin is installed, default new/unseeded
// connections to it so the admin doesn't have to pick.
const defaultSelection: Pick<IntegrationFormState, "installation_id" | "capability_id"> =
routerInstallations.length === 1
? {
installation_id: String(routerInstallations[0]?.installationID ?? ""),
capability_id: routerInstallations[0]?.capability.id ?? "",
}
: { installation_id: "", capability_id: "" };
const [cards, setCards] = useState<IntegrationCard[]>(() =>
integrations.map((integration) => ({
key: integration.id || nextCardKey(),
form: integrationToForm(integration),
pluginConfig: { ...(integration.plugin_config ?? {}) },
source: integration,
})),
);
function updateCard(key: string, patch: Partial<IntegrationFormState>) {
setCards((current) =>
current.map((card) =>
card.key === key ? { ...card, form: { ...card.form, ...patch } } : card,
),
);
}
function updateCardConfig(key: string, pluginConfig: Record<string, unknown>) {
setCards((current) => {
const mapped = current.map((card) => ({
key: card.key,
installationId: card.form.installation_id,
config: card.key === key ? pluginConfig : card.pluginConfig,
}));
const fieldsFor = (installationId: string) =>
routerInstallations.find((entry) => String(entry.installationID) === installationId)
?.capability.config_schema?.[0]?.admin_form?.fields ?? [];
const next = applyExclusivity(mapped, key, pluginConfig, fieldsFor);
const byKey = new Map(next.map((entry) => [entry.key, entry.config]));
return current.map((card) => ({
...card,
pluginConfig: byKey.get(card.key) ?? card.pluginConfig,
}));
});
}
function addCard() {
setCards((current) => [
...current,
{
key: nextCardKey(),
form: { ...integrationToForm(), ...defaultSelection },
pluginConfig: {},
source: null,
},
]);
}
function removeCard(key: string) {
setCards((current) => current.filter((card) => card.key !== key));
}
const noRouterPlugin = !installationsQuery.isLoading && routerInstallations.length === 0;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold tracking-normal">Connections</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={addCard}
disabled={noRouterPlugin}
>
<Plus className="h-4 w-4" />
Add connection
</Button>
</div>
{noRouterPlugin ? (
<EmptyPanel
title="No request-router plugin installed"
detail={`Install a plugin that exposes the ${REQUEST_ROUTER_CAPABILITY} capability before adding connections.`}
/>
) : cards.length === 0 ? (
<EmptyPanel
title="No connections"
detail="Add a connection and pick a plugin to route requests."
/>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{cards.map((card) => (
<IntegrationEditor
key={card.key}
form={card.form}
pluginConfig={card.pluginConfig}
installations={routerInstallations}
installationsLoading={installationsQuery.isLoading}
source={card.source}
onChange={(patch) => updateCard(card.key, patch)}
onConfigChange={(config) => updateCardConfig(card.key, config)}
onRemove={() => removeCard(card.key)}
/>
))}
</div>
)}
</div>
);
}
function IntegrationEditor({
form,
pluginConfig,
installations,
installationsLoading,
source,
onChange,
onConfigChange,
onRemove,
}: {
form: IntegrationFormState;
pluginConfig: Record<string, unknown>;
installations: RequestRouterInstallation[];
installationsLoading: boolean;
source: RequestIntegration | null;
onChange: (patch: Partial<IntegrationFormState>) => void;
onConfigChange: (config: Record<string, unknown>) => void;
onRemove: () => void;
}) {
const isNew = form.id === "";
const createIntegration = useCreateRequestIntegration();
const updateIntegration = useUpdateRequestIntegration();
const deleteIntegration = useDeleteRequestIntegration();
const [confirmDelete, setConfirmDelete] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
// SchemaForm owns config validation and reports it up; the editor consumes the
// result instead of re-running validateSchemaValues itself.
const [schemaValid, setSchemaValid] = useState(true);
// Any edit invalidates a prior failed save's server-side errors, so clear them
// wholesale on edit. A corrected field's stale "does not exist" then disappears.
function clearSaveErrors() {
setFieldErrors((current) => (Object.keys(current).length === 0 ? current : {}));
setFormError((current) => (current === null ? current : null));
}
function patchForm(patch: Partial<IntegrationFormState>) {
clearSaveErrors();
onChange(patch);
}
function patchConfig(config: Record<string, unknown>) {
clearSaveErrors();
onConfigChange(config);
}
// Default-select the only installed request-router plugin once installations
// have loaded, when this connection has no installation set yet. Depend on a
// stable scalar (not the array identity) so a refetch returning the same single
// plugin does not re-fire and re-select after a deliberate clear.
const soleInstallation = installations.length === 1 ? installations[0] : undefined;
const soleInstallationID =
soleInstallation !== undefined ? String(soleInstallation.installationID) : undefined;
useEffect(() => {
if (form.installation_id || soleInstallation === undefined) return;
onChange({
installation_id: String(soleInstallation.installationID),
capability_id: soleInstallation.capability.id,
});
// onChange identity is stable per card; intentionally depend on the data only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.installation_id, soleInstallationID]);
const selectedInstallationID = Number(form.installation_id);
const hasInstallation = Number.isInteger(selectedInstallationID) && selectedInstallationID > 0;
// Match on both installation and capability sub-id so a multi-capability
// installation resolves the exact backend; fall back to installation-only for
// connections whose capability_id isn't set yet (adopts that installation's
// capability).
const selected =
installations.find(
(entry) =>
entry.installationID === selectedInstallationID &&
entry.capability.id === form.capability_id,
) ?? installations.find((entry) => entry.installationID === selectedInstallationID);
// Switching the selected plugin must drop the previous plugin's config (so its
// keys never reach the new plugin's schema in the options probe or save) and
// clear stale save errors (handled by patchForm -> clearSaveErrors).
function handlePluginChange(value: string) {
const entry = installations.find((e) => installationOptionValue(e) === value);
if (!entry) return;
if (
entry.installationID === selectedInstallationID &&
entry.capability.id === form.capability_id
) {
return; // no actual change; keep existing config
}
patchForm({
installation_id: String(entry.installationID),
capability_id: entry.capability.id,
});
onConfigChange({});
}
const selectedConfigSchema = selected?.capability.config_schema?.[0];
const descriptor = selectedConfigSchema?.admin_form;
const title = selected?.capability.display_name || selected?.pluginID || "Connection";
const { options, status: optionsStatus } = useConnectionOptions(form.id, {
base_url: form.base_url,
api_key_ref: form.api_key_ref,
has_api_key: form.has_api_key,
installation_id: hasInstallation ? selectedInstallationID : undefined,
capability_id: selected?.capability.id ?? "",
plugin_config: pluginConfig,
});
// Once dynamic options arrive, default each empty single-SELECT field to its
// first option (restores the old root_folder/quality_profile pre-selection
// generically). MULTI_SELECT (tags) is left empty; values the admin already
// chose are never clobbered. Read config/onChange from refs so the effect keys
// only on options + descriptor and doesn't re-fire on unrelated edits.
const configRef = useRef(pluginConfig);
configRef.current = pluginConfig;
const onConfigChangeRef = useRef(onConfigChange);
onConfigChangeRef.current = onConfigChange;
useEffect(() => {
if (!descriptor) return;
const current = configRef.current;
const patch: Record<string, unknown> = {};
for (const field of descriptor.fields) {
if (field.control !== "SELECT" || !field.dynamic_options) continue;
const first = options[field.key]?.[0];
if (!first) continue;
const value = current[field.key];
const empty = value === undefined || value === null || value === "";
if (empty) patch[field.key] = first.value;
}
if (Object.keys(patch).length > 0) {
onConfigChangeRef.current({ ...current, ...patch });
}
}, [options, descriptor]);
const saving = createIntegration.isPending || updateIntegration.isPending;
// New instances must carry an API key (there's no saved key to fall back on);
// edits may leave it blank to keep the stored key (has_api_key).
const hasApiKey = form.api_key_ref.trim().length > 0 || form.has_api_key;
// schemaValid is reported by SchemaForm via onValidityChange. With no descriptor
// (no plugin form) there's nothing to validate, so the chrome checks govern.
const canSave =
form.name.trim().length > 0 &&
form.base_url.trim().length > 0 &&
hasApiKey &&
hasInstallation &&
(!descriptor || schemaValid);
function handleSave() {
// Coercion is driven by the plugin's declared json_schema types so e.g. a
// numeric string isn't sent where a string is declared (and vice versa).
const fieldTypes = parseFieldTypes(selectedConfigSchema?.json_schema);
const nextPluginConfig = descriptor
? buildSchemaValues(descriptor, pluginConfig, fieldTypes)
: pluginConfig;
// plugin_config is the sole source of truth for fulfillment; the host owns
// only the generic connection chrome (name, base_url, api_key, installation).
const payload = {
id: form.id,
name: form.name.trim(),
enabled: form.enabled,
base_url: form.base_url.trim(),
api_key_ref: form.api_key_ref.trim() || undefined,
capability_id: selected?.capability.id ?? "",
installation_id: hasInstallation ? selectedInstallationID : undefined,
supported_media_types: supportedMediaTypesForConfig(nextPluginConfig, source),
plugin_config: nextPluginConfig,
} as RequestIntegration;
setFieldErrors({});
setFormError(null);
const mut = isNew ? createIntegration : updateIntegration;
mut.mutate(payload, {
onError: (err) => {
const body = (err as { body?: unknown })?.body as
| RequestIntegrationValidationError
| undefined;
if (body?.field_errors) setFieldErrors(body.field_errors);
if (body?.form_error) setFormError(body.form_error);
},
});
}
return (
<div className="border-border bg-card space-y-5 rounded-lg border p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Plug className="text-primary h-4 w-4" />
<h2 className="text-lg font-semibold tracking-normal">{title}</h2>
{form.has_api_key ? <Badge variant="secondary">Key saved</Badge> : null}
{isNew ? <Badge variant="outline">New</Badge> : null}
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm select-none">
<span className="text-muted-foreground">{form.enabled ? "Enabled" : "Disabled"}</span>
<Switch checked={form.enabled} onCheckedChange={(enabled) => patchForm({ enabled })} />
</label>
</div>
{formError ? (
<p className="border-destructive/40 bg-destructive/10 text-destructive rounded-md border px-3 py-2 text-sm">
{formError}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name">
<Input
value={form.name}
onChange={(event) => patchForm({ name: event.target.value })}
placeholder="Connection name"
/>
</Field>
<Field label="API key or setting key">
<Input
value={form.api_key_ref}
onChange={(event) => patchForm({ api_key_ref: event.target.value })}
placeholder={form.has_api_key ? "Leave blank to keep saved key" : "API key"}
/>
</Field>
</div>
<Field label="Base URL">
<Input
value={form.base_url}
onChange={(event) => patchForm({ base_url: event.target.value })}
placeholder="http://localhost:7878"
/>
</Field>
<Field label="Plugin">
{installationsLoading ? (
<Skeleton className="h-9 w-full rounded-md" />
) : installations.length === 0 ? (
<p className="text-destructive text-xs">
No installed plugin exposes the {REQUEST_ROUTER_CAPABILITY} capability. Install a
request-router plugin before adding connections.
</p>
) : (
<Select
value={selected ? installationOptionValue(selected) : ""}
onValueChange={handlePluginChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select plugin" />
</SelectTrigger>
<SelectContent>
{installations.map((entry) => (
<SelectItem
key={installationOptionValue(entry)}
value={installationOptionValue(entry)}
>
{installationOptionLabel(entry)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{!installationsLoading && installations.length > 0 && !hasInstallation ? (
<p className="text-destructive text-xs">Select a plugin to fulfill this connection.</p>
) : null}
</Field>
{descriptor ? (
<div className="space-y-2">
<SchemaForm
descriptor={descriptor}
values={pluginConfig}
onChange={patchConfig}
dynamicOptions={options}
optionsLoading={optionsStatus === "loading"}
errors={fieldErrors}
onValidityChange={setSchemaValid}
idPrefix={`conn-${form.id || form.installation_id || "new"}`}
/>
{optionsStatus === "error" && form.base_url.trim().length > 0 ? (
<p className="border-destructive/40 bg-destructive/10 text-destructive flex items-start gap-2 rounded-md border px-3 py-2 text-xs">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Couldn&apos;t load options from the service — check the base URL and API key, then
edit a field to retry.
</span>
</p>
) : null}
</div>
) : hasInstallation ? (
<p className="text-muted-foreground text-sm">
This plugin does not expose a connection configuration form.
</p>
) : (
<p className="text-muted-foreground text-sm">
Select a plugin to configure this connection.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={handleSave} disabled={!canSave || saving}>
<Save className="h-4 w-4" />
{isNew ? "Create connection" : "Save"}
</Button>
{isNew ? (
<Button type="button" variant="ghost" onClick={onRemove}>
<X className="h-4 w-4" />
Discard
</Button>
) : (
<Button
type="button"
variant="ghost"
className="text-destructive"
onClick={() => setConfirmDelete(true)}
disabled={deleteIntegration.isPending}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
)}
</div>
<Dialog
open={confirmDelete}
onOpenChange={(open) => {
if (!open) setConfirmDelete(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete connection</DialogTitle>
<DialogDescription>
{`"${form.name.trim() || title}" will be permanently removed. New requests will no longer route to this connection.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
deleteIntegration.mutate(form.id);
setConfirmDelete(false);
}}
disabled={deleteIntegration.isPending}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
type UserLimitFormState = {
limit_mode: RequestLimitMode;
max_requests: string;
window_days: string;
approval_mode: RequestApprovalMode;
};
function UserOverridesTab() {
const users = useAdminUsers();
const [selectedUserID, setSelectedUserID] = useState<number | undefined>();
const effectiveUserID = selectedUserID ?? users.data?.[0]?.id;
const limit = useRequestUserLimit(effectiveUserID);
const selectedUser = useMemo(
() => users.data?.find((user) => user.id === effectiveUserID),
[effectiveUserID, users.data],
);
if (users.isLoading) return <RowsSkeleton />;
if (users.isError) {
return <EmptyPanel title="Users failed" detail="Users could not be loaded." />;
}
return (
<div className="border-border bg-card max-w-3xl space-y-5 rounded-lg border p-5">
<div className="flex items-center gap-2">
<SlidersHorizontal className="text-primary h-4 w-4" />
<h2 className="text-lg font-semibold tracking-normal">User Overrides</h2>
</div>
<Field label="User">
<Select
value={effectiveUserID ? String(effectiveUserID) : ""}
onValueChange={(value) => setSelectedUserID(Number(value))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
{(users.data ?? []).map((user) => (
<SelectItem key={user.id} value={String(user.id)}>
{user.username}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{limit.isLoading ? (
<RowsSkeleton />
) : limit.isError || !limit.data || !effectiveUserID ? (
<EmptyPanel title="Limit failed" detail="The selected user limit could not be loaded." />
) : (
<UserLimitEditor
key={userLimitFormKey(limit.data)}
userID={effectiveUserID}
limit={limit.data}
userAvailable={Boolean(selectedUser)}
/>
)}
</div>
);
}
function UserLimitEditor({
userID,
limit,
userAvailable,
}: {
userID: number;
limit: RequestUserLimit;
userAvailable: boolean;
}) {
const updateLimit = useUpdateRequestUserLimit();
const [form, setForm] = useState<UserLimitFormState>(() => ({
limit_mode: limit.limit_mode,
max_requests: limit.max_requests == null ? "" : String(limit.max_requests),
window_days: limit.window_days == null ? "" : String(limit.window_days),
approval_mode: limit.approval_mode,
}));
function saveLimit() {
const custom = form.limit_mode === "custom";
const payload: RequestUserLimit = {
user_id: userID,
limit_mode: form.limit_mode,
approval_mode: form.approval_mode,
max_requests: custom ? Math.max(0, Number(form.max_requests) || 0) : undefined,
window_days: custom ? Math.max(1, Number(form.window_days) || 1) : undefined,
};
updateLimit.mutate({ userId: userID, body: payload });
}
return (
<>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Limit mode">
<Select
value={form.limit_mode}
onValueChange={(value) =>
setForm((current) => ({ ...current, limit_mode: value as RequestLimitMode }))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="unlimited">Unlimited</SelectItem>
<SelectItem value="blocked">Blocked</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Approval mode">
<Select
value={form.approval_mode}
onValueChange={(value) =>
setForm((current) => ({
...current,
approval_mode: value as RequestApprovalMode,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="blocked">Blocked</SelectItem>
</SelectContent>
</Select>
</Field>
{form.limit_mode === "custom" ? (
<>
<Field label="Max requests">
<Input
type="number"
min={0}
value={form.max_requests}
onChange={(event) =>
setForm((current) => ({ ...current, max_requests: event.target.value }))
}
/>
</Field>
<Field label="Window days">
<Input
type="number"
min={1}
value={form.window_days}
onChange={(event) =>
setForm((current) => ({ ...current, window_days: event.target.value }))
}
/>
</Field>
</>
) : null}
</div>
<Button onClick={saveLimit} disabled={!userAvailable || updateLimit.isPending}>
<Save className="h-4 w-4" />
Save Override
</Button>
</>
);
}
function userLimitFormKey(limit: RequestUserLimit): string {
return `${limit.user_id}:${limit.updated_at ?? ""}:${limit.limit_mode}:${limit.approval_mode}`;
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="space-y-2">
<Label>{label}</Label>
{children}
</div>
);
}
function SwitchField({
label,
checked,
onCheckedChange,
description,
disabled,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
description?: string;
disabled?: boolean;
}) {
return (
<div className="border-border flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="space-y-1">
<Label>{label}</Label>
{description ? <p className="text-muted-foreground text-xs">{description}</p> : null}
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
}
function RowsSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-16 rounded-lg" />
))}
</div>
);
}
function EmptyPanel({ title, detail }: { title: string; detail: string }) {
return (
<div className="border-border bg-card flex flex-col items-center justify-center gap-2 rounded-lg border px-4 py-10 text-center">
<p className="text-sm font-semibold">{title}</p>
<p className="text-muted-foreground max-w-sm text-sm">{detail}</p>
</div>
);
}