basic swarm

This commit is contained in:
mbecker20
2025-11-19 18:02:50 -08:00
parent d5e03d6d16
commit 80f439d472
22 changed files with 2480 additions and 1641 deletions

View File

@@ -81,7 +81,12 @@ enum ReadRequest {
GetSwarm(GetSwarm),
GetSwarmActionState(GetSwarmActionState),
ListSwarms(ListSwarms),
InspectSwarm(InspectSwarm),
ListFullSwarms(ListFullSwarms),
ListSwarmNodes(ListSwarmNodes),
ListSwarmServices(ListSwarmServices),
ListSwarmTasks(ListSwarmTasks),
ListSwarmSecrets(ListSwarmSecrets),
// ==== SERVER ====
GetServersSummary(GetServersSummary),

View File

@@ -9,8 +9,10 @@ use komodo_client::{
use resolver_api::Resolve;
use crate::{
helpers::query::get_all_tags, permission::get_check_permissions,
resource, state::{action_states, swarm_status_cache},
helpers::query::get_all_tags,
permission::get_check_permissions,
resource,
state::{action_states, swarm_status_cache},
};
use super::ReadArgs;
@@ -138,3 +140,109 @@ impl Resolve<ReadArgs> for GetSwarmsSummary {
Ok(res)
}
}
impl Resolve<ReadArgs> for InspectSwarm {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<InspectSwarmResponse> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Read.into(),
)
.await?;
let cache =
swarm_status_cache().get_or_insert_default(&swarm.id).await;
let inspect = cache
.inspect
.as_ref()
.cloned()
.context("SwarmInspectInfo not available")?;
Ok(inspect)
}
}
impl Resolve<ReadArgs> for ListSwarmNodes {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListSwarmNodesResponse> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Read.into(),
)
.await?;
let cache =
swarm_status_cache().get_or_insert_default(&swarm.id).await;
if let Some(lists) = &cache.lists {
Ok(lists.nodes.clone())
} else {
Ok(Vec::new())
}
}
}
impl Resolve<ReadArgs> for ListSwarmServices {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListSwarmServicesResponse> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Read.into(),
)
.await?;
let cache =
swarm_status_cache().get_or_insert_default(&swarm.id).await;
if let Some(lists) = &cache.lists {
Ok(lists.services.clone())
} else {
Ok(Vec::new())
}
}
}
impl Resolve<ReadArgs> for ListSwarmTasks {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListSwarmTasksResponse> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Read.into(),
)
.await?;
let cache =
swarm_status_cache().get_or_insert_default(&swarm.id).await;
if let Some(lists) = &cache.lists {
Ok(lists.tasks.clone())
} else {
Ok(Vec::new())
}
}
}
impl Resolve<ReadArgs> for ListSwarmSecrets {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListSwarmSecretsResponse> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Read.into(),
)
.await?;
let cache =
swarm_status_cache().get_or_insert_default(&swarm.id).await;
if let Some(lists) = &cache.lists {
Ok(lists.secrets.clone())
} else {
Ok(Vec::new())
}
}
}

View File

@@ -7,6 +7,7 @@ use anyhow::anyhow;
use async_timing_util::wait_until_timelength;
use cache::CloneCache;
use database::mungos::find::find_collect;
use formatting::format_serror;
use futures_util::future::join_all;
use komodo_client::entities::{
docker::node::NodeState,
@@ -100,9 +101,9 @@ pub async fn update_cache_for_swarm(swarm: &Swarm, force: bool) {
state: SwarmState::Unknown,
inspect: None,
lists: None,
err: Some(
anyhow!("No Servers configured as manager nodes").into(),
),
err: Some(format_serror(
&anyhow!("No Servers configured as manager nodes").into(),
)),
}
.into(),
)
@@ -121,7 +122,7 @@ pub async fn update_cache_for_swarm(swarm: &Swarm, force: bool) {
state: SwarmState::Unknown,
inspect: None,
lists: None,
err: Some(e.into()),
err: Some(format_serror(&e.into())),
}
.into(),
)

View File

@@ -45,10 +45,10 @@ impl super::KomodoResource for Swarm {
async fn to_list_item(
swarm: Resource<Self::Config, Self::Info>,
) -> Self::ListItem {
let state = swarm_status_cache()
let (state, err) = swarm_status_cache()
.get(&swarm.id)
.await
.map(|status| status.state)
.map(|status| (status.state, status.err.clone()))
.unwrap_or_default();
SwarmListItem {
name: swarm.name,
@@ -59,6 +59,7 @@ impl super::KomodoResource for Swarm {
info: SwarmListItemInfo {
server_ids: swarm.config.server_ids,
state,
err,
},
}
}

View File

@@ -82,7 +82,7 @@ pub struct CachedSwarmStatus {
pub inspect: Option<SwarmInspectInfo>,
pub lists: Option<SwarmLists>,
/// Store the error in communicating with Swarm
pub err: Option<serror::Serror>,
pub err: Option<String>,
}
pub type SwarmStatusCache =

View File

@@ -137,7 +137,7 @@ pub enum PeripheryRequest {
PruneSystem(PruneSystem),
// Swarm
GetSwarmLists(PollSwarmStatus),
PollSwarmStatus(PollSwarmStatus),
InspectSwarmNode(InspectSwarmNode),
InspectSwarmService(InspectSwarmService),
InspectSwarmTask(InspectSwarmTask),

View File

@@ -3,8 +3,12 @@ use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::swarm::{
Swarm, SwarmActionState, SwarmListItem, SwarmQuery,
use crate::entities::{
docker::{
node::SwarmNode, secret::SwarmSecret, service::SwarmService,
swarm::SwarmInspectInfo, task::SwarmTask,
},
swarm::{Swarm, SwarmActionState, SwarmListItem, SwarmQuery},
};
use super::KomodoReadRequest;
@@ -111,3 +115,103 @@ pub struct GetSwarmsSummaryResponse {
/// The number of Swarms with Unknown state
pub unknown: u32,
}
//
/// Inspect information about the swarm.
/// Response: [SwarmInspectInfo].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(InspectSwarmResponse)]
#[error(serror::Error)]
pub struct InspectSwarm {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub swarm: String,
}
#[typeshare]
pub type InspectSwarmResponse = SwarmInspectInfo;
//
/// List nodes part of the target Swarm.
/// Response: [ListSwarmNodesResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListSwarmNodesResponse)]
#[error(serror::Error)]
pub struct ListSwarmNodes {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub swarm: String,
}
#[typeshare]
pub type ListSwarmNodesResponse = Vec<SwarmNode>;
//
/// List services on the target Swarm.
/// Response: [ListSwarmServicesResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListSwarmServicesResponse)]
#[error(serror::Error)]
pub struct ListSwarmServices {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub swarm: String,
}
#[typeshare]
pub type ListSwarmServicesResponse = Vec<SwarmService>;
//
/// List tasks on the target Swarm.
/// Response: [ListSwarmTasksResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListSwarmTasksResponse)]
#[error(serror::Error)]
pub struct ListSwarmTasks {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub swarm: String,
}
#[typeshare]
pub type ListSwarmTasksResponse = Vec<SwarmTask>;
//
/// List secrets on the target Swarm.
/// Response: [ListSwarmSecretsResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListSwarmSecretsResponse)]
#[error(serror::Error)]
pub struct ListSwarmSecrets {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub swarm: String,
}
#[typeshare]
pub type ListSwarmSecretsResponse = Vec<SwarmSecret>;

View File

@@ -22,6 +22,9 @@ pub struct SwarmListItemInfo {
pub server_ids: Vec<String>,
/// The Swarm state
pub state: SwarmState,
/// If there is an error reaching
/// Swarm, message will be given here.
pub err: Option<String>,
}
#[typeshare]

View File

@@ -28,6 +28,11 @@ export type ReadResponses = {
GetSwarmActionState: Types.GetSwarmActionStateResponse;
ListSwarms: Types.ListSwarmsResponse;
ListFullSwarms: Types.ListFullSwarmsResponse;
InspectSwarm: Types.InspectSwarmResponse;
ListSwarmNodes: Types.ListSwarmNodesResponse;
ListSwarmServices: Types.ListSwarmServicesResponse;
ListSwarmTasks: Types.ListSwarmTasksResponse;
ListSwarmSecrets: Types.ListSwarmSecretsResponse;
// ==== SERVER ====
GetServersSummary: Types.GetServersSummaryResponse;

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,11 @@ export type ReadResponses = {
GetSwarmActionState: Types.GetSwarmActionStateResponse;
ListSwarms: Types.ListSwarmsResponse;
ListFullSwarms: Types.ListFullSwarmsResponse;
InspectSwarm: Types.InspectSwarmResponse;
ListSwarmNodes: Types.ListSwarmNodesResponse;
ListSwarmServices: Types.ListSwarmServicesResponse;
ListSwarmTasks: Types.ListSwarmTasksResponse;
ListSwarmSecrets: Types.ListSwarmSecretsResponse;
GetServersSummary: Types.GetServersSummaryResponse;
GetServer: Types.GetServerResponse;
GetServerState: Types.GetServerStateResponse;

File diff suppressed because it is too large Load Diff

View File

@@ -492,6 +492,11 @@ export var ClusterVolumePublishStatusStateEnum;
ClusterVolumePublishStatusStateEnum["PendingNodeUnpublish"] = "pending-node-unpublish";
ClusterVolumePublishStatusStateEnum["PendingControllerUnpublish"] = "pending-controller-unpublish";
})(ClusterVolumePublishStatusStateEnum || (ClusterVolumePublishStatusStateEnum = {}));
export var SwarmSpecCaConfigExternalCasProtocolEnum;
(function (SwarmSpecCaConfigExternalCasProtocolEnum) {
SwarmSpecCaConfigExternalCasProtocolEnum["EMPTY"] = "";
SwarmSpecCaConfigExternalCasProtocolEnum["CFSSL"] = "cfssl";
})(SwarmSpecCaConfigExternalCasProtocolEnum || (SwarmSpecCaConfigExternalCasProtocolEnum = {}));
export var PortTypeEnum;
(function (PortTypeEnum) {
PortTypeEnum["EMPTY"] = "";
@@ -572,63 +577,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.
*/
export var TerminalRecreateMode;
(function (TerminalRecreateMode) {
/**
* Never kill the old terminal if it already exists.
* If the init command is different, returns error.
*/
TerminalRecreateMode["Never"] = "Never";
/** Always kill the old terminal and create new one */
TerminalRecreateMode["Always"] = "Always";
/** Only kill and recreate if the command is different. */
TerminalRecreateMode["DifferentCommand"] = "DifferentCommand";
})(TerminalRecreateMode || (TerminalRecreateMode = {}));
/** Specify the container terminal mode (exec or attach) */
export var ContainerTerminalMode;
(function (ContainerTerminalMode) {
ContainerTerminalMode["Exec"] = "exec";
ContainerTerminalMode["Attach"] = "attach";
})(ContainerTerminalMode || (ContainerTerminalMode = {}));
export var EndpointPortConfigProtocolEnum;
(function (EndpointPortConfigProtocolEnum) {
EndpointPortConfigProtocolEnum["EMPTY"] = "";
EndpointPortConfigProtocolEnum["TCP"] = "tcp";
EndpointPortConfigProtocolEnum["UDP"] = "udp";
EndpointPortConfigProtocolEnum["SCTP"] = "sctp";
})(EndpointPortConfigProtocolEnum || (EndpointPortConfigProtocolEnum = {}));
export var EndpointPortConfigPublishModeEnum;
(function (EndpointPortConfigPublishModeEnum) {
EndpointPortConfigPublishModeEnum["EMPTY"] = "";
EndpointPortConfigPublishModeEnum["INGRESS"] = "ingress";
EndpointPortConfigPublishModeEnum["HOST"] = "host";
})(EndpointPortConfigPublishModeEnum || (EndpointPortConfigPublishModeEnum = {}));
export var EndpointSpecModeEnum;
(function (EndpointSpecModeEnum) {
EndpointSpecModeEnum["EMPTY"] = "";
EndpointSpecModeEnum["VIP"] = "vip";
EndpointSpecModeEnum["DNSRR"] = "dnsrr";
})(EndpointSpecModeEnum || (EndpointSpecModeEnum = {}));
/** Reachability represents the reachability of a node. */
export var NodeReachability;
(function (NodeReachability) {
NodeReachability["UNKNOWN"] = "unknown";
NodeReachability["UNREACHABLE"] = "unreachable";
NodeReachability["REACHABLE"] = "reachable";
})(NodeReachability || (NodeReachability = {}));
export var NodeSpecRoleEnum;
(function (NodeSpecRoleEnum) {
NodeSpecRoleEnum["EMPTY"] = "";
@@ -650,26 +598,13 @@ export var NodeState;
NodeState["READY"] = "ready";
NodeState["DISCONNECTED"] = "disconnected";
})(NodeState || (NodeState = {}));
export var DefaultRepoFolder;
(function (DefaultRepoFolder) {
/** /${root_directory}/stacks */
DefaultRepoFolder["Stacks"] = "Stacks";
/** /${root_directory}/builds */
DefaultRepoFolder["Builds"] = "Builds";
/** /${root_directory}/repos */
DefaultRepoFolder["Repos"] = "Repos";
/**
* If the repo is only cloned
* in the core repo cache (resource sync),
* this isn't relevant.
*/
DefaultRepoFolder["NotApplicable"] = "NotApplicable";
})(DefaultRepoFolder || (DefaultRepoFolder = {}));
export var SearchCombinator;
(function (SearchCombinator) {
SearchCombinator["Or"] = "Or";
SearchCombinator["And"] = "And";
})(SearchCombinator || (SearchCombinator = {}));
/** Reachability represents the reachability of a node. */
export var NodeReachability;
(function (NodeReachability) {
NodeReachability["UNKNOWN"] = "unknown";
NodeReachability["UNREACHABLE"] = "unreachable";
NodeReachability["REACHABLE"] = "reachable";
})(NodeReachability || (NodeReachability = {}));
export var TaskSpecContainerSpecPrivilegesSeccompModeEnum;
(function (TaskSpecContainerSpecPrivilegesSeccompModeEnum) {
TaskSpecContainerSpecPrivilegesSeccompModeEnum["EMPTY"] = "";
@@ -722,6 +657,25 @@ export var ServiceSpecRollbackConfigOrderEnum;
ServiceSpecRollbackConfigOrderEnum["STOP_FIRST"] = "stop-first";
ServiceSpecRollbackConfigOrderEnum["START_FIRST"] = "start-first";
})(ServiceSpecRollbackConfigOrderEnum || (ServiceSpecRollbackConfigOrderEnum = {}));
export var EndpointSpecModeEnum;
(function (EndpointSpecModeEnum) {
EndpointSpecModeEnum["EMPTY"] = "";
EndpointSpecModeEnum["VIP"] = "vip";
EndpointSpecModeEnum["DNSRR"] = "dnsrr";
})(EndpointSpecModeEnum || (EndpointSpecModeEnum = {}));
export var EndpointPortConfigProtocolEnum;
(function (EndpointPortConfigProtocolEnum) {
EndpointPortConfigProtocolEnum["EMPTY"] = "";
EndpointPortConfigProtocolEnum["TCP"] = "tcp";
EndpointPortConfigProtocolEnum["UDP"] = "udp";
EndpointPortConfigProtocolEnum["SCTP"] = "sctp";
})(EndpointPortConfigProtocolEnum || (EndpointPortConfigProtocolEnum = {}));
export var EndpointPortConfigPublishModeEnum;
(function (EndpointPortConfigPublishModeEnum) {
EndpointPortConfigPublishModeEnum["EMPTY"] = "";
EndpointPortConfigPublishModeEnum["INGRESS"] = "ingress";
EndpointPortConfigPublishModeEnum["HOST"] = "host";
})(EndpointPortConfigPublishModeEnum || (EndpointPortConfigPublishModeEnum = {}));
export var ServiceUpdateStatusStateEnum;
(function (ServiceUpdateStatusStateEnum) {
ServiceUpdateStatusStateEnum["EMPTY"] = "";
@@ -732,11 +686,6 @@ export var ServiceUpdateStatusStateEnum;
ServiceUpdateStatusStateEnum["ROLLBACK_PAUSED"] = "rollback_paused";
ServiceUpdateStatusStateEnum["ROLLBACK_COMPLETED"] = "rollback_completed";
})(ServiceUpdateStatusStateEnum || (ServiceUpdateStatusStateEnum = {}));
export var SwarmSpecCaConfigExternalCasProtocolEnum;
(function (SwarmSpecCaConfigExternalCasProtocolEnum) {
SwarmSpecCaConfigExternalCasProtocolEnum["EMPTY"] = "";
SwarmSpecCaConfigExternalCasProtocolEnum["CFSSL"] = "cfssl";
})(SwarmSpecCaConfigExternalCasProtocolEnum || (SwarmSpecCaConfigExternalCasProtocolEnum = {}));
export var TaskState;
(function (TaskState) {
TaskState["NEW"] = "new";
@@ -755,6 +704,57 @@ export var TaskState;
TaskState["REMOVE"] = "remove";
TaskState["ORPHANED"] = "orphaned";
})(TaskState || (TaskState = {}));
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.
*/
export var TerminalRecreateMode;
(function (TerminalRecreateMode) {
/**
* Never kill the old terminal if it already exists.
* If the init command is different, returns error.
*/
TerminalRecreateMode["Never"] = "Never";
/** Always kill the old terminal and create new one */
TerminalRecreateMode["Always"] = "Always";
/** Only kill and recreate if the command is different. */
TerminalRecreateMode["DifferentCommand"] = "DifferentCommand";
})(TerminalRecreateMode || (TerminalRecreateMode = {}));
/** Specify the container terminal mode (exec or attach) */
export var ContainerTerminalMode;
(function (ContainerTerminalMode) {
ContainerTerminalMode["Exec"] = "exec";
ContainerTerminalMode["Attach"] = "attach";
})(ContainerTerminalMode || (ContainerTerminalMode = {}));
export var DefaultRepoFolder;
(function (DefaultRepoFolder) {
/** /${root_directory}/stacks */
DefaultRepoFolder["Stacks"] = "Stacks";
/** /${root_directory}/builds */
DefaultRepoFolder["Builds"] = "Builds";
/** /${root_directory}/repos */
DefaultRepoFolder["Repos"] = "Repos";
/**
* If the repo is only cloned
* in the core repo cache (resource sync),
* this isn't relevant.
*/
DefaultRepoFolder["NotApplicable"] = "NotApplicable";
})(DefaultRepoFolder || (DefaultRepoFolder = {}));
export var SearchCombinator;
(function (SearchCombinator) {
SearchCombinator["Or"] = "Or";
SearchCombinator["And"] = "And";
})(SearchCombinator || (SearchCombinator = {}));
/** Days of the week */
export var DayOfWeek;
(function (DayOfWeek) {

View File

@@ -1,12 +1,19 @@
import { Config } from "@components/config";
import { ConfigItem } from "@components/config/util";
import { ConfigItem, ConfigList } from "@components/config/util";
import { useLocalStorage, usePermissions, useRead, useWrite } from "@lib/hooks";
import { Types } from "komodo_client";
import { ResourceSelector } from "../common";
import { Button } from "@ui/button";
import { MinusCircle, PlusCircle } from "lucide-react";
import { ReactNode } from "react";
export const SwarmConfig = ({ id }: { id: string }) => {
export const SwarmConfig = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
const { canWrite } = usePermissions({ type: "Swarm", id });
const swarm = useRead("GetSwarm", { swarm: id }).data;
const config = swarm?.config;
@@ -24,6 +31,7 @@ export const SwarmConfig = ({ id }: { id: string }) => {
return (
<Config
titleOther={titleOther}
disabled={disabled}
original={config}
update={update}
@@ -41,6 +49,7 @@ export const SwarmConfig = ({ id }: { id: string }) => {
return (
<ConfigItem
label="Manager Nodes"
boldLabel
description="Select the Servers which have joined the Swarm as Manager Nodes."
>
<div className="flex flex-col gap-4 w-full">
@@ -100,6 +109,25 @@ export const SwarmConfig = ({ id }: { id: string }) => {
},
},
},
{
label: "Links",
labelHidden: true,
components: {
links: (values, set) => (
<ConfigList
label="Links"
boldLabel
addLabel="Add Link"
description="Add quick links in the resource header"
field="links"
values={values ?? []}
set={set}
disabled={disabled}
placeholder="Input link"
/>
),
},
},
],
}}
/>

View File

@@ -1,18 +1,20 @@
import { useRead } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Boxes } from "lucide-react";
import { SwarmConfig } from "./config";
import { DeleteResource, NewResource, ResourcePageHeader } from "../common";
import { SwarmTable } from "./table";
import {
swarm_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { cn } from "@lib/utils";
import { cn, updateLogToHtml } from "@lib/utils";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@components/util";
import { StatusBadge } from "@components/util";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { Card } from "@ui/card";
import { SwarmTabs } from "./tabs";
export const useSwarm = (id?: string) =>
useRead("ListSwarms", {}, { refetchInterval: 10_000 }).data?.find(
@@ -73,13 +75,36 @@ export const SwarmComponents: RequiredResourceComponents = {
Info: {},
Status: {},
Status: {
Err: ({ id }) => {
const err = useSwarm(id)?.info.err;
if (!err) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 bg-destructive/75 hover:bg-destructive transition-colors cursor-pointer">
<div className="text-sm text-nowrap overflow-hidden overflow-ellipsis">
Error
</div>
</Card>
</TooltipTrigger>
<TooltipContent className="w-fit max-w-[90vw] md:max-w-[60vw]">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(err),
}}
/>
</TooltipContent>
</Tooltip>
);
},
},
Actions: {},
Page: {},
Config: SwarmConfig,
Config: SwarmTabs,
DangerZone: ({ id }) => <DeleteResource type="Swarm" id={id} />,

View File

@@ -0,0 +1,88 @@
import { Section } from "@components/layouts";
import { ReactNode, useMemo, useState } from "react";
import { useSwarm } from "..";
import { Types } from "komodo_client";
import { useLocalStorage } from "@lib/hooks";
import {
MobileFriendlyTabsSelector,
TabNoContent,
} from "@ui/mobile-friendly-tabs";
import { SwarmNodes } from "./nodes";
import { SwarmSecrets } from "./secrets";
import { SwarmServices } from "./services";
import { SwarmTasks } from "./tasks";
type SwarmInfoView = "Nodes" | "Services" | "Tasks" | "Secrets";
export const SwarmInfo = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
const _search = useState("");
const state = useSwarm(id)?.info.state ?? Types.SwarmState.Unknown;
const [view, setView] = useLocalStorage<SwarmInfoView>(
"swarm-info-view-v1",
"Nodes"
);
if (state === Types.SwarmState.Unknown) {
return (
<Section titleOther={titleOther}>
<h2 className="text-muted-foreground">
Swarm unreachable, info is not available
</h2>
</Section>
);
}
const tabsNoContent = useMemo<TabNoContent<SwarmInfoView>[]>(
() => [
{
value: "Nodes",
},
{
value: "Services",
},
{
value: "Tasks",
},
{
value: "Secrets",
},
],
[]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabsNoContent}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
const Component = () => {
switch (view) {
case "Nodes":
return <SwarmNodes id={id} titleOther={Selector} _search={_search} />;
case "Services":
return (
<SwarmServices id={id} titleOther={Selector} _search={_search} />
);
case "Tasks":
return <SwarmTasks id={id} titleOther={Selector} _search={_search} />;
case "Secrets":
return <SwarmSecrets id={id} titleOther={Selector} _search={_search} />;
}
};
return (
<Section titleOther={titleOther}>
<Component />
</Section>
);
};

View File

@@ -0,0 +1,79 @@
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";
export const SwarmNodes = ({
id,
titleOther,
_search,
}: {
id: string;
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const nodes =
useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 })
.data ?? [];
const filtered = filterBySplit(
nodes,
search,
(node) => node.Spec?.Name ?? node.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="server-nodes"
data={filtered}
columns={[
{
accessorKey: "Name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => row.original.Spec?.Name ?? "Unknown",
size: 200,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "Status",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
cell: ({ row }) =>
row.original.Status ? row.original.Status.State : "Unknown",
},
]}
/>
</Section>
);
};

View File

@@ -0,0 +1,71 @@
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";
export const SwarmSecrets = ({
id,
titleOther,
_search,
}: {
id: string;
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const secrets =
useRead("ListSwarmSecrets", { swarm: id }, { refetchInterval: 10_000 })
.data ?? [];
const filtered = filterBySplit(
secrets,
search,
(secret) => secret.Spec?.Name ?? secret.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="server-secrets"
data={filtered}
columns={[
{
accessorKey: "Name",
header: ({ column }) => (
<SortableHeader column={column} title="ID" />
),
cell: ({ row }) => row.original.Spec?.Name ?? "Unknown",
size: 200,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
]}
/>
</Section>
);
};

View File

@@ -0,0 +1,79 @@
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";
export const SwarmServices = ({
id,
titleOther,
_search,
}: {
id: string;
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.Spec?.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="server-services"
data={filtered}
columns={[
{
accessorKey: "Name",
header: ({ column }) => (
<SortableHeader column={column} title="ID" />
),
cell: ({ row }) => row.original.Spec?.Name ?? "Unknown",
size: 200,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "ServiceStatus",
header: ({ column }) => (
<SortableHeader column={column} title="Status" />
),
cell: ({ row }) => row.original.UpdateStatus?.State ?? "Unknown",
size: 200,
},
]}
/>
</Section>
);
};

View File

@@ -0,0 +1,59 @@
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";
export const SwarmTasks = ({
id,
titleOther,
_search,
}: {
id: string;
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const tasks =
useRead("ListSwarmTasks", { swarm: id }, { refetchInterval: 10_000 })
.data ?? [];
const filtered = filterBySplit(tasks, search, (task) => task.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="server-tasks"
data={filtered}
columns={[
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
]}
/>
</Section>
);
};

View File

@@ -0,0 +1,47 @@
import { useLocalStorage } from "@lib/hooks";
import { useMemo } from "react";
import {
MobileFriendlyTabsSelector,
TabNoContent,
} from "@ui/mobile-friendly-tabs";
import { SwarmConfig } from "./config";
import { SwarmInfo } from "./info";
type SwarmTabsView = "Config" | "Info";
export const SwarmTabs = ({ id }: { id: string }) => {
const [view, setView] = useLocalStorage<SwarmTabsView>(
`swarm-${id}-tab-v1`,
"Config"
);
// const swarm_info = useSwarm(id)?.info;
const tabs = useMemo<TabNoContent<SwarmTabsView>[]>(
() => [
{
value: "Config",
},
{
value: "Info",
},
],
[]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabs}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
switch (view) {
case "Config":
return <SwarmConfig id={id} titleOther={Selector} />;
case "Info":
return <SwarmInfo id={id} titleOther={Selector} />;
}
};

View File

@@ -147,6 +147,7 @@ const RecentsDashboard = () => {
</p>
</div>
)}
<ResourceRow type="Swarm" />
<ResourceRow type="Server" />
<ResourceRow type="Stack" />
<ResourceRow type="Deployment" />