Explore swarm info

This commit is contained in:
mbecker20
2025-11-20 13:00:29 -08:00
parent 80f439d472
commit 04531f1dea
20 changed files with 416 additions and 35 deletions

View File

@@ -1,6 +1,6 @@
import { useRead } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Boxes } from "lucide-react";
import { Component } from "lucide-react";
import { DeleteResource, NewResource, ResourcePageHeader } from "../common";
import { SwarmTable } from "./table";
import {
@@ -27,7 +27,7 @@ export const useFullSwarm = (id: string) =>
const SwarmIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useSwarm(id)?.info.state;
const color = stroke_color_class_by_intention(swarm_state_intention(state));
return <Boxes className={cn(`w-${size} h-${size}`, state && color)} />;
return <Component className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const SwarmComponents: RequiredResourceComponents = {

View File

@@ -11,8 +11,9 @@ import { SwarmNodes } from "./nodes";
import { SwarmSecrets } from "./secrets";
import { SwarmServices } from "./services";
import { SwarmTasks } from "./tasks";
import { SwarmInspect } from "./inspect";
type SwarmInfoView = "Nodes" | "Services" | "Tasks" | "Secrets";
type SwarmInfoView = "Inspect" | "Nodes" | "Services" | "Tasks" | "Secrets";
export const SwarmInfo = ({
id,
@@ -25,7 +26,7 @@ export const SwarmInfo = ({
const state = useSwarm(id)?.info.state ?? Types.SwarmState.Unknown;
const [view, setView] = useLocalStorage<SwarmInfoView>(
"swarm-info-view-v1",
"Nodes"
"Inspect"
);
if (state === Types.SwarmState.Unknown) {
@@ -40,6 +41,9 @@ export const SwarmInfo = ({
const tabsNoContent = useMemo<TabNoContent<SwarmInfoView>[]>(
() => [
{
value: "Inspect",
},
{
value: "Nodes",
},
@@ -67,6 +71,8 @@ export const SwarmInfo = ({
const Component = () => {
switch (view) {
case "Inspect":
return <SwarmInspect id={id} titleOther={Selector} />;
case "Nodes":
return <SwarmNodes id={id} titleOther={Selector} _search={_search} />;
case "Services":

View File

@@ -0,0 +1,25 @@
import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { ReactNode } from "react";
import { MonacoEditor } from "@components/monaco";
export const SwarmInspect = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
const inspect =
useRead("InspectSwarm", { swarm: id }, { refetchInterval: 10_000 }).data ??
[];
return (
<Section titleOther={titleOther}>
<MonacoEditor
value={JSON.stringify(inspect, undefined, 2)}
language="json"
/>
</Section>
);
};

View File

@@ -2,9 +2,10 @@ import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Search } from "lucide-react";
import { Diamond, Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { Link } from "react-router-dom";
export const SwarmNodes = ({
id,
@@ -45,15 +46,23 @@ export const SwarmNodes = ({
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="server-nodes"
tableKey="swarm-nodes"
data={filtered}
columns={[
{
accessorKey: "Name",
accessorKey: "Description.Hostname",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
<SortableHeader column={column} title="Hostname" />
),
cell: ({ row }) => (
<Link
to={`/swarms/${id}/swarm-node/${row.original.ID}`}
className="flex gap-2 items-center hover:underline"
>
<Diamond className="w-4 h-4" />
{row.original.Description?.Hostname ?? "Unknown"}
</Link>
),
cell: ({ row }) => row.original.Spec?.Name ?? "Unknown",
size: 200,
},
{
@@ -65,7 +74,7 @@ export const SwarmNodes = ({
size: 200,
},
{
accessorKey: "Status",
accessorKey: "Status.State",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),

View File

@@ -2,9 +2,10 @@ import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Search } from "lucide-react";
import { KeyRound, Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { Link } from "react-router-dom";
export const SwarmSecrets = ({
id,
@@ -45,7 +46,7 @@ export const SwarmSecrets = ({
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="server-secrets"
tableKey="swarm-secrets"
data={filtered}
columns={[
{
@@ -53,7 +54,15 @@ export const SwarmSecrets = ({
header: ({ column }) => (
<SortableHeader column={column} title="ID" />
),
cell: ({ row }) => row.original.Spec?.Name ?? "Unknown",
cell: ({ row }) => (
<Link
to={`/swarms/${id}/swarm-secret/${row.original.ID}`}
className="flex gap-2 items-center hover:underline"
>
<KeyRound className="w-4 h-4" />
{row.original.Spec?.Name ?? "Unknown"}
</Link>
),
size: 200,
},
{

View File

@@ -2,9 +2,10 @@ import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Search } from "lucide-react";
import { FolderCode, Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { Link } from "react-router-dom";
export const SwarmServices = ({
id,
@@ -45,15 +46,23 @@ export const SwarmServices = ({
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="server-services"
tableKey="swarm-services"
data={filtered}
columns={[
{
accessorKey: "Name",
accessorKey: "Spec.Name",
header: ({ column }) => (
<SortableHeader column={column} title="ID" />
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<Link
to={`/swarms/${id}/swarm-service/${row.original.ID}`}
className="flex gap-2 items-center hover:underline"
>
<FolderCode className="w-4 h-4" />
{row.original.Spec?.Name ?? "Unknown"}
</Link>
),
cell: ({ row }) => row.original.Spec?.Name ?? "Unknown",
size: 200,
},
{
@@ -65,9 +74,9 @@ export const SwarmServices = ({
size: 200,
},
{
accessorKey: "ServiceStatus",
accessorKey: "UpdateStatus.State",
header: ({ column }) => (
<SortableHeader column={column} title="Status" />
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => row.original.UpdateStatus?.State ?? "Unknown",
size: 200,

View File

@@ -2,9 +2,10 @@ import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Search } from "lucide-react";
import { ListTodo, Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { Link } from "react-router-dom";
export const SwarmTasks = ({
id,
@@ -41,7 +42,7 @@ export const SwarmTasks = ({
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="server-tasks"
tableKey="swarm-tasks"
data={filtered}
columns={[
{
@@ -49,7 +50,15 @@ export const SwarmTasks = ({
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
cell: ({ row }) => (
<Link
to={`/swarms/${id}/swarm-task/${row.original.ID}`}
className="flex gap-2 items-center hover:underline"
>
<ListTodo className="w-4 h-4" />
{row.original.ID ?? "Unknown"}
</Link>
),
size: 200,
},
]}

View File

@@ -782,7 +782,7 @@ export const StackServiceLink = ({
);
};
export const DockerResourcePageName = ({ name: _name }: { name?: string }) => {
export const PageHeaderName = ({ name: _name }: { name?: string }) => {
const name = _name ?? "Unknown";
return (
<h1

View File

@@ -6,7 +6,7 @@ import {
DOCKER_LINK_ICONS,
DockerContainersSection,
DockerLabelsSection,
DockerResourcePageName,
PageHeaderName,
ShowHideButton,
} from "@components/util";
import { fmt_date_with_minutes, format_size_bytes } from "@lib/formatting";
@@ -126,7 +126,7 @@ const ImagePageInner = ({
<div className="mt-1">
<DOCKER_LINK_ICONS.image server_id={id} name={image.Id} size={8} />
</div>
<DockerResourcePageName name={image_name} />
<PageHeaderName name={image_name} />
{unused && <Badge variant="destructive">Unused</Badge>}
</div>

View File

@@ -7,7 +7,7 @@ import {
DockerLabelsSection,
DockerOptions,
DockerResourceLink,
DockerResourcePageName,
PageHeaderName,
ShowHideButton,
} from "@components/util";
import { useExecute, usePermissions, useRead, useSetTitle } from "@lib/hooks";
@@ -130,7 +130,7 @@ const NetworkPageInner = ({
size={8}
/>
</div>
<DockerResourcePageName name={network_name} />
<PageHeaderName name={network_name} />
{unused && <Badge variant="destructive">Unused</Badge>}
</div>

View File

@@ -7,7 +7,7 @@ import {
DockerContainersSection,
DockerLabelsSection,
DockerOptions,
DockerResourcePageName,
PageHeaderName,
ShowHideButton,
} from "@components/util";
import { useExecute, usePermissions, useRead, useSetTitle } from "@lib/hooks";
@@ -116,7 +116,7 @@ const VolumePageInner = ({
size={8}
/>
</div>
<DockerResourcePageName name={volume_name} />
<PageHeaderName name={volume_name} />
{containers && containers.length === 0 && (
<Badge variant="destructive">Unused</Badge>
)}

View File

@@ -0,0 +1,75 @@
import { ResourceLink } from "@components/resources/common";
import { PageHeaderName } from "@components/util";
import { useRead, useSetTitle } from "@lib/hooks";
import { Button } from "@ui/button";
import { ChevronLeft, Diamond, Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { useSwarm } from "@components/resources/swarm";
export default function SwarmNodePage() {
const { id, node: __node } = useParams() as {
id: string;
node: string;
};
const _node = decodeURIComponent(__node);
const swarm = useSwarm(id);
const { data, isPending } = useRead("ListSwarmNodes", { swarm: id });
const node = data?.find((node) => node.ID === _node);
useSetTitle(
`${swarm?.name} | Node | ${node?.Spec?.Name ?? node?.ID ?? "Unknown"}`
);
const nav = useNavigate();
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!node) {
return <div className="flex w-full py-4">Failed to inspect node.</div>;
}
return (
<div className="flex flex-col gap-16 mb-24">
{/* HEADER */}
<div className="flex flex-col gap-4">
{/* BACK */}
<div className="flex items-center justify-between mb-4">
<Button
className="gap-2"
variant="secondary"
onClick={() => nav("/swarms/" + id)}
>
<ChevronLeft className="w-4" /> Back
</Button>
</div>
{/* TITLE */}
<div className="flex items-center gap-4">
<div className="mt-1">
<Diamond className="w-8 h-8" />
</div>
<PageHeaderName
name={node.Spec?.Name ?? node.Description?.Hostname ?? node.ID}
/>
</div>
{/* INFO */}
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
Swarm Node
<ResourceLink type="Swarm" id={id} />
</div>
</div>
<MonacoEditor
value={JSON.stringify(node, null, 2)}
language="json"
readOnly
/>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { ResourceLink } from "@components/resources/common";
import { PageHeaderName } from "@components/util";
import { useRead, useSetTitle } from "@lib/hooks";
import { Button } from "@ui/button";
import { ChevronLeft, KeyRound, Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { useSwarm } from "@components/resources/swarm";
export default function SwarmSecretPage() {
const { id, secret: __secret } = useParams() as {
id: string;
secret: string;
};
const _secret = decodeURIComponent(__secret);
const swarm = useSwarm(id);
const { data, isPending } = useRead("ListSwarmSecrets", { swarm: id });
const secret = data?.find((secret) => secret.ID === _secret);
useSetTitle(
`${swarm?.name} | Secret | ${secret?.Spec?.Name ?? secret?.ID ?? "Unknown"}`
);
const nav = useNavigate();
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!secret) {
return <div className="flex w-full py-4">Failed to inspect secret.</div>;
}
return (
<div className="flex flex-col gap-16 mb-24">
{/* HEADER */}
<div className="flex flex-col gap-4">
{/* BACK */}
<div className="flex items-center justify-between mb-4">
<Button
className="gap-2"
variant="secondary"
onClick={() => nav("/swarms/" + id)}
>
<ChevronLeft className="w-4" /> Back
</Button>
</div>
{/* TITLE */}
<div className="flex items-center gap-4">
<div className="mt-1">
<KeyRound className="w-8 h-8" />
</div>
<PageHeaderName name={secret?.Spec?.Name ?? secret?.ID} />
</div>
{/* INFO */}
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
Swarm Secret
<ResourceLink type="Swarm" id={id} />
</div>
</div>
<MonacoEditor
value={JSON.stringify(secret, null, 2)}
language="json"
readOnly
/>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { ResourceLink } from "@components/resources/common";
import { PageHeaderName } from "@components/util";
import { useRead, useSetTitle } from "@lib/hooks";
import { Button } from "@ui/button";
import { ChevronLeft, FolderCode, Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { useSwarm } from "@components/resources/swarm";
export default function SwarmServicePage() {
const { id, service: __service } = useParams() as {
id: string;
service: string;
};
const _service = decodeURIComponent(__service);
const swarm = useSwarm(id);
const { data, isPending } = useRead("ListSwarmServices", { swarm: id });
const service = data?.find((service) => service.ID === _service);
useSetTitle(
`${swarm?.name} | Service | ${service?.Spec?.Name ?? service?.ID ?? "Unknown"}`
);
const nav = useNavigate();
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!service) {
return <div className="flex w-full py-4">Failed to inspect service.</div>;
}
return (
<div className="flex flex-col gap-16 mb-24">
{/* HEADER */}
<div className="flex flex-col gap-4">
{/* BACK */}
<div className="flex items-center justify-between mb-4">
<Button
className="gap-2"
variant="secondary"
onClick={() => nav("/swarms/" + id)}
>
<ChevronLeft className="w-4" /> Back
</Button>
</div>
{/* TITLE */}
<div className="flex items-center gap-4">
<div className="mt-1">
<FolderCode className="w-8 h-8" />
</div>
<PageHeaderName name={service.Spec?.Name ?? service.ID} />
</div>
{/* INFO */}
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
Swarm Service
<ResourceLink type="Swarm" id={id} />
</div>
</div>
<MonacoEditor
value={JSON.stringify(service, null, 2)}
language="json"
readOnly
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { ResourceLink } from "@components/resources/common";
import { PageHeaderName } from "@components/util";
import { useRead, useSetTitle } from "@lib/hooks";
import { Button } from "@ui/button";
import { ChevronLeft, ListTodo, Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { useSwarm } from "@components/resources/swarm";
export default function SwarmTaskPage() {
const { id, task: __task } = useParams() as {
id: string;
task: string;
};
const _task = decodeURIComponent(__task);
const swarm = useSwarm(id);
const { data, isPending } = useRead("ListSwarmTasks", { swarm: id });
const task = data?.find((task) => task.ID === _task);
useSetTitle(`${swarm?.name} | Task | ${task?.ID ?? "Unknown"}`);
const nav = useNavigate();
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!task) {
return <div className="flex w-full py-4">Failed to inspect task.</div>;
}
return (
<div className="flex flex-col gap-16 mb-24">
{/* HEADER */}
<div className="flex flex-col gap-4">
{/* BACK */}
<div className="flex items-center justify-between mb-4">
<Button
className="gap-2"
variant="secondary"
onClick={() => nav("/swarms/" + id)}
>
<ChevronLeft className="w-4" /> Back
</Button>
</div>
{/* TITLE */}
<div className="flex items-center gap-4">
<div className="mt-1">
<ListTodo className="w-8 h-8" />
</div>
<PageHeaderName name={task.ID} />
</div>
{/* INFO */}
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
Swarm Task
<ResourceLink type="Swarm" id={id} />
</div>
</div>
<MonacoEditor
value={JSON.stringify(task, null, 2)}
language="json"
readOnly
/>
</div>
);
}

View File

@@ -24,14 +24,18 @@ const UserPage = lazy(() => import("@pages/user"));
const UserGroupPage = lazy(() => import("@pages/user-group"));
const Settings = lazy(() => import("@pages/settings"));
const StackServicePage = lazy(() => import("@pages/stack-service"));
const NetworkPage = lazy(() => import("@pages/server-info/network"));
const ImagePage = lazy(() => import("@pages/server-info/image"));
const VolumePage = lazy(() => import("@pages/server-info/volume"));
const ContainerPage = lazy(() => import("@pages/server-info/container"));
const NetworkPage = lazy(() => import("@pages/docker/network"));
const ImagePage = lazy(() => import("@pages/docker/image"));
const VolumePage = lazy(() => import("@pages/docker/volume"));
const ContainerPage = lazy(() => import("@pages/docker/container"));
const ContainersPage = lazy(() => import("@pages/containers"));
const TerminalsPage = lazy(() => import("@pages/terminals"));
const TerminalPage = lazy(() => import("@pages/terminal"));
const SchedulesPage = lazy(() => import("@pages/schedules"));
const SwarmNodePage = lazy(() => import("@pages/swarm/node"));
const SwarmServicePage = lazy(() => import("@pages/swarm/service"));
const SwarmTaskPage = lazy(() => import("@pages/swarm/task"));
const SwarmSecretPage = lazy(() => import("@pages/swarm/secret"));
const sanitize_query = (search: URLSearchParams) => {
search.delete("token");
@@ -101,6 +105,7 @@ export const Router = () => {
<Route path="alerts" element={<AlertsPage />} />
<Route path="user-groups/:id" element={<UserGroupPage />} />
<Route path="users/:id" element={<UserPage />} />
{/* Updates */}
<Route path="updates">
<Route path="" element={<UpdatesPage />} />
<Route path=":id" element={<UpdatePage />} />
@@ -121,7 +126,24 @@ export const Router = () => {
<Route path=":id/network/:network" element={<NetworkPage />} />
<Route path=":id/image/:image" element={<ImagePage />} />
<Route path=":id/volume/:volume" element={<VolumePage />} />
{/* TerminalPage */}
{/* Swarm Resource */}
<Route
path=":id/swarm-node/:node"
element={<SwarmNodePage />}
/>
<Route
path=":id/swarm-service/:service"
element={<SwarmServicePage />}
/>
<Route
path=":id/swarm-task/:task"
element={<SwarmTaskPage />}
/>
<Route
path=":id/swarm-secret/:secret"
element={<SwarmSecretPage />}
/>
{/* Terminal Page */}
<Route
path=":id/terminal/:terminal"
element={<TerminalPage />}