+
+
+
+
Fleet
+
{running}/{containers.length}
+
containers running
+
+
+
Needs attention
+
0 ? "text-red-300" : "text-emerald-300"}`}>
+ {unhealthy.length}
+
+
stopped or unhealthy
+
+
+
Updates
+
0 ? "text-blue-300" : "text-slate-100"}`}>{updates}
+
available image updates
+
+
+
Resource pulse
+
{avgCpu.toFixed(1)}%
+
+ avg CPU, {totalMemLimit > 0 ? `${Math.round((totalMemUsage / totalMemLimit) * 100)}%` : "unknown"} memory
+
+
+
+
+
+
+
+
+
+
Dashboard
+
Fleet command center
+
+ This first v2 pass keeps existing container controls available while introducing the service-aware overview shape.
+
+
+
+
+
Suggested services
+
{suggestedServices.length}
+
+
+
Ungrouped
+
{ungrouped.length}
+
+
+
+
+
+
+
+
+
+
Needs attention
+
Current exceptions
+
+
+ {unhealthy.length + updates} items
+
+
+
+ {unhealthy.slice(0, 4).map((container) => (
+
+
{container.name}
+
{container.state}
+
+ ))}
+ {updates > 0 && (
+
+ {updates} container{updates !== 1 ? "s" : ""} with image updates available.
+
+ )}
+ {unhealthy.length === 0 && updates === 0 && (
+
+ No immediate container issues.
+
+ )}
+
+
+
+
+
+
+
Services preview
+
Suggested flat groups
+
+
+ compose-derived
+
+
+
+ {suggestedServices.slice(0, 6).map((service) => (
+
+
+
{service.project}
+
{service.running}/{service.members.length}
+
+
+
0 ? (service.running / service.members.length) * 100 : 0}%` }}
+ />
+
+ {service.updates > 0 &&
{service.updates} update{service.updates !== 1 ? "s" : ""}
}
+
+ ))}
+ {suggestedServices.length === 0 && (
+
+ No Compose-derived service suggestions yet.
+
+ )}
+
+
+
+
{/* Toolbar */}
)}
-
+
{/* Sidebar — recent events */}
+
>
);
}
diff --git a/frontend/src/pages/PlaceholderPage.tsx b/frontend/src/pages/PlaceholderPage.tsx
new file mode 100644
index 0000000..1168b96
--- /dev/null
+++ b/frontend/src/pages/PlaceholderPage.tsx
@@ -0,0 +1,75 @@
+import { FiArrowRight, FiBell, FiBox, FiCpu, FiLayers, FiSettings, FiZap } from "react-icons/fi";
+
+interface PlaceholderPageProps {
+ page: "Services" | "Containers" | "Hosts" | "Alerts" | "Notifications" | "Integrations";
+}
+
+const PAGE_COPY: Record
= {
+ Services: {
+ icon: FiLayers,
+ summary: "Flat, user-managed groupings that sit above containers without being locked to Docker Compose.",
+ items: ["Suggested services from Compose and labels", "Linked containers and URLs", "Intermediate health rollups"],
+ },
+ Containers: {
+ icon: FiBox,
+ summary: "Technical inventory and drilldown for every discovered container.",
+ items: ["Dense table and saved filters", "Update and health state", "Links to logs and metrics"],
+ },
+ Hosts: {
+ icon: FiCpu,
+ summary: "Node-level health, capacity, and Docker runtime context.",
+ items: ["CPU, memory, disk, and Docker status", "Hosted container inventory", "Recent host-level events"],
+ },
+ Alerts: {
+ icon: FiBell,
+ summary: "Triage-focused inbox for active and historical issues.",
+ items: ["Active, resolved, acknowledged, and silenced states", "Severity filtering", "Alert detail timeline"],
+ },
+ Notifications: {
+ icon: FiSettings,
+ summary: "Notification policy owns providers, delivery rules, quiet hours, and exceptions.",
+ items: ["Channels and test sends", "Default rules and quiet hours", "Container and service exceptions"],
+ },
+ Integrations: {
+ icon: FiZap,
+ summary: "Connection health and setup for external systems Nestview talks to.",
+ items: ["Docker endpoints", "Auth and webhooks", "Provider status checks"],
+ },
+};
+
+export default function PlaceholderPage({ page }: PlaceholderPageProps) {
+ const config = PAGE_COPY[page];
+ const Icon = config.icon;
+
+ return (
+
+
+
+
+
+
+
+
+
v2.0 section
+
{page}
+
{config.summary}
+
+
+
+ Layout pass pending
+
+
+
+
+
+
+ {config.items.map((item) => (
+
+
{item}
+
Planned for the page-specific buildout after the shell review.
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx
new file mode 100644
index 0000000..4066741
--- /dev/null
+++ b/frontend/src/pages/Services.tsx
@@ -0,0 +1,427 @@
+import { useMemo, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ FiArrowRight,
+ FiBox,
+ FiCheck,
+ FiChevronRight,
+ FiLayers,
+ FiRefreshCw,
+ FiRotateCcw,
+ FiSearch,
+} from "react-icons/fi";
+import { Link } from "../router";
+import { api } from "../api";
+import StatusBadge from "../components/StatusBadge";
+import Toast from "../components/Toast";
+import { useToast } from "../hooks/useToast";
+import type { Container } from "../types";
+import { formatBytes, formatUptime } from "../utils";
+
+type ServicesMode = "compose" | "blank";
+
+interface ServiceGroup {
+ id: string;
+ name: string;
+ source: "compose";
+ members: Container[];
+}
+
+const SETUP_MODE_KEY = "nestview:services-setup-mode";
+
+function loadSetupMode(): ServicesMode | null {
+ try {
+ const stored = localStorage.getItem(SETUP_MODE_KEY);
+ return stored === "compose" || stored === "blank" ? stored : null;
+ } catch {
+ return null;
+ }
+}
+
+function saveSetupMode(mode: ServicesMode) {
+ try {
+ localStorage.setItem(SETUP_MODE_KEY, mode);
+ } catch {
+ // The page can still run with in-memory state if storage is unavailable.
+ }
+}
+
+function clearSetupMode() {
+ try {
+ localStorage.removeItem(SETUP_MODE_KEY);
+ } catch {
+ // Ignore storage failures; the chooser can still reopen in-memory.
+ }
+}
+
+function runningCount(containers: Container[]) {
+ return containers.filter((container) => container.state === "running").length;
+}
+
+function serviceTone(service: ServiceGroup) {
+ const updates = service.members.some((container) => container.update_available);
+ const unhealthy = service.members.some((container) => container.state !== "running" || container.health_status === "unhealthy");
+ if (unhealthy) return "danger";
+ if (updates) return "warn";
+ return "good";
+}
+
+function buildComposeServices(containers: Container[]): ServiceGroup[] {
+ const groups = new Map();
+
+ for (const container of containers) {
+ if (!container.compose_project) continue;
+ const members = groups.get(container.compose_project) ?? [];
+ members.push(container);
+ groups.set(container.compose_project, members);
+ }
+
+ return Array.from(groups.entries())
+ .map(([project, members]) => ({
+ id: project,
+ name: project,
+ source: "compose" as const,
+ members: [...members].sort((a, b) => (a.compose_service ?? a.name).localeCompare(b.compose_service ?? b.name)),
+ }))
+ .sort((a, b) => {
+ const toneOrder = { danger: 0, warn: 1, good: 2 };
+ return toneOrder[serviceTone(a)] - toneOrder[serviceTone(b)] || a.name.localeCompare(b.name);
+ });
+}
+
+function StatTile({ label, value, subtext }: { label: string; value: string | number; subtext: string }) {
+ return (
+
+
{label}
+
{value}
+
{subtext}
+
+ );
+}
+
+function SetupChoice({
+ composeCount,
+ standaloneCount,
+ onChoose,
+}: {
+ composeCount: number;
+ standaloneCount: number;
+ onChoose: (mode: ServicesMode) => void;
+}) {
+ return (
+
+
+
+
Services setup
+
Choose a starting point
+
+ Services are the operator-facing layer above containers. Start from Compose stacks for an instant layout, or keep the page blank until manual service groups are ready.
+
+
+
+
+
+
+
+
+ );
+}
+
+function ServiceCard({
+ service,
+ isChecking,
+ onCheckUpdates,
+}: {
+ service: ServiceGroup;
+ isChecking: boolean;
+ onCheckUpdates: (service: ServiceGroup) => void;
+}) {
+ const running = runningCount(service.members);
+ const updates = service.members.filter((container) => container.update_available).length;
+ const unhealthy = service.members.filter((container) => container.state !== "running" || container.health_status === "unhealthy").length;
+ const totalCpu = service.members
+ .filter((container) => container.state === "running")
+ .reduce((sum, container) => sum + container.cpu_percent, 0);
+ const totalMemUsage = service.members.reduce((sum, container) => sum + container.mem_usage, 0);
+ const totalMemLimit = service.members.reduce((sum, container) => sum + container.mem_limit, 0);
+ const tone = serviceTone(service);
+ const dotColor = tone === "danger" ? "bg-red-400" : tone === "warn" ? "bg-blue-300" : "bg-emerald-400";
+ const borderColor = tone === "danger" ? "border-red-500/35" : tone === "warn" ? "border-blue-500/30" : "border-border";
+
+ return (
+
+
+
+
+
+
+
{service.name}
+
+
Compose stack · {running}/{service.members.length} running
+
+
+
+
+
+
+
+
CPU
+
{totalCpu.toFixed(1)}%
+
+
+
Memory
+
+ {totalMemLimit > 0 ? `${Math.round((totalMemUsage / totalMemLimit) * 100)}%` : formatBytes(totalMemUsage)}
+
+
+
+
Attention
+
0 ? "text-blue-300" : "text-slate-200"}`}>
+ {unhealthy + updates}
+
+
+
+
+
+ {service.members.map((container) => (
+
+
+ {container.compose_service ?? container.name}
+
+ {container.started_at && container.state === "running" ? formatUptime(container.started_at) : container.status}
+
+
+
+ {container.update_available && Update}
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function BlankState({ composeCount, onUseCompose }: { composeCount: number; onUseCompose: () => void }) {
+ return (
+
+
+ No services yet
+
+ Manual service groups are next. For now, you can switch back to Compose-derived services whenever you want.
+
+ {composeCount > 0 && (
+
+ )}
+
+ );
+}
+
+export default function Services() {
+ const queryClient = useQueryClient();
+ const [setupMode, setSetupMode] = useState(loadSetupMode);
+ const [search, setSearch] = useState("");
+ const [checkingProject, setCheckingProject] = useState(null);
+ const { toastState, showToast, dismissToast } = useToast();
+
+ const { data: containers = [], isLoading, isError } = useQuery({
+ queryKey: ["containers"],
+ queryFn: api.containers.list,
+ refetchInterval: 10_000,
+ });
+
+ const checkUpdatesMutation = useMutation({
+ mutationFn: (service: ServiceGroup) => api.stacks.checkForUpdates(service.id),
+ onMutate: (service) => {
+ setCheckingProject(service.id);
+ },
+ onSuccess: (result) => {
+ showToast(`Checked ${result.checked} container${result.checked === 1 ? "" : "s"}`, "success");
+ queryClient.invalidateQueries({ queryKey: ["containers"] });
+ },
+ onError: (error: Error) => {
+ showToast(error.message, "error");
+ },
+ onSettled: () => {
+ setCheckingProject(null);
+ },
+ });
+
+ const composeServices = useMemo(() => buildComposeServices(containers), [containers]);
+ const standalone = useMemo(() => containers.filter((container) => !container.compose_project), [containers]);
+ const visibleServices = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return composeServices;
+ return composeServices.filter((service) => {
+ return [
+ service.name,
+ ...service.members.flatMap((container) => [container.name, container.compose_service ?? "", container.image]),
+ ].some((value) => value.toLowerCase().includes(q));
+ });
+ }, [composeServices, search]);
+
+ const runningServices = composeServices.filter((service) => runningCount(service.members) === service.members.length).length;
+ const servicesWithAttention = composeServices.filter((service) => serviceTone(service) !== "good").length;
+ const updateCount = containers.filter((container) => container.update_available).length;
+
+ function chooseMode(mode: ServicesMode) {
+ saveSetupMode(mode);
+ setSetupMode(mode);
+ }
+
+ if (isLoading) {
+ return Loading services...
;
+ }
+
+ if (isError) {
+ return (
+
+ Unable to load services from container data.
+
+ );
+ }
+
+ return (
+
+
+
+
+
Services
+
Service groups
+
+ Operator-facing groups built from the containers Nestview already tracks.
+
+
+ {setupMode && (
+
+ )}
+
+
+
+ {!setupMode ? (
+
+ ) : (
+ <>
+
+
+ {setupMode === "blank" ? (
+
chooseMode("compose")} />
+ ) : (
+ <>
+
+
+ {visibleServices.length > 0 ? (
+
+ {visibleServices.map((service) => (
+ checkUpdatesMutation.mutate(service)}
+ />
+ ))}
+
+ ) : (
+
+
+ No matching services
+ Try a different service, container, or image search.
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ {toastState && }
+
+ );
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index dae964e..33f52bc 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -192,6 +192,10 @@ function AboutTab() {
Version
{sysInfo.version}
+
+ Build
+ {sysInfo.display_version}
+
Build channel
{resolveBuildChannel(sysInfo.build_channel)}
@@ -1552,7 +1556,7 @@ export default function Settings({ authMode }: { authMode?: string }) {
Settings
{versionData && (
- Nestview v{versionData.version}
+ Nestview {versionData.display_version}
)}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
new file mode 100644
index 0000000..26ad620
--- /dev/null
+++ b/frontend/src/router.tsx
@@ -0,0 +1,113 @@
+import { createContext, type AnchorHTMLAttributes, type MouseEvent, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
+
+interface RouterState {
+ pathname: string;
+ navigate: (to: string | number, options?: { replace?: boolean }) => void;
+}
+
+const RouterContext = createContext
(null);
+
+function currentPath() {
+ return window.location.pathname || "/";
+}
+
+export function RouterProvider({ children }: { children: ReactNode }) {
+ const [pathname, setPathname] = useState(currentPath);
+
+ useEffect(() => {
+ const onPopState = () => setPathname(currentPath());
+ window.addEventListener("popstate", onPopState);
+ return () => window.removeEventListener("popstate", onPopState);
+ }, []);
+
+ const value = useMemo(() => ({
+ pathname,
+ navigate(to, options) {
+ if (typeof to === "number") {
+ window.history.go(to);
+ return;
+ }
+
+ const next = to.startsWith("/") ? to : `/${to}`;
+ if (next === currentPath()) return;
+
+ if (options?.replace) {
+ window.history.replaceState(null, "", next);
+ } else {
+ window.history.pushState(null, "", next);
+ }
+ setPathname(currentPath());
+ },
+ }), [pathname]);
+
+ return {children};
+}
+
+function useRouter() {
+ const router = useContext(RouterContext);
+ if (!router) throw new Error("RouterProvider is required");
+ return router;
+}
+
+export function useLocation() {
+ return { pathname: useRouter().pathname };
+}
+
+export function useNavigate() {
+ return useRouter().navigate;
+}
+
+interface LinkProps extends Omit, "href"> {
+ to: string;
+}
+
+export function Link({ to, onClick, ...props }: LinkProps) {
+ const { navigate } = useRouter();
+
+ function handleClick(event: MouseEvent) {
+ onClick?.(event);
+ if (
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ props.target
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ navigate(to);
+ }
+
+ return ;
+}
+
+interface NavLinkProps extends Omit {
+ className?: string | ((state: { isActive: boolean }) => string);
+}
+
+export function NavLink({ to, className, ...props }: NavLinkProps) {
+ const { pathname } = useRouter();
+ const isActive = pathname === to || pathname.startsWith(`${to}/`);
+ const resolvedClassName = typeof className === "function" ? className({ isActive }) : className;
+ return ;
+}
+
+export function Redirect({ to, replace = true }: { to: string; replace?: boolean }) {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ navigate(to, { replace });
+ }, [navigate, replace, to]);
+
+ return null;
+}
+
+export function useParams>() {
+ const { pathname } = useRouter();
+ const containerMatch = pathname.match(/^\/containers\/([^/]+)$/);
+ return (containerMatch ? { id: decodeURIComponent(containerMatch[1]) } : {}) as Partial;
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index d724bf0..63aa67d 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -21,8 +21,15 @@ export interface Container {
update_available: boolean;
image_size: number | null;
last_digest_check: string | null;
+ last_pulled: string | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
+ health_status: string | null;
+ restart_policy: string | null;
+ exit_code: number | null;
+ oom_killed: boolean;
+ finished_at: string | null;
+ container_error: string | null;
}
export interface ContainerLog {
@@ -132,7 +139,9 @@ export interface AnalyticsStatus {
export interface SystemInfo {
version: string;
build_channel: string;
+ build_label: string;
build_sha: string | null;
+ display_version: string;
uptime_seconds: number;
db_size_bytes: number | null;
docker_connected: boolean;