Merge pull request 'tracking machine install state' (#4803) from feat/machine-install-state into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4803
This commit is contained in:
@@ -255,6 +255,16 @@ in
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
installedAt = lib.mkOption {
|
||||||
|
type = types.nullOr types.int;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Indicates when the machine was first installed.
|
||||||
|
|
||||||
|
Timestamp is in unix time (seconds since epoch).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
tags = lib.mkOption {
|
tags = lib.mkOption {
|
||||||
description = ''
|
description = ''
|
||||||
List of tags for the machine.
|
List of tags for the machine.
|
||||||
|
|||||||
6223
pkgs/clan-app/ui/api/Inventory.ts
Normal file
6223
pkgs/clan-app/ui/api/Inventory.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
span.machine-status {
|
span.machine-status {
|
||||||
@apply flex items-center gap-1;
|
@apply flex items-center gap-1.5;
|
||||||
|
|
||||||
.indicator {
|
.indicator {
|
||||||
@apply w-1.5 h-1.5 rounded-full m-1.5;
|
@apply w-1.5 h-1.5 rounded-full m-1.5;
|
||||||
@@ -13,7 +13,7 @@ span.machine-status {
|
|||||||
background-color: theme(colors.fg.semantic.error.1);
|
background-color: theme(colors.fg.semantic.error.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.installed > .indicator {
|
&.out-of-sync > .indicator {
|
||||||
background-color: theme(colors.fg.inv.3);
|
background-color: theme(colors.fg.inv.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,27 +20,38 @@ export default meta;
|
|||||||
|
|
||||||
type Story = StoryObj<MachineStatusProps>;
|
type Story = StoryObj<MachineStatusProps>;
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
|
|
||||||
export const Online: Story = {
|
export const Online: Story = {
|
||||||
args: {
|
args: {
|
||||||
status: "Online",
|
status: "online",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Offline: Story = {
|
export const Offline: Story = {
|
||||||
args: {
|
args: {
|
||||||
status: "Offline",
|
status: "offline",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Installed: Story = {
|
export const OutOfSync: Story = {
|
||||||
args: {
|
args: {
|
||||||
status: "Installed",
|
status: "out_of_sync",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NotInstalled: Story = {
|
export const NotInstalled: Story = {
|
||||||
args: {
|
args: {
|
||||||
status: "Not Installed",
|
status: "not_installed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoadingWithLabel: Story = {
|
||||||
|
args: {
|
||||||
|
...Loading.args,
|
||||||
|
label: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +71,7 @@ export const OfflineWithLabel: Story = {
|
|||||||
|
|
||||||
export const InstalledWithLabel: Story = {
|
export const InstalledWithLabel: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Installed.args,
|
...OutOfSync.args,
|
||||||
label: true,
|
label: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,41 +2,58 @@ import "./MachineStatus.css";
|
|||||||
|
|
||||||
import { Badge } from "@kobalte/core/badge";
|
import { Badge } from "@kobalte/core/badge";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { Show } from "solid-js";
|
import { Match, Show, Switch } from "solid-js";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { MachineStatus as MachineStatusModel } from "@/src/hooks/queries";
|
||||||
export type MachineStatus =
|
import { Loader } from "../Loader/Loader";
|
||||||
| "Online"
|
|
||||||
| "Offline"
|
|
||||||
| "Installed"
|
|
||||||
| "Not Installed";
|
|
||||||
|
|
||||||
export interface MachineStatusProps {
|
export interface MachineStatusProps {
|
||||||
label?: boolean;
|
label?: boolean;
|
||||||
status: MachineStatus;
|
status?: MachineStatusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MachineStatus = (props: MachineStatusProps) => (
|
export const MachineStatus = (props: MachineStatusProps) => {
|
||||||
<Badge
|
const status = () => props.status;
|
||||||
class={cx("machine-status", {
|
|
||||||
online: props.status == "Online",
|
// remove the '_' from the enum
|
||||||
offline: props.status == "Offline",
|
// we will use css transform in the typography component to capitalize
|
||||||
installed: props.status == "Installed",
|
const statusText = () => props.status?.replaceAll("_", " ");
|
||||||
"not-installed": props.status == "Not Installed",
|
|
||||||
})}
|
return (
|
||||||
textValue={props.status}
|
<Switch>
|
||||||
>
|
<Match when={!status()}>
|
||||||
{props.label && (
|
<Loader />
|
||||||
<Typography hierarchy="label" size="xs" weight="medium" inverted={true}>
|
</Match>
|
||||||
{props.status}
|
<Match when={status()}>
|
||||||
</Typography>
|
<Badge
|
||||||
)}
|
class={cx("machine-status", {
|
||||||
<Show
|
online: status() == "online",
|
||||||
when={props.status == "Not Installed"}
|
offline: status() == "offline",
|
||||||
fallback={<div class="indicator" />}
|
"out-of-sync": status() == "out_of_sync",
|
||||||
>
|
"not-installed": status() == "not_installed",
|
||||||
<Icon icon="Offline" inverted={true} />
|
})}
|
||||||
</Show>
|
textValue={status()}
|
||||||
</Badge>
|
>
|
||||||
);
|
{props.label && (
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="xs"
|
||||||
|
weight="medium"
|
||||||
|
inverted={true}
|
||||||
|
transform="capitalize"
|
||||||
|
>
|
||||||
|
{statusText()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Show
|
||||||
|
when={status() != "not_installed"}
|
||||||
|
fallback={<Icon icon="Offline" inverted={true} />}
|
||||||
|
>
|
||||||
|
<div class="indicator" />
|
||||||
|
</Show>
|
||||||
|
</Badge>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
div.sidebar {
|
.sidebar {
|
||||||
@apply w-60 border-none z-10;
|
@apply w-60 border-none z-10;
|
||||||
|
|
||||||
& > div.header {
|
.body {
|
||||||
}
|
|
||||||
|
|
||||||
& > div.body {
|
|
||||||
@apply pt-4 pb-3 px-2;
|
@apply pt-4 pb-3 px-2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "./Sidebar.css";
|
import styles from "./Sidebar.module.css";
|
||||||
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
||||||
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
export interface LinkProps {
|
export interface LinkProps {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -13,16 +14,15 @@ export interface SectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
|
class?: string;
|
||||||
staticSections?: SectionProps[];
|
staticSections?: SectionProps[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar = (props: SidebarProps) => {
|
export const Sidebar = (props: SidebarProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<div class={cx(styles.sidebar, props.class)}>
|
||||||
<div class="sidebar">
|
<SidebarHeader />
|
||||||
<SidebarHeader />
|
<SidebarBody class={cx(styles.body)} {...props} />
|
||||||
<SidebarBody {...props} />
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,47 +6,53 @@ import { Typography } from "@/src/components/Typography/Typography";
|
|||||||
import { For } from "solid-js";
|
import { For } from "solid-js";
|
||||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||||
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||||
import { useMachinesQuery } from "@/src/hooks/queries";
|
import { useMachinesQuery, useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
import { SidebarProps } from "./Sidebar";
|
import { SidebarProps } from "./Sidebar";
|
||||||
|
|
||||||
interface MachineProps {
|
interface MachineProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
machineID: string;
|
machineID: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: MachineStatus;
|
|
||||||
serviceCount: number;
|
serviceCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MachineRoute = (props: MachineProps) => (
|
const MachineRoute = (props: MachineProps) => {
|
||||||
<A href={buildMachinePath(props.clanURI, props.machineID)}>
|
const statusQuery = useMachineStateQuery(props.clanURI, props.machineID);
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<div class="flex flex-row items-center justify-between">
|
const status = () =>
|
||||||
<Typography
|
statusQuery.isSuccess ? statusQuery.data.status : undefined;
|
||||||
hierarchy="label"
|
|
||||||
size="xs"
|
return (
|
||||||
weight="bold"
|
<A href={buildMachinePath(props.clanURI, props.machineID)}>
|
||||||
color="primary"
|
<div class="flex w-full flex-col gap-2">
|
||||||
inverted={true}
|
<div class="flex flex-row items-center justify-between">
|
||||||
>
|
<Typography
|
||||||
{props.name}
|
hierarchy="label"
|
||||||
</Typography>
|
size="xs"
|
||||||
<MachineStatus status={props.status} />
|
weight="bold"
|
||||||
|
color="primary"
|
||||||
|
inverted={true}
|
||||||
|
>
|
||||||
|
{props.name}
|
||||||
|
</Typography>
|
||||||
|
<MachineStatus status={status()} />
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full flex-row items-center gap-1">
|
||||||
|
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="s"
|
||||||
|
inverted={true}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{props.serviceCount}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-row items-center gap-1">
|
</A>
|
||||||
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
);
|
||||||
<Typography
|
};
|
||||||
hierarchy="label"
|
|
||||||
family="mono"
|
|
||||||
size="s"
|
|
||||||
inverted={true}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{props.serviceCount}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</A>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SidebarBody = (props: SidebarProps) => {
|
export const SidebarBody = (props: SidebarProps) => {
|
||||||
const clanURI = useClanURI();
|
const clanURI = useClanURI();
|
||||||
@@ -96,7 +102,6 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
clanURI={clanURI}
|
clanURI={clanURI}
|
||||||
machineID={id}
|
machineID={id}
|
||||||
name={machine.name || id}
|
name={machine.name || id}
|
||||||
status="Not Installed"
|
|
||||||
serviceCount={0}
|
serviceCount={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.machineStatus {
|
||||||
|
@apply flex flex-col gap-2 w-full;
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
@apply flex flex-row justify-between items-center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import styles from "./SidebarMachineStatus.module.css";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
|
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||||
|
|
||||||
|
export interface SidebarMachineStatusProps {
|
||||||
|
class?: string;
|
||||||
|
clanURI: string;
|
||||||
|
machineName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarMachineStatus = (props: SidebarMachineStatusProps) => {
|
||||||
|
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.machineStatus}>
|
||||||
|
<div class={styles.summary}>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xs"
|
||||||
|
weight="medium"
|
||||||
|
inverted={true}
|
||||||
|
color="tertiary"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</Typography>
|
||||||
|
<MachineStatus
|
||||||
|
label
|
||||||
|
status={query.isSuccess ? query.data.status : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ div.sidebar-pane {
|
|||||||
animation: sidebarPaneHide 250ms ease-out 300ms forwards;
|
animation: sidebarPaneHide 250ms ease-out 300ms forwards;
|
||||||
|
|
||||||
& > div.header > *,
|
& > div.header > *,
|
||||||
|
& > div.sub-header > *,
|
||||||
& > div.body > * {
|
& > div.body > * {
|
||||||
animation: sidebarFadeOut 250ms ease-out forwards;
|
animation: sidebarFadeOut 250ms ease-out forwards;
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,25 @@ div.sidebar-pane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > div.sub-header {
|
||||||
|
@apply px-3 py-1;
|
||||||
|
@apply border-b-[1px] border-b-bg-inv-4;
|
||||||
|
@apply border-r-[1px] border-r-bg-inv-3 border-l-[1px] border-l-bg-inv-3;
|
||||||
|
|
||||||
|
background:
|
||||||
|
linear-gradient(0deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.08) 100%),
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
theme(colors.bg.inv.3) 0%,
|
||||||
|
theme(colors.bg.inv.4) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
@apply opacity-0;
|
||||||
|
animation: sidebarFadeIn 250ms ease-in 250ms forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
& > div.body {
|
& > div.body {
|
||||||
@apply flex flex-col gap-4 px-2 pt-4 pb-3 w-full h-full;
|
@apply flex flex-col gap-4 px-2 pt-4 pb-3 w-full h-full;
|
||||||
@apply backdrop-blur-md;
|
@apply backdrop-blur-md;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, JSX, onMount } from "solid-js";
|
import { createSignal, JSX, onMount, Show } from "solid-js";
|
||||||
import "./SidebarPane.css";
|
import "./SidebarPane.css";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
@@ -6,8 +6,10 @@ import { Button as KButton } from "@kobalte/core/button";
|
|||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
|
|
||||||
export interface SidebarPaneProps {
|
export interface SidebarPaneProps {
|
||||||
|
class?: string;
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
subHeader?: () => JSX.Element;
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +28,12 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={cx("sidebar-pane", { closing: closing(), open: open() })}>
|
<div
|
||||||
|
class={cx("sidebar-pane", props.class, {
|
||||||
|
closing: closing(),
|
||||||
|
open: open(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
|
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
|
||||||
{props.title}
|
{props.title}
|
||||||
@@ -35,6 +42,9 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
|||||||
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
|
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
|
||||||
</KButton>
|
</KButton>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={props.subHeader}>
|
||||||
|
<div class="sub-header">{props.subHeader!()}</div>
|
||||||
|
</Show>
|
||||||
<div class="body">{props.children}</div>
|
<div class="body">{props.children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.install {
|
||||||
|
@apply flex flex-col gap-4 w-full justify-center items-center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
|
import { Button } from "@/src/components/Button/Button";
|
||||||
|
import { InstallModal } from "@/src/workflows/Install/install";
|
||||||
|
import { useMachineName } from "@/src/hooks/clan";
|
||||||
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
|
import styles from "./SidebarSectionInstall.module.css";
|
||||||
|
import { Alert } from "../Alert/Alert";
|
||||||
|
|
||||||
|
export interface SidebarSectionInstallProps {
|
||||||
|
clanURI: string;
|
||||||
|
machineName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
||||||
|
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||||
|
|
||||||
|
const [showInstall, setShowModal] = createSignal(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={query.isSuccess && query.data.status == "not_installed"}>
|
||||||
|
<div class={styles.install}>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
size="s"
|
||||||
|
title="Your machine is not installed yet"
|
||||||
|
description="Start the process by clicking the button below."
|
||||||
|
></Alert>
|
||||||
|
<Button hierarchy="primary" size="s" onClick={() => setShowModal(true)}>
|
||||||
|
Install machine
|
||||||
|
</Button>
|
||||||
|
<Show when={showInstall()}>
|
||||||
|
<InstallModal
|
||||||
|
open={showInstall()}
|
||||||
|
machineName={useMachineName()}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,10 @@ export type ClanDetailsWithURI = ClanDetails & { uri: string };
|
|||||||
|
|
||||||
export type Tags = SuccessData<"list_tags">;
|
export type Tags = SuccessData<"list_tags">;
|
||||||
export type Machine = SuccessData<"get_machine">;
|
export type Machine = SuccessData<"get_machine">;
|
||||||
|
|
||||||
|
export type MachineState = SuccessData<"get_machine_state">;
|
||||||
|
export type MachineStatus = MachineState["status"];
|
||||||
|
|
||||||
export type ListMachines = SuccessData<"list_machines">;
|
export type ListMachines = SuccessData<"list_machines">;
|
||||||
export type MachineDetails = SuccessData<"get_machine_details">;
|
export type MachineDetails = SuccessData<"get_machine_details">;
|
||||||
|
|
||||||
@@ -94,6 +98,33 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||||
|
const client = useApiClient();
|
||||||
|
return useQuery<MachineState>(() => ({
|
||||||
|
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||||
|
refetchInterval: 1000 * 60, // poll every 60 seconds
|
||||||
|
queryFn: async () => {
|
||||||
|
const apiCall = client.fetch("get_machine_state", {
|
||||||
|
machine: {
|
||||||
|
name: machineName,
|
||||||
|
flake: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await apiCall.result;
|
||||||
|
if (result.status === "error") {
|
||||||
|
throw new Error(
|
||||||
|
"Error fetching machine status: " + result.errors[0].message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
export const useMachineDetailsQuery = (
|
export const useMachineDetailsQuery = (
|
||||||
clanURI: string,
|
clanURI: string,
|
||||||
machineName: string,
|
machineName: string,
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
.fade-out {
|
.fadeOut {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.5s ease;
|
transition: opacity 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-backdrop {
|
.createBackdrop {
|
||||||
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
|
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
|
||||||
-webkit-backdrop-filter: blur(4px);
|
-webkit-backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-modal {
|
.createModal {
|
||||||
@apply min-w-96;
|
@apply min-w-96;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-container {
|
.sidebar {
|
||||||
}
|
@apply absolute left-4 top-10 w-60;
|
||||||
|
@apply min-h-96;
|
||||||
|
|
||||||
div.sidebar {
|
height: calc(100vh - 8rem);
|
||||||
@apply absolute top-10 bottom-20 left-4 w-60;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.sidebar-pane {
|
|
||||||
@apply absolute top-12 bottom-20 left-[16.5rem] w-64;
|
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ import { produce } from "solid-js/store";
|
|||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
import { Splash } from "@/src/scene/splash";
|
import { Splash } from "@/src/scene/splash";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import "./Clan.css";
|
import styles from "./Clan.module.css";
|
||||||
import { Modal } from "@/src/components/Modal/Modal";
|
import { Modal } from "@/src/components/Modal/Modal";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
||||||
@@ -37,7 +37,7 @@ import { useNavigate } from "@solidjs/router";
|
|||||||
export const Clan: Component<RouteSectionProps> = (props) => {
|
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar />
|
<Sidebar class={cx(styles.sidebar)} />
|
||||||
{props.children}
|
{props.children}
|
||||||
<ClanSceneController {...props} />
|
<ClanSceneController {...props} />
|
||||||
</>
|
</>
|
||||||
@@ -59,7 +59,7 @@ const MockCreateMachine = (props: MockProps) => {
|
|||||||
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={(el) => (container = el)} class="create-backdrop">
|
<div ref={(el) => (container = el)} class={cx(styles.createBackdrop)}>
|
||||||
<Modal
|
<Modal
|
||||||
open={true}
|
open={true}
|
||||||
mount={container!}
|
mount={container!}
|
||||||
@@ -67,7 +67,7 @@ const MockCreateMachine = (props: MockProps) => {
|
|||||||
reset(form);
|
reset(form);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
}}
|
}}
|
||||||
class="create-modal"
|
class={cx(styles.createModal)}
|
||||||
title="Create Machine"
|
title="Create Machine"
|
||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
@@ -234,7 +234,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
class={cx({
|
class={cx({
|
||||||
"fade-out": !machinesQuery.isLoading && loadingCooldown(),
|
[styles.fadeOut]: !machinesQuery.isLoading && loadingCooldown(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Splash />
|
<Splash />
|
||||||
|
|||||||
6
pkgs/clan-app/ui/src/routes/Machine/Machine.module.css
Normal file
6
pkgs/clan-app/ui/src/routes/Machine/Machine.module.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.sidebarPane {
|
||||||
|
@apply absolute left-[16.5rem] top-12 w-64;
|
||||||
|
@apply min-h-96;
|
||||||
|
|
||||||
|
height: calc(100vh - 10rem);
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||||
import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
|
import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
|
||||||
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
|
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
|
||||||
import { createSignal, Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { SectionGeneral } from "./SectionGeneral";
|
import { SectionGeneral } from "./SectionGeneral";
|
||||||
import { InstallModal } from "@/src/workflows/Install/install";
|
|
||||||
import { Button } from "@/src/components/Button/Button";
|
|
||||||
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
|
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
|
||||||
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
||||||
import { callApi } from "@/src/hooks/api";
|
import { callApi } from "@/src/hooks/api";
|
||||||
|
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
||||||
|
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||||
|
|
||||||
|
import cx from "classnames";
|
||||||
|
import styles from "./Machine.module.css";
|
||||||
|
|
||||||
export const Machine = (props: RouteSectionProps) => {
|
export const Machine = (props: RouteSectionProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -18,10 +21,6 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
navigateToClan(navigate, clanURI);
|
navigateToClan(navigate, clanURI);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showInstall, setShowModal] = createSignal(false);
|
|
||||||
|
|
||||||
let container: Node;
|
|
||||||
|
|
||||||
const sidebarPane = (machineName: string) => {
|
const sidebarPane = (machineName: string) => {
|
||||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||||
|
|
||||||
@@ -53,7 +52,15 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarPane title={machineName} onClose={onClose}>
|
<SidebarPane
|
||||||
|
class={cx(styles.sidebarPane)}
|
||||||
|
title={machineName}
|
||||||
|
onClose={onClose}
|
||||||
|
subHeader={() => (
|
||||||
|
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
||||||
<SectionGeneral {...sectionProps} />
|
<SectionGeneral {...sectionProps} />
|
||||||
<SectionTags {...sectionProps} />
|
<SectionTags {...sectionProps} />
|
||||||
</SidebarPane>
|
</SidebarPane>
|
||||||
@@ -62,22 +69,6 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={useMachineName()} keyed>
|
<Show when={useMachineName()} keyed>
|
||||||
<Button
|
|
||||||
hierarchy="primary"
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
class="absolute right-0 top-0 m-4"
|
|
||||||
>
|
|
||||||
Install me!
|
|
||||||
</Button>
|
|
||||||
{/* Unmount the whole component to destroy the store and form values */}
|
|
||||||
<Show when={showInstall()}>
|
|
||||||
<InstallModal
|
|
||||||
open={showInstall()}
|
|
||||||
machineName={useMachineName()}
|
|
||||||
mount={container!}
|
|
||||||
onClose={() => setShowModal(false)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
{sidebarPane(useMachineName())}
|
{sidebarPane(useMachineName())}
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-container {
|
.toolbar-container {
|
||||||
@apply absolute bottom-8 z-10 w-full;
|
@apply absolute bottom-10 z-10 w-full;
|
||||||
@apply flex justify-center items-center;
|
@apply flex justify-center items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
/nix/store/q012qk78pwldxl3qjy09nwrx9jlamivm-nixpkgs
|
/nix/store/apspgd56g9qy6fca8d44qnhdaiqrdf2c-nixpkgs
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from enum import StrEnum
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from clan_lib.api import API
|
from clan_lib.api import API
|
||||||
@@ -25,6 +26,18 @@ class ListOptions(TypedDict):
|
|||||||
filter: MachineFilter
|
filter: MachineFilter
|
||||||
|
|
||||||
|
|
||||||
|
class MachineStatus(StrEnum):
|
||||||
|
NOT_INSTALLED = "not_installed"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
OUT_OF_SYNC = "out_of_sync"
|
||||||
|
ONLINE = "online"
|
||||||
|
|
||||||
|
|
||||||
|
class MachineState(TypedDict):
|
||||||
|
status: MachineStatus
|
||||||
|
# add more info later when retrieving remote state
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def list_machines(
|
def list_machines(
|
||||||
flake: Flake, opts: ListOptions | None = None
|
flake: Flake, opts: ListOptions | None = None
|
||||||
@@ -154,3 +167,47 @@ def get_machine_fields_schema(machine: Machine) -> dict[str, FieldSchema]:
|
|||||||
}
|
}
|
||||||
for field in field_names
|
for field in field_names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@API.register
|
||||||
|
def list_machine_state(flake: Flake) -> dict[str, MachineState]:
|
||||||
|
"""
|
||||||
|
Retrieve the current state of all machines in the clan.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flake (Flake): The flake object representing the configuration source.
|
||||||
|
"""
|
||||||
|
inventory_store = InventoryStore(flake=flake)
|
||||||
|
inventory = inventory_store.read()
|
||||||
|
|
||||||
|
# todo integrate with remote state when implementing https://git.clan.lol/clan/clan-core/issues/4748
|
||||||
|
machines = inventory.get("machines", {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
machine_name: MachineState(
|
||||||
|
status=MachineStatus.OFFLINE
|
||||||
|
if get_value_by_path(machine, "installedAt", None)
|
||||||
|
else MachineStatus.NOT_INSTALLED
|
||||||
|
)
|
||||||
|
for machine_name, machine in machines.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@API.register
|
||||||
|
def get_machine_state(machine: Machine) -> MachineState:
|
||||||
|
"""
|
||||||
|
Retrieve the current state of the machine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
machine (Machine): The machine object for which we want to retrieve the latest state.
|
||||||
|
"""
|
||||||
|
inventory_store = InventoryStore(flake=machine.flake)
|
||||||
|
inventory = inventory_store.read()
|
||||||
|
|
||||||
|
# todo integrate with remote state when implementing https://git.clan.lol/clan/clan-core/issues/4748
|
||||||
|
|
||||||
|
return MachineState(
|
||||||
|
status=MachineStatus.OFFLINE
|
||||||
|
if get_value_by_path(inventory, f"machines.{machine.name}.installedAt", None)
|
||||||
|
else MachineStatus.NOT_INSTALLED
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from unittest.mock import ANY, patch
|
from unittest.mock import ANY, patch
|
||||||
@@ -12,7 +13,16 @@ from clan_lib.nix_models.clan import Clan, InventoryMachine, Unknown
|
|||||||
from clan_lib.persist.inventory_store import InventoryStore
|
from clan_lib.persist.inventory_store import InventoryStore
|
||||||
from clan_lib.persist.util import get_value_by_path, set_value_by_path
|
from clan_lib.persist.util import get_value_by_path, set_value_by_path
|
||||||
|
|
||||||
from .actions import get_machine, get_machine_fields_schema, list_machines, set_machine
|
from .actions import (
|
||||||
|
MachineState,
|
||||||
|
MachineStatus,
|
||||||
|
get_machine,
|
||||||
|
get_machine_fields_schema,
|
||||||
|
get_machine_state,
|
||||||
|
list_machine_state,
|
||||||
|
list_machines,
|
||||||
|
set_machine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
@@ -219,7 +229,44 @@ def test_get_machine_writeability(clan_flake: Callable[..., Flake]) -> None:
|
|||||||
"deploy.buildHost",
|
"deploy.buildHost",
|
||||||
"description",
|
"description",
|
||||||
"icon",
|
"icon",
|
||||||
|
"installedAt",
|
||||||
}
|
}
|
||||||
assert read_only_fields == {"machineClass", "name"}
|
assert read_only_fields == {"machineClass", "name"}
|
||||||
|
|
||||||
assert write_info["tags"]["readonly_members"] == ["nix1", "all", "nixos"]
|
assert write_info["tags"]["readonly_members"] == ["nix1", "all", "nixos"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.with_core
|
||||||
|
def test_machine_state(clan_flake: Callable[..., Flake]) -> None:
|
||||||
|
now = int(time.time())
|
||||||
|
yesterday = now - 86400
|
||||||
|
last_week = now - 604800
|
||||||
|
|
||||||
|
flake = clan_flake(
|
||||||
|
# clan.nix, cannot be changed
|
||||||
|
clan={
|
||||||
|
"inventory": {
|
||||||
|
"machines": {
|
||||||
|
"jon": {},
|
||||||
|
"sara": {"installedAt": yesterday},
|
||||||
|
"bob": {"installedAt": last_week},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_machine_state(flake) == {
|
||||||
|
"jon": MachineState(status=MachineStatus.NOT_INSTALLED),
|
||||||
|
"sara": MachineState(status=MachineStatus.OFFLINE),
|
||||||
|
"bob": MachineState(status=MachineStatus.OFFLINE),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert get_machine_state(Machine("jon", flake)) == MachineState(
|
||||||
|
status=MachineStatus.NOT_INSTALLED
|
||||||
|
)
|
||||||
|
assert get_machine_state(Machine("sara", flake)) == MachineState(
|
||||||
|
status=MachineStatus.OFFLINE
|
||||||
|
)
|
||||||
|
assert get_machine_state(Machine("bob", flake)) == MachineState(
|
||||||
|
status=MachineStatus.OFFLINE
|
||||||
|
)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class InventoryMachineDeploy(TypedDict):
|
|||||||
InventoryMachineDeployType = InventoryMachineDeploy
|
InventoryMachineDeployType = InventoryMachineDeploy
|
||||||
InventoryMachineDescriptionType = str | None
|
InventoryMachineDescriptionType = str | None
|
||||||
InventoryMachineIconType = str | None
|
InventoryMachineIconType = str | None
|
||||||
|
InventoryMachineInstalledatType = int | None
|
||||||
InventoryMachineMachineclassType = Literal["nixos", "darwin"]
|
InventoryMachineMachineclassType = Literal["nixos", "darwin"]
|
||||||
InventoryMachineNameType = str
|
InventoryMachineNameType = str
|
||||||
InventoryMachineTagsType = list[str]
|
InventoryMachineTagsType = list[str]
|
||||||
@@ -80,6 +81,7 @@ class InventoryMachine(TypedDict):
|
|||||||
deploy: NotRequired[InventoryMachineDeployType]
|
deploy: NotRequired[InventoryMachineDeployType]
|
||||||
description: NotRequired[InventoryMachineDescriptionType]
|
description: NotRequired[InventoryMachineDescriptionType]
|
||||||
icon: NotRequired[InventoryMachineIconType]
|
icon: NotRequired[InventoryMachineIconType]
|
||||||
|
installedAt: NotRequired[InventoryMachineInstalledatType]
|
||||||
machineClass: NotRequired[InventoryMachineMachineclassType]
|
machineClass: NotRequired[InventoryMachineMachineclassType]
|
||||||
name: NotRequired[InventoryMachineNameType]
|
name: NotRequired[InventoryMachineNameType]
|
||||||
tags: NotRequired[InventoryMachineTagsType]
|
tags: NotRequired[InventoryMachineTagsType]
|
||||||
|
|||||||
Reference in New Issue
Block a user