Rebase services-as-hub-ia onto mobile-responsive-parity

Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
This commit is contained in:
Developer
2026-06-26 20:55:00 +00:00
parent b583d5a365
commit 01527ae4f0
75 changed files with 5343 additions and 4926 deletions
@@ -0,0 +1,456 @@
/**
* ActionsTab — operational content for the ssh_tasks service page.
*
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
* provides the active ssh_tasks service id, which is used as the default run
* service. The page-level header is removed (the service page provides it).
* The task editor dialog, saved-task rail, and run history are preserved.
*/
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 { 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";
type ActionTab = "new" | string;
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,
onChange,
}: {
task: SavedTaskInput;
onChange: (task: SavedTaskInput) => void;
}) {
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>
</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>
<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,
onClose,
onChange,
onSave,
onDelete,
}: {
open: boolean;
task: SavedTaskInput;
baseline: SavedTaskInput;
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 this SSH task service instance.
</DialogDescription>
</DialogHeader>
<TaskEditor task={task} 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 ActionsTab({ instance }: { instance: ServiceInstance }) {
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 [editOpen, setEditOpen] = useState(false);
// Default to this instance's service id for task runs.
const runServiceId = instance.id;
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 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);
};
return (
<div className="flex flex-col gap-4">
{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={() => openEdit(emptyTask())}
>
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">
{selectedTask ? (
<SectionCard
title={selectedTask.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(selectedTask))}
>
Edit
</Button>
<Button
disabled={runTask.isPending}
onClick={async () => {
await runTask.mutateAsync({
taskId: selectedTask.id,
serviceId: runServiceId,
});
}}
>
{runTask.isPending ? "Running..." : "Run action"}
</Button>
</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>
) : (
<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."
>
{tasks[0] && (
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
Select first action
</Button>
)}
</SectionCard>
)}
</div>
</div>
<TaskDialog
open={editOpen}
task={draft}
baseline={draftBaseline}
onClose={() => setEditOpen(false)}
onChange={setDraft}
onSave={saveDraft}
onDelete={
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
}
/>
</div>
);
}
@@ -0,0 +1,188 @@
/**
* Alertmanager Alerts tab (spec R2.4, R8.2).
*
* Lifts the Alertmanager alerts content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Renders the active-alert
* summary (total + by severity) and the expandable alert list.
*
* The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global /
* first-configured for now — they don't accept a service_id yet. Wiring
* `instance.id` into them is a documented follow-up once the hooks gain the
* parameter. The `instance` prop is accepted for future scoping.
*/
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
import {
useAlertmanagerAlerts,
useAlertmanagerStatus,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { AlertmanagerAlert, ServiceInstance } 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 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>
);
}
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
// Global / first-configured hooks for now; instance.id scoping is a
// follow-up (see file docstring).
void instance;
const {
data: alertsSummary,
isLoading: alertsLoading,
error: alertsError,
} = useAlertmanagerAlerts();
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: statusLoading
? "checking…"
: "unreachable";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bell className="h-4 w-4" />
Alertmanager {statusDetail}
</div>
{alertsError && (
<Alert variant="destructive">
<AlertTitle>Failed to load alerts</AlertTitle>
<AlertDescription>{alertsError.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Active Alerts ({alertsSummary?.total ?? 0})
</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 ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Inbox className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No active alerts</div>
<div className="max-w-md text-sm text-muted-foreground">
Everything looks quiet. Firing alerts will appear here.
</div>
</div>
) : (
<>
{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>
</div>
);
}
@@ -0,0 +1,815 @@
/**
* FilesTab — operational content for the ssh_tasks service page.
*
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
* instance id (from the `instance` prop) replaces the machine_id. The initial
* path is read from `?path=` search param for deep-link support (resolves the
* MediaTab row-click navigation from slice 5). Everything else — directory
* listing, path bar, ffprobe preview, job execution — is preserved.
*/
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../../hooks/useFiles";
import { usePersistentState } from "../../hooks/usePersistentState";
import { SectionCard } from "../../components/SectionCard";
import type { ServiceInstance } from "../../types";
import { useIsMobile } from "../../hooks/useIsMobile";
// Mobile card fields (mobile-parity pattern).
const fileCardFields: MobileCardField<DisplayRow>[] = [
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
{ key: "type", label: "Type", render: (r) => r.type },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
];
// --- Types (lifted verbatim) ---
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[];
}
// --- Helpers (lifted verbatim) ---
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));
}
// --- Column defs (lifted verbatim) ---
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,
},
];
// --- State + helpers (lifted) ---
const FILE_TAB_STATE_KEY = "manage.files.tabState";
type FileBrowserState = {
currentDir: string;
pathInput: string;
selectedPath: string | null;
selectedJob: string;
};
function defaultFileBrowserState(): FileBrowserState {
return {
currentDir: "/",
pathInput: "/",
selectedPath: null,
selectedJob: "",
};
}
// --- Ffprobe rendering (lifted verbatim) ---
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((s) => s.codec_type === "video");
const audioStreams = streams.filter((s) => s.codec_type === "audio");
const subtitleStreams = streams.filter((s) => s.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>
);
}
// --- Component ---
export function FilesTab({ instance }: { instance: ServiceInstance }) {
const isMobile = useIsMobile();
const machineId = instance.id;
const [searchParams] = useSearchParams();
const requestedPath = searchParams.get("path");
const [columnVisibility, setColumnVisibility] = useState<
Record<string, boolean>
>({});
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
`${FILE_TAB_STATE_KEY}.${instance.id}`,
() => {
const path = requestedPath ?? "/";
const selectedPath =
path !== "/" && (isVideoFile(path) || path.includes("."))
? path.replace(/\/+$/, "")
: null;
const currentDir = selectedPath
? selectedPath.replace(/\/[^/]+$/, "") || "/"
: path.replace(/\/+$/, "") || "/";
return {
...defaultFileBrowserState(),
currentDir,
pathInput: path || currentDir,
selectedPath,
};
},
);
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
setBrowserState((current) => ({ ...current, ...patch }));
const {
data: listing,
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir, machineId);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
error: ffprobeError,
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
machineId,
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob(machineId);
const navigate = (path: string) => {
updateBrowserState({
currentDir: path,
pathInput: path,
selectedPath: null,
});
};
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,
});
}
}
const handleRowClick = (row: DisplayRow) => {
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
return;
}
updateBrowserState({
selectedPath: row.path,
currentDir,
pathInput: row.path,
});
};
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">
<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">
{isMobile ? (
<div className="p-4">
<MobileCardRow
rows={rows}
fields={fileCardFields}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
/>
</div>
) : (
<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 ? (
<Alert>
<AlertDescription>Loading ffprobe data...</AlertDescription>
</Alert>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Alert>
<AlertDescription>No ffprobe data available.</AlertDescription>
</Alert>
)
) : (
<Alert>
<AlertDescription>
Select a video file to view ffprobe details.
</AlertDescription>
</Alert>
)
) : (
<Alert>
<AlertDescription>
Select a file in Browser to view ffprobe details.
</AlertDescription>
</Alert>
)}
</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>
) : (
<Alert>
<AlertDescription>
Select a file in Browser to run jobs.
</AlertDescription>
</Alert>
)}
</SectionCard>
</div>
);
}
@@ -0,0 +1,87 @@
/**
* JobsTab — operational content for the backups service page.
*
* Lifted from the old top-level `components/BackupsPage.tsx`. The three
* sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
*
* NOTE: the backup hooks currently query globally (no service_id filter).
* The backend gained `service_id` attribution in Slice 3, but the hooks don't
* yet accept a serviceId param. This tab shows ALL backups data for now;
* per-instance scoping by `instance.id` is a follow-up once the hooks gain the
* parameter.
*/
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
useAcknowledgeAlert,
useBackupAlerts,
useBackupJobs,
useBackupRuns,
} from "../../hooks/useBackups";
import BackupAlertsTable from "../../components/BackupAlertsTable";
import BackupJobsTable from "../../components/BackupJobsTable";
import BackupRunsTable from "../../components/BackupRunsTable";
import type { ServiceInstance } from "../../types";
export function JobsTab({ instance }: { instance: ServiceInstance }) {
// instance.id is not yet used — backup hooks query globally (see file
// docstring). Per-instance scoping is a follow-up.
void instance;
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">
<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>
);
}
@@ -0,0 +1,194 @@
/**
* Grafana Links tab (spec R2.4, R8.2).
*
* Lifts the Grafana deep-link content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Shows service health + the
* configured Grafana deep-links (node-exporter dashboard, Loki logs per
* machine).
*
* The hooks (useGrafanaStatus, useMonitoringMachines) are global /
* first-configured for now. Wiring `instance.id` into the status hook is a
* follow-up. The machine links use the configured Grafana base_url from the
* instance's config.
*/
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
import {
useGrafanaStatus,
useMonitoringMachines,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ServiceInstance } from "../../types";
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 LinksTab({ instance }: { instance: ServiceInstance }) {
const { data: status, isLoading, error } = useGrafanaStatus();
const { data: machines = [], isLoading: machinesLoading } =
useMonitoringMachines();
const [selectedMachineId, setSelectedMachineId] = useState("");
const grafanaBaseUrl =
(instance.config?.base_url as string | undefined) ?? "";
const selectedMachine = useMemo(
() =>
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
[machines, selectedMachineId],
);
const nodeExporterDashboardUrl = useMemo(() => {
if (!selectedMachine || !grafanaBaseUrl) return "";
const inst = `${selectedMachine.host || "localhost"}:9100`;
return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
}, [selectedMachine, grafanaBaseUrl]);
const logsUrl = useMemo(() => {
if (!selectedMachine || !grafanaBaseUrl) return "";
const container =
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
JSON.stringify({
datasource: "Loki",
queries: [{ refId: "A", expr: `{container="${container}"}` }],
range: { from: "now-1h", to: "now" },
}),
)}`;
}, [selectedMachine, grafanaBaseUrl]);
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: isLoading
? "checking…"
: error
? "unreachable"
: "not configured";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Gauge className="h-4 w-4" />
Grafana {statusDetail}
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to reach Grafana</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
<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>
{machines.length > 0 ? (
<Select
value={selectedMachine?.id ?? ""}
onValueChange={setSelectedMachineId}
disabled={machinesLoading}
>
<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>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : selectedMachine && grafanaBaseUrl ? (
<>
<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}
/>
</>
) : !grafanaBaseUrl ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Gauge className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No Grafana base URL configured</div>
<div className="max-w-md text-sm text-muted-foreground">
Add a Grafana service instance to enable deep-links to
dashboards and logs.
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/services">Open Services</Link>
</Button>
</div>
) : (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<ServerOff className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No machine selected</div>
<div className="max-w-md text-sm text-muted-foreground">
Add monitoring machines in Settings to see Grafana drill-down
links.
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,563 @@
/**
* MediaTab — operational content for the Jellyfin service page.
*
* Lifted from the old top-level `pages/Media.tsx`. The service-id source is
* changed from URL search params to the `instance` prop (the active service
* instance selected on the service page). The service-selection dropdown and
* its URL-sync effect are removed; everything else is preserved verbatim.
*/
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import type {
ColumnDef,
OnChangeFn,
PaginationState,
RowSelectionState,
VisibilityState,
} from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { TablePagination } from "@/components/ui/table-pagination";
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 { useIsMobile } from "../../hooks/useIsMobile";
import type { MediaItem, ServiceInstance } from "../../types";
import { useCounts, useLibraries } from "../../hooks/useDashboard";
import { useServiceInstances } from "../../hooks/useServices";
// --- Format helpers (lifted verbatim from Media.tsx) ---
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`;
}
// --- Column definitions (lifted verbatim) ---
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" },
];
function getMediaRowId(row: MediaItem): string {
return row.path;
}
// Mobile card fields (mobile-parity pattern): title primary + 4 key fields.
const mediaCardFields: MobileCardField<MediaItem>[] = [
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{ key: "hdr", label: "HDR", render: (r) => r.hdr || "-" },
{ key: "library", label: "Library", render: (r) => r.library || "-" },
{
key: "year",
label: "Year",
render: (r) => (r.year != null ? String(r.year) : "-"),
},
];
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
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;
}
// --- Small UI helpers (lifted verbatim) ---
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>
);
}
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))} />;
}
// --- Component ---
export function MediaTab({ instance }: { instance: ServiceInstance }) {
const navigate = useNavigate();
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
const isSmall = usePrefersSmallScreen();
const isMobile = useIsMobile();
const serviceId = instance.id;
const { data: counts } = useCounts(serviceId);
const { data: libraries } = useLibraries(serviceId);
const { data: status } = useMediaStatus(serviceId);
const buildIndex = useBuildIndex(serviceId);
const stopBuildIndex = useStopBuildIndex(serviceId);
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY,
defaultMediaTabState,
);
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>({});
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
search,
hdr_filter: hdrFilter,
sort_key: sortKey,
sort_order: sortOrder,
limit: pageSize,
offset,
jellyfinServiceId: serviceId,
enabled: status?.exists ?? false,
});
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;
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 };
});
};
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]);
const handleRowClick = (row: MediaItem) => {
// Navigate to the ssh_tasks service page with the path query param.
// If an ssh_tasks instance exists, open its Files tab; otherwise land
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
const sshInstance = sshServices.find((s) => s.enabled);
const base = sshInstance
? `/services/ssh_tasks/${sshInstance.id}`
: "/services/ssh_tasks";
navigate(`${base}?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">
{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 &&
(isMobile ? (
<div className="rounded-lg border bg-card">
<div className="p-4">
<MobileCardRow
rows={queryResult?.items ?? []}
fields={mediaCardFields}
getRowId={getMediaRowId}
onRowClick={handleRowClick}
/>
</div>
{queryResult && (
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
pageSizeOptions={[50, 100, 200]}
totalRows={total}
pageCount={totalPages}
onPaginationChange={handlePaginationChange}
className="p-4"
/>
)}
</div>
) : (
<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>
);
}
@@ -0,0 +1,129 @@
/** MessagingTab — compose email to Authentik users via the mail queue. */
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
useAuthentikUsers,
useSendAuthentikMessage,
} from "../../hooks/useAuthentik";
import type { ServiceInstance } from "../../types";
const DEFAULT_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
export function MessagingTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState("");
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
const [subject, setSubject] = useState("");
const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY);
const { data } = useAuthentikUsers(instance.id, {
search,
page: 1,
page_size: 100,
});
const sendMessage = useSendAuthentikMessage(instance.id);
const users = (data?.items ?? []).filter((u) => u.email);
const error = data?.error;
function toggleEmail(email: string) {
setSelectedEmails((prev) => {
const next = new Set(prev);
if (next.has(email)) next.delete(email);
else next.add(email);
return next;
});
}
function handleSend() {
if (!subject.trim() || selectedEmails.size === 0) return;
sendMessage.mutate({
recipient_emails: Array.from(selectedEmails),
subject: subject.trim(),
html_body: htmlBody,
});
}
const canSend =
subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending;
return (
<div className="flex flex-col gap-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{sendMessage.data ? (
<Alert>
<AlertDescription>
{sendMessage.data.status === "queued"
? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).`
: `Error: ${sendMessage.data.error ?? "unknown"}`}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="msg-search">Find recipients</Label>
<Input
id="msg-search"
placeholder="Search users to add as recipients…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
{users.length > 0 ? (
<div className="flex flex-wrap gap-2">
{users.slice(0, 20).map((user) => (
<Button
key={user.pk}
variant={selectedEmails.has(user.email) ? "default" : "outline"}
size="sm"
onClick={() => toggleEmail(user.email)}
>
{user.name || user.username}
</Button>
))}
</div>
) : null}
{selectedEmails.size > 0 ? (
<p className="text-sm text-muted-foreground">
{selectedEmails.size} recipient
{selectedEmails.size === 1 ? "" : "s"} selected.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-subject">Subject</Label>
<Input
id="msg-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-body">Message (HTML)</Label>
<Textarea
id="msg-body"
rows={8}
value={htmlBody}
onChange={(e) => setHtmlBody(e.target.value)}
className="font-mono text-xs"
/>
</div>
<div>
<Button onClick={handleSend} disabled={!canSend}>
{sendMessage.isPending ? "Sending…" : "Send message"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
/**
* Prometheus Metrics tab (spec R2.4, R8.2).
*
* Lifts the Prometheus status + targets content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Shows service health and
* the Node Exporter scrape-targets list.
*
* The hooks (usePrometheusStatus, usePrometheusTargets) are global /
* first-configured for now. Wiring `instance.id` is a follow-up.
*/
import { Radio } from "lucide-react";
import {
usePrometheusStatus,
usePrometheusTargets,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import type { PrometheusTarget, ServiceInstance } from "../../types";
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>
);
}
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
// Global / first-configured hooks for now; instance.id scoping is a
// follow-up (see file docstring).
void instance;
const {
data: status,
isLoading: statusLoading,
error: statusError,
} = usePrometheusStatus();
const {
data: targets,
isLoading: targetsLoading,
error: targetsError,
} = usePrometheusTargets();
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: statusLoading
? "checking…"
: "unreachable";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Radio className="h-4 w-4" />
Prometheus {statusDetail}
</div>
{statusError && (
<Alert variant="destructive">
<AlertTitle>Failed to reach Prometheus</AlertTitle>
<AlertDescription>{statusError.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Node Exporter Targets ({targets?.length ?? 0})
</CardTitle>
</CardHeader>
<CardContent>
{targetsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !targets || targets.length === 0 ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Radio className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No Node Exporter targets</div>
<div className="max-w-md text-sm text-muted-foreground">
Enable Node Exporter on an SSH machine in Settings to populate
Prometheus scrape targets.
</div>
</div>
) : (
<TargetsTable targets={targets} />
)}
</CardContent>
</Card>
{targetsError && (
<Alert variant="destructive">
<AlertTitle>Failed to load targets</AlertTitle>
<AlertDescription>{targetsError.message}</AlertDescription>
</Alert>
)}
</div>
);
}
@@ -0,0 +1,64 @@
/**
* RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
*
* Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
* configured, it shows the URL and a placeholder (no requests backend endpoint
* exists yet — building one is out of scope for this slice). When not
* configured, it shows an empty-state CTA directing the user to add the fields
* to the Jellyfin config.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ExternalLink } from "lucide-react";
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
const jellyseerrUrl = String(
(instance.config as Record<string, unknown>).jellyseerr_url ?? "",
).trim();
const jellyseerrApiKey = String(
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
).trim();
if (!jellyseerrUrl || !jellyseerrApiKey) {
return (
<Alert>
<AlertDescription>
Jellyseerr is not configured for this Jellyfin instance. Add
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_url
</code>
and
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_api_key
</code>
to the Jellyfin config (Config tab) to enable request management.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Jellyseerr</h3>
<a
href={jellyseerrUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
{jellyseerrUrl}
<ExternalLink className="size-3.5" />
</a>
</div>
<Alert>
<AlertDescription>
Jellyseerr is configured. The requests view will show pending and
recently fulfilled media requests. (This surface is under
development.)
</AlertDescription>
</Alert>
</div>
);
}
@@ -0,0 +1,136 @@
/** UsersTab — Authentik user directory for the Authentik service page. */
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { ServiceInstance } from "../../types";
import { useAuthentikUsers } from "../../hooks/useAuthentik";
const PAGE_SIZE = 25;
export function UsersTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [committedSearch, setCommittedSearch] = useState("");
const { data, isLoading } = useAuthentikUsers(instance.id, {
search: committedSearch,
page,
page_size: PAGE_SIZE,
});
const error = data?.error;
const users = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
function handleSearch() {
setPage(1);
setCommittedSearch(search);
}
return (
<div className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex items-center gap-2">
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
className="max-w-xs"
/>
<Button variant="outline" onClick={handleSearch}>
Search
</Button>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-24">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && users.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
Loading
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
No users found.
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.pk}>
<TableCell className="font-medium">
{user.name || "—"}
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell className="text-muted-foreground">
{user.email || "—"}
</TableCell>
<TableCell>
<Badge variant={user.is_active ? "default" : "secondary"}>
{user.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{total > 0 ? (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} user{total === 1 ? "" : "s"} · Page {page} of {totalPages}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</Button>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { ActionsTab } from "../ActionsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "ssh-1",
service_type: "ssh_tasks",
name: "Storage Server",
config: {},
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useSettings", () => ({
useTasks: () => ({
data: [
{
id: "t1",
name: "Disk usage",
task_type: "shell",
content: "df -h",
enabled: true,
default_service_id: "",
notes: "",
},
],
}),
useTaskRuns: () => ({ data: { items: [] } }),
useSaveTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteTask: () => ({ mutate: vi.fn(), isPending: false }),
useRunTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function renderTab() {
return render(
<MemoryRouter>
<ActionsTab instance={instance} />
</MemoryRouter>,
);
}
describe("ActionsTab", () => {
it("renders the saved-actions rail and task detail", () => {
renderTab();
expect(screen.getByText("Saved actions")).toBeInTheDocument();
expect(screen.getByText("Disk usage")).toBeInTheDocument();
});
it("renders the Add action button", () => {
renderTab();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { AlertsTab } from "../AlertsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "am-1",
service_type: "alertmanager",
name: "Main Alertmanager",
config: { base_url: "https://am.example.com", timeout_seconds: 5 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
useAlertmanagerAlerts: () => ({
data: {
total: 2,
by_severity: { critical: 1, warning: 1 },
alerts: [
{
name: "DiskFull",
severity: "critical",
category: "disk",
job_name: "node",
summary: "Disk is almost full",
description: "Disk usage above 90%",
active_since: "2026-06-26T10:00:00Z",
state: "firing",
labels: { instance: "node1" },
},
{
name: "HighCpu",
severity: "warning",
category: "cpu",
job_name: "node",
summary: "High CPU usage",
description: "",
active_since: "2026-06-26T09:00:00Z",
state: "firing",
labels: {},
},
],
},
isLoading: false,
error: null,
}),
useAlertmanagerStatus: () => ({
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
isLoading: false,
error: null,
}),
}));
describe("AlertsTab", () => {
it("renders the alert count and alert names", () => {
render(<AlertsTab instance={instance} />);
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
expect(screen.getByText("DiskFull")).toBeInTheDocument();
expect(screen.getByText("HighCpu")).toBeInTheDocument();
});
it("renders severity badges", () => {
render(<AlertsTab instance={instance} />);
expect(screen.getByText("critical")).toBeInTheDocument();
expect(screen.getByText("warning")).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { FilesTab } from "../FilesTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "ssh-1",
service_type: "ssh_tasks",
name: "Storage Server",
config: {},
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: {
count: 2,
entries: [
{ name: "movies", type: "d", size: 0, mtime: 1700000000 },
{ name: "video.mkv", type: "f", size: 1024, mtime: 1700000000 },
],
},
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
}));
vi.mock("../../../hooks/usePersistentState", () => ({
usePersistentState: vi.fn((_key: string, initial: () => unknown) => [
initial(),
vi.fn(),
]),
}));
function renderTab(path = "/services/ssh_tasks/ssh-1") {
return render(
<MemoryRouter initialEntries={[path]}>
<FilesTab instance={instance} />
</MemoryRouter>,
);
}
describe("FilesTab", () => {
it("renders the directory listing with instance-scoped hooks", () => {
renderTab();
expect(screen.getByText("movies")).toBeInTheDocument();
expect(screen.getByText("video.mkv")).toBeInTheDocument();
});
it("renders the path bar and browser section", () => {
renderTab();
expect(screen.getByText("Browser")).toBeInTheDocument();
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { JobsTab } from "../JobsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "bkp-1",
service_type: "backups",
name: "Main Backups",
config: { ingestion_label: "default" },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useBackups", () => ({
useBackupJobs: () => ({
data: [
{
id: "job-1",
name: "nightly",
source: "/data",
target: "s3://bucket",
schedule_interval_seconds: 86400,
created_at: 1_700_000_000,
},
],
isLoading: false,
}),
useBackupRuns: () => ({
data: [],
isLoading: false,
}),
useBackupAlerts: () => ({
data: [],
isLoading: false,
}),
useAcknowledgeAlert: () => ({ mutate: vi.fn() }),
}));
function renderTab() {
return render(<JobsTab instance={instance} />);
}
describe("JobsTab", () => {
it("renders the Jobs, Runs, and Alerts sub-tabs", () => {
renderTab();
expect(screen.getByRole("tab", { name: "Jobs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Runs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /Alerts/ })).toBeInTheDocument();
});
it("renders the backup job name in the Jobs tab", () => {
renderTab();
expect(screen.getByText("nightly")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { LinksTab } from "../LinksTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "graf-1",
service_type: "grafana",
name: "Main Grafana",
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
useGrafanaStatus: () => ({
data: {
up: true,
version: "11.0.0",
service_id: "graf-1",
name: "Main Grafana",
},
isLoading: false,
error: null,
}),
useMonitoringMachines: () => ({
data: [
{
id: "m1",
name: "storage",
mode: "ssh",
host: "10.0.0.5",
enabled: true,
services: [],
port: 22,
username: "admin",
},
],
isLoading: false,
}),
}));
describe("LinksTab", () => {
it("renders the Grafana version and machine dashboard links", () => {
render(<LinksTab instance={instance} />);
expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
expect(screen.getByText(/storage logs/)).toBeInTheDocument();
});
it("renders open-in-grafana link buttons", () => {
render(<LinksTab instance={instance} />);
const links = screen.getAllByText("Open in Grafana");
expect(links).toHaveLength(2);
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MediaTab } from "../MediaTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "jellyfin-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useMedia", () => ({
useMediaStatus: () => ({
data: { exists: true, item_count: 42, updated_at_label: "today" },
}),
useMediaQuery: () => ({ data: { items: [], total: 0 }, isLoading: false }),
useBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
useStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
useForceStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("../../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 30 },
}),
useLibraries: () => ({ data: [{ id: "lib1" }] }),
}));
vi.mock("../../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }),
}));
vi.mock("../../../hooks/usePersistentState", () => ({
usePersistentState: () => [
{
search: "",
types: "Movie,Episode",
hdrFilter: "All",
sortKey: "title",
sortOrder: "Ascending",
offset: 0,
pageSize: 100,
columnVisibility: {},
},
vi.fn(),
],
}));
function renderTab() {
return render(
<MemoryRouter>
<MediaTab instance={instance} />
</MemoryRouter>,
);
}
describe("MediaTab", () => {
it("renders index status and build controls with instance-scoped data", () => {
renderTab();
expect(screen.getByText(/42 items/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Build index/i }),
).toBeInTheDocument();
});
it("renders library counts", () => {
renderTab();
expect(screen.getByText(/10 movies/)).toBeInTheDocument();
expect(screen.getByText(/5 series/)).toBeInTheDocument();
});
it("renders the filter card with search input", () => {
renderTab();
expect(screen.getByLabelText("Search")).toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MessagingTab } from "../MessagingTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "auth-1",
service_type: "authentik",
name: "Main Authentik",
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
secrets_set: { api_token: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useAuthentik", () => ({
useAuthentikUsers: vi.fn(() => ({
data: {
items: [
{
pk: 1,
username: "alice",
name: "Alice",
email: "alice@example.com",
is_active: true,
},
],
total: 1,
page: 1,
page_size: 100,
},
})),
useSendAuthentikMessage: vi.fn(() => ({
mutate: vi.fn(),
isPending: false,
data: undefined,
})),
}));
describe("MessagingTab", () => {
it("renders the compose form (subject, body, send)", () => {
render(<MessagingTab instance={instance} />);
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
expect(screen.getByLabelText("Message (HTML)")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Send message" }),
).toBeInTheDocument();
});
it("renders recipient toggle buttons from the directory", () => {
render(<MessagingTab instance={instance} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
});
});
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricsTab } from "../MetricsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "prom-1",
service_type: "prometheus",
name: "Main Prometheus",
config: { base_url: "https://prom.example.com", timeout_seconds: 10 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
usePrometheusStatus: () => ({
data: {
up: true,
version: "2.52.0",
service_id: "prom-1",
name: "Main Prometheus",
},
isLoading: false,
error: null,
}),
usePrometheusTargets: () => ({
data: [
{
targets: ["10.0.0.5:9100"],
labels: { instance: "storage", job: "node_exporter" },
},
],
isLoading: false,
error: null,
}),
}));
describe("MetricsTab", () => {
it("renders the Prometheus version and target list", () => {
render(<MetricsTab instance={instance} />);
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
});
it("renders the target count in the heading", () => {
render(<MetricsTab instance={instance} />);
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { RequestsTab } from "../RequestsTab";
import type { ServiceInstance } from "../../../types";
function makeInstance(config: Record<string, unknown>): ServiceInstance {
return {
id: "jellyfin-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config,
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
}
describe("RequestsTab", () => {
it("shows empty-state CTA when Jellyseerr is not configured", () => {
render(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
user_id: "u1",
})}
/>,
);
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
});
it("shows the configured Jellyseerr URL when both fields are set", () => {
render(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
jellyseerr_url: "https://requests.example.com",
jellyseerr_api_key: "secret-key",
})}
/>,
);
expect(
screen.getByText("https://requests.example.com"),
).toBeInTheDocument();
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
});
it("shows empty-state when only URL is set (missing api_key)", () => {
render(
<RequestsTab
instance={makeInstance({
jellyseerr_url: "https://requests.example.com",
})}
/>,
);
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { UsersTab } from "../UsersTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "auth-1",
service_type: "authentik",
name: "Main Authentik",
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
secrets_set: { api_token: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useAuthentik", () => ({
useAuthentikUsers: vi.fn(() => ({
data: {
items: [
{
pk: 1,
username: "alice",
name: "Alice",
email: "alice@example.com",
is_active: true,
},
{
pk: 2,
username: "bob",
name: "Bob",
email: "bob@example.com",
is_active: false,
},
],
total: 2,
page: 1,
page_size: 25,
},
isLoading: false,
})),
}));
describe("UsersTab", () => {
it("renders the directory table with users", () => {
render(<UsersTab instance={instance} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("bob")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Inactive")).toBeInTheDocument();
});
it("renders search input and pagination", () => {
render(<UsersTab instance={instance} />);
expect(screen.getByPlaceholderText("Search users…")).toBeInTheDocument();
expect(screen.getByText(/2 users/)).toBeInTheDocument();
expect(screen.getByText("Previous")).toBeInTheDocument();
expect(screen.getByText("Next")).toBeInTheDocument();
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Per-type content-tab descriptors for the service page skeleton.
*
* Each entry names a tab and its component. The service page renders
* `[Overview, ...contentTabs(type), Widgets, Config]`.
*/
import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import { OverviewTab } from "./stubs";
import { AlertsTab } from "./AlertsTab";
import { LinksTab } from "./LinksTab";
import { MetricsTab } from "./MetricsTab";
import { MediaTab } from "./MediaTab";
import { RequestsTab } from "./RequestsTab";
import { FilesTab } from "./FilesTab";
import { ActionsTab } from "./ActionsTab";
import { JobsTab } from "./JobsTab";
import { UsersTab } from "./UsersTab";
import { MessagingTab } from "./MessagingTab";
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
export interface ContentTab {
label: string;
Component: ServiceTabComponent;
}
/** Overview tab (shared across all service types). */
export const OVERVIEW_TAB: ContentTab = {
label: "Overview",
Component: OverviewTab,
};
/**
* Returns the type-specific content tabs for a service type.
* Types with no operational content return `[]` (only Overview + Widgets + Config).
*/
export function serviceContentTabs(serviceType: string): ContentTab[] {
switch (serviceType) {
case "jellyfin":
return [
{ label: "Media", Component: MediaTab },
{ label: "Requests", Component: RequestsTab },
];
case "ssh_tasks":
return [
{ label: "Files", Component: FilesTab },
{ label: "Actions", Component: ActionsTab },
];
case "backups":
return [{ label: "Jobs", Component: JobsTab }];
case "authentik":
return [
{ label: "Users", Component: UsersTab },
{ label: "Messaging", Component: MessagingTab },
];
case "alertmanager":
return [{ label: "Alerts", Component: AlertsTab }];
case "grafana":
return [{ label: "Links", Component: LinksTab }];
case "prometheus":
return [{ label: "Metrics", Component: MetricsTab }];
default:
return [];
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Service-page content tab stubs.
*
* Each stub renders a "coming soon" placeholder. Slices 59 replace these with
* real operational content lifted from the old top-level pages. All stubs accept
* an `instance` prop so the real implementations can scope queries by instance.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
function Stub({
label,
instance,
}: {
label: string;
instance: ServiceInstance;
}) {
return (
<Alert>
<AlertDescription>
{label} for {instance.name} coming soon.
</AlertDescription>
</Alert>
);
}
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Service overview" instance={instance} />;
}