From 087bcb70447fc72f19f07c563c84cc1f3d71bb6e Mon Sep 17 00:00:00 2001 From: mbecker20 Date: Tue, 2 Dec 2025 14:20:05 -0800 Subject: [PATCH] improve swarm stack page --- bin/periphery/src/api/swarm.rs | 6 +- bin/periphery/src/docker/compose.rs | 8 +- bin/periphery/src/docker/config.rs | 17 +- bin/periphery/src/docker/container.rs | 3 + bin/periphery/src/docker/image.rs | 9 +- bin/periphery/src/docker/network.rs | 9 +- bin/periphery/src/docker/node.rs | 12 +- bin/periphery/src/docker/secret.rs | 11 +- bin/periphery/src/docker/service.rs | 11 +- bin/periphery/src/docker/stack.rs | 129 ++++- bin/periphery/src/docker/task.rs | 9 +- bin/periphery/src/docker/volume.rs | 9 +- client/core/rs/src/api/read/swarm.rs | 4 +- client/core/rs/src/entities/docker/stack.rs | 20 +- client/core/rs/src/entities/swarm.rs | 18 +- client/core/ts/src/types.ts | 38 +- client/periphery/rs/src/api/swarm.rs | 4 +- frontend/public/client/types.d.ts | 36 +- frontend/public/client/types.js | 18 +- .../resources/swarm/info/services.tsx | 87 +--- .../resources/swarm/info/stacks.tsx | 6 + .../components/resources/swarm/info/tasks.tsx | 137 +----- .../src/components/resources/swarm/table.tsx | 443 +++++++++++++++++- frontend/src/pages/swarm/service.tsx | 8 +- frontend/src/pages/swarm/stack.tsx | 195 ++++++-- frontend/src/pages/swarm/task.tsx | 7 +- 26 files changed, 918 insertions(+), 336 deletions(-) diff --git a/bin/periphery/src/api/swarm.rs b/bin/periphery/src/api/swarm.rs index 8a6b6a730..cfe534153 100644 --- a/bin/periphery/src/api/swarm.rs +++ b/bin/periphery/src/api/swarm.rs @@ -5,8 +5,8 @@ use command::{ use komodo_client::entities::{ docker::{ SwarmLists, config::SwarmConfig, node::SwarmNode, - secret::SwarmSecret, service::SwarmService, - stack::SwarmStackLists, task::SwarmTask, + secret::SwarmSecret, service::SwarmService, stack::SwarmStack, + task::SwarmTask, }, update::Log, }; @@ -144,7 +144,7 @@ impl Resolve for InspectSwarmStack { async fn resolve( self, _: &super::Args, - ) -> anyhow::Result { + ) -> anyhow::Result { inspect_swarm_stack(self.stack).await } } diff --git a/bin/periphery/src/docker/compose.rs b/bin/periphery/src/docker/compose.rs index 1e3c1fa9b..996a249a4 100644 --- a/bin/periphery/src/docker/compose.rs +++ b/bin/periphery/src/docker/compose.rs @@ -29,7 +29,7 @@ pub async fn list_compose_projects() ))); } - let res = + let mut res = serde_json::from_str::>(&res.stdout) .with_context(|| res.stdout.clone()) .with_context(|| { @@ -48,7 +48,11 @@ pub async fn list_compose_projects() .map(str::to_string) .collect(), }) - .collect(); + .collect::>(); + + res.sort_by(|a, b| { + a.status.cmp(&b.status).then_with(|| a.name.cmp(&b.name)) + }); Ok(res) } diff --git a/bin/periphery/src/docker/config.rs b/bin/periphery/src/docker/config.rs index 75b83b9c0..fbb178c42 100644 --- a/bin/periphery/src/docker/config.rs +++ b/bin/periphery/src/docker/config.rs @@ -20,11 +20,18 @@ pub async fn list_swarm_configs() } // The output is in JSONL, need to convert to standard JSON vec. - serde_json::from_str(&format!( - "[{}]", - res.stdout.trim().replace('\n', ",") - )) - .context("Failed to parse 'docker config ls' response from json") + let mut res = serde_json::from_str::>( + &format!("[{}]", res.stdout.trim().replace('\n', ",")), + ) + .context("Failed to parse 'docker config ls' response from json")?; + + res.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); + + Ok(res) } pub async fn inspect_swarm_config( diff --git a/bin/periphery/src/docker/container.rs b/bin/periphery/src/docker/container.rs index 366790b6b..ee5776d6e 100644 --- a/bin/periphery/src/docker/container.rs +++ b/bin/periphery/src/docker/container.rs @@ -100,6 +100,9 @@ impl DockerClient { container.network_mode = container_id_to_network.get(container_id).cloned(); }); + containers.sort_by(|a, b| { + a.state.cmp(&b.state).then_with(|| a.name.cmp(&b.name)) + }); Ok(containers) } diff --git a/bin/periphery/src/docker/image.rs b/bin/periphery/src/docker/image.rs index 07710df96..ca3169a34 100644 --- a/bin/periphery/src/docker/image.rs +++ b/bin/periphery/src/docker/image.rs @@ -11,7 +11,7 @@ impl DockerClient { &self, containers: &[ContainerListItem], ) -> anyhow::Result> { - let images = self + let mut images = self .docker .list_images(Option::::None) .await? @@ -37,7 +37,12 @@ impl DockerClient { in_use, } }) - .collect(); + .collect::>(); + + images.sort_by(|a, b| { + a.in_use.cmp(&b.in_use).then_with(|| a.name.cmp(&b.name)) + }); + Ok(images) } diff --git a/bin/periphery/src/docker/network.rs b/bin/periphery/src/docker/network.rs index 1a0bd126d..9373088e9 100644 --- a/bin/periphery/src/docker/network.rs +++ b/bin/periphery/src/docker/network.rs @@ -12,7 +12,7 @@ impl DockerClient { &self, containers: &[ContainerListItem], ) -> anyhow::Result> { - let networks = self + let mut networks = self .docker .list_networks(Option::::None) .await? @@ -54,7 +54,12 @@ impl DockerClient { in_use, } }) - .collect(); + .collect::>(); + + networks.sort_by(|a, b| { + a.in_use.cmp(&b.in_use).then_with(|| a.name.cmp(&b.name)) + }); + Ok(networks) } diff --git a/bin/periphery/src/docker/node.rs b/bin/periphery/src/docker/node.rs index e814c1795..54f9dc8ce 100644 --- a/bin/periphery/src/docker/node.rs +++ b/bin/periphery/src/docker/node.rs @@ -11,14 +11,22 @@ impl DockerClient { pub async fn list_swarm_nodes( &self, ) -> anyhow::Result> { - let nodes = self + let mut nodes = self .docker .list_nodes(Option::::None) .await .context("Failed to query for swarm node list")? .into_iter() .map(convert_node_list_item) - .collect(); + .collect::>(); + + nodes.sort_by(|a, b| { + a.state + .cmp(&b.state) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.hostname.cmp(&b.hostname)) + }); + Ok(nodes) } diff --git a/bin/periphery/src/docker/secret.rs b/bin/periphery/src/docker/secret.rs index 790c72b7c..06006b5e0 100644 --- a/bin/periphery/src/docker/secret.rs +++ b/bin/periphery/src/docker/secret.rs @@ -10,14 +10,21 @@ impl DockerClient { pub async fn list_swarm_secrets( &self, ) -> anyhow::Result> { - let secrets = self + let mut secrets = self .docker .list_secrets(Option::::None) .await .context("Failed to query for swarm secret list")? .into_iter() .map(convert_secret_list_item) - .collect(); + .collect::>(); + + secrets.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); + Ok(secrets) } diff --git a/bin/periphery/src/docker/service.rs b/bin/periphery/src/docker/service.rs index 1b586f77a..a2057189a 100644 --- a/bin/periphery/src/docker/service.rs +++ b/bin/periphery/src/docker/service.rs @@ -13,14 +13,21 @@ impl DockerClient { pub async fn list_swarm_services( &self, ) -> anyhow::Result> { - let services = self + let mut services = self .docker .list_services(Option::::None) .await .context("Failed to query for swarm service list")? .into_iter() .map(convert_service_list_item) - .collect(); + .collect::>(); + + services.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); + Ok(services) } diff --git a/bin/periphery/src/docker/stack.rs b/bin/periphery/src/docker/stack.rs index 2d2d5054a..4f0a968ba 100644 --- a/bin/periphery/src/docker/stack.rs +++ b/bin/periphery/src/docker/stack.rs @@ -1,19 +1,25 @@ use anyhow::{Context, anyhow}; use command::run_komodo_standard_command; -use komodo_client::entities::docker::stack::{ - SwarmStackListItem, SwarmStackLists, SwarmStackServiceListItem, - SwarmStackTaskListItem, +use futures_util::{StreamExt, stream::FuturesOrdered}; +use komodo_client::entities::{ + docker::stack::{ + SwarmStack, SwarmStackListItem, SwarmStackServiceListItem, + SwarmStackTaskListItem, + }, + swarm::SwarmState, }; pub async fn inspect_swarm_stack( name: String, -) -> anyhow::Result { - let (services, tasks) = tokio::try_join!( - list_swarm_stack_services(&name), +) -> anyhow::Result { + let (tasks, services) = tokio::try_join!( list_swarm_stack_tasks(&name), + list_swarm_stack_services(&name) )?; - Ok(SwarmStackLists { + let state = state_from_tasks(&tasks); + Ok(SwarmStack { name, + state, services, tasks, }) @@ -35,11 +41,35 @@ pub async fn list_swarm_stacks() } // The output is in JSONL, need to convert to standard JSON vec. - serde_json::from_str(&format!( - "[{}]", - res.stdout.trim().replace('\n', ",") - )) - .context("Failed to parse 'docker stack ls' response from json") + let mut stacks = serde_json::from_str::>( + &format!("[{}]", res.stdout.trim().replace('\n', ",")), + ) + .context("Failed to parse 'docker stack ls' response from json")? + // Attach state concurrently from tasks. Still include stack + // if it fails, just with None state. + .into_iter() + .map(|mut stack| async move { + let res = async { + let tasks = + list_swarm_stack_tasks(stack.name.as_ref()?).await.ok()?; + Some(state_from_tasks(&tasks)) + } + .await; + if let Some(state) = res { + stack.state = Some(state); + } + stack + }) + .collect::>() + .collect::>() + .await; + + stacks.sort_by(|a, b| { + cmp_option(a.state, b.state) + .then_with(|| cmp_option(a.name.as_ref(), b.name.as_ref())) + }); + + Ok(stacks) } pub async fn list_swarm_stack_services( @@ -59,13 +89,18 @@ pub async fn list_swarm_stack_services( } // The output is in JSONL, need to convert to standard JSON vec. - serde_json::from_str(&format!( - "[{}]", - res.stdout.trim().replace('\n', ",") - )) - .context( - "Failed to parse 'docker stack services' response from json", - ) + let mut services = + serde_json::from_str::>(&format!( + "[{}]", + res.stdout.trim().replace('\n', ",") + )) + .context( + "Failed to parse 'docker stack services' response from json", + )?; + + services.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(services) } pub async fn list_swarm_stack_tasks( @@ -74,7 +109,7 @@ pub async fn list_swarm_stack_tasks( let res = run_komodo_standard_command( "List Swarm Stack Tasks", None, - format!("docker stack ps --format json {stack}"), + format!("docker stack ps --format json --no-trunc {stack}"), ) .await; @@ -85,9 +120,53 @@ pub async fn list_swarm_stack_tasks( } // The output is in JSONL, need to convert to standard JSON vec. - serde_json::from_str(&format!( - "[{}]", - res.stdout.trim().replace('\n', ",") - )) - .context("Failed to parse 'docker stack ps' response from json") + let mut tasks = + serde_json::from_str::>(&format!( + "[{}]", + res.stdout.trim().replace('\n', ",") + )) + .context( + "Failed to parse 'docker stack ps' response from json", + )?; + + tasks.sort_by(|a, b| { + a.desired_state + .cmp(&b.desired_state) + .then_with(|| a.name.cmp(&b.name)) + }); + + Ok(tasks) +} + +pub fn state_from_tasks<'a>( + tasks: impl IntoIterator, +) -> SwarmState { + for task in tasks { + let (Some(current), Some(desired)) = + (&task.current_state, &task.desired_state) + else { + continue; + }; + // CurrentState example: 'Running 44 minutes ago'. + // Only want first "word" + let Some(current) = current.split(" ").next() else { + continue; + }; + if current != desired { + return SwarmState::Unhealthy; + } + } + SwarmState::Healthy +} + +fn cmp_option( + a: Option, + b: Option, +) -> std::cmp::Ordering { + match (a, b) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } } diff --git a/bin/periphery/src/docker/task.rs b/bin/periphery/src/docker/task.rs index 5e0294c03..dab210c55 100644 --- a/bin/periphery/src/docker/task.rs +++ b/bin/periphery/src/docker/task.rs @@ -8,14 +8,19 @@ impl DockerClient { pub async fn list_swarm_tasks( &self, ) -> anyhow::Result> { - let tasks = self + let mut tasks = self .docker .list_tasks(Option::::None) .await .context("Failed to query for swarm tasks list")? .into_iter() .map(convert_task_list_item) - .collect(); + .collect::>(); + + tasks.sort_by(|a, b| { + a.state.cmp(&b.state).then_with(|| a.name.cmp(&b.name)) + }); + Ok(tasks) } diff --git a/bin/periphery/src/docker/volume.rs b/bin/periphery/src/docker/volume.rs index c7917217e..798712ac4 100644 --- a/bin/periphery/src/docker/volume.rs +++ b/bin/periphery/src/docker/volume.rs @@ -10,7 +10,7 @@ impl DockerClient { &self, containers: &[ContainerListItem], ) -> anyhow::Result> { - let volumes = self + let mut volumes = self .docker .list_volumes(Option::::None) .await? @@ -45,7 +45,12 @@ impl DockerClient { in_use, } }) - .collect(); + .collect::>(); + + volumes.sort_by(|a, b| { + a.in_use.cmp(&b.in_use).then_with(|| a.name.cmp(&b.name)) + }); + Ok(volumes) } diff --git a/client/core/rs/src/api/read/swarm.rs b/client/core/rs/src/api/read/swarm.rs index 98c127006..e156d5c7b 100644 --- a/client/core/rs/src/api/read/swarm.rs +++ b/client/core/rs/src/api/read/swarm.rs @@ -10,7 +10,7 @@ use crate::entities::{ node::{SwarmNode, SwarmNodeListItem}, secret::{SwarmSecret, SwarmSecretListItem}, service::{SwarmService, SwarmServiceListItem}, - stack::{SwarmStackListItem, SwarmStackLists}, + stack::{SwarmStack, SwarmStackListItem}, swarm::SwarmInspectInfo, task::{SwarmTask, SwarmTaskListItem}, }, @@ -484,4 +484,4 @@ pub struct InspectSwarmStack { } #[typeshare] -pub type InspectSwarmStackResponse = SwarmStackLists; +pub type InspectSwarmStackResponse = SwarmStack; diff --git a/client/core/rs/src/entities/docker/stack.rs b/client/core/rs/src/entities/docker/stack.rs index d03534181..8d37e8ff2 100644 --- a/client/core/rs/src/entities/docker/stack.rs +++ b/client/core/rs/src/entities/docker/stack.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; +use crate::entities::swarm::SwarmState; + /// Swarm stack list item. /// Returned by `docker stack ls --format json` /// @@ -14,6 +16,14 @@ pub struct SwarmStackListItem { #[serde(rename = "Name")] pub name: Option, + /// Swarm stack state. + /// - Healthy if all associated tasks match their desired state + /// - Unhealthy otherwise + /// + /// Not included in docker cli return, computed by Komodo + #[serde(rename = "State")] + pub state: Option, + /// Number of services which are part of the stack #[serde(rename = "Services")] pub services: Option, @@ -37,11 +47,19 @@ pub struct SwarmStackListItem { #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] -pub struct SwarmStackLists { +pub struct SwarmStack { /// Swarm stack name. #[serde(rename = "Name")] pub name: String, + /// Swarm stack state. + /// - Healthy if all associated tasks match their desired state (or report no desired state) + /// - Unhealthy otherwise + /// + /// Not included in docker cli return, computed by Komodo + #[serde(rename = "State")] + pub state: SwarmState, + /// Services part of the stack #[serde(rename = "Services")] pub services: Vec, diff --git a/client/core/rs/src/entities/swarm.rs b/client/core/rs/src/entities/swarm.rs index bbfb28048..dd0540a81 100644 --- a/client/core/rs/src/entities/swarm.rs +++ b/client/core/rs/src/entities/swarm.rs @@ -29,16 +29,26 @@ pub struct SwarmListItemInfo { #[typeshare] #[derive( - Debug, Clone, Copy, Default, Serialize, Deserialize, Display, + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Default, + Serialize, + Deserialize, + Display, )] pub enum SwarmState { - /// Unknown case - #[default] - Unknown, /// The Swarm is healthy, all nodes OK Healthy, /// The Swarm is unhealthy Unhealthy, + /// Unknown case + #[default] + Unknown, } #[typeshare] diff --git a/client/core/ts/src/types.ts b/client/core/ts/src/types.ts index a538d99e7..1b2556c3e 100644 --- a/client/core/ts/src/types.ts +++ b/client/core/ts/src/types.ts @@ -4344,6 +4344,15 @@ export interface SwarmService { export type InspectSwarmServiceResponse = SwarmService; +export enum SwarmState { + /** The Swarm is healthy, all nodes OK */ + Healthy = "Healthy", + /** The Swarm is unhealthy */ + Unhealthy = "Unhealthy", + /** Unknown case */ + Unknown = "Unknown", +} + /** * Swarm stack service list item. * Returned by `docker stack services --format json ` @@ -4394,16 +4403,24 @@ export interface SwarmStackTaskListItem { * docker stack ps --format json * ``` */ -export interface SwarmStackLists { +export interface SwarmStack { /** Swarm stack name. */ Name: string; + /** + * Swarm stack state. + * - Healthy if all associated tasks match their desired state (or report no desired state) + * - Unhealthy otherwise + * + * Not included in docker cli return, computed by Komodo + */ + State: SwarmState; /** Services part of the stack */ Services: SwarmStackServiceListItem[]; /** Tasks part of the stack */ Tasks: SwarmStackTaskListItem[]; } -export type InspectSwarmStackResponse = SwarmStackLists; +export type InspectSwarmStackResponse = SwarmStack; export enum TaskState { NEW = "new", @@ -5131,6 +5148,14 @@ export type ListSwarmServicesResponse = SwarmServiceListItem[]; export interface SwarmStackListItem { /** Swarm stack name. */ Name?: string; + /** + * Swarm stack state. + * - Healthy if all associated tasks match their desired state + * - Unhealthy otherwise + * + * Not included in docker cli return, computed by Komodo + */ + State?: SwarmState; /** Number of services which are part of the stack */ Services?: string; /** The stack orchestrator */ @@ -5161,15 +5186,6 @@ export interface SwarmTaskListItem { export type ListSwarmTasksResponse = SwarmTaskListItem[]; -export enum SwarmState { - /** Unknown case */ - Unknown = "Unknown", - /** The Swarm is healthy, all nodes OK */ - Healthy = "Healthy", - /** The Swarm is unhealthy */ - Unhealthy = "Unhealthy", -} - export interface SwarmListItemInfo { /** Servers part of the swarm */ server_ids: string[]; diff --git a/client/periphery/rs/src/api/swarm.rs b/client/periphery/rs/src/api/swarm.rs index 8c22abe5c..e6dbbaa34 100644 --- a/client/periphery/rs/src/api/swarm.rs +++ b/client/periphery/rs/src/api/swarm.rs @@ -8,7 +8,7 @@ use komodo_client::entities::{ node::{NodeSpecAvailabilityEnum, NodeSpecRoleEnum, SwarmNode}, secret::SwarmSecret, service::SwarmService, - stack::SwarmStackLists, + stack::SwarmStack, swarm::SwarmInspectInfo, task::SwarmTask, }, @@ -74,7 +74,7 @@ pub struct UpdateSwarmNode { // ======= #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] -#[response(SwarmStackLists)] +#[response(SwarmStack)] #[error(anyhow::Error)] pub struct InspectSwarmStack { /// The swarm stack name diff --git a/frontend/public/client/types.d.ts b/frontend/public/client/types.d.ts index 3f569f79b..567b3ea40 100644 --- a/frontend/public/client/types.d.ts +++ b/frontend/public/client/types.d.ts @@ -4309,6 +4309,14 @@ export interface SwarmService { JobStatus?: ServiceJobStatus; } export type InspectSwarmServiceResponse = SwarmService; +export declare enum SwarmState { + /** The Swarm is healthy, all nodes OK */ + Healthy = "Healthy", + /** The Swarm is unhealthy */ + Unhealthy = "Unhealthy", + /** Unknown case */ + Unknown = "Unknown" +} /** * Swarm stack service list item. * Returned by `docker stack services --format json ` @@ -4357,15 +4365,23 @@ export interface SwarmStackTaskListItem { * docker stack ps --format json * ``` */ -export interface SwarmStackLists { +export interface SwarmStack { /** Swarm stack name. */ Name: string; + /** + * Swarm stack state. + * - Healthy if all associated tasks match their desired state (or report no desired state) + * - Unhealthy otherwise + * + * Not included in docker cli return, computed by Komodo + */ + State: SwarmState; /** Services part of the stack */ Services: SwarmStackServiceListItem[]; /** Tasks part of the stack */ Tasks: SwarmStackTaskListItem[]; } -export type InspectSwarmStackResponse = SwarmStackLists; +export type InspectSwarmStackResponse = SwarmStack; export declare enum TaskState { NEW = "new", ALLOCATED = "allocated", @@ -5005,6 +5021,14 @@ export type ListSwarmServicesResponse = SwarmServiceListItem[]; export interface SwarmStackListItem { /** Swarm stack name. */ Name?: string; + /** + * Swarm stack state. + * - Healthy if all associated tasks match their desired state + * - Unhealthy otherwise + * + * Not included in docker cli return, computed by Komodo + */ + State?: SwarmState; /** Number of services which are part of the stack */ Services?: string; /** The stack orchestrator */ @@ -5031,14 +5055,6 @@ export interface SwarmTaskListItem { UpdatedAt?: string; } export type ListSwarmTasksResponse = SwarmTaskListItem[]; -export declare enum SwarmState { - /** Unknown case */ - Unknown = "Unknown", - /** The Swarm is healthy, all nodes OK */ - Healthy = "Healthy", - /** The Swarm is unhealthy */ - Unhealthy = "Unhealthy" -} export interface SwarmListItemInfo { /** Servers part of the swarm */ server_ids: string[]; diff --git a/frontend/public/client/types.js b/frontend/public/client/types.js index 272fe51ca..ee2e694f3 100644 --- a/frontend/public/client/types.js +++ b/frontend/public/client/types.js @@ -611,6 +611,15 @@ export var ServiceUpdateStatusStateEnum; ServiceUpdateStatusStateEnum["ROLLBACK_PAUSED"] = "rollback_paused"; ServiceUpdateStatusStateEnum["ROLLBACK_COMPLETED"] = "rollback_completed"; })(ServiceUpdateStatusStateEnum || (ServiceUpdateStatusStateEnum = {})); +export var SwarmState; +(function (SwarmState) { + /** The Swarm is healthy, all nodes OK */ + SwarmState["Healthy"] = "Healthy"; + /** The Swarm is unhealthy */ + SwarmState["Unhealthy"] = "Unhealthy"; + /** Unknown case */ + SwarmState["Unknown"] = "Unknown"; +})(SwarmState || (SwarmState = {})); export var TaskState; (function (TaskState) { TaskState["NEW"] = "new"; @@ -709,15 +718,6 @@ export var StackState; /** Server not reachable for status */ StackState["Unknown"] = "unknown"; })(StackState || (StackState = {})); -export var SwarmState; -(function (SwarmState) { - /** Unknown case */ - SwarmState["Unknown"] = "Unknown"; - /** The Swarm is healthy, all nodes OK */ - SwarmState["Healthy"] = "Healthy"; - /** The Swarm is unhealthy */ - SwarmState["Unhealthy"] = "Unhealthy"; -})(SwarmState || (SwarmState = {})); /** * Configures the behavior of [CreateTerminal] if the * specified terminal name already exists. diff --git a/frontend/src/components/resources/swarm/info/services.tsx b/frontend/src/components/resources/swarm/info/services.tsx index 1192f709d..1fa97207c 100644 --- a/frontend/src/components/resources/swarm/info/services.tsx +++ b/frontend/src/components/resources/swarm/info/services.tsx @@ -1,11 +1,6 @@ -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 { Input } from "@ui/input"; -import { filterBySplit } from "@lib/utils"; -import { SwarmLink } from ".."; +import { SwarmServicesTable } from "../table"; export const SwarmServices = ({ id, @@ -16,86 +11,16 @@ export const SwarmServices = ({ titleOther: ReactNode; _search: [string, Dispatch>]; }) => { - const [search, setSearch] = _search; const services = useRead("ListSwarmServices", { swarm: id }, { refetchInterval: 10_000 }) .data ?? []; - const filtered = filterBySplit( - services, - search, - (service) => service.Name ?? service.ID ?? "Unknown" - ); - return ( -
-
- - setSearch(e.target.value)} - placeholder="search..." - className="pl-8 w-[200px] lg:w-[300px]" - /> -
- - } - > - ( - - ), - cell: ({ row }) => ( - - ), - size: 200, - }, - { - accessorKey: "ID", - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.ID ?? "Unknown", - size: 200, - }, - { - accessorKey: "UpdatedAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => - row.original.UpdatedAt - ? new Date(row.original.UpdatedAt).toLocaleString() - : "Unknown", - size: 200, - }, - { - accessorKey: "CreatedAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => - row.original.CreatedAt - ? new Date(row.original.CreatedAt).toLocaleString() - : "Unknown", - size: 200, - }, - ]} - /> -
+ _search={_search} + /> ); }; diff --git a/frontend/src/components/resources/swarm/info/stacks.tsx b/frontend/src/components/resources/swarm/info/stacks.tsx index c7bcd7134..d3c7eb04a 100644 --- a/frontend/src/components/resources/swarm/info/stacks.tsx +++ b/frontend/src/components/resources/swarm/info/stacks.tsx @@ -69,6 +69,12 @@ export const SwarmStacks = ({ ), }, + { + accessorKey: "State", + header: ({ column }) => ( + + ), + }, ]} /> diff --git a/frontend/src/components/resources/swarm/info/tasks.tsx b/frontend/src/components/resources/swarm/info/tasks.tsx index ccd33c234..de1da0989 100644 --- a/frontend/src/components/resources/swarm/info/tasks.tsx +++ b/frontend/src/components/resources/swarm/info/tasks.tsx @@ -1,11 +1,6 @@ -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 { Input } from "@ui/input"; -import { filterBySplit } from "@lib/utils"; -import { SwarmLink } from ".."; +import { SwarmTasksTable } from "../table"; export const SwarmTasks = ({ id, @@ -16,134 +11,16 @@ export const SwarmTasks = ({ titleOther: ReactNode; _search: [string, Dispatch>]; }) => { - const [search, setSearch] = _search; - const nodes = - useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 }) - .data ?? []; - const services = - useRead("ListSwarmServices", { swarm: id }, { refetchInterval: 10_000 }) - .data ?? []; - const _tasks = + const tasks = useRead("ListSwarmTasks", { swarm: id }, { refetchInterval: 10_000 }) .data ?? []; - const tasks = _tasks.map((task) => { - return { - ...task, - node: nodes.find((node) => task.NodeID === node.ID), - service: services.find((service) => task.ServiceID === service.ID), - }; - }); - - const filtered = filterBySplit( - tasks, - search, - (task) => task.Name ?? task.service?.Name ?? "Unknown" - ); - return ( -
-
- - setSearch(e.target.value)} - placeholder="search..." - className="pl-8 w-[200px] lg:w-[300px]" - /> -
- - } - > - ( - - ), - cell: ({ row }) => ( - - ), - size: 200, - }, - { - accessorKey: "service.Name", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - size: 200, - }, - { - accessorKey: "node.Hostname", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - size: 200, - }, - { - accessorKey: "State", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "DesiredState", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "UpdatedAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => - row.original.UpdatedAt - ? new Date(row.original.UpdatedAt).toLocaleString() - : "Unknown", - size: 200, - }, - { - accessorKey: "CreatedAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => - row.original.CreatedAt - ? new Date(row.original.CreatedAt).toLocaleString() - : "Unknown", - size: 200, - }, - ]} - /> -
+ _search={_search} + /> ); }; diff --git a/frontend/src/components/resources/swarm/table.tsx b/frontend/src/components/resources/swarm/table.tsx index f8dbd3086..a4b54ea6f 100644 --- a/frontend/src/components/resources/swarm/table.tsx +++ b/frontend/src/components/resources/swarm/table.tsx @@ -1,9 +1,14 @@ import { DataTable, SortableHeader } from "@ui/data-table"; import { ResourceLink } from "../common"; import { TableTags } from "@components/tags"; -import { SwarmComponents } from "."; +import { SwarmComponents, SwarmLink } from "."; import { Types } from "komodo_client"; -import { useSelectedResources } from "@lib/hooks"; +import { useRead, useSelectedResources } from "@lib/hooks"; +import { Dispatch, ReactNode, SetStateAction } from "react"; +import { filterBySplit } from "@lib/utils"; +import { Section } from "@components/layouts"; +import { Search } from "lucide-react"; +import { Input } from "@ui/input"; export const SwarmTable = ({ swarms }: { swarms: Types.SwarmListItem[] }) => { const [_, setSelectedResources] = useSelectedResources("Swarm"); @@ -41,3 +46,437 @@ export const SwarmTable = ({ swarms }: { swarms: Types.SwarmListItem[] }) => { /> ); }; + +export const SwarmServicesTable = ({ + id, + services, + titleOther, + _search, +}: { + id: string; + services: Types.SwarmServiceListItem[]; + titleOther: ReactNode; + _search: [string, Dispatch>]; +}) => { + const [search, setSearch] = _search; + const filtered = filterBySplit( + services, + search, + (service) => service.Name ?? service.ID ?? "Unknown" + ); + return ( +
+
+ + setSearch(e.target.value)} + placeholder="search..." + className="pl-8 w-[200px] lg:w-[300px]" + /> +
+ + } + > + ( + + ), + cell: ({ row }) => ( + + ), + size: 200, + }, + { + accessorKey: "ID", + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.ID ?? "Unknown", + size: 200, + }, + { + accessorKey: "UpdatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.UpdatedAt + ? new Date(row.original.UpdatedAt).toLocaleString() + : "Unknown", + size: 200, + }, + { + accessorKey: "CreatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.CreatedAt + ? new Date(row.original.CreatedAt).toLocaleString() + : "Unknown", + size: 200, + }, + ]} + /> +
+ ); +}; + +export const SwarmStackServicesTable = ({ + id, + services, + titleOther, + _search, +}: { + id: string; + services: Types.SwarmStackServiceListItem[]; + titleOther: ReactNode; + _search: [string, Dispatch>]; +}) => { + const [search, setSearch] = _search; + const filtered = filterBySplit( + services, + search, + (service) => service.Name ?? service.ID ?? "Unknown" + ); + return ( +
+
+ + setSearch(e.target.value)} + placeholder="search..." + className="pl-8 w-[200px] lg:w-[300px]" + /> +
+ + } + > + ( + + ), + cell: ({ row }) => ( + + ), + size: 200, + }, + { + accessorKey: "ID", + header: ({ column }) => ( + + ), + size: 200, + }, + { + accessorKey: "Image", + header: ({ column }) => ( + + ), + size: 200, + }, + { + accessorKey: "Mode", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "Replicas", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "Ports", + header: ({ column }) => ( + + ), + }, + ]} + /> +
+ ); +}; + +export const SwarmTasksTable = ({ + id, + tasks: _tasks, + titleOther, + _search, +}: { + id: string; + tasks: Types.SwarmTaskListItem[]; + titleOther: ReactNode; + _search: [string, Dispatch>]; +}) => { + const [search, setSearch] = _search; + + const nodes = + useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 }) + .data ?? []; + const services = + useRead("ListSwarmServices", { swarm: id }, { refetchInterval: 10_000 }) + .data ?? []; + const tasks = _tasks.map((task) => { + return { + ...task, + node: nodes.find((node) => task.NodeID === node.ID), + service: services.find((service) => task.ServiceID === service.ID), + }; + }); + + const filtered = filterBySplit( + tasks, + search, + (task) => + task.Name ?? task.service?.Name ?? task.node?.Hostname ?? "Unknown" + ); + + return ( +
+
+ + setSearch(e.target.value)} + placeholder="search..." + className="pl-8 w-[200px] lg:w-[300px]" + /> +
+ + } + > + ( + + ), + cell: ({ row }) => ( + + ), + size: 200, + }, + { + accessorKey: "service.Name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + size: 200, + }, + { + accessorKey: "node.Hostname", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + size: 200, + }, + { + accessorKey: "State", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "DesiredState", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "UpdatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.UpdatedAt + ? new Date(row.original.UpdatedAt).toLocaleString() + : "Unknown", + size: 200, + }, + { + accessorKey: "CreatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.CreatedAt + ? new Date(row.original.CreatedAt).toLocaleString() + : "Unknown", + size: 200, + }, + ]} + /> +
+ ); +}; + +export const SwarmStackTasksTable = ({ + id, + tasks: _tasks, + titleOther, + _search, +}: { + id: string; + tasks: Types.SwarmStackTaskListItem[]; + titleOther: ReactNode; + _search: [string, Dispatch>]; +}) => { + const [search, setSearch] = _search; + + const nodes = + useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 }) + .data ?? []; + const tasks = _tasks.map((task) => { + return { + ...task, + node: nodes.find( + (node) => + (task.Node ?? false) && + (task.Node === node.ID || + task.Node === node.Hostname || + task.Node === node.Name) + ), + }; + }); + + const filtered = filterBySplit( + tasks, + search, + (task) => task.Name ?? task.node?.Hostname ?? "Unknown" + ); + + return ( +
+
+ + setSearch(e.target.value)} + placeholder="search..." + className="pl-8 w-[200px] lg:w-[300px]" + /> +
+ + } + > + ( + + ), + cell: ({ row }) => ( + + ), + size: 200, + }, + { + accessorKey: "node.Hostname", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + size: 200, + }, + { + accessorKey: "Image", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "CurrentState", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "DesiredState", + header: ({ column }) => ( + + ), + }, + ]} + /> +
+ ); +}; diff --git a/frontend/src/pages/swarm/service.tsx b/frontend/src/pages/swarm/service.tsx index 953f915c9..f6c9f47c3 100644 --- a/frontend/src/pages/swarm/service.tsx +++ b/frontend/src/pages/swarm/service.tsx @@ -36,7 +36,13 @@ export default function SwarmServicePage() { const { data: services, isPending } = useRead("ListSwarmServices", { swarm: id, }); - const service = services?.find((service) => service.ID === _service); + const service = services?.find( + (service) => + _service && + // First match on name here. + // Then better to match on ID start to accept short ids too. + (service.Name === _service || service.ID?.startsWith(_service)) + ); const tasks = useRead("ListSwarmTasks", { swarm: id, diff --git a/frontend/src/pages/swarm/stack.tsx b/frontend/src/pages/swarm/stack.tsx index 0c0ef5612..1d3640197 100644 --- a/frontend/src/pages/swarm/stack.tsx +++ b/frontend/src/pages/swarm/stack.tsx @@ -1,11 +1,33 @@ -import { ResourceLink } from "@components/resources/common"; -import { PageHeaderName } from "@components/util"; -import { useRead, useSetTitle } from "@lib/hooks"; +import { + ResourceDescription, + ResourceLink, + ResourcePageHeader, +} from "@components/resources/common"; +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 { MonacoEditor } from "@components/monaco"; +import { Link, useParams } from "react-router-dom"; import { SWARM_ICONS, useSwarm } from "@components/resources/swarm"; +import { + stroke_color_class_by_intention, + swarm_state_intention, +} from "@lib/color"; +import { ExportButton } from "@components/export"; +import { ResourceNotifications } from "@pages/resource-notifications"; +import { Types } from "komodo_client"; +import { ReactNode, useMemo, useState } from "react"; +import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs"; +import { Section } from "@components/layouts"; +import { MonacoEditor } from "@components/monaco"; +import { + SwarmStackServicesTable, + SwarmStackTasksTable, +} from "@components/resources/swarm/table"; export default function SwarmStackPage() { const { id, stack: __stack } = useParams() as { @@ -13,14 +35,16 @@ export default function SwarmStackPage() { stack: string; }; const _stack = decodeURIComponent(__stack); - console.log(_stack); const swarm = useSwarm(id); const { data: stack, isPending } = useRead("InspectSwarmStack", { swarm: id, stack: _stack, }); + const { canWrite } = usePermissions({ + type: "Swarm", + id, + }); useSetTitle(`${swarm?.name} | Stack | ${stack?.Name ?? "Unknown"}`); - const nav = useNavigate(); if (isPending) { return ( @@ -35,42 +59,147 @@ export default function SwarmStackPage() { } const Icon = SWARM_ICONS.Stack; + const state = stack.State; + const intention = swarm_state_intention(state); + const strokeColor = stroke_color_class_by_intention(intention); return ( -
- {/* HEADER */} -
- {/* BACK */} -
- -
- - {/* TITLE */} +
-
- -
- -
- - {/* INFO */} -
- Swarm Stack - +
+
+ {/* HEADER */} +
+
+ } + resource={undefined} + name={stack.Name} + state={state} + status={`${stack.Services.length} Services`} + /> +
+
+ +
|
+
Swarm Stack
+
+
+
+ +
+ {/** NOTIFICATIONS */} + +
+
+ {/* Actions */} + + {/* Tabs */} +
+ {swarm && } +
+
+
+ ); +} + +type SwarmStackTabsView = "Services" | "Tasks" | "Inspect"; + +const SwarmStackTabs = ({ + swarm, + stack, +}: { + swarm: Types.SwarmListItem; + stack: Types.SwarmStack; +}) => { + const [_view, setView] = useLocalStorage( + `swarm-${swarm.id}-stack-${stack}-tabs-v2`, + "Services" + ); + const _search = useState(""); + const { specificInspect } = usePermissions({ + type: "Swarm", + id: swarm.id, + }); + + const view = !specificInspect && _view === "Inspect" ? "Log" : _view; + + const tabs = useMemo( + () => [ + { + value: "Services", + }, + { + value: "Tasks", + }, + { + value: "Inspect", + disabled: !specificInspect, + }, + ], + [specificInspect] + ); + + const Selector = ( + + ); + + switch (view) { + case "Services": + return ( + + ); + case "Tasks": + return ( + + ); + case "Inspect": + return ; + } +}; + +const SwarmStackInspect = ({ + stack, + titleOther, +}: { + stack: Types.SwarmStack; + titleOther: ReactNode; +}) => { + return ( +
- +
); -} +}; diff --git a/frontend/src/pages/swarm/task.tsx b/frontend/src/pages/swarm/task.tsx index 63e893c12..763518a73 100644 --- a/frontend/src/pages/swarm/task.tsx +++ b/frontend/src/pages/swarm/task.tsx @@ -36,7 +36,12 @@ export default function SwarmTaskPage() { const { data: tasks, isPending } = useRead("ListSwarmTasks", { swarm: id, }); - const task = tasks?.find((task) => task.ID === _task); + const task = tasks?.find( + (task) => + _task && + // Better to match on start to accept short ids too + task.ID?.startsWith(_task) + ); const node = useRead("ListSwarmNodes", { swarm: id }).data?.find( (node) => node.ID === task?.NodeID );