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 {
description = ''
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 {
@apply flex items-center gap-1;
@apply flex items-center gap-1.5;
.indicator {
@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);
}
&.installed > .indicator {
&.out-of-sync > .indicator {
background-color: theme(colors.fg.inv.3);
}
}

View File

@@ -20,27 +20,38 @@ export default meta;
type Story = StoryObj<MachineStatusProps>;
export const Loading: Story = {
args: {},
};
export const Online: Story = {
args: {
status: "Online",
status: "online",
},
};
export const Offline: Story = {
args: {
status: "Offline",
status: "offline",
},
};
export const Installed: Story = {
export const OutOfSync: Story = {
args: {
status: "Installed",
status: "out_of_sync",
},
};
export const NotInstalled: Story = {
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 = {
args: {
...Installed.args,
...OutOfSync.args,
label: true,
},
};

View File

@@ -2,41 +2,58 @@ import "./MachineStatus.css";
import { Badge } from "@kobalte/core/badge";
import cx from "classnames";
import { Show } from "solid-js";
import { Match, Show, Switch } from "solid-js";
import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography";
export type MachineStatus =
| "Online"
| "Offline"
| "Installed"
| "Not Installed";
import { MachineStatus as MachineStatusModel } from "@/src/hooks/queries";
import { Loader } from "../Loader/Loader";
export interface MachineStatusProps {
label?: boolean;
status: MachineStatus;
status?: MachineStatusModel;
}
export const MachineStatus = (props: MachineStatusProps) => (
export const MachineStatus = (props: MachineStatusProps) => {
const status = () => props.status;
// remove the '_' from the enum
// we will use css transform in the typography component to capitalize
const statusText = () => props.status?.replaceAll("_", " ");
return (
<Switch>
<Match when={!status()}>
<Loader />
</Match>
<Match when={status()}>
<Badge
class={cx("machine-status", {
online: props.status == "Online",
offline: props.status == "Offline",
installed: props.status == "Installed",
"not-installed": props.status == "Not Installed",
online: status() == "online",
offline: status() == "offline",
"out-of-sync": status() == "out_of_sync",
"not-installed": status() == "not_installed",
})}
textValue={props.status}
textValue={status()}
>
{props.label && (
<Typography hierarchy="label" size="xs" weight="medium" inverted={true}>
{props.status}
<Typography
hierarchy="label"
size="xs"
weight="medium"
inverted={true}
transform="capitalize"
>
{statusText()}
</Typography>
)}
<Show
when={props.status == "Not Installed"}
fallback={<div class="indicator" />}
when={status() != "not_installed"}
fallback={<Icon icon="Offline" inverted={true} />}
>
<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;
& > div.header {
}
& > div.body {
.body {
@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 { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
import cx from "classnames";
export interface LinkProps {
path: string;
@@ -13,16 +14,15 @@ export interface SectionProps {
}
export interface SidebarProps {
class?: string;
staticSections?: SectionProps[];
}
export const Sidebar = (props: SidebarProps) => {
return (
<>
<div class="sidebar">
<div class={cx(styles.sidebar, props.class)}>
<SidebarHeader />
<SidebarBody {...props} />
<SidebarBody class={cx(styles.body)} {...props} />
</div>
</>
);
};

View File

@@ -6,18 +6,23 @@ import { Typography } from "@/src/components/Typography/Typography";
import { For } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachinesQuery } from "@/src/hooks/queries";
import { useMachinesQuery, useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar";
interface MachineProps {
clanURI: string;
machineID: string;
name: string;
status: MachineStatus;
serviceCount: number;
}
const MachineRoute = (props: MachineProps) => (
const MachineRoute = (props: MachineProps) => {
const statusQuery = useMachineStateQuery(props.clanURI, props.machineID);
const status = () =>
statusQuery.isSuccess ? statusQuery.data.status : undefined;
return (
<A href={buildMachinePath(props.clanURI, props.machineID)}>
<div class="flex w-full flex-col gap-2">
<div class="flex flex-row items-center justify-between">
@@ -30,7 +35,7 @@ const MachineRoute = (props: MachineProps) => (
>
{props.name}
</Typography>
<MachineStatus status={props.status} />
<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" />
@@ -46,7 +51,8 @@ const MachineRoute = (props: MachineProps) => (
</div>
</div>
</A>
);
);
};
export const SidebarBody = (props: SidebarProps) => {
const clanURI = useClanURI();
@@ -96,7 +102,6 @@ export const SidebarBody = (props: SidebarProps) => {
clanURI={clanURI}
machineID={id}
name={machine.name || id}
status="Not Installed"
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;
& > div.header > *,
& > div.sub-header > *,
& > div.body > * {
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 {
@apply flex flex-col gap-4 px-2 pt-4 pb-3 w-full h-full;
@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 { Typography } from "@/src/components/Typography/Typography";
import Icon from "../Icon/Icon";
@@ -6,8 +6,10 @@ import { Button as KButton } from "@kobalte/core/button";
import cx from "classnames";
export interface SidebarPaneProps {
class?: string;
title: string;
onClose: () => void;
subHeader?: () => JSX.Element;
children: JSX.Element;
}
@@ -26,7 +28,12 @@ export const SidebarPane = (props: SidebarPaneProps) => {
});
return (
<div class={cx("sidebar-pane", { closing: closing(), open: open() })}>
<div
class={cx("sidebar-pane", props.class, {
closing: closing(),
open: open(),
})}
>
<div class="header">
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
{props.title}
@@ -35,6 +42,9 @@ export const SidebarPane = (props: SidebarPaneProps) => {
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
</KButton>
</div>
<Show when={props.subHeader}>
<div class="sub-header">{props.subHeader!()}</div>
</Show>
<div class="body">{props.children}</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 Machine = SuccessData<"get_machine">;
export type MachineState = SuccessData<"get_machine_state">;
export type MachineStatus = MachineState["status"];
export type ListMachines = SuccessData<"list_machines">;
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 = (
clanURI: string,
machineName: string,

View File

@@ -1,24 +1,20 @@
.fade-out {
.fadeOut {
opacity: 0;
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;
-webkit-backdrop-filter: blur(4px);
}
.create-modal {
.createModal {
@apply min-w-96;
}
.sidebar-container {
}
.sidebar {
@apply absolute left-4 top-10 w-60;
@apply min-h-96;
div.sidebar {
@apply absolute top-10 bottom-20 left-4 w-60;
}
div.sidebar-pane {
@apply absolute top-12 bottom-20 left-[16.5rem] w-64;
height: calc(100vh - 8rem);
}

View File

@@ -27,7 +27,7 @@ import { produce } from "solid-js/store";
import { Button } from "@/src/components/Button/Button";
import { Splash } from "@/src/scene/splash";
import cx from "classnames";
import "./Clan.css";
import styles from "./Clan.module.css";
import { Modal } from "@/src/components/Modal/Modal";
import { TextInput } from "@/src/components/Form/TextInput";
import { createForm, FieldValues, reset } from "@modular-forms/solid";
@@ -37,7 +37,7 @@ import { useNavigate } from "@solidjs/router";
export const Clan: Component<RouteSectionProps> = (props) => {
return (
<>
<Sidebar />
<Sidebar class={cx(styles.sidebar)} />
{props.children}
<ClanSceneController {...props} />
</>
@@ -59,7 +59,7 @@ const MockCreateMachine = (props: MockProps) => {
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
return (
<div ref={(el) => (container = el)} class="create-backdrop">
<div ref={(el) => (container = el)} class={cx(styles.createBackdrop)}>
<Modal
open={true}
mount={container!}
@@ -67,7 +67,7 @@ const MockCreateMachine = (props: MockProps) => {
reset(form);
props.onClose();
}}
class="create-modal"
class={cx(styles.createModal)}
title="Create Machine"
>
{() => (
@@ -234,7 +234,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
</Button>
<div
class={cx({
"fade-out": !machinesQuery.isLoading && loadingCooldown(),
[styles.fadeOut]: !machinesQuery.isLoading && loadingCooldown(),
})}
>
<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 { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
import { createSignal, Show } from "solid-js";
import { Show } from "solid-js";
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 { SectionTags } from "@/src/routes/Machine/SectionTags";
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) => {
const navigate = useNavigate();
@@ -18,10 +21,6 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI);
};
const [showInstall, setShowModal] = createSignal(false);
let container: Node;
const sidebarPane = (machineName: string) => {
const machineQuery = useMachineQuery(clanURI, machineName);
@@ -53,7 +52,15 @@ export const Machine = (props: RouteSectionProps) => {
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
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} />
<SectionTags {...sectionProps} />
</SidebarPane>
@@ -62,22 +69,6 @@ export const Machine = (props: RouteSectionProps) => {
return (
<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())}
</Show>
);

View File

@@ -5,7 +5,7 @@
}
.toolbar-container {
@apply absolute bottom-8 z-10 w-full;
@apply absolute bottom-10 z-10 w-full;
@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 clan_lib.api import API
@@ -25,6 +26,18 @@ class ListOptions(TypedDict):
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
def list_machines(
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
}
@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 typing import cast
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.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
@@ -219,7 +229,44 @@ def test_get_machine_writeability(clan_flake: Callable[..., Flake]) -> None:
"deploy.buildHost",
"description",
"icon",
"installedAt",
}
assert read_only_fields == {"machineClass", "name"}
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
InventoryMachineDescriptionType = str | None
InventoryMachineIconType = str | None
InventoryMachineInstalledatType = int | None
InventoryMachineMachineclassType = Literal["nixos", "darwin"]
InventoryMachineNameType = str
InventoryMachineTagsType = list[str]
@@ -80,6 +81,7 @@ class InventoryMachine(TypedDict):
deploy: NotRequired[InventoryMachineDeployType]
description: NotRequired[InventoryMachineDescriptionType]
icon: NotRequired[InventoryMachineIconType]
installedAt: NotRequired[InventoryMachineInstalledatType]
machineClass: NotRequired[InventoryMachineMachineclassType]
name: NotRequired[InventoryMachineNameType]
tags: NotRequired[InventoryMachineTagsType]