mirror of
https://github.com/moghtech/komodo.git
synced 2025-12-05 19:17:36 -06:00
swarm service page with logs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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} /> */}
|
||||
|
||||
115
frontend/src/pages/swarm/log.tsx
Normal file
115
frontend/src/pages/swarm/log.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user