* 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>
1459 lines
50 KiB
TypeScript
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'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>
|
|
);
|
|
}
|