Files
k-tv/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx
Gabriel Kaszewski 311fdd4006 feat: multi-instance provider support
- provider_configs: add id TEXT PK; migrate existing rows (provider_type becomes id)
- local_files_index: add provider_id column + index; scope all queries per instance
- ProviderConfigRow: add id field; add get_by_id to trait
- LocalIndex:🆕 add provider_id param; all SQL scoped by provider_id
- factory: thread provider_id through build_local_files_bundle
- AppState.local_index: Option<Arc<LocalIndex>> → HashMap<String, Arc<LocalIndex>>
- admin_providers: restructured routes (POST /admin/providers create, PUT/DELETE /{id}, POST /test)
- admin_providers: use row.id as registry key for jellyfin and local_files
- files.rescan: optional ?provider=<id> query param
- frontend: add id to ProviderConfig, update api/hooks, new multi-instance panel UX
2026-03-19 22:54:41 +01:00

439 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import {
useProviderConfigs,
useCreateProvider,
useUpdateProvider,
useDeleteProvider,
useTestProvider,
} from "@/hooks/use-admin-providers";
import { useConfig } from "@/hooks/use-config";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CheckCircle, XCircle, Loader2, Plus, Trash2 } from "lucide-react";
import { ApiRequestError } from "@/lib/api";
import type { ProviderConfig } from "@/lib/types";
const PROVIDER_FIELDS: Record<
string,
Array<{ key: string; label: string; type?: string; required?: boolean }>
> = {
jellyfin: [
{ key: "base_url", label: "Base URL", required: true },
{ key: "api_key", label: "API Key", type: "password", required: true },
{ key: "user_id", label: "User ID", required: true },
],
local_files: [
{ key: "files_dir", label: "Files Directory", required: true },
{ key: "transcode_dir", label: "Transcode Directory" },
{ key: "cleanup_ttl_hours", label: "Cleanup TTL Hours" },
],
};
function isValidInstanceId(id: string): boolean {
return id.length >= 1 && id.length <= 40 && /^[a-zA-Z0-9-]+$/.test(id);
}
// ---------------------------------------------------------------------------
// Existing instance card
// ---------------------------------------------------------------------------
interface ProviderCardProps {
config: ProviderConfig;
existingIds: string[];
}
function ProviderCard({ config }: ProviderCardProps) {
const fields = PROVIDER_FIELDS[config.provider_type] ?? [];
const [formValues, setFormValues] = useState<Record<string, string>>(
() => config.config_json ?? {},
);
const [enabled, setEnabled] = useState(config.enabled);
const [conflictError, setConflictError] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const updateProvider = useUpdateProvider();
const deleteProvider = useDeleteProvider();
const testProvider = useTestProvider();
const handleSave = async () => {
setConflictError(false);
try {
await updateProvider.mutateAsync({
id: config.id,
payload: { config_json: formValues, enabled },
});
} catch (e: unknown) {
if (e instanceof ApiRequestError && e.status === 409) {
setConflictError(true);
}
}
};
const handleTest = async () => {
setTestResult(null);
const result = await testProvider.mutateAsync({
provider_type: config.provider_type,
config_json: formValues,
});
setTestResult(result);
};
const handleDelete = async () => {
if (!confirm(`Delete provider instance "${config.id}"?`)) return;
await deleteProvider.mutateAsync(config.id);
};
return (
<Card className="border-zinc-800 bg-zinc-900">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono text-xs text-zinc-300 border-zinc-600">
{config.id}
</Badge>
<span className="text-xs text-zinc-500 capitalize">
{config.provider_type.replace("_", " ")}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-400">Enabled</span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteProvider.isPending}
className="h-7 w-7 text-zinc-500 hover:text-red-400"
>
{deleteProvider.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{conflictError && (
<div className="rounded border border-yellow-600/40 bg-yellow-950/30 px-3 py-2 text-xs text-yellow-400">
UI config disabled set <code>CONFIG_SOURCE=db</code> on the server
</div>
)}
{fields.map((field) => (
<div key={field.key} className="space-y-1">
<Label className="text-xs text-zinc-400">
{field.label}
{field.required && <span className="ml-1 text-red-400">*</span>}
</Label>
<Input
type={field.type ?? "text"}
value={formValues[field.key] ?? ""}
onChange={(e) =>
setFormValues((prev) => ({ ...prev, [field.key]: e.target.value }))
}
placeholder={
field.type === "password" ? "••••••••" : `Enter ${field.label.toLowerCase()}`
}
className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100"
/>
</div>
))}
{testResult && (
<div
className={`flex items-center gap-2 rounded px-3 py-2 text-xs ${
testResult.ok
? "bg-green-950/30 text-green-400"
: "bg-red-950/30 text-red-400"
}`}
>
{testResult.ok ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<XCircle className="h-3.5 w-3.5" />
)}
{testResult.message}
</div>
)}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testProvider.isPending}
className="border-zinc-700 text-xs"
>
{testProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Test Connection
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={updateProvider.isPending}
className="text-xs"
>
{updateProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Save
</Button>
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Add Instance dialog
// ---------------------------------------------------------------------------
interface AddInstanceDialogProps {
open: boolean;
onClose: () => void;
availableTypes: string[];
existingIds: string[];
}
function AddInstanceDialog({ open, onClose, availableTypes, existingIds }: AddInstanceDialogProps) {
const [instanceId, setInstanceId] = useState("");
const [providerType, setProviderType] = useState(availableTypes[0] ?? "");
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [idError, setIdError] = useState<string | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const createProvider = useCreateProvider();
const testProvider = useTestProvider();
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const fields = PROVIDER_FIELDS[providerType] ?? [];
const handleTypeChange = (t: string) => {
setProviderType(t);
setFormValues({});
setTestResult(null);
};
const validateId = (id: string): string | null => {
if (!id) return "ID is required";
if (!isValidInstanceId(id)) return "Only alphanumeric characters and hyphens, 140 chars";
if (existingIds.includes(id)) return "An instance with this ID already exists";
return null;
};
const handleCreate = async () => {
const err = validateId(instanceId);
if (err) { setIdError(err); return; }
setIdError(null);
setApiError(null);
try {
await createProvider.mutateAsync({
id: instanceId,
provider_type: providerType,
config_json: formValues,
enabled: true,
});
onClose();
setInstanceId("");
setFormValues({});
setTestResult(null);
} catch (e: unknown) {
if (e instanceof ApiRequestError && e.status === 409) {
setIdError("An instance with this ID already exists");
} else if (e instanceof ApiRequestError) {
setApiError(e.message);
}
}
};
const handleTest = async () => {
setTestResult(null);
const result = await testProvider.mutateAsync({
provider_type: providerType,
config_json: formValues,
});
setTestResult(result);
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="border-zinc-800 bg-zinc-950 text-zinc-100 max-w-md">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Add Provider Instance</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1">
<Label className="text-xs text-zinc-400">
Instance ID <span className="text-red-400">*</span>
</Label>
<Input
value={instanceId}
onChange={(e) => {
setInstanceId(e.target.value);
setIdError(null);
}}
placeholder="e.g. jellyfin-main"
className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100 font-mono"
/>
{idError && <p className="text-xs text-red-400">{idError}</p>}
<p className="text-xs text-zinc-600">Alphanumeric + hyphens, 140 chars</p>
</div>
<div className="space-y-1">
<Label className="text-xs text-zinc-400">
Provider Type <span className="text-red-400">*</span>
</Label>
<Select value={providerType} onValueChange={handleTypeChange}>
<SelectTrigger className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-700 bg-zinc-900">
{availableTypes.map((t) => (
<SelectItem key={t} value={t} className="text-xs capitalize text-zinc-200">
{t.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{fields.map((field) => (
<div key={field.key} className="space-y-1">
<Label className="text-xs text-zinc-400">
{field.label}
{field.required && <span className="ml-1 text-red-400">*</span>}
</Label>
<Input
type={field.type ?? "text"}
value={formValues[field.key] ?? ""}
onChange={(e) =>
setFormValues((prev) => ({ ...prev, [field.key]: e.target.value }))
}
placeholder={
field.type === "password" ? "••••••••" : `Enter ${field.label.toLowerCase()}`
}
className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100"
/>
</div>
))}
{testResult && (
<div
className={`flex items-center gap-2 rounded px-3 py-2 text-xs ${
testResult.ok
? "bg-green-950/30 text-green-400"
: "bg-red-950/30 text-red-400"
}`}
>
{testResult.ok ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<XCircle className="h-3.5 w-3.5" />
)}
{testResult.message}
</div>
)}
{apiError && (
<p className="text-xs text-red-400">{apiError}</p>
)}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testProvider.isPending}
className="border-zinc-700 text-xs"
>
{testProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Test
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createProvider.isPending}
className="text-xs"
>
{createProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
export function ProviderSettingsPanel() {
const { data: config } = useConfig();
const { data: providerConfigs = [] } = useProviderConfigs();
const [addOpen, setAddOpen] = useState(false);
const availableTypes = config?.available_provider_types ?? [];
const existingIds = providerConfigs.map((c) => c.id);
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-zinc-100">Provider Instances</h2>
<p className="mt-0.5 text-xs text-zinc-500">
Manage media provider instances. Requires <code>CONFIG_SOURCE=db</code> on the server.
</p>
</div>
{availableTypes.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => setAddOpen(true)}
className="border-zinc-700 text-xs gap-1"
>
<Plus className="h-3.5 w-3.5" />
Add Instance
</Button>
)}
</div>
{availableTypes.length === 0 ? (
<p className="text-xs text-zinc-500">No providers available in this build.</p>
) : providerConfigs.length === 0 ? (
<p className="text-xs text-zinc-500">
No provider instances configured. Click &quot;Add Instance&quot; to get started.
</p>
) : (
<div className="space-y-4">
{providerConfigs.map((c) => (
<ProviderCard key={c.id} config={c} existingIds={existingIds} />
))}
</div>
)}
<AddInstanceDialog
open={addOpen}
onClose={() => setAddOpen(false)}
availableTypes={availableTypes}
existingIds={existingIds}
/>
</div>
);
}