improve swarm stack page

This commit is contained in:
mbecker20
2025-12-02 14:20:05 -08:00
parent 387e07f178
commit 395f29d3de
26 changed files with 918 additions and 336 deletions

View File

@@ -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<super::Args> for InspectSwarmStack {
async fn resolve(
self,
_: &super::Args,
) -> anyhow::Result<SwarmStackLists> {
) -> anyhow::Result<SwarmStack> {
inspect_swarm_stack(self.stack).await
}
}

View File

@@ -29,7 +29,7 @@ pub async fn list_compose_projects()
)));
}
let res =
let mut res =
serde_json::from_str::<Vec<DockerComposeLsItem>>(&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::<Vec<_>>();
res.sort_by(|a, b| {
a.status.cmp(&b.status).then_with(|| a.name.cmp(&b.name))
});
Ok(res)
}

View File

@@ -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::<Vec<SwarmConfigListItem>>(
&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(

View File

@@ -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)
}

View File

@@ -11,7 +11,7 @@ impl DockerClient {
&self,
containers: &[ContainerListItem],
) -> anyhow::Result<Vec<ImageListItem>> {
let images = self
let mut images = self
.docker
.list_images(Option::<ListImagesOptions>::None)
.await?
@@ -37,7 +37,12 @@ impl DockerClient {
in_use,
}
})
.collect();
.collect::<Vec<_>>();
images.sort_by(|a, b| {
a.in_use.cmp(&b.in_use).then_with(|| a.name.cmp(&b.name))
});
Ok(images)
}

View File

@@ -12,7 +12,7 @@ impl DockerClient {
&self,
containers: &[ContainerListItem],
) -> anyhow::Result<Vec<NetworkListItem>> {
let networks = self
let mut networks = self
.docker
.list_networks(Option::<ListNetworksOptions>::None)
.await?
@@ -54,7 +54,12 @@ impl DockerClient {
in_use,
}
})
.collect();
.collect::<Vec<_>>();
networks.sort_by(|a, b| {
a.in_use.cmp(&b.in_use).then_with(|| a.name.cmp(&b.name))
});
Ok(networks)
}

View File

@@ -11,14 +11,22 @@ impl DockerClient {
pub async fn list_swarm_nodes(
&self,
) -> anyhow::Result<Vec<SwarmNodeListItem>> {
let nodes = self
let mut nodes = self
.docker
.list_nodes(Option::<ListNodesOptions>::None)
.await
.context("Failed to query for swarm node list")?
.into_iter()
.map(convert_node_list_item)
.collect();
.collect::<Vec<_>>();
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)
}

View File

@@ -10,14 +10,21 @@ impl DockerClient {
pub async fn list_swarm_secrets(
&self,
) -> anyhow::Result<Vec<SwarmSecretListItem>> {
let secrets = self
let mut secrets = self
.docker
.list_secrets(Option::<ListSecretsOptions>::None)
.await
.context("Failed to query for swarm secret list")?
.into_iter()
.map(convert_secret_list_item)
.collect();
.collect::<Vec<_>>();
secrets.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| b.updated_at.cmp(&a.updated_at))
});
Ok(secrets)
}

View File

@@ -13,14 +13,21 @@ impl DockerClient {
pub async fn list_swarm_services(
&self,
) -> anyhow::Result<Vec<SwarmServiceListItem>> {
let services = self
let mut services = self
.docker
.list_services(Option::<ListServicesOptions>::None)
.await
.context("Failed to query for swarm service list")?
.into_iter()
.map(convert_service_list_item)
.collect();
.collect::<Vec<_>>();
services.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| b.updated_at.cmp(&a.updated_at))
});
Ok(services)
}

View File

@@ -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<SwarmStackLists> {
let (services, tasks) = tokio::try_join!(
list_swarm_stack_services(&name),
) -> anyhow::Result<SwarmStack> {
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::<Vec<SwarmStackListItem>>(
&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::<FuturesOrdered<_>>()
.collect::<Vec<_>>()
.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::<Vec<SwarmStackServiceListItem>>(&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::<Vec<SwarmStackTaskListItem>>(&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<Item = &'a SwarmStackTaskListItem>,
) -> 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<T: Ord>(
a: Option<T>,
b: Option<T>,
) -> 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,
}
}

View File

@@ -8,14 +8,19 @@ impl DockerClient {
pub async fn list_swarm_tasks(
&self,
) -> anyhow::Result<Vec<SwarmTaskListItem>> {
let tasks = self
let mut tasks = self
.docker
.list_tasks(Option::<ListTasksOptions>::None)
.await
.context("Failed to query for swarm tasks list")?
.into_iter()
.map(convert_task_list_item)
.collect();
.collect::<Vec<_>>();
tasks.sort_by(|a, b| {
a.state.cmp(&b.state).then_with(|| a.name.cmp(&b.name))
});
Ok(tasks)
}

View File

@@ -10,7 +10,7 @@ impl DockerClient {
&self,
containers: &[ContainerListItem],
) -> anyhow::Result<Vec<VolumeListItem>> {
let volumes = self
let mut volumes = self
.docker
.list_volumes(Option::<ListVolumesOptions>::None)
.await?
@@ -45,7 +45,12 @@ impl DockerClient {
in_use,
}
})
.collect();
.collect::<Vec<_>>();
volumes.sort_by(|a, b| {
a.in_use.cmp(&b.in_use).then_with(|| a.name.cmp(&b.name))
});
Ok(volumes)
}

View File

@@ -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;

View File

@@ -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<String>,
/// 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<SwarmState>,
/// Number of services which are part of the stack
#[serde(rename = "Services")]
pub services: Option<String>,
@@ -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<SwarmStackServiceListItem>,

View File

@@ -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]

View File

@@ -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 <NAME>`
@@ -4394,16 +4403,24 @@ export interface SwarmStackTaskListItem {
* docker stack ps --format json <STACK>
* ```
*/
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[];

View File

@@ -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

View File

@@ -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 <NAME>`
@@ -4357,15 +4365,23 @@ export interface SwarmStackTaskListItem {
* docker stack ps --format json <STACK>
* ```
*/
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[];

View File

@@ -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.

View File

@@ -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<SetStateAction<string>>];
}) => {
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 (
<Section
<SwarmServicesTable
id={id}
services={services}
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-services"
data={filtered}
columns={[
{
accessorKey: "Name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<SwarmLink
type="Service"
swarm_id={id}
resource_id={row.original.ID}
name={row.original.Name}
/>
),
size: 200,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "UpdatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Updated" />
),
cell: ({ row }) =>
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) =>
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
</Section>
_search={_search}
/>
);
};

View File

@@ -69,6 +69,12 @@ export const SwarmStacks = ({
<SortableHeader column={column} title="Services" />
),
},
{
accessorKey: "State",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
},
]}
/>
</Section>

View File

@@ -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<SetStateAction<string>>];
}) => {
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 (
<Section
<SwarmTasksTable
id={id}
tasks={tasks}
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-tasks"
data={filtered}
columns={[
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => (
<SwarmLink
type="Task"
swarm_id={id}
resource_id={row.original.ID}
name={row.original.ID}
/>
),
size: 200,
},
{
accessorKey: "service.Name",
header: ({ column }) => (
<SortableHeader column={column} title="Service" />
),
cell: ({ row }) => (
<SwarmLink
type="Service"
swarm_id={id}
resource_id={row.original.service?.ID}
name={row.original.service?.Name}
/>
),
size: 200,
},
{
accessorKey: "node.Hostname",
header: ({ column }) => (
<SortableHeader column={column} title="Node" />
),
cell: ({ row }) => (
<SwarmLink
type="Node"
swarm_id={id}
resource_id={row.original.node?.ID}
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
accessorKey: "State",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
},
{
accessorKey: "DesiredState",
header: ({ column }) => (
<SortableHeader column={column} title="Desired State" />
),
},
{
accessorKey: "UpdatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Updated" />
),
cell: ({ row }) =>
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) =>
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
</Section>
_search={_search}
/>
);
};

View File

@@ -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<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const filtered = filterBySplit(
services,
search,
(service) => service.Name ?? service.ID ?? "Unknown"
);
return (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-services"
data={filtered}
columns={[
{
accessorKey: "Name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<SwarmLink
type="Service"
swarm_id={id}
resource_id={row.original.Name}
name={row.original.Name}
/>
),
size: 200,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "UpdatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Updated" />
),
cell: ({ row }) =>
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) =>
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
</Section>
);
};
export const SwarmStackServicesTable = ({
id,
services,
titleOther,
_search,
}: {
id: string;
services: Types.SwarmStackServiceListItem[];
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const filtered = filterBySplit(
services,
search,
(service) => service.Name ?? service.ID ?? "Unknown"
);
return (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-services"
data={filtered}
columns={[
{
accessorKey: "Name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<SwarmLink
type="Service"
swarm_id={id}
resource_id={row.original.Name}
name={row.original.Name}
/>
),
size: 200,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
size: 200,
},
{
accessorKey: "Image",
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
size: 200,
},
{
accessorKey: "Mode",
header: ({ column }) => (
<SortableHeader column={column} title="Mode" />
),
},
{
accessorKey: "Replicas",
header: ({ column }) => (
<SortableHeader column={column} title="Replicas" />
),
},
{
accessorKey: "Ports",
header: ({ column }) => (
<SortableHeader column={column} title="Ports" />
),
},
]}
/>
</Section>
);
};
export const SwarmTasksTable = ({
id,
tasks: _tasks,
titleOther,
_search,
}: {
id: string;
tasks: Types.SwarmTaskListItem[];
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
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 (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-services"
data={filtered}
columns={[
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => (
<SwarmLink
type="Task"
swarm_id={id}
resource_id={row.original.ID}
name={row.original.ID}
/>
),
size: 200,
},
{
accessorKey: "service.Name",
header: ({ column }) => (
<SortableHeader column={column} title="Service" />
),
cell: ({ row }) => (
<SwarmLink
type="Service"
swarm_id={id}
resource_id={row.original.service?.ID}
name={row.original.service?.Name}
/>
),
size: 200,
},
{
accessorKey: "node.Hostname",
header: ({ column }) => (
<SortableHeader column={column} title="Node" />
),
cell: ({ row }) => (
<SwarmLink
type="Node"
swarm_id={id}
resource_id={row.original.node?.ID}
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
accessorKey: "State",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
},
{
accessorKey: "DesiredState",
header: ({ column }) => (
<SortableHeader column={column} title="Desired State" />
),
},
{
accessorKey: "UpdatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Updated" />
),
cell: ({ row }) =>
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) =>
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
</Section>
);
};
export const SwarmStackTasksTable = ({
id,
tasks: _tasks,
titleOther,
_search,
}: {
id: string;
tasks: Types.SwarmStackTaskListItem[];
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
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 (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-tasks"
data={filtered}
columns={[
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => (
<SwarmLink
type="Task"
swarm_id={id}
resource_id={row.original.ID}
name={row.original.ID}
/>
),
size: 200,
},
{
accessorKey: "node.Hostname",
header: ({ column }) => (
<SortableHeader column={column} title="Node" />
),
cell: ({ row }) => (
<SwarmLink
type="Node"
swarm_id={id}
resource_id={row.original.node?.ID}
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
accessorKey: "Image",
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
},
{
accessorKey: "CurrentState",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
},
{
accessorKey: "DesiredState",
header: ({ column }) => (
<SortableHeader column={column} title="Desired State" />
),
},
]}
/>
</Section>
);
};

View File

@@ -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,

View File

@@ -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 (
<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
<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>
</div>
{/* TITLE */}
</Link>
<div className="flex items-center gap-4">
<div className="mt-1">
<Icon size={8} />
</div>
<PageHeaderName name={stack?.Name} />
</div>
{/* INFO */}
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
Swarm Stack
<ResourceLink type="Swarm" id={id} />
<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={stack.Name}
state={state}
status={`${stack.Services.length} Services`}
/>
<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 Stack</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 && <SwarmStackTabs swarm={swarm} stack={stack} />}
</div>
</div>
</div>
);
}
type SwarmStackTabsView = "Services" | "Tasks" | "Inspect";
const SwarmStackTabs = ({
swarm,
stack,
}: {
swarm: Types.SwarmListItem;
stack: Types.SwarmStack;
}) => {
const [_view, setView] = useLocalStorage<SwarmStackTabsView>(
`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 = (
<MobileFriendlyTabsSelector
tabs={tabs}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
switch (view) {
case "Services":
return (
<SwarmStackServicesTable
id={swarm.id}
services={stack.Services}
titleOther={Selector}
_search={_search}
/>
);
case "Tasks":
return (
<SwarmStackTasksTable
id={swarm.id}
tasks={stack.Tasks}
titleOther={Selector}
_search={_search}
/>
);
case "Inspect":
return <SwarmStackInspect stack={stack} titleOther={Selector} />;
}
};
const SwarmStackInspect = ({
stack,
titleOther,
}: {
stack: Types.SwarmStack;
titleOther: ReactNode;
}) => {
return (
<Section titleOther={titleOther}>
<MonacoEditor
value={JSON.stringify(stack, null, 2)}
language="json"
readOnly
/>
</div>
</Section>
);
}
};

View File

@@ -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
);