swarm service page with logs

This commit is contained in:
mbecker20
2025-11-23 12:48:23 -08:00
parent 9c86b2d239
commit 7ff2dba82f
6 changed files with 374 additions and 19 deletions

View File

@@ -1,5 +1,7 @@
use anyhow::Context as _;
use command::run_komodo_standard_command;
use command::{
run_komodo_shell_command, run_komodo_standard_command,
};
use komodo_client::entities::{
docker::{
SwarmLists, config::SwarmConfig, node::SwarmNode,
@@ -144,7 +146,7 @@ impl Resolve<super::Args> for GetSwarmServiceLogSearch {
"docker service logs --tail 5000{timestamps}{no_task_ids}{no_resolve}{details} {service} 2>&1 | {grep}",
);
Ok(
run_komodo_standard_command(
run_komodo_shell_command(
"Search Swarm Service Log",
None,
command,

View File

@@ -143,14 +143,26 @@ export type SwarmResourceType =
| "Stack";
export const SWARM_ICONS: {
[type in SwarmResourceType]: React.FC<{ size?: number }>;
[type in SwarmResourceType]: React.FC<{ size?: number; className?: string }>;
} = {
Node: ({ size }) => <Diamond className={`w-${size} h-${size}`} />,
Service: ({ size }) => <FolderCode className={`w-${size} h-${size}`} />,
Task: ({ size }) => <ListTodo className={`w-${size} h-${size}`} />,
Secret: ({ size }) => <KeyRound className={`w-${size} h-${size}`} />,
Config: ({ size }) => <Settings className={`w-${size} h-${size}`} />,
Stack: ({ size }) => <SquareStack className={`w-${size} h-${size}`} />,
Node: ({ size, className }) => (
<Diamond className={cn(`w-${size} h-${size}`, className)} />
),
Service: ({ size, className }) => (
<FolderCode className={cn(`w-${size} h-${size}`, className)} />
),
Task: ({ size, className }) => (
<ListTodo className={cn(`w-${size} h-${size}`, className)} />
),
Secret: ({ size, className }) => (
<KeyRound className={cn(`w-${size} h-${size}`, className)} />
),
Config: ({ size, className }) => (
<Settings className={cn(`w-${size} h-${size}`, className)} />
),
Stack: ({ size, className }) => (
<SquareStack className={cn(`w-${size} h-${size}`, className)} />
),
};
export const SwarmLink = ({

View File

@@ -130,13 +130,11 @@ export const soft_text_color_class_by_intention = (
};
export const swarm_state_intention: (
state?: Types.SwarmState,
hasVersionMismatch?: boolean
) => ColorIntention = (state, hasVersionMismatch) => {
state?: Types.SwarmState
) => ColorIntention = (state) => {
switch (state) {
case Types.SwarmState.Healthy:
// If there's a version mismatch and the server is "Ok", show warning instead
return hasVersionMismatch ? "Warning" : "Good";
return "Good";
case Types.SwarmState.Unhealthy:
return "Critical";
case Types.SwarmState.Unknown:

View File

@@ -91,7 +91,7 @@ const StackServicePageInner = ({
</div>
</div>
<div className="flex flex-col xl:flex-row gap-4">
{/** HEADER */}
{/* HEADER */}
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col gap-2 border rounded-md">
{/* <Components.ResourcePageHeader id={id} /> */}

View File

@@ -0,0 +1,115 @@
import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { Log, LogSection } from "@components/log";
import { ReactNode } from "react";
import { Section } from "@components/layouts";
/* Can be used for service or task logs */
export const SwarmServiceLogs = ({
id,
service,
titleOther,
disabled,
}: {
/* Swarm id */
id: string;
/* Swarm service / task id */
service: string;
titleOther?: ReactNode;
disabled: boolean;
}) => {
if (disabled) {
return (
<Section titleOther={titleOther}>
<h1>Logs are disabled.</h1>
</Section>
);
}
return (
<SwarmServiceLogsInner titleOther={titleOther} id={id} service={service} />
);
};
const SwarmServiceLogsInner = ({
id,
service,
titleOther,
}: {
/// Swarm id
id: string;
service: string;
titleOther?: ReactNode;
}) => {
return (
<LogSection
titleOther={titleOther}
regular_logs={(timestamps, stream, tail, poll) =>
NoSearchLogs(id, service, tail, timestamps, stream, poll)
}
search_logs={(timestamps, terms, invert, poll) =>
SearchLogs(id, service, terms, invert, timestamps, poll)
}
/>
);
};
const NoSearchLogs = (
id: string,
service: string,
tail: number,
timestamps: boolean,
stream: string,
poll: boolean
) => {
const { data: log, refetch } = useRead(
"GetSwarmServiceLog",
{
swarm: id,
service,
tail,
timestamps,
},
{ refetchInterval: poll ? 3000 : false }
);
return {
Log: (
<div className="relative">
<Log log={log} stream={stream as "stdout" | "stderr"} />
</div>
),
refetch,
stderr: !!log?.stderr,
};
};
const SearchLogs = (
id: string,
service: string,
terms: string[],
invert: boolean,
timestamps: boolean,
poll: boolean
) => {
const { data: log, refetch } = useRead(
"SearchSwarmServiceLog",
{
swarm: id,
service,
terms,
combinator: Types.SearchCombinator.And,
invert,
timestamps,
},
{ refetchInterval: poll ? 10000 : false }
);
return {
Log: (
<div className="h-full relative">
<Log log={log} stream="stdout" />
</div>
),
refetch,
stderr: !!log?.stderr,
};
};

View File

@@ -1,13 +1,33 @@
import { ResourceLink } from "@components/resources/common";
import {
ResourceDescription,
ResourceLink,
ResourcePageHeader,
} from "@components/resources/common";
import { PageHeaderName } from "@components/util";
import { useRead, useSetTitle } from "@lib/hooks";
import {
useLocalStorage,
usePermissions,
useRead,
useSetTitle,
} from "@lib/hooks";
import { Button } from "@ui/button";
import { ChevronLeft, Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { SWARM_ICONS, useSwarm } from "@components/resources/swarm";
import { ExportButton } from "@components/export";
import { Types } from "komodo_client";
import {
stroke_color_class_by_intention,
swarm_state_intention,
} from "@lib/color";
import { ResourceNotifications } from "@pages/resource-notifications";
import { ReactNode, useMemo } from "react";
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
import { SwarmServiceLogs } from "./log";
import { Section } from "@components/layouts";
export default function SwarmServicePage() {
function SwarmServicePage() {
const { id, service: __service } = useParams() as {
id: string;
service: string;
@@ -75,3 +95,211 @@ export default function SwarmServicePage() {
</div>
);
}
export default function SwarmServicePage2() {
const { id, service: __service } = useParams() as {
id: string;
service: string;
};
const _service = decodeURIComponent(__service);
const swarm = useSwarm(id);
const { canWrite } = usePermissions({
type: "Swarm",
id,
});
const { data: service, isPending } = useRead("InspectSwarmService", {
swarm: id,
service: _service,
});
const tasks =
useRead("ListSwarmTasks", {
swarm: id,
}).data?.filter((task) => service?.ID && task.ServiceID === service.ID) ??
[];
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>;
}
const Icon = SWARM_ICONS.Service;
const state = get_service_state_from_tasks(tasks);
const intention = swarm_state_intention(state);
const strokeColor = stroke_color_class_by_intention(intention);
return (
<div>
<div className="w-full flex items-center justify-between mb-12">
<Link to={"/swarms/" + id}>
<Button className="gap-2" variant="secondary">
<ChevronLeft className="w-4" />
Back
</Button>
</Link>
<div className="flex items-center gap-4">
<ExportButton targets={[{ type: "Swarm", id }]} />
</div>
</div>
<div className="flex flex-col xl:flex-row gap-4">
{/* HEADER */}
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col gap-2 border rounded-md">
<ResourcePageHeader
type={undefined}
id={undefined}
intent={intention}
icon={<Icon size={8} className={strokeColor} />}
resource={undefined}
name={service.Spec?.Name}
state={state}
status={`${tasks.length} Tasks`}
/>
<div className="flex flex-col pb-2 px-4">
<div className="flex items-center gap-x-4 gap-y-0 flex-wrap text-muted-foreground">
<ResourceLink type="Swarm" id={id} />
<div>|</div>
<div>Swarm Service</div>
</div>
</div>
</div>
<ResourceDescription type="Swarm" id={id} disabled={!canWrite} />
</div>
{/** NOTIFICATIONS */}
<ResourceNotifications type="Swarm" id={id} />
</div>
<div className="mt-8 flex flex-col gap-12">
{/* Actions */}
{/* Tabs */}
<div className="pt-4">
{swarm && <SwarmServiceTabs swarm={swarm} service={_service} />}
</div>
</div>
</div>
);
}
type SwarmServiceTabsView = "Log" | "Inspect";
const SwarmServiceTabs = ({
swarm,
service,
}: {
swarm: Types.SwarmListItem;
service: string;
}) => {
const [_view, setView] = useLocalStorage<SwarmServiceTabsView>(
`swarm-${swarm.id}-${service}-tabs-v2`,
"Log"
);
const { specificLogs, specificInspect } = usePermissions({
type: "Swarm",
id: swarm.id,
});
const view = !specificInspect && _view === "Inspect" ? "Log" : _view;
const tabs = useMemo(
() => [
{
value: "Log",
disabled: !specificLogs,
},
{
value: "Inspect",
disabled: !specificInspect,
},
],
[specificLogs, specificInspect]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabs}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
switch (view) {
case "Log":
return (
<SwarmServiceLogs
id={swarm.id}
service={service}
titleOther={Selector}
disabled={!specificLogs}
/>
);
case "Inspect":
return (
<SwarmServiceInspect
swarm={swarm.id}
service={service}
titleOther={Selector}
/>
);
}
};
const SwarmServiceInspect = ({
swarm,
service,
titleOther,
}: {
swarm: string;
service: string;
titleOther: ReactNode;
}) => {
const { data: inspect, isPending } = useRead("InspectSwarmService", {
swarm,
service,
});
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 (
<Section titleOther={titleOther}>
<MonacoEditor
value={JSON.stringify(inspect, null, 2)}
language="json"
readOnly
/>
</Section>
);
};
const get_service_state_from_tasks = (
tasks: Types.SwarmTaskListItem[]
): Types.SwarmState => {
for (const task of tasks) {
const current = task.State?.split(" ")[0];
if (current !== task.DesiredState) {
return Types.SwarmState.Unhealthy;
}
}
return Types.SwarmState.Healthy;
};