Cleanup: delete dead top-level pages + update docs (Slice 11)
Delete the old top-level page files whose content was migrated into service-page tabs in slices 5-9: - pages/Media.tsx, Applications.tsx (-> MediaTab) - pages/FileBrowser.tsx, FileBrowser.impl.tsx (-> FilesTab) - pages/Actions.tsx (-> ActionsTab) - pages/Users.tsx, UsersPage.impl.tsx (replaced by Authentik tabs) - components/BackupsPage.tsx (-> JobsTab) - components/ObservabilityPage.tsx (split into Alerts/Links/Metrics tabs) - hooks/useUsers.ts (orphaned after Users page deletion) - the corresponding page test files (Media, FileBrowser, Applications, Actions, UsersPage) that tested the deleted pages directly. The service-tab components are the live implementations; ServicePage renders them. No live code references the deleted files. Docs: append an Information Architecture section to REQUIREMENTS.md documenting the services-as-hub model (nav shape, service-page tabs, service type registry, Users->Authentik, Observability split, legacy route 404s, empty state). Add a CHANGELOG entry under [Unreleased]. 92 frontend tests pass (was 112; -20 deleted page tests); 271 backend tests pass; lint/build green. Refs openspec/changes/services-as-hub-ia/ (tasks slice 11).
This commit is contained in:
@@ -1,72 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
useAcknowledgeAlert,
|
||||
useBackupAlerts,
|
||||
useBackupJobs,
|
||||
useBackupRuns,
|
||||
} from "../hooks/useBackups";
|
||||
import BackupAlertsTable from "./BackupAlertsTable";
|
||||
import BackupJobsTable from "./BackupJobsTable";
|
||||
import BackupRunsTable from "./BackupRunsTable";
|
||||
|
||||
export default function BackupsPage() {
|
||||
const [tab, setTab] = useState("jobs");
|
||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
||||
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const acknowledgeMutation = useAcknowledgeAlert();
|
||||
|
||||
// Build a map of latest runs per job
|
||||
const latestRuns = new Map();
|
||||
if (runsData) {
|
||||
for (const run of runsData) {
|
||||
const existing = latestRuns.get(run.job_id);
|
||||
if (!existing || run.started_at > existing.started_at) {
|
||||
latestRuns.set(run.job_id, run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alertsLabel = alertsData ? `Alerts (${alertsData.length})` : "Alerts";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="jobs">Jobs</TabsTrigger>
|
||||
<TabsTrigger value="runs">Runs</TabsTrigger>
|
||||
<TabsTrigger value="alerts">{alertsLabel}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="jobs">
|
||||
{jobsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading jobs…</p>
|
||||
) : (
|
||||
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="runs">
|
||||
{runsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading runs…</p>
|
||||
) : (
|
||||
<BackupRunsTable runs={runsData ?? []} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="alerts">
|
||||
{alertsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
||||
) : (
|
||||
<BackupAlertsTable
|
||||
alerts={alertsData ?? []}
|
||||
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,667 +0,0 @@
|
||||
import { useMemo, useState, type ElementType, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Gauge,
|
||||
Inbox,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
ServerOff,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAlertmanagerAlerts,
|
||||
useAlertmanagerStatus,
|
||||
useGrafanaStatus,
|
||||
usePrometheusStatus,
|
||||
usePrometheusTargets,
|
||||
useMonitoringMachines,
|
||||
} from "../hooks/useObservability";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type {
|
||||
AlertmanagerAlert,
|
||||
MonitoringMachine,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
|
||||
function severityVariant(
|
||||
severity: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "info":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function HealthCard({
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
icon: Icon,
|
||||
isLoading,
|
||||
}: {
|
||||
title: string;
|
||||
status: "ok" | "warning" | "error" | "unknown";
|
||||
detail: string;
|
||||
icon: ElementType;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const statusIcon =
|
||||
status === "ok" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : status === "warning" ? (
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
) : status === "error" ? (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? <Skeleton className="h-5 w-5" /> : statusIcon}
|
||||
<span className="text-2xl font-bold capitalize">{status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryError({
|
||||
label,
|
||||
error,
|
||||
refetch,
|
||||
}: {
|
||||
label: string;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{label} failed</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="break-words">{error.message}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-sm">{alert.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{alert.summary || alert.description}
|
||||
</div>
|
||||
{alert.active_since && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||
Since {new Date(alert.active_since).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
|
||||
{alert.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description:</span>{" "}
|
||||
{alert.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{alert.job_name && (
|
||||
<div>
|
||||
<span className="font-medium">Job:</span> {alert.job_name}
|
||||
</div>
|
||||
)}
|
||||
{alert.category && (
|
||||
<div>
|
||||
<span className="font-medium">Category:</span> {alert.category}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">State:</span> {alert.state}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Since:</span>{" "}
|
||||
{alert.active_since
|
||||
? new Date(alert.active_since).toLocaleString()
|
||||
: "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{Object.entries(alert.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="secondary" className="text-[10px]">
|
||||
{key}={value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-3">
|
||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(target.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px]">
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrafanaLinkCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservabilityPage() {
|
||||
const {
|
||||
data: alertsSummary,
|
||||
isLoading: alertsLoading,
|
||||
error: alertsError,
|
||||
refetch: refetchAlerts,
|
||||
} = useAlertmanagerAlerts();
|
||||
const {
|
||||
data: alertmanagerStatus,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
refetch: refetchStatus,
|
||||
} = useAlertmanagerStatus();
|
||||
const {
|
||||
data: grafanaStatus,
|
||||
isLoading: grafanaLoading,
|
||||
error: grafanaError,
|
||||
refetch: refetchGrafana,
|
||||
} = useGrafanaStatus();
|
||||
const {
|
||||
data: prometheusStatus,
|
||||
isLoading: prometheusLoading,
|
||||
error: prometheusError,
|
||||
refetch: refetchPrometheus,
|
||||
} = usePrometheusStatus();
|
||||
const {
|
||||
data: prometheusTargets,
|
||||
isLoading: targetsLoading,
|
||||
error: targetsError,
|
||||
refetch: refetchTargets,
|
||||
} = usePrometheusTargets();
|
||||
const {
|
||||
data: machines = [],
|
||||
isLoading: machinesLoading,
|
||||
error: machinesError,
|
||||
refetch: refetchMachines,
|
||||
} = useMonitoringMachines();
|
||||
const { data: grafanaServices = [] } = useServiceInstances("grafana");
|
||||
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
|
||||
|
||||
const grafanaService =
|
||||
grafanaServices.find((s) => s.enabled) ?? grafanaServices[0];
|
||||
const GRAFANA_BASE_URL =
|
||||
(grafanaService?.config?.base_url as string | undefined) ?? "";
|
||||
|
||||
const selectedMachine = useMemo<MonitoringMachine | null>(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||
const instance = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
|
||||
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||
|
||||
const alertmanagerStatusDetail = alertmanagerStatus?.up
|
||||
? alertmanagerStatus.version
|
||||
? `version ${alertmanagerStatus.version}`
|
||||
: "reachable"
|
||||
: "unreachable";
|
||||
|
||||
const targetsCount = prometheusTargets?.length ?? 0;
|
||||
const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading
|
||||
? "unknown"
|
||||
: targetsError
|
||||
? "error"
|
||||
: targetsCount > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading
|
||||
? "unknown"
|
||||
: alertsError
|
||||
? "error"
|
||||
: (alertsSummary?.total ?? 0) > 0
|
||||
? alertsSummary?.alerts.some((a) => a.severity === "critical")
|
||||
? "error"
|
||||
: "warning"
|
||||
: "ok";
|
||||
|
||||
const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading
|
||||
? "unknown"
|
||||
: machinesError
|
||||
? "error"
|
||||
: machines.length > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Observability</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unified view of metrics, logs, and alerts from Prometheus, Loki, and
|
||||
Alertmanager. Deep dashboards live in Grafana.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<HealthCard
|
||||
title="Alertmanager"
|
||||
status={
|
||||
statusError
|
||||
? "error"
|
||||
: alertmanagerStatus?.up
|
||||
? "ok"
|
||||
: statusLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={alertmanagerStatusDetail}
|
||||
icon={Bell}
|
||||
isLoading={statusLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Active Alerts"
|
||||
status={alertStatus}
|
||||
detail={`${alertsSummary?.total ?? 0} firing alert${(alertsSummary?.total ?? 0) === 1 ? "" : "s"}`}
|
||||
icon={AlertTriangle}
|
||||
isLoading={alertsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Prometheus Targets"
|
||||
status={targetsStatus}
|
||||
detail={`${targetsCount} remote Node Exporter target${targetsCount === 1 ? "" : "s"}`}
|
||||
icon={Radio}
|
||||
isLoading={targetsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Machines"
|
||||
status={machinesStatus}
|
||||
detail={`${machines.length} monitoring machine${machines.length === 1 ? "" : "s"}`}
|
||||
icon={Server}
|
||||
isLoading={machinesLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Grafana"
|
||||
status={
|
||||
grafanaError
|
||||
? "error"
|
||||
: grafanaStatus?.up
|
||||
? "ok"
|
||||
: grafanaLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={
|
||||
grafanaStatus?.up
|
||||
? grafanaStatus.version
|
||||
? `version ${grafanaStatus.version}`
|
||||
: "reachable"
|
||||
: grafanaStatus?.error === "no_service_configured"
|
||||
? "not configured"
|
||||
: "unreachable"
|
||||
}
|
||||
icon={Gauge}
|
||||
isLoading={grafanaLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Prometheus"
|
||||
status={
|
||||
prometheusError
|
||||
? "error"
|
||||
: prometheusStatus?.up
|
||||
? "ok"
|
||||
: prometheusLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={
|
||||
prometheusStatus?.up
|
||||
? prometheusStatus.version
|
||||
? `version ${prometheusStatus.version}`
|
||||
: "reachable"
|
||||
: prometheusStatus?.error === "no_service_configured"
|
||||
? "not configured"
|
||||
: "unreachable"
|
||||
}
|
||||
icon={Radio}
|
||||
isLoading={prometheusLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{statusError && (
|
||||
<QueryError
|
||||
label="Alertmanager status"
|
||||
error={statusError}
|
||||
refetch={refetchStatus}
|
||||
/>
|
||||
)}
|
||||
{alertsError && (
|
||||
<QueryError
|
||||
label="Active alerts"
|
||||
error={alertsError}
|
||||
refetch={refetchAlerts}
|
||||
/>
|
||||
)}
|
||||
{targetsError && (
|
||||
<QueryError
|
||||
label="Prometheus targets"
|
||||
error={targetsError}
|
||||
refetch={refetchTargets}
|
||||
/>
|
||||
)}
|
||||
{machinesError && (
|
||||
<QueryError
|
||||
label="Monitoring machines"
|
||||
error={machinesError}
|
||||
refetch={refetchMachines}
|
||||
/>
|
||||
)}
|
||||
{grafanaError && (
|
||||
<QueryError
|
||||
label="Grafana status"
|
||||
error={grafanaError}
|
||||
refetch={refetchGrafana}
|
||||
/>
|
||||
)}
|
||||
{prometheusError && (
|
||||
<QueryError
|
||||
label="Prometheus status"
|
||||
error={prometheusError}
|
||||
refetch={refetchPrometheus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{alertsSummary?.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Alertmanager unreachable</AlertTitle>
|
||||
<AlertDescription>
|
||||
The UI cannot reach Alertmanager right now. Alerts shown here may be
|
||||
stale.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Recent Alerts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alertsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !alertsSummary || alertsSummary.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No active alerts"
|
||||
description="Everything looks quiet. Alertmanager will list firing alerts here when they occur."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{alertsSummary.alerts.map((alert, idx) => (
|
||||
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
|
||||
))}
|
||||
{alertsSummary.total > alertsSummary.alerts.length && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
{alertsSummary.total - alertsSummary.alerts.length} more
|
||||
alert
|
||||
{alertsSummary.total - alertsSummary.alerts.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
in Alertmanager
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus Targets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targetsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !prometheusTargets || prometheusTargets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Radio}
|
||||
title="No Node Exporter targets"
|
||||
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TargetsTable targets={prometheusTargets} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machines.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedMachine ? (
|
||||
GRAFANA_BASE_URL ? (
|
||||
<>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||
href={nodeExporterDashboardUrl}
|
||||
/>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} logs`}
|
||||
description="Explore Loki logs for this machine in Grafana."
|
||||
href={logsUrl}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Gauge}
|
||||
title="No Grafana service configured"
|
||||
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/services">Open Services</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title="No machine selected"
|
||||
description="Add monitoring machines in Settings to see Grafana drill-down links."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUsers } from "../api/client";
|
||||
import type { UserDirectoryResponse } from "../types";
|
||||
|
||||
export function useUsers(jellyfinServiceId?: string) {
|
||||
return useQuery<UserDirectoryResponse>({
|
||||
queryKey: ["users", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchUsers(jellyfinServiceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../hooks/useSettings";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { SelectionRailCard } from "../components/SelectionRailCard";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
// Radix Select disallows empty-string item values; the "None" option maps to
|
||||
// this sentinel and converts back to "" at the draft boundary.
|
||||
const NONE = "__none__";
|
||||
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||
function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
helperText,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
helperText?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor={htmlFor} className="mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{helperText ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyTask(): SavedTaskInput {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.default_service_id === b.default_service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_service_id: task.default_service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
services,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
services: ServiceInstance[];
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
const selectedService = services.find(
|
||||
(service) => service.id === task.default_service_id,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
{selectedService && (
|
||||
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
id="task-name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<FormField label="Type">
|
||||
<Select
|
||||
value={task.task_type}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shell">Shell</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<FormField label="Default SSH task service">
|
||||
<Select
|
||||
value={task.default_service_id || NONE}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
default_service_id: value === NONE ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
rows={9}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
services,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
services: ServiceInstance[];
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const requestClose = () => {
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) requestClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on the selected SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} services={services} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const runTask = useRunTask();
|
||||
const [tab, setTab] = useState<ActionTab>("new");
|
||||
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [runServiceId, setRunServiceId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
||||
|
||||
const openEdit = (initial: SavedTaskInput) => {
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const createNew = () => {
|
||||
const initial = emptyTask();
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setRunServiceId(sshServices[0]?.id || "");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync(draft);
|
||||
setTab(saved.id);
|
||||
setEditOpen(false);
|
||||
const nextDraft = {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
default_service_id: saved.default_service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
const editingTask = selectedTask;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Actions</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Save reusable server tasks and switch between them with tabs.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
|
||||
</div>
|
||||
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{runTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{}}
|
||||
footer={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={createNew}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList variant="line" className="h-fit w-full justify-start">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={task.id}
|
||||
className="w-full justify-start pr-9"
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => openEdit(initialFromTask(task))}
|
||||
>
|
||||
{task.name}
|
||||
</TabsTrigger>
|
||||
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
||||
<HoverEditButton
|
||||
onClick={() => openEdit(initialFromTask(task))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{editingTask ? (
|
||||
<SectionCard
|
||||
title={editingTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(editingTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending || !runServiceId}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: editingTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormField
|
||||
label="Run on SSH task service"
|
||||
htmlFor="run-service-id"
|
||||
>
|
||||
<Select
|
||||
value={runServiceId}
|
||||
onValueChange={(value) => setRunServiceId(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="run-service-id"
|
||||
className="min-w-[240px]"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue placeholder="Select service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id}>
|
||||
<CardContent className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{run.stdout_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stdout
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stdout_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stderr
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stderr_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{run.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No runs yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="What this panel shows">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved actions stay on the left rail, while details, run
|
||||
controls, and recent history appear here.
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
services={sshServices}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Media } from "./Media";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
|
||||
function JellyfinLibraryStats() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||
const selectedServiceId =
|
||||
searchParams.get("jellyfin_service_id") ||
|
||||
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||
"";
|
||||
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Library stats"
|
||||
description="Compact Jellyfin summary for the selected machine."
|
||||
action={
|
||||
<Badge variant="outline">
|
||||
{selectedServiceId ? "Selected service" : "Default service"}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{counts ? (
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Total</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{(
|
||||
counts.movies +
|
||||
counts.series +
|
||||
counts.episodes
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Movies</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.movies.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Series</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.series.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Episodes</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.episodes.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{libraries?.length ? (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{libraries.map((library) => (
|
||||
<div
|
||||
key={library.library}
|
||||
className="rounded-lg border bg-card px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{library.library}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total {library.total.toLocaleString()} · Movies{" "}
|
||||
{library.movies.toLocaleString()} · Series{" "}
|
||||
{library.series.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function Applications() {
|
||||
const [tab, setTab] = useState("jellyfin");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Applications</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse application-specific tools from a compact tabbed workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TabbedCard
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||
Jellyfin
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||
Nextcloud
|
||||
</TabsTrigger>,
|
||||
]}
|
||||
>
|
||||
{tab === "jellyfin" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<JellyfinLibraryStats />
|
||||
<Media />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Nextcloud support will be added in a future update.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,862 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
useDirectoryListing,
|
||||
useFfprobe,
|
||||
useJobTemplates,
|
||||
useRunJob,
|
||||
} from "../hooks/useFiles";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
|
||||
interface DisplayRow {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface FfprobeStream {
|
||||
index?: number;
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
codec_long_name?: string;
|
||||
profile?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bit_rate?: string | number;
|
||||
duration?: string | number;
|
||||
channels?: number;
|
||||
sample_rate?: string | number;
|
||||
channel_layout?: string;
|
||||
pix_fmt?: string;
|
||||
sample_aspect_ratio?: string;
|
||||
display_aspect_ratio?: string;
|
||||
field_order?: string;
|
||||
level?: number | string;
|
||||
color_range?: string;
|
||||
color_space?: string;
|
||||
color_transfer?: string;
|
||||
color_primaries?: string;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FfprobeFormat {
|
||||
filename?: string;
|
||||
format_name?: string;
|
||||
format_long_name?: string;
|
||||
duration?: string | number;
|
||||
size?: string | number;
|
||||
bit_rate?: string | number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FfprobeData {
|
||||
format?: FfprobeFormat;
|
||||
streams?: FfprobeStream[];
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "-";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatTime(epoch: number): string {
|
||||
if (!epoch) return "";
|
||||
return new Date(epoch * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function humanBytes(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const bytes = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(bytes)) return "-";
|
||||
return formatSize(bytes);
|
||||
}
|
||||
|
||||
function humanRate(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const rate = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(rate)) return "-";
|
||||
const units = ["bps", "Kbps", "Mbps", "Gbps"];
|
||||
let v = rate;
|
||||
let unitIdx = 0;
|
||||
while (v >= 1000 && unitIdx < units.length - 1) {
|
||||
v /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function humanDuration(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const seconds = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(seconds)) return "-";
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0)
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
return `${minutes}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fieldLabel(_key: string, value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isVideoFile(name: string): boolean {
|
||||
const exts = [
|
||||
".mkv",
|
||||
".mp4",
|
||||
".avi",
|
||||
".m4v",
|
||||
".ts",
|
||||
".wmv",
|
||||
".mov",
|
||||
".flv",
|
||||
".webm",
|
||||
];
|
||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
// Design §3.2: referentially-stable column defs (a new array each render would
|
||||
// destabilize the TanStack table instance and drop controlled selection).
|
||||
// Visibility-only: no sorting, no sizing/resizing (design §3.3).
|
||||
const fileColumns: ColumnDef<DisplayRow>[] = [
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: () => "Type",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.original.type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => "Name",
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "ext",
|
||||
header: () => "Ext",
|
||||
cell: ({ row }) => row.original.ext,
|
||||
},
|
||||
{
|
||||
accessorKey: "size",
|
||||
header: () => "Size",
|
||||
cell: ({ row }) => row.original.size,
|
||||
},
|
||||
{
|
||||
accessorKey: "modified",
|
||||
header: () => "Modified",
|
||||
cell: ({ row }) => row.original.modified,
|
||||
},
|
||||
];
|
||||
|
||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||||
|
||||
type FileBrowserState = {
|
||||
currentDir: string;
|
||||
pathInput: string;
|
||||
selectedPath: string | null;
|
||||
selectedJob: string;
|
||||
};
|
||||
|
||||
function defaultFileBrowserState(): FileBrowserState {
|
||||
return {
|
||||
currentDir: "/",
|
||||
pathInput: "/",
|
||||
selectedPath: null,
|
||||
selectedJob: "",
|
||||
};
|
||||
}
|
||||
|
||||
function FfprobeChip({
|
||||
children,
|
||||
variant = "outline",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: "outline" | "secondary" | "warning" | "default";
|
||||
}) {
|
||||
return <Badge variant={variant}>{children}</Badge>;
|
||||
}
|
||||
|
||||
function StreamBlock({ children }: { children: React.ReactNode }) {
|
||||
return <div className="rounded-md border p-3">{children}</div>;
|
||||
}
|
||||
|
||||
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
const format = data.format ?? {};
|
||||
const streams = data.streams ?? [];
|
||||
const videoStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "video",
|
||||
);
|
||||
const audioStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "audio",
|
||||
);
|
||||
const subtitleStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "subtitle",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold">ffprobe details</div>
|
||||
<div className="text-xs text-muted-foreground">{path}</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="text-sm font-semibold">Container / format</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="text-sm space-y-0.5">
|
||||
<div>
|
||||
<span className="font-semibold">Format:</span>{" "}
|
||||
{fieldLabel("format", format.format_name)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Long name:</span>{" "}
|
||||
{fieldLabel("format_long_name", format.format_long_name)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Duration:</span>{" "}
|
||||
{humanDuration(format.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm space-y-0.5">
|
||||
<div>
|
||||
<span className="font-semibold">Size:</span>{" "}
|
||||
{humanBytes(format.size)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Bitrate:</span>{" "}
|
||||
{humanRate(format.bit_rate)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Filename:</span>{" "}
|
||||
{fieldLabel("filename", format.filename)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="text-sm font-semibold">Streams</div>
|
||||
|
||||
{videoStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Video streams</div>
|
||||
<div className="mt-1.5 flex flex-col gap-2">
|
||||
{videoStreams.map((stream, index) => {
|
||||
const isHdr =
|
||||
(stream.color_transfer ?? "")
|
||||
.toLowerCase()
|
||||
.includes("2084") ||
|
||||
(stream.color_transfer ?? "")
|
||||
.toLowerCase()
|
||||
.includes("b67") ||
|
||||
(stream.color_space ?? "")
|
||||
.toLowerCase()
|
||||
.includes("bt2020") ||
|
||||
(stream.color_primaries ?? "")
|
||||
.toLowerCase()
|
||||
.includes("bt2020");
|
||||
return (
|
||||
<StreamBlock key={`video-${stream.index ?? index}`}>
|
||||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||||
<FfprobeChip>#{stream.index ?? index}</FfprobeChip>
|
||||
<FfprobeChip variant="default">
|
||||
{stream.codec_type ?? "video"}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.codec_name ?? "unknown codec"}
|
||||
</FfprobeChip>
|
||||
{stream.codec_long_name && (
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.codec_long_name}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.profile && (
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.profile}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.bit_rate && (
|
||||
<FfprobeChip variant="outline">
|
||||
{humanRate(stream.bit_rate)}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.duration && (
|
||||
<FfprobeChip variant="outline">
|
||||
{humanDuration(stream.duration)}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.width && stream.height && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`${stream.width}×${stream.height}`}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.pix_fmt && (
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.pix_fmt}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.display_aspect_ratio && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`DAR ${stream.display_aspect_ratio}`}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.sample_aspect_ratio && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`SAR ${stream.sample_aspect_ratio}`}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.level !== undefined &&
|
||||
stream.level !== null && (
|
||||
<FfprobeChip variant="outline">{`L${stream.level}`}</FfprobeChip>
|
||||
)}
|
||||
{stream.field_order &&
|
||||
stream.field_order !== "unknown" && (
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.field_order}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{(stream.color_range ||
|
||||
stream.color_space ||
|
||||
stream.color_transfer ||
|
||||
stream.color_primaries) && (
|
||||
<FfprobeChip variant={isHdr ? "warning" : "outline"}>
|
||||
{[
|
||||
stream.color_range,
|
||||
stream.color_space,
|
||||
stream.color_transfer,
|
||||
stream.color_primaries,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm">
|
||||
{stream.tags?.language
|
||||
? `Language: ${stream.tags.language}. `
|
||||
: ""}
|
||||
{stream.tags?.title
|
||||
? `Title: ${stream.tags.title}.`
|
||||
: ""}
|
||||
</div>
|
||||
</StreamBlock>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Audio streams</div>
|
||||
<div className="mt-1.5 flex flex-col gap-2">
|
||||
{audioStreams.map((stream, index) => (
|
||||
<StreamBlock key={`audio-${stream.index ?? index}`}>
|
||||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||||
<FfprobeChip>#{stream.index ?? index}</FfprobeChip>
|
||||
<FfprobeChip variant="secondary">
|
||||
{stream.codec_type ?? "audio"}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.codec_name ?? "unknown codec"}
|
||||
</FfprobeChip>
|
||||
{stream.channels && (
|
||||
<FfprobeChip variant="outline">{`${stream.channels} ch`}</FfprobeChip>
|
||||
)}
|
||||
{stream.sample_rate && (
|
||||
<FfprobeChip variant="outline">{`${stream.sample_rate} Hz`}</FfprobeChip>
|
||||
)}
|
||||
{stream.bit_rate && (
|
||||
<FfprobeChip variant="outline">
|
||||
{humanRate(stream.bit_rate)}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.duration && (
|
||||
<FfprobeChip variant="outline">
|
||||
{humanDuration(stream.duration)}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm">
|
||||
{stream.codec_long_name
|
||||
? `${stream.codec_long_name}. `
|
||||
: ""}
|
||||
{stream.channel_layout
|
||||
? `Layout: ${stream.channel_layout}. `
|
||||
: ""}
|
||||
{stream.tags?.language
|
||||
? `Language: ${stream.tags.language}. `
|
||||
: ""}
|
||||
{stream.tags?.title ? `Title: ${stream.tags.title}.` : ""}
|
||||
</div>
|
||||
</StreamBlock>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subtitleStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Subtitle streams
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-col gap-2">
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<StreamBlock key={`subtitle-${stream.index ?? index}`}>
|
||||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||||
<FfprobeChip>#{stream.index ?? index}</FfprobeChip>
|
||||
<FfprobeChip variant="secondary">
|
||||
{stream.codec_type ?? "subtitle"}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.codec_name ?? "unknown codec"}
|
||||
</FfprobeChip>
|
||||
{stream.tags?.language && (
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.tags.language}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.tags?.title && (
|
||||
<FfprobeChip variant="outline">
|
||||
{stream.tags.title}
|
||||
</FfprobeChip>
|
||||
)}
|
||||
</div>
|
||||
</StreamBlock>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streams.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No streams found.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{Object.keys(format.tags ?? {}).length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold">Tags</div>
|
||||
<div className="flex flex-row flex-wrap gap-1.5">
|
||||
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
||||
<FfprobeChip key={key} variant="outline">
|
||||
{`${key}: ${value}`}
|
||||
</FfprobeChip>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoAlert({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{children}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileBrowser() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [columnVisibility, setColumnVisibility] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const { data: machines } = useMonitoringSettings();
|
||||
const fileMachines = useMemo(
|
||||
() =>
|
||||
(machines ?? []).filter(
|
||||
(machine) =>
|
||||
machine.enabled &&
|
||||
(machine.services.includes("files") ||
|
||||
machine.services.includes("monitoring")),
|
||||
),
|
||||
[machines],
|
||||
);
|
||||
const initialRequestedPath = searchParams.get("path");
|
||||
const initialMachineId =
|
||||
searchParams.get("machine_id") || fileMachines[0]?.id || "";
|
||||
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
|
||||
FILE_BROWSER_STATE_KEY,
|
||||
() => {
|
||||
const requestedPath = initialRequestedPath ?? "/";
|
||||
const selectedPath =
|
||||
requestedPath !== "/" &&
|
||||
(isVideoFile(requestedPath) || requestedPath.includes("."))
|
||||
? requestedPath.replace(/\/+$/, "")
|
||||
: null;
|
||||
const currentDir = selectedPath
|
||||
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
||||
: requestedPath.replace(/\/+$/, "") || "/";
|
||||
return {
|
||||
...defaultFileBrowserState(),
|
||||
currentDir,
|
||||
pathInput: requestedPath || currentDir,
|
||||
selectedPath,
|
||||
};
|
||||
},
|
||||
);
|
||||
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
||||
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
|
||||
const navigateToSettings = useNavigate();
|
||||
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
||||
setBrowserState((current) => ({ ...current, ...patch }));
|
||||
|
||||
const {
|
||||
data: listing,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
|
||||
const {
|
||||
data: ffprobeData,
|
||||
isLoading: ffprobeLoading,
|
||||
error: ffprobeError,
|
||||
} = useFfprobe(
|
||||
selectedPath ?? "",
|
||||
!!selectedPath && isVideoFile(selectedPath),
|
||||
selectedMachineId || undefined,
|
||||
);
|
||||
const { data: templates } = useJobTemplates();
|
||||
const runJob = useRunJob(selectedMachineId || undefined);
|
||||
|
||||
const navigate = (path: string) => {
|
||||
updateBrowserState({
|
||||
currentDir: path,
|
||||
pathInput: path,
|
||||
selectedPath: null,
|
||||
});
|
||||
};
|
||||
|
||||
const setMachine = (machineId: string) => {
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
if (machineId) next.set("machine_id", machineId);
|
||||
else next.delete("machine_id");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") navigate(pathInput || "/");
|
||||
};
|
||||
|
||||
const rows: DisplayRow[] = [];
|
||||
if (currentDir !== "/") {
|
||||
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
|
||||
rows.push({
|
||||
id: `up-${parent}`,
|
||||
type: "up",
|
||||
name: "..",
|
||||
ext: "",
|
||||
size: "-",
|
||||
modified: "",
|
||||
path: parent,
|
||||
});
|
||||
}
|
||||
if (listing) {
|
||||
for (const entry of listing.entries) {
|
||||
const kind = entry.type === "d" ? "dir" : "file";
|
||||
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
|
||||
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
|
||||
rows.push({
|
||||
id: path,
|
||||
type: kind,
|
||||
name: entry.name,
|
||||
ext,
|
||||
size: kind === "dir" ? "-" : formatSize(entry.size),
|
||||
modified: formatTime(entry.mtime),
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate;
|
||||
// file rows select the file for ffprobe preview (also feeds pathInput).
|
||||
const handleRowClick = (row: DisplayRow) => {
|
||||
if (row.type === "dir" || row.type === "up") {
|
||||
navigate(row.path);
|
||||
return;
|
||||
}
|
||||
updateBrowserState({
|
||||
selectedPath: row.path,
|
||||
currentDir,
|
||||
pathInput: row.path,
|
||||
});
|
||||
};
|
||||
|
||||
// Single-select checkbox behavior (DataTable adds a selection column under
|
||||
// enableRowSelection): mirrors the row-click selection for file rows.
|
||||
const rowSelection: RowSelectionState = selectedPath
|
||||
? { [selectedPath]: true }
|
||||
: {};
|
||||
const handleSelectionChange = (
|
||||
updater:
|
||||
| RowSelectionState
|
||||
| ((prev: RowSelectionState) => RowSelectionState),
|
||||
) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
const selectedIds = Object.keys(next).filter((id) => next[id]);
|
||||
const id = selectedIds[selectedIds.length - 1];
|
||||
if (!id) {
|
||||
updateBrowserState({ selectedPath: null });
|
||||
return;
|
||||
}
|
||||
const target = rows.find((row) => row.id === id);
|
||||
if (target && target.type === "file") {
|
||||
updateBrowserState({ selectedPath: target.path, pathInput: target.path });
|
||||
} else {
|
||||
updateBrowserState({ selectedPath: null });
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4.5">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">File Browser</h2>
|
||||
<Badge variant="outline">
|
||||
{fileMachines.length
|
||||
? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
|
||||
: "No file machines"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<TabbedCard
|
||||
value={fileMachines.length > 0 ? selectedMachineId : ""}
|
||||
onChange={setMachine}
|
||||
tabs={fileMachines.map((machine) => (
|
||||
<TabsTrigger key={machine.id} value={machine.id}>
|
||||
{`${machine.name} · ${machine.mode}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
>
|
||||
{fileMachines.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="Browser"
|
||||
description="Read-only listing with explicit open/select actions."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label htmlFor="remote-path">Remote path</Label>
|
||||
<Input
|
||||
id="remote-path"
|
||||
value={pathInput}
|
||||
onChange={(e) =>
|
||||
updateBrowserState({ pathInput: e.target.value })
|
||||
}
|
||||
onKeyDown={handlePathSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Current: ${currentDir} `}
|
||||
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
||||
{listing ? `| Entries: ${listing.count}` : ""}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<DataTable
|
||||
columns={fileColumns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={handleSelectionChange}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading directory..."
|
||||
: "This directory is empty."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Media info"
|
||||
description="ffprobe metadata for the selected media file."
|
||||
>
|
||||
{selectedPath ? (
|
||||
isVideoFile(selectedPath) ? (
|
||||
ffprobeError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{String(ffprobeError)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeLoading && !ffprobeData ? (
|
||||
<InfoAlert>Loading ffprobe data...</InfoAlert>
|
||||
) : ffprobeData ? (
|
||||
<FfprobeDetails
|
||||
path={selectedPath}
|
||||
data={ffprobeData as FfprobeData}
|
||||
/>
|
||||
) : (
|
||||
<InfoAlert>No ffprobe data available.</InfoAlert>
|
||||
)
|
||||
) : (
|
||||
<InfoAlert>
|
||||
Select a video file to view ffprobe details.
|
||||
</InfoAlert>
|
||||
)
|
||||
) : (
|
||||
<InfoAlert>
|
||||
Select a file in Browser to view ffprobe details.
|
||||
</InfoAlert>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Jobs"
|
||||
description="Run predefined safe jobs against the selected file."
|
||||
>
|
||||
{selectedPath && templates && templates.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="job-template">Job template</Label>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
onValueChange={(value) =>
|
||||
updateBrowserState({ selectedJob: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="job-template" className="w-full">
|
||||
<SelectValue placeholder="Select a job" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((tpl) => (
|
||||
<SelectItem key={tpl.key} value={tpl.key}>
|
||||
{tpl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||
<Button
|
||||
disabled={!selectedJob || runJob.isPending}
|
||||
onClick={() =>
|
||||
runJob.mutate({
|
||||
jobKey: selectedJob,
|
||||
path: selectedPath,
|
||||
})
|
||||
}
|
||||
>
|
||||
Run job
|
||||
</Button>
|
||||
{selectedTemplate && (
|
||||
<div className="self-center text-sm text-muted-foreground">
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{runJob.data && (
|
||||
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
||||
{`Exit: ${runJob.data.exit_status}`}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<InfoAlert>Select a file in Browser to run jobs.</InfoAlert>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No file-capable machines are configured yet.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateToSettings("/settings")}
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { FileBrowser } from "./FileBrowser.impl";
|
||||
@@ -1,564 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
ColumnDef,
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import {
|
||||
useMediaStatus,
|
||||
useMediaQuery as useMediaDataQuery,
|
||||
useBuildIndex,
|
||||
useStopBuildIndex,
|
||||
useForceStopBuildIndex,
|
||||
} from "../hooks/useMedia";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import type { MediaItem } from "../types";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
|
||||
function formatDuration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || Number.isNaN(seconds)) return "-";
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
|
||||
// TanStack table instance stays stable — an unstable columns array drops the
|
||||
// controlled selection/visibility state (7a discovery). Visibility-only parity
|
||||
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
|
||||
const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||
{ accessorKey: "title", header: "Title" },
|
||||
{ accessorKey: "series", header: "Series" },
|
||||
{ accessorKey: "season", header: "Season" },
|
||||
{ accessorKey: "episode", header: "Episode" },
|
||||
{ accessorKey: "type", header: "Type" },
|
||||
{ accessorKey: "year", header: "Year" },
|
||||
{ accessorKey: "runtime_min", header: "Runtime" },
|
||||
{ accessorKey: "size", header: "Size" },
|
||||
{ accessorKey: "bitrate", header: "Bitrate" },
|
||||
{ accessorKey: "hdr", header: "HDR" },
|
||||
{ accessorKey: "video", header: "Video codec" },
|
||||
{ accessorKey: "resolution", header: "Resolution" },
|
||||
{ accessorKey: "date_added", header: "Date added" },
|
||||
{ accessorKey: "library", header: "Library" },
|
||||
{ accessorKey: "path", header: "Path" },
|
||||
];
|
||||
|
||||
// Stable path-derived identity so row selection survives server-driven paging
|
||||
// (design §3.4): the id is the item's filesystem path, which is stable across
|
||||
// limit/offset page changes.
|
||||
function getMediaRowId(row: MediaItem): string {
|
||||
return row.path;
|
||||
}
|
||||
|
||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||
const MOBILE_HIDDEN_COLUMNS = [
|
||||
"series",
|
||||
"season",
|
||||
"episode",
|
||||
"bitrate",
|
||||
"video",
|
||||
"resolution",
|
||||
"date_added",
|
||||
"library",
|
||||
"path",
|
||||
];
|
||||
|
||||
type MediaTabState = {
|
||||
search: string;
|
||||
types: string;
|
||||
hdrFilter: string;
|
||||
sortKey: string;
|
||||
sortOrder: string;
|
||||
offset: number;
|
||||
pageSize: number;
|
||||
columnVisibility: Record<string, boolean>;
|
||||
};
|
||||
|
||||
function defaultMediaTabState(): MediaTabState {
|
||||
return {
|
||||
search: "",
|
||||
types: "Movie,Episode",
|
||||
hdrFilter: "All",
|
||||
sortKey: "title",
|
||||
sortOrder: "Ascending",
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
columnVisibility: {},
|
||||
};
|
||||
}
|
||||
|
||||
function usePrefersSmallScreen(): boolean {
|
||||
const supportsMatchMedia =
|
||||
typeof window !== "undefined" && typeof window.matchMedia === "function";
|
||||
const [small, setSmall] = useState(() =>
|
||||
supportsMatchMedia ? window.matchMedia(SMALL_BREAKPOINT).matches : false,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!supportsMatchMedia) return;
|
||||
const mql = window.matchMedia(SMALL_BREAKPOINT);
|
||||
const onChange = () => setSmall(mql.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [supportsMatchMedia]);
|
||||
return small;
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
|
||||
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
|
||||
// "indeterminate" affordance for unknown build progress.
|
||||
function BuildProgress({ value }: { value: number | null }) {
|
||||
if (value == null) {
|
||||
return (
|
||||
<div className="h-1 w-full animate-pulse rounded-full bg-muted-foreground/30" />
|
||||
);
|
||||
}
|
||||
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
|
||||
}
|
||||
|
||||
export function Media() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||
const selectedServiceId =
|
||||
searchParams.get("jellyfin_service_id") ||
|
||||
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||
"";
|
||||
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||
const { data: status } = useMediaStatus(selectedServiceId || undefined);
|
||||
const buildIndex = useBuildIndex(selectedServiceId || undefined);
|
||||
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
|
||||
const forceStopBuildIndex = useForceStopBuildIndex(
|
||||
selectedServiceId || undefined,
|
||||
);
|
||||
|
||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||
MEDIA_TAB_STATE_KEY,
|
||||
defaultMediaTabState,
|
||||
);
|
||||
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
|
||||
// without pageSize/columnVisibility) never yields undefined fields.
|
||||
const mediaState: MediaTabState = {
|
||||
...defaultMediaTabState(),
|
||||
...rawMediaState,
|
||||
};
|
||||
const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } =
|
||||
mediaState;
|
||||
const updateMediaState = (patch: Partial<MediaTabState>) =>
|
||||
setMediaState((current) => ({ ...current, ...patch }));
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("jellyfin_service_id", selectedServiceId);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [searchParams, selectedServiceId, setSearchParams]);
|
||||
|
||||
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||
types,
|
||||
search,
|
||||
hdr_filter: hdrFilter,
|
||||
sort_key: sortKey,
|
||||
sort_order: sortOrder,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
jellyfinServiceId: selectedServiceId || undefined,
|
||||
enabled: status?.exists ?? false,
|
||||
});
|
||||
|
||||
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
|
||||
// persistent media state and drive useMediaQuery { limit, offset }.
|
||||
const pageIndex = Math.floor(offset / pageSize);
|
||||
const pagination: PaginationState = { pageIndex, pageSize };
|
||||
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next =
|
||||
typeof updater === "function"
|
||||
? updater({ pageIndex, pageSize })
|
||||
: updater;
|
||||
const nextPageSize = next.pageSize || pageSize;
|
||||
// Restart at page 0 whenever the page size changes (keeps offset sane
|
||||
// under server-driven paging).
|
||||
const nextOffset =
|
||||
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
|
||||
setMediaState((current) => ({
|
||||
...current,
|
||||
offset: nextOffset,
|
||||
pageSize: nextPageSize,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleColumnVisibilityChange: OnChangeFn<VisibilityState> = (
|
||||
updater,
|
||||
) => {
|
||||
setMediaState((current) => {
|
||||
const prev = current.columnVisibility ?? {};
|
||||
const next = typeof updater === "function" ? updater(prev) : updater;
|
||||
return { ...current, columnVisibility: next };
|
||||
});
|
||||
};
|
||||
|
||||
// On small screens force the same set of columns hidden as the pre-rework
|
||||
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
|
||||
// toggles freely (the toggleable set still equals the locked 15).
|
||||
const effectiveColumnVisibility = useMemo(() => {
|
||||
const base = mediaState.columnVisibility ?? {};
|
||||
if (!isSmall) return base;
|
||||
const merged = { ...base };
|
||||
for (const key of MOBILE_HIDDEN_COLUMNS) merged[key] = false;
|
||||
return merged;
|
||||
}, [mediaState.columnVisibility, isSmall]);
|
||||
|
||||
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
|
||||
// the item's path.
|
||||
const handleRowClick = (row: MediaItem) => {
|
||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||
};
|
||||
|
||||
const total = queryResult?.total ?? 0;
|
||||
const totalPages = queryResult ? Math.max(1, Math.ceil(total / pageSize)) : 1;
|
||||
|
||||
const buildRunning = status?.build_running ?? false;
|
||||
const buildProgress = status?.build_progress ?? null;
|
||||
const buildLibraryProgress = status?.build_library_progress ?? null;
|
||||
const buildCancelRequested = status?.build_cancel_requested ?? false;
|
||||
const buildLabel = buildRunning
|
||||
? status?.build_message || "Building media index..."
|
||||
: status?.build_error
|
||||
? `Build failed: ${status.build_error}`
|
||||
: "";
|
||||
const elapsedLabel = formatDuration(status?.build_elapsed_seconds);
|
||||
const etaLabel =
|
||||
buildRunning && status?.build_eta_seconds != null
|
||||
? formatDuration(status.build_eta_seconds)
|
||||
: "-";
|
||||
const libraryElapsedLabel = formatDuration(
|
||||
status?.build_library_elapsed_seconds,
|
||||
);
|
||||
const libraryEtaLabel =
|
||||
buildRunning && status?.build_library_eta_seconds != null
|
||||
? formatDuration(status.build_library_eta_seconds)
|
||||
: "-";
|
||||
const libraryLabel =
|
||||
status?.build_current_library ||
|
||||
(status?.build_library_index && status?.build_libraries_total
|
||||
? `Library ${status.build_library_index} / ${status.build_libraries_total}`
|
||||
: "Current library");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="media-service">Service</Label>
|
||||
<Select
|
||||
value={selectedServiceId}
|
||||
onValueChange={(value) =>
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("jellyfin_service_id", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
|
||||
<SelectValue placeholder="Select a service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jellyfinServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{status?.exists ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Index: {status.item_count.toLocaleString()} items
|
||||
{status.updated_at_label
|
||||
? ` | updated ${status.updated_at_label}`
|
||||
: ""}
|
||||
</p>
|
||||
) : (
|
||||
<Alert variant="destructive" className="py-0">
|
||||
<AlertDescription>No index built yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{counts && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
|
||||
{counts.series.toLocaleString()} series ·{" "}
|
||||
{counts.episodes.toLocaleString()} episodes ·{" "}
|
||||
{(libraries?.length ?? 0).toLocaleString()} libraries
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => buildIndex.mutate()}
|
||||
disabled={
|
||||
buildIndex.isPending || buildRunning || buildCancelRequested
|
||||
}
|
||||
>
|
||||
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
|
||||
</Button>
|
||||
{buildRunning && (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => stopBuildIndex.mutate()}
|
||||
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
||||
>
|
||||
{buildCancelRequested || stopBuildIndex.isPending
|
||||
? "Stopping..."
|
||||
: "Stop build"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
|
||||
onClick={() => forceStopBuildIndex.mutate()}
|
||||
disabled={forceStopBuildIndex.isPending}
|
||||
>
|
||||
{forceStopBuildIndex.isPending
|
||||
? "Force stopping..."
|
||||
: "Force stop"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(buildRunning || status?.build_error) && (
|
||||
<div className="flex w-full min-w-[260px] flex-col gap-2">
|
||||
<p
|
||||
className={
|
||||
status?.build_error
|
||||
? "text-sm text-destructive"
|
||||
: "text-sm text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{buildLabel ||
|
||||
(buildRunning
|
||||
? "Building media index..."
|
||||
: status?.build_error || "")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overall:{" "}
|
||||
{buildProgress != null
|
||||
? `${Math.round(buildProgress * 100)}%`
|
||||
: "pending"}
|
||||
{buildRunning
|
||||
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
|
||||
: ""}
|
||||
</p>
|
||||
<BuildProgress value={buildProgress} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status?.build_items_processed?.toLocaleString() ?? 0}/
|
||||
{status?.build_items_total?.toLocaleString() ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current: {libraryLabel}
|
||||
{buildRunning
|
||||
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
|
||||
: ""}
|
||||
</p>
|
||||
<BuildProgress value={buildLibraryProgress} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status?.build_library_items_processed?.toLocaleString() ?? 0}/
|
||||
{status?.build_library_items_total?.toLocaleString() ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<div className="col-span-1 flex flex-col gap-1.5 md:col-span-4">
|
||||
<Label htmlFor="media-search">Search</Label>
|
||||
<Input
|
||||
id="media-search"
|
||||
value={search}
|
||||
onChange={(e) =>
|
||||
updateMediaState({ search: e.target.value, offset: 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-types"
|
||||
label="Types"
|
||||
value={types}
|
||||
onChange={(value) =>
|
||||
updateMediaState({ types: value, offset: 0 })
|
||||
}
|
||||
options={[
|
||||
{ value: "Movie,Episode", label: "Movies + Episodes" },
|
||||
{ value: "Movie", label: "Movies only" },
|
||||
{ value: "Episode", label: "Episodes only" },
|
||||
{ value: "Movie,Episode,Video", label: "All video" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-hdr"
|
||||
label="HDR"
|
||||
value={hdrFilter}
|
||||
onChange={(value) =>
|
||||
updateMediaState({ hdrFilter: value, offset: 0 })
|
||||
}
|
||||
options={[
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "HDR only", label: "HDR only" },
|
||||
{ value: "SDR/unknown only", label: "SDR/unknown only" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-sort"
|
||||
label="Sort"
|
||||
value={sortKey}
|
||||
onChange={(value) => updateMediaState({ sortKey: value })}
|
||||
options={[
|
||||
{ value: "title", label: "Title" },
|
||||
{ value: "series", label: "Series" },
|
||||
{ value: "size", label: "Size" },
|
||||
{ value: "bitrate", label: "Bitrate" },
|
||||
{ value: "runtime", label: "Runtime" },
|
||||
{ value: "year", label: "Year" },
|
||||
{ value: "date_added", label: "Date added" },
|
||||
{ value: "resolution", label: "Resolution" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-order"
|
||||
label="Order"
|
||||
value={sortOrder}
|
||||
onChange={(value) => updateMediaState({ sortOrder: value })}
|
||||
options={[
|
||||
{ value: "Ascending", label: "Ascending" },
|
||||
{ value: "Descending", label: "Descending" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{queryResult && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing {queryResult.items.length} of {total.toLocaleString()} items |
|
||||
Page {pageIndex + 1} of {totalPages}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.exists && (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<DataTable
|
||||
columns={mediaColumns}
|
||||
data={queryResult?.items ?? []}
|
||||
getRowId={getMediaRowId}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={effectiveColumnVisibility}
|
||||
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||
enablePagination
|
||||
manualPagination
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
pageSizeOptions={[50, 100, 200]}
|
||||
rowCount={total}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading media..."
|
||||
: "No media items match these filters."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { UsersPage } from "./UsersPage.impl";
|
||||
@@ -1,983 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ChangeEvent } from "react";
|
||||
// Slice 6b: compose dialog (shadcn Dialog family) + lucide icons. The file is
|
||||
// now fully @mui-free (6a migrated the directory surface, drawer, and the
|
||||
// compose content's shared leaf components).
|
||||
import {
|
||||
X,
|
||||
Paperclip,
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
Mail,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Label } from "@/components/ui/label";
|
||||
// Slice 6a directory surface + drawer primitives.
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button as UiButton } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Alert as UIAlert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||
import { useUsers } from "../hooks/useUsers";
|
||||
import { useActivity } from "../hooks/useDashboard";
|
||||
import { useSendUserMessage } from "../hooks/useSendUserMessage";
|
||||
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
|
||||
import type { UserDirectoryItem } from "../types";
|
||||
import { buildUserDrawerModel } from "../users";
|
||||
import {
|
||||
mergeUsersWithActivity,
|
||||
resolveUserSelection,
|
||||
type UserStateItem,
|
||||
} from "../userState";
|
||||
|
||||
// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free
|
||||
// matchMedia hook for the compose dialog's mobile fullScreen behavior.
|
||||
function useIsMobile(query = "(max-width: 900px)") {
|
||||
const [mobile, setMobile] = useState(() =>
|
||||
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
? window.matchMedia(query).matches
|
||||
: false,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const mql = window.matchMedia(query);
|
||||
const onChange = (event: MediaQueryListEvent) => setMobile(event.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return mobile;
|
||||
}
|
||||
|
||||
function userLabel(user: UserDirectoryItem) {
|
||||
return user.display_name || user.username || user.jellyfin_id;
|
||||
}
|
||||
|
||||
// Activity → Badge status variant (design §2.3: healthy/active = success chart-2,
|
||||
// paused = warning chart-3, neutral = secondary).
|
||||
function activityBadgeVariant(
|
||||
label: string,
|
||||
): "success" | "warning" | "secondary" {
|
||||
if (label === "Playing") return "success";
|
||||
if (label === "Paused") return "warning";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
||||
|
||||
export function UsersPage() {
|
||||
const { data, isError, error } = useUsers();
|
||||
const { data: activity } = useActivity();
|
||||
const queueStatusQuery = useUserMessageQueueStatus();
|
||||
const sendUserMessage = useSendUserMessage();
|
||||
const isMobile = useIsMobile();
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const htmlBodyRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const baseRows = data?.items ?? [];
|
||||
const rows = useMemo(
|
||||
() => mergeUsersWithActivity(baseRows, activity ?? []),
|
||||
[baseRows, activity],
|
||||
);
|
||||
const filteredRows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => {
|
||||
return [
|
||||
row.username,
|
||||
row.display_name,
|
||||
row.email,
|
||||
row.email_source,
|
||||
row.avatar_source,
|
||||
row.name_source,
|
||||
row.access_source,
|
||||
row.user_type_label,
|
||||
row.role,
|
||||
row.permissions_label,
|
||||
row.jellyseerr_username,
|
||||
row.activity_label,
|
||||
row.activity_summary,
|
||||
row.activity.primary_session?.title || "",
|
||||
String(row.jellyseerr_user_id ?? ""),
|
||||
].some((value) => value.toLowerCase().includes(term));
|
||||
});
|
||||
}, [rows, search]);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const total = baseRows.length;
|
||||
const contactable = rows.filter((row) => row.contactable).length;
|
||||
const enriched = rows.filter(
|
||||
(row) => row.jellyseerr_user_id !== null,
|
||||
).length;
|
||||
const admins = rows.filter((row) => row.role === "admin").length;
|
||||
return { total, contactable, enriched, admins };
|
||||
}, [baseRows]);
|
||||
|
||||
const queueStatus = queueStatusQuery.data;
|
||||
const queueBanner = useMemo(() => {
|
||||
if (!queueStatus) {
|
||||
return null;
|
||||
}
|
||||
const activeCount = queueStatus.active_request_id ? 1 : 0;
|
||||
const totalCount = queueStatus.pending_count + activeCount;
|
||||
const countLabel =
|
||||
totalCount > 0
|
||||
? `${totalCount} item${totalCount === 1 ? "" : "s"} in queue (${queueStatus.pending_count} waiting${activeCount ? ", 1 processing" : ""})`
|
||||
: "0 items in queue";
|
||||
if (!queueStatus.worker_running) {
|
||||
return {
|
||||
severity: "warning" as const,
|
||||
message:
|
||||
queueStatus.last_error ||
|
||||
"Email queue worker is not running. New messages cannot be delivered until it restarts.",
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}
|
||||
if (queueStatus.state === "error") {
|
||||
return {
|
||||
severity: "error" as const,
|
||||
message: queueStatus.last_error || "The last email delivery failed.",
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}
|
||||
if (queueStatus.state === "busy") {
|
||||
const active = queueStatus.active_request_id
|
||||
? `processing ${queueStatus.active_request_id.slice(0, 8)}`
|
||||
: "processing a message";
|
||||
const waiting = queueStatus.pending_count
|
||||
? `${queueStatus.pending_count} waiting`
|
||||
: "no backlog";
|
||||
return {
|
||||
severity: "info" as const,
|
||||
message: `Email queue is busy: ${active}, ${waiting}.`,
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
severity: "success" as const,
|
||||
message: "Email queue is idle and empty.",
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}, [queueStatus]);
|
||||
|
||||
const selectedIdSet = useMemo(
|
||||
() => new Set(selectedUserIds),
|
||||
[selectedUserIds],
|
||||
);
|
||||
const selectedRows = useMemo(
|
||||
() => rows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
|
||||
[rows, selectedIdSet],
|
||||
);
|
||||
const selectedDeliverableRows = useMemo(
|
||||
() => selectedRows.filter((row) => row.contactable && row.email),
|
||||
[selectedRows],
|
||||
);
|
||||
const skippedRows = useMemo(
|
||||
() => selectedRows.filter((row) => !row.contactable || !row.email),
|
||||
[selectedRows],
|
||||
);
|
||||
const visibleSelectedRows = useMemo(
|
||||
() => filteredRows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
|
||||
[filteredRows, selectedIdSet],
|
||||
);
|
||||
const allVisibleSelected =
|
||||
filteredRows.length > 0 &&
|
||||
visibleSelectedRows.length === filteredRows.length;
|
||||
|
||||
const toggleUserSelected = (userId: string) => {
|
||||
setSelectedUserIds((current) =>
|
||||
current.includes(userId)
|
||||
? current.filter((id) => id !== userId)
|
||||
: [...current, userId],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleVisibleSelection = (checked: boolean) => {
|
||||
setSelectedUserIds((current) => {
|
||||
const next = new Set(current);
|
||||
filteredRows.forEach((row) => {
|
||||
if (checked) {
|
||||
next.add(row.jellyfin_id);
|
||||
} else {
|
||||
next.delete(row.jellyfin_id);
|
||||
}
|
||||
});
|
||||
return Array.from(next);
|
||||
});
|
||||
};
|
||||
|
||||
const selectedUserParam = searchParams.get("user") || "";
|
||||
const selectedUser = useMemo(
|
||||
() =>
|
||||
selectedUserParam
|
||||
? (resolveUserSelection(
|
||||
rows,
|
||||
selectedUserParam,
|
||||
) as UserStateItem | null)
|
||||
: null,
|
||||
[rows, selectedUserParam],
|
||||
);
|
||||
const drawerModel = selectedUser ? buildUserDrawerModel(selectedUser) : null;
|
||||
|
||||
const openCompose = () => {
|
||||
if (!selectedRows.length) {
|
||||
return;
|
||||
}
|
||||
sendUserMessage.reset();
|
||||
if (!subject.trim()) {
|
||||
setSubject(
|
||||
`Manage update for ${selectedDeliverableRows.length} user${selectedDeliverableRows.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
if (!htmlBody.trim()) {
|
||||
setHtmlBody(DEFAULT_HTML_BODY);
|
||||
}
|
||||
setComposeOpen(true);
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
setComposeOpen(false);
|
||||
sendUserMessage.reset();
|
||||
};
|
||||
|
||||
const insertMarkup = (before: string, after = before) => {
|
||||
const textarea = htmlBodyRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart ?? htmlBody.length;
|
||||
const end = textarea.selectionEnd ?? htmlBody.length;
|
||||
const selected = htmlBody.slice(start, end) || "text";
|
||||
const next =
|
||||
htmlBody.slice(0, start) +
|
||||
before +
|
||||
selected +
|
||||
after +
|
||||
htmlBody.slice(end);
|
||||
setHtmlBody(next);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
const cursorStart = start + before.length;
|
||||
const cursorEnd = cursorStart + selected.length;
|
||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = () => {
|
||||
const url = window.prompt("Link URL", "https://");
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
insertMarkup(`<a href="${url}">`, "</a>");
|
||||
};
|
||||
|
||||
const handleAttachments = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length) {
|
||||
setAttachments((current) => [...current, ...files]);
|
||||
}
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments((current) => current.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const allSelectedRows = selectedRows;
|
||||
if (!allSelectedRows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"recipient_ids",
|
||||
JSON.stringify(allSelectedRows.map((row) => row.jellyfin_id)),
|
||||
);
|
||||
formData.append("subject", subject);
|
||||
formData.append("html_body", htmlBody);
|
||||
attachments.forEach((file) => {
|
||||
formData.append("attachments", file, file.name);
|
||||
});
|
||||
|
||||
try {
|
||||
await sendUserMessage.mutateAsync(formData);
|
||||
setComposeOpen(false);
|
||||
setAttachments([]);
|
||||
setSubject("");
|
||||
setHtmlBody(DEFAULT_HTML_BODY);
|
||||
} catch {
|
||||
// Mutation state is shown inline.
|
||||
}
|
||||
};
|
||||
|
||||
// Sticky table-header base (opaque so rows don't bleed through on scroll).
|
||||
const thBase = "font-semibold sticky top-0 z-10 bg-card";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Users</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Read-only Jellyfin users with optional Jellyseerr enrichment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<UIAlert variant="destructive">
|
||||
<AlertDescription>
|
||||
Unable to load users: {(error as Error)?.message || "Unknown error"}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{data && !data.jellyseerr_configured ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is not configured in the backend yet. Check
|
||||
JELLYSEERR_URL and JELLYSEERR_API_KEY, then restart the API.
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{data?.jellyseerr_error ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Jellyseerr enrichment is unavailable: {data.jellyseerr_error}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{data?.jellyseerr_configured &&
|
||||
!data.jellyseerr_error &&
|
||||
data.enriched_count === 0 ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is connected, but no Jellyfin users were matched yet. The
|
||||
backend found {data.jellyseerr_jellyfin_user_count} Jellyfin-linked
|
||||
entries and {data.jellyseerr_user_count} Jellyseerr users.
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{queueStatusQuery.isError ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Unable to load email queue status:{" "}
|
||||
{String(
|
||||
(queueStatusQuery.error as Error)?.message || "Unknown error",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : queueBanner ? (
|
||||
<UIAlert
|
||||
variant={queueBanner.severity === "error" ? "destructive" : undefined}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{queueBanner.message}</span>
|
||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
||||
</div>
|
||||
<AlertDescription>{queueBanner.subtext}</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<MetricCard label="Total users" value={String(metrics.total)} />
|
||||
<MetricCard label="Contactable" value={String(metrics.contactable)} />
|
||||
<MetricCard label="Enriched" value={String(metrics.enriched)} />
|
||||
<MetricCard label="Admins" value={String(metrics.admins)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">User list</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredRows.length} visible of {rows.length} total
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Badge variant="outline">{selectedRows.length} selected</Badge>
|
||||
<Badge
|
||||
variant={selectedDeliverableRows.length ? "success" : "outline"}
|
||||
>
|
||||
{selectedDeliverableRows.length} deliverable
|
||||
</Badge>
|
||||
<UiButton
|
||||
variant="default"
|
||||
disabled={!selectedDeliverableRows.length}
|
||||
onClick={openCompose}
|
||||
>
|
||||
<Mail />
|
||||
Message selected
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
disabled={!selectedRows.length}
|
||||
onClick={() => setSelectedUserIds([])}
|
||||
>
|
||||
Clear selection
|
||||
</UiButton>
|
||||
<Input
|
||||
aria-label="Search"
|
||||
placeholder="Name, email, role, permission..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-full sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[660px] overflow-auto rounded-lg border">
|
||||
<Table aria-label="Users table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className={cn(thBase, "w-14 p-2")}>
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
aria-label="Select all visible users"
|
||||
onCheckedChange={(checked) =>
|
||||
toggleVisibleSelection(checked === true)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className={thBase}>User</TableHead>
|
||||
<TableHead className={thBase}>Email</TableHead>
|
||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||
Activity
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-[140px] text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||
Jellyseerr
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-[120px] text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Role
|
||||
</TableHead>
|
||||
<TableHead className={thBase}>Permissions</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-24 text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Reqs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-[120px] text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Contact
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const linked =
|
||||
row.jellyseerr_user_id !== null &&
|
||||
row.jellyseerr_user_id !== undefined;
|
||||
const checked = selectedIdSet.has(row.jellyfin_id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.jellyfin_id}
|
||||
data-state={
|
||||
checked || selectedUser?.jellyfin_id === row.jellyfin_id
|
||||
? "selected"
|
||||
: undefined
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSearchParams({ user: row.jellyfin_id })}
|
||||
>
|
||||
<TableCell className="w-14 p-2">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
aria-label={`Select ${userLabel(row)}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={() =>
|
||||
toggleUserSelected(row.jellyfin_id)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage
|
||||
src={row.avatar || undefined}
|
||||
alt={userLabel(row)}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{userLabel(row).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold leading-tight">
|
||||
{userLabel(row)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{row.username && row.username !== row.display_name
|
||||
? row.username
|
||||
: row.jellyfin_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="truncate font-medium">
|
||||
{row.email || "—"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={activityBadgeVariant(row.activity_label)}
|
||||
>
|
||||
{row.activity_label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center md:table-cell">
|
||||
<Badge variant="outline">{row.user_type_label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={linked ? "success" : "secondary"}>
|
||||
{linked
|
||||
? `Linked #${row.jellyseerr_user_id}`
|
||||
: "Base only"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center md:table-cell">
|
||||
<Badge variant="outline">{row.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-normal">
|
||||
{row.permissions_label}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center font-semibold md:table-cell">
|
||||
{row.request_count ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center md:table-cell">
|
||||
<Badge
|
||||
variant={row.contactable ? "success" : "secondary"}
|
||||
>
|
||||
{row.contactable ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={Boolean(drawerModel)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchParams({});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="w-full gap-6 overflow-y-auto p-6 sm:max-w-[440px]"
|
||||
>
|
||||
{selectedUser && drawerModel ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="size-14">
|
||||
<AvatarImage
|
||||
src={selectedUser.avatar || undefined}
|
||||
alt={drawerModel.title}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{drawerModel.title.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-lg font-bold">
|
||||
{drawerModel.title}
|
||||
</h2>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{drawerModel.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{drawerModel.contactState.label}
|
||||
</Badge>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
aria-label="Close user details"
|
||||
onClick={() => setSearchParams({})}
|
||||
>
|
||||
<X />
|
||||
Close
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{selectedUser.user_type_label}</Badge>
|
||||
<Badge variant="default">{selectedUser.role}</Badge>
|
||||
<Badge variant="secondary">{drawerModel.syncStatus}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Identity</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{drawerModel.identity.map((field) => (
|
||||
<div key={field.label} className="flex gap-4">
|
||||
<span className="min-w-[120px] text-xs uppercase text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="break-words text-sm">{field.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Activity</h3>
|
||||
<SessionActivityPanel
|
||||
sessions={selectedUser.activity.sessions}
|
||||
selectedUserLabel={
|
||||
selectedUser.display_name ||
|
||||
selectedUser.username ||
|
||||
selectedUser.jellyfin_id
|
||||
}
|
||||
emptyMessage="No live sessions matched to this user."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Contact actions</h3>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
{drawerModel.contactState.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{drawerModel.contactActions.map((action) => (
|
||||
<UiButton
|
||||
key={action.label}
|
||||
variant="outline"
|
||||
disabled={!action.enabled}
|
||||
>
|
||||
{action.label}
|
||||
</UiButton>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{drawerModel.contactActions
|
||||
.map((action) => action.hint)
|
||||
.join(" ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Permissions</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{drawerModel.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="secondary">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This panel is read-only for now. Communication actions will be
|
||||
added later without redesigning the list.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Dialog
|
||||
open={composeOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeCompose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
||||
isMobile &&
|
||||
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
||||
)}
|
||||
>
|
||||
<DialogHeader className="gap-1 px-4 pt-4">
|
||||
<DialogTitle className="pr-8">Message selected users</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Compose a message to the selected deliverable users.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{sendUserMessage.isPending ? (
|
||||
<Progress value={100} className="animate-pulse" />
|
||||
) : null}
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||
{sendUserMessage.isError ? (
|
||||
<UIAlert variant="destructive">
|
||||
<AlertDescription>
|
||||
Unable to send message:{" "}
|
||||
{(sendUserMessage.error as Error)?.message || "Unknown error"}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
{sendUserMessage.isSuccess ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Queued for {sendUserMessage.data.recipient_count} recipients
|
||||
{sendUserMessage.data.attachment_count
|
||||
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
||||
: ""}
|
||||
{sendUserMessage.data.request_id
|
||||
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
||||
: ""}
|
||||
.
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{queueBanner ? (
|
||||
<UIAlert
|
||||
variant={
|
||||
queueBanner.severity === "error" ? "destructive" : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{queueBanner.message}
|
||||
</span>
|
||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
||||
</div>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
|
||||
deliverable.
|
||||
{skippedRows.length
|
||||
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
||||
: ""}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedDeliverableRows.map((row) => (
|
||||
<Badge key={row.jellyfin_id} variant="secondary">
|
||||
{`${userLabel(row)} <${row.email}>`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="compose-subject">Subject</Label>
|
||||
<Input
|
||||
id="compose-subject"
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => insertMarkup("<strong>", "</strong>")}
|
||||
aria-label="Bold"
|
||||
>
|
||||
<Bold />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bold</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => insertMarkup("<em>", "</em>")}
|
||||
aria-label="Italic"
|
||||
>
|
||||
<Italic />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Italic</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={addLink}
|
||||
aria-label="Link"
|
||||
>
|
||||
<Link />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Link</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bullet list</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="compose-body">HTML message body</Label>
|
||||
<Textarea
|
||||
id="compose-body"
|
||||
ref={htmlBodyRef}
|
||||
value={htmlBody}
|
||||
onChange={(event) => setHtmlBody(event.target.value)}
|
||||
className="min-h-[260px] font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Formatting is sent as HTML; a plain-text fallback is generated
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/40 p-4">
|
||||
<p className="mb-2 text-sm font-semibold">Preview</p>
|
||||
<div className="overflow-hidden rounded-md border bg-card">
|
||||
<iframe
|
||||
title="Email preview"
|
||||
sandbox=""
|
||||
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
||||
style={{ width: "100%", minHeight: 220, border: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<UiButton asChild variant="outline">
|
||||
<label className="cursor-pointer">
|
||||
<Paperclip />
|
||||
Add attachment
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleAttachments}
|
||||
/>
|
||||
</label>
|
||||
</UiButton>
|
||||
{attachments.map((file, index) => (
|
||||
<Badge
|
||||
key={`${file.name}-${index}`}
|
||||
variant="secondary"
|
||||
className="gap-1 pr-1"
|
||||
>
|
||||
{file.name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
onClick={() => removeAttachment(index)}
|
||||
className="inline-flex items-center text-current [&>svg]:size-3"
|
||||
>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="m-0 border-t p-4">
|
||||
<UiButton variant="ghost" onClick={closeCompose}>
|
||||
Cancel
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="default"
|
||||
disabled={
|
||||
sendUserMessage.isPending ||
|
||||
!selectedDeliverableRows.length ||
|
||||
!subject.trim()
|
||||
}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send />
|
||||
Send message
|
||||
</UiButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Actions } from "../Actions";
|
||||
import type { SavedTask, ServiceInstance } from "../../types";
|
||||
|
||||
const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||
id: "t1",
|
||||
name: "Restart svc",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
});
|
||||
const deleteTaskMutate = vi.fn();
|
||||
const runTaskMutate = vi.fn().mockResolvedValue({});
|
||||
|
||||
let sshServices: ServiceInstance[] = [];
|
||||
let tasks: SavedTask[] = [];
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useTasks: () => ({ data: tasks }),
|
||||
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
|
||||
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: sshServices }),
|
||||
}));
|
||||
|
||||
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
|
||||
return {
|
||||
id: "s1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Box",
|
||||
config: { host: "box", username: "u" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as ServiceInstance;
|
||||
}
|
||||
|
||||
function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Restart svc",
|
||||
task_type: "shell",
|
||||
content: "systemctl restart foo",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as SavedTask;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveTaskMutate.mockClear();
|
||||
deleteTaskMutate.mockClear();
|
||||
runTaskMutate.mockClear();
|
||||
sshServices = [];
|
||||
tasks = [];
|
||||
});
|
||||
|
||||
describe("Actions", () => {
|
||||
it("shows the empty state and creates a task via the editor dialog", async () => {
|
||||
render(<Actions />);
|
||||
|
||||
expect(screen.getByText("No action selected")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add action" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
|
||||
// Editor dialog opened (Name field is unique to the editor).
|
||||
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
|
||||
// Controlled input parity: name + default shell type flow through.
|
||||
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
|
||||
|
||||
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
|
||||
const saved = saveTaskMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Restart svc");
|
||||
expect(saved.task_type).toBe("shell");
|
||||
expect(saved.default_service_id).toBe("");
|
||||
});
|
||||
|
||||
it("disables the Run button until a run service is selected", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
// Selecting a saved task tab exposes the detail + Run control.
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
|
||||
const runButton = screen.getByRole("button", { name: "Run action" });
|
||||
expect(runButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("runs a task on the selected SSH task service", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", { name: "Run on SSH task service" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("option", { name: "Box" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(runTaskMutate).toHaveBeenCalledTimes(1);
|
||||
expect(runTaskMutate).toHaveBeenCalledWith({
|
||||
taskId: "t1",
|
||||
serviceId: "s1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Applications } from "../Applications";
|
||||
|
||||
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
|
||||
// so this slice-4 test stays focused on the migrated Applications shell and
|
||||
// does not pull the still-MUI DataGrid into the jsdom render.
|
||||
vi.mock("../Media", () => ({
|
||||
Media: () => <div data-testid="media-child">Media</div>,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "Main",
|
||||
enabled: true,
|
||||
services: ["jellyfin"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: [
|
||||
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({
|
||||
data: { movies: 10, series: 5, episodes: 100 },
|
||||
}),
|
||||
useLibraries: () => ({
|
||||
data: [
|
||||
{ library: "Movies", total: 10, movies: 10, series: 0 },
|
||||
{ library: "Shows", total: 5, movies: 0, series: 5 },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Applications", () => {
|
||||
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
|
||||
render(<Applications />);
|
||||
|
||||
// Library stats header.
|
||||
expect(screen.getByText("Library stats")).toBeInTheDocument();
|
||||
|
||||
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
|
||||
expect(screen.getByText("115")).toBeInTheDocument();
|
||||
expect(screen.getByText("Episodes")).toBeInTheDocument();
|
||||
|
||||
// Library rows render their per-library totals (unique strings).
|
||||
expect(
|
||||
screen.getByText(/Total 10 · Movies 10 · Series 0/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Total 5 · Movies 0 · Series 5/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Tabs present.
|
||||
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
|
||||
|
||||
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
|
||||
expect(screen.getByTestId("media-child")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowser } from "../FileBrowser.impl";
|
||||
import type { DirectoryListing, MonitoringMachine } from "../../types";
|
||||
|
||||
// usePersistentState (browserState) reads/writes localStorage; clear between tests
|
||||
// so the selectedPath / currentDir state never leaks across cases.
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
function machineFixture(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["files", "monitoring"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
notes: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function listingFixture(
|
||||
entries: {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}[],
|
||||
): DirectoryListing {
|
||||
return { path: "/", entries, count: entries.length };
|
||||
}
|
||||
|
||||
let listing: DirectoryListing;
|
||||
let machines: MonitoringMachine[];
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useFiles", () => ({
|
||||
useDirectoryListing: () => ({
|
||||
data: listing,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
|
||||
useJobTemplates: () => ({ data: [] }),
|
||||
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: machines }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
machines = [machineFixture()];
|
||||
listing = listingFixture([
|
||||
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
|
||||
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
|
||||
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
||||
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => h.textContent);
|
||||
// The leading selection column header is empty (checkbox); the 5 data
|
||||
// columns are Type, Name, Ext, Size, Modified in that order.
|
||||
expect(headers).toEqual(
|
||||
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
|
||||
);
|
||||
expect(headers.filter((h) => h === "Type").length).toBe(1);
|
||||
expect(headers.filter((h) => h === "Modified").length).toBe(1);
|
||||
});
|
||||
|
||||
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
// The selected-file path surfaces in the Browser status caption once chosen.
|
||||
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByText("video.mkv"));
|
||||
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
|
||||
|
||||
// A recognized video file enters the ffprobe branch; with empty ffprobe
|
||||
// data it shows the "No ffprobe data available." status (proving the
|
||||
// selected file routed into the Media info preview flow).
|
||||
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
await userEvent.click(screen.getByText("movies"));
|
||||
// After navigating into /movies, the status caption shows the new cwd and
|
||||
// NO "Selected:" segment (directories are opened, not selected for preview).
|
||||
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Media } from "../Media";
|
||||
import type {
|
||||
MediaIndexStatus,
|
||||
MediaItem,
|
||||
MediaQueryResponse,
|
||||
MonitoringMachine,
|
||||
} from "../../types";
|
||||
|
||||
// Shared navigate mock so the row-click test can assert the call. The vi.mock
|
||||
// factory is hoisted above this const, but it only closes over `navigate`
|
||||
// lazily (the arrow runs at render time, well after init) — no TDZ access.
|
||||
const navigate = vi.fn();
|
||||
|
||||
function machineFixture(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["jellyfin", "monitoring"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
notes: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statusFixture(
|
||||
overrides: Partial<MediaIndexStatus> = {},
|
||||
): MediaIndexStatus {
|
||||
return {
|
||||
exists: true,
|
||||
item_count: 2,
|
||||
updated_at: 1,
|
||||
updated_at_label: "now",
|
||||
build_duration_seconds: null,
|
||||
build_running: false,
|
||||
build_stage: "",
|
||||
build_message: "",
|
||||
build_progress: null,
|
||||
build_items_processed: 0,
|
||||
build_items_total: 0,
|
||||
build_current_library: "",
|
||||
build_library_index: 0,
|
||||
build_libraries_total: 0,
|
||||
build_library_progress: null,
|
||||
build_library_items_processed: 0,
|
||||
build_library_items_total: 0,
|
||||
build_elapsed_seconds: null,
|
||||
build_eta_seconds: null,
|
||||
build_library_elapsed_seconds: null,
|
||||
build_library_eta_seconds: null,
|
||||
build_cancel_requested: false,
|
||||
build_pid: null,
|
||||
build_error: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
series: "",
|
||||
season: "",
|
||||
episode: null,
|
||||
type: "Movie",
|
||||
year: 2010,
|
||||
runtime_min: 148,
|
||||
size: "12.4 GB",
|
||||
bitrate: "35.0 Mbps",
|
||||
hdr: "HDR10",
|
||||
video: "HEVC",
|
||||
resolution: "4K",
|
||||
date_added: "2024-01-01",
|
||||
library: "Movies",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let status: MediaIndexStatus;
|
||||
let queryResult: MediaQueryResponse;
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigate,
|
||||
useSearchParams: () => [
|
||||
new URLSearchParams("jellyfin_service_id=jfs1"),
|
||||
vi.fn(),
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMedia", () => ({
|
||||
useMediaStatus: () => ({ data: status }),
|
||||
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
|
||||
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: [
|
||||
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({ data: undefined }),
|
||||
useLibraries: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
navigate.mockClear();
|
||||
status = statusFixture();
|
||||
queryResult = {
|
||||
items: [
|
||||
mediaItem({
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
}),
|
||||
mediaItem({
|
||||
id: "2",
|
||||
title: "Matrix",
|
||||
path: "/media/movies/Matrix.mkv",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
|
||||
|
||||
const toggleable = screen
|
||||
.getAllByRole("menuitemcheckbox")
|
||||
.map((item) => (item.textContent ?? "").trim());
|
||||
expect([...toggleable].sort()).toEqual(
|
||||
[
|
||||
"title",
|
||||
"series",
|
||||
"season",
|
||||
"episode",
|
||||
"type",
|
||||
"year",
|
||||
"runtime_min",
|
||||
"size",
|
||||
"bitrate",
|
||||
"hdr",
|
||||
"video",
|
||||
"resolution",
|
||||
"date_added",
|
||||
"library",
|
||||
"path",
|
||||
].sort(),
|
||||
);
|
||||
// The leading selection column is never toggleable (enableHiding=false).
|
||||
expect(toggleable).toHaveLength(15);
|
||||
expect(toggleable).not.toContain("__select__");
|
||||
});
|
||||
|
||||
it("renders the 15 data column headers", () => {
|
||||
render(<Media />);
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => (h.textContent ?? "").trim());
|
||||
for (const expected of [
|
||||
"Title",
|
||||
"Series",
|
||||
"Season",
|
||||
"Episode",
|
||||
"Type",
|
||||
"Year",
|
||||
"Runtime",
|
||||
"Size",
|
||||
"Bitrate",
|
||||
"HDR",
|
||||
"Video codec",
|
||||
"Resolution",
|
||||
"Date added",
|
||||
"Library",
|
||||
"Path",
|
||||
]) {
|
||||
expect(headers).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("navigates to the file browser at the item path on row click", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByText("Inception"));
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT navigate when toggling a row selection checkbox", async () => {
|
||||
render(<Media />);
|
||||
|
||||
const firstCheckbox = screen.getAllByRole("checkbox", {
|
||||
name: "Select row",
|
||||
})[0];
|
||||
await userEvent.click(firstCheckbox);
|
||||
expect(firstCheckbox).toBeChecked();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the server-driven pagination total + page controls", () => {
|
||||
render(<Media />);
|
||||
|
||||
// DataTable manual-pagination footer surfaces the server total + pager.
|
||||
// ("Page 1 of 1" also appears in the page caption, so match all and assert
|
||||
// the pager footer text is present alongside the unique total.)
|
||||
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables Build index while a build is running", () => {
|
||||
status = statusFixture({ build_running: true });
|
||||
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
|
||||
// Stop + Force stop surface only while running.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Stop build" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Force stop" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UsersPage } from "../UsersPage.impl";
|
||||
import { TooltipProvider } from "../../components/ui/tooltip";
|
||||
import type {
|
||||
NowPlayingSession,
|
||||
UserDirectoryItem,
|
||||
UserDirectoryResponse,
|
||||
} from "../../types";
|
||||
|
||||
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
|
||||
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
|
||||
beforeEach(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
// The compose formatting actions defer a focus/selection restore via
|
||||
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
|
||||
// synchronously, so make it synchronous so the slice-6b compose test can
|
||||
// observe the html-body value update.
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
});
|
||||
|
||||
// Keep the drawer's nested session panel out of the DOM under test.
|
||||
vi.mock("../../components/SessionActivityPanel", () => ({
|
||||
SessionActivityPanel: ({
|
||||
selectedUserLabel,
|
||||
}: {
|
||||
selectedUserLabel: string;
|
||||
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
|
||||
}));
|
||||
|
||||
let users: UserDirectoryItem[] = [];
|
||||
let activity: NowPlayingSession[] = [];
|
||||
|
||||
function directoryResponse(): UserDirectoryResponse {
|
||||
return {
|
||||
items: users,
|
||||
total: users.length,
|
||||
jellyseerr_configured: true,
|
||||
jellyseerr_available: true,
|
||||
jellyseerr_error: "",
|
||||
jellyseerr_jellyfin_user_count: 0,
|
||||
jellyseerr_user_count: 0,
|
||||
enriched_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../hooks/useUsers", () => ({
|
||||
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
|
||||
}));
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useActivity: () => ({ data: activity }),
|
||||
}));
|
||||
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
|
||||
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
|
||||
}));
|
||||
vi.mock("../../hooks/useSendUserMessage", () => ({
|
||||
useSendUserMessage: () => ({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
reset: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
|
||||
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
|
||||
let currentParams: Record<string, string> = {};
|
||||
const setSearchParams = vi.fn((next: Record<string, string>) => {
|
||||
currentParams = { ...next };
|
||||
});
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
|
||||
}));
|
||||
|
||||
function userFixture(
|
||||
overrides: Partial<UserDirectoryItem> = {},
|
||||
): UserDirectoryItem {
|
||||
return {
|
||||
jellyfin_id: "u1",
|
||||
username: "alice",
|
||||
display_name: "Alice",
|
||||
email: "alice@example.com",
|
||||
email_source: "jellyfin",
|
||||
avatar: "",
|
||||
avatar_source: "",
|
||||
contactable: true,
|
||||
source: "jellyfin",
|
||||
source_summary: "",
|
||||
name_source: "jellyfin",
|
||||
access_source: "jellyfin",
|
||||
jellyseerr_user_id: null,
|
||||
jellyseerr_username: "",
|
||||
user_type: 1,
|
||||
user_type_label: "User",
|
||||
role: "admin",
|
||||
permissions: 1,
|
||||
permissions_label: "Administrator",
|
||||
request_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
users = [];
|
||||
activity = [];
|
||||
currentParams = {};
|
||||
setSearchParams.mockClear();
|
||||
});
|
||||
|
||||
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
|
||||
it("renders the directory table and metric counts", () => {
|
||||
users = [userFixture()];
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText("Total users")).toBeInTheDocument();
|
||||
expect(screen.getByText("User list")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles row selection and reflects the selected-count badge", async () => {
|
||||
users = [
|
||||
userFixture({ jellyfin_id: "u1" }),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
|
||||
// Selection-across-pagination: toggling a row updates the selected-id set.
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
|
||||
// Toggling again removes it (the set survives, membership flips).
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects all visible rows via the header select-all checkbox", async () => {
|
||||
users = [
|
||||
userFixture({ jellyfin_id: "u1" }),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select all visible users" }),
|
||||
);
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
|
||||
users = [userFixture({ jellyfin_id: "u1" })];
|
||||
render(<UsersPage />);
|
||||
|
||||
// Clicking the row body (not the checkbox) opens the detail drawer.
|
||||
await userEvent.click(screen.getByText("Alice"));
|
||||
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
|
||||
});
|
||||
|
||||
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
|
||||
users = [
|
||||
userFixture({
|
||||
jellyfin_id: "u1",
|
||||
username: "alice",
|
||||
display_name: "Alice",
|
||||
}),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
activity = [
|
||||
{
|
||||
user: "alice",
|
||||
title: "Movie",
|
||||
type: "Movie",
|
||||
state: "playing",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "Web",
|
||||
session_id: "s1",
|
||||
},
|
||||
{
|
||||
user: "bob",
|
||||
title: "Show",
|
||||
type: "Episode",
|
||||
state: "paused",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "TV",
|
||||
session_id: "s2",
|
||||
},
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
|
||||
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
|
||||
"success",
|
||||
);
|
||||
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the user detail drawer (Sheet) when a user is selected", () => {
|
||||
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||
currentParams = { user: "u1" };
|
||||
render(<UsersPage />);
|
||||
|
||||
// buildUserDrawerModel title = display name; rendered as the drawer heading.
|
||||
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
|
||||
// Drawer sections (identity / contact actions) + the activity panel render.
|
||||
expect(screen.getByText("Identity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Contact actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
|
||||
it("opens compose and inserts bold markup into the html body", async () => {
|
||||
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
|
||||
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsersPage />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Select a deliverable user so the "Message selected" button enables.
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Message selected" }),
|
||||
);
|
||||
|
||||
// Compose dialog opens (shadcn Dialog family).
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Message selected users" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Bold action wraps the cursor selection in <strong></strong> via the
|
||||
// preserved insertMarkup helper (markup insertion actions parity).
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
|
||||
|
||||
const body = screen.getByRole("textbox", {
|
||||
name: "HTML message body",
|
||||
}) as HTMLTextAreaElement;
|
||||
expect(body.value).toContain("<strong>");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user