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:
brianmcgee
2025-08-19 14:23:35 +00:00
24 changed files with 6636 additions and 128 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
.machineStatus {
@apply flex flex-col gap-2 w-full;
.summary {
@apply flex flex-row justify-between items-center;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
.install {
@apply flex flex-col gap-4 w-full justify-center items-center;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
.sidebarPane {
@apply absolute left-[16.5rem] top-12 w-64;
@apply min-h-96;
height: calc(100vh - 10rem);
}

View File

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

View File

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

View File

@@ -1 +1 @@
/nix/store/q012qk78pwldxl3qjy09nwrx9jlamivm-nixpkgs /nix/store/apspgd56g9qy6fca8d44qnhdaiqrdf2c-nixpkgs

View File

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

View File

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

View File

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