diff --git a/frontend/src/pages/service-tabs/AlertsTab.tsx b/frontend/src/pages/service-tabs/AlertsTab.tsx
new file mode 100644
index 0000000..cd40c9e
--- /dev/null
+++ b/frontend/src/pages/service-tabs/AlertsTab.tsx
@@ -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 (
+
+
+
+
+
{alert.name}
+
+
+ {alert.severity}
+
+
+
+
+
+ {alert.summary || alert.description}
+
+ {alert.active_since && (
+
+ Since {new Date(alert.active_since).toLocaleString()}
+
+ )}
+
+
+
+
+ {alert.description && (
+
+ Description:{" "}
+ {alert.description}
+
+ )}
+
+ {alert.job_name && (
+
+ Job: {alert.job_name}
+
+ )}
+ {alert.category && (
+
+ Category: {alert.category}
+
+ )}
+
+ State: {alert.state}
+
+
+ Since:{" "}
+ {alert.active_since
+ ? new Date(alert.active_since).toLocaleString()
+ : "unknown"}
+
+
+ {alert.labels && Object.keys(alert.labels).length > 0 && (
+
+ {Object.entries(alert.labels).map(([key, value]) => (
+
+ {key}={value}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+
+ Alertmanager {statusDetail}
+
+
+ {alertsError && (
+
+ Failed to load alerts
+ {alertsError.message}
+
+ )}
+
+
+
+
+
+ Active Alerts ({alertsSummary?.total ?? 0})
+
+
+
+ {alertsLoading ? (
+
+
+
+
+
+ ) : !alertsSummary || alertsSummary.total === 0 ? (
+
+
+
No active alerts
+
+ Everything looks quiet. Firing alerts will appear here.
+
+
+ ) : (
+ <>
+ {alertsSummary.alerts.map((alert, idx) => (
+
+ ))}
+ {alertsSummary.total > alertsSummary.alerts.length && (
+
+ {alertsSummary.total - alertsSummary.alerts.length} more alert
+ {alertsSummary.total - alertsSummary.alerts.length === 1
+ ? ""
+ : "s"}{" "}
+ in Alertmanager
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/LinksTab.tsx b/frontend/src/pages/service-tabs/LinksTab.tsx
new file mode 100644
index 0000000..07ea98d
--- /dev/null
+++ b/frontend/src/pages/service-tabs/LinksTab.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+
+
+ Grafana {statusDetail}
+
+
+ {error && (
+
+ Failed to reach Grafana
+ {error.message}
+
+ )}
+
+
+
+
+
+ Machine Dashboard
+
+ {machines.length > 0 ? (
+
+ ) : null}
+
+
+ {isLoading ? (
+
+ ) : selectedMachine && grafanaBaseUrl ? (
+ <>
+
+
+ >
+ ) : !grafanaBaseUrl ? (
+
+
+
No Grafana base URL configured
+
+ Add a Grafana service instance to enable deep-links to
+ dashboards and logs.
+
+
+
+ ) : (
+
+
+
No machine selected
+
+ Add monitoring machines in Settings to see Grafana drill-down
+ links.
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/MetricsTab.tsx b/frontend/src/pages/service-tabs/MetricsTab.tsx
new file mode 100644
index 0000000..3adec82
--- /dev/null
+++ b/frontend/src/pages/service-tabs/MetricsTab.tsx
@@ -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 (
+
+ {targets.map((target, idx) => (
+
+
{target.targets.join(", ")}
+ {target.labels && Object.keys(target.labels).length > 0 && (
+
+ {Object.entries(target.labels).map(([key, value]) => (
+
+ {key}: {value}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+ Prometheus {statusDetail}
+
+
+ {statusError && (
+
+ Failed to reach Prometheus
+ {statusError.message}
+
+ )}
+
+
+
+
+
+ Node Exporter Targets ({targets?.length ?? 0})
+
+
+
+ {targetsLoading ? (
+
+
+
+
+ ) : !targets || targets.length === 0 ? (
+
+
+
No Node Exporter targets
+
+ Enable Node Exporter on an SSH machine in Settings to populate
+ Prometheus scrape targets.
+
+
+ ) : (
+
+ )}
+
+
+
+ {targetsError && (
+
+ Failed to load targets
+ {targetsError.message}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx
new file mode 100644
index 0000000..ec95414
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx
@@ -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();
+ expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
+ expect(screen.getByText("DiskFull")).toBeInTheDocument();
+ expect(screen.getByText("HighCpu")).toBeInTheDocument();
+ });
+
+ it("renders severity badges", () => {
+ render();
+ expect(screen.getByText("critical")).toBeInTheDocument();
+ expect(screen.getByText("warning")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx
new file mode 100644
index 0000000..8f9d4b6
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx
@@ -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();
+ 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();
+ const links = screen.getAllByText("Open in Grafana");
+ expect(links).toHaveLength(2);
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx
new file mode 100644
index 0000000..e2edce6
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx
@@ -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();
+ 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();
+ expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts
index 8f809f8..d169055 100644
--- a/frontend/src/pages/service-tabs/index.ts
+++ b/frontend/src/pages/service-tabs/index.ts
@@ -6,7 +6,10 @@
*/
import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
-import { AlertsTab, LinksTab, MetricsTab, OverviewTab } from "./stubs";
+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";
diff --git a/frontend/src/pages/service-tabs/stubs.tsx b/frontend/src/pages/service-tabs/stubs.tsx
index 523ea26..64de68d 100644
--- a/frontend/src/pages/service-tabs/stubs.tsx
+++ b/frontend/src/pages/service-tabs/stubs.tsx
@@ -27,15 +27,3 @@ function Stub({
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return ;
}
-
-export function AlertsTab({ instance }: { instance: ServiceInstance }) {
- return ;
-}
-
-export function LinksTab({ instance }: { instance: ServiceInstance }) {
- return ;
-}
-
-export function MetricsTab({ instance }: { instance: ServiceInstance }) {
- return ;
-}