Clan-app: Add loading animations & improve async data handling

This commit is contained in:
Johannes Kirschbauer
2024-08-06 22:29:11 +02:00
parent ec16059abc
commit e69d6b22f0
10 changed files with 203 additions and 140 deletions

View File

@@ -13,15 +13,13 @@ log = logging.getLogger(__name__)
@API.register @API.register
def list_inventory_machines( def list_inventory_machines(flake_url: str | Path) -> dict[str, Machine]:
flake_url: str | Path, debug: bool = False
) -> dict[str, Machine]:
inventory = load_inventory_eval(flake_url) inventory = load_inventory_eval(flake_url)
return inventory.machines return inventory.machines
@API.register @API.register
def list_nixos_machines(flake_url: str | Path, debug: bool = False) -> list[str]: def list_nixos_machines(flake_url: str | Path) -> list[str]:
cmd = nix_eval( cmd = nix_eval(
[ [
f"{flake_url}#nixosConfigurations", f"{flake_url}#nixosConfigurations",
@@ -42,7 +40,7 @@ def list_nixos_machines(flake_url: str | Path, debug: bool = False) -> list[str]
def list_command(args: argparse.Namespace) -> None: def list_command(args: argparse.Namespace) -> None:
flake_path = args.flake.path flake_path = args.flake.path
for name in list_nixos_machines(flake_path, args.debug): for name in list_nixos_machines(flake_path):
print(name) print(name)

View File

@@ -7,9 +7,6 @@ import { makePersisted } from "@solid-primitives/storage";
// Some global state // Some global state
const [route, setRoute] = createSignal<Route>("machines"); const [route, setRoute] = createSignal<Route>("machines");
createEffect(() => {
console.log(route());
});
export { route, setRoute }; export { route, setRoute };

View File

@@ -69,6 +69,11 @@ export const routes = {
label: "diskConfig", label: "diskConfig",
icon: "disk", icon: "disk",
}, },
"machines/edit": {
child: CreateMachine,
label: "Edit Machine",
icon: "edit",
},
}; };
interface RouterProps { interface RouterProps {

View File

@@ -123,7 +123,7 @@ export const callApi = <K extends OperationNames>(
return new Promise<OperationResponse<K>>((resolve) => { return new Promise<OperationResponse<K>>((resolve) => {
const id = nanoid(); const id = nanoid();
pyApi[method].receive((response) => { pyApi[method].receive((response) => {
console.log("Received response: ", { response }); console.log(method, "Received response: ", { response });
resolve(response); resolve(response);
}, id); }, id);
@@ -136,7 +136,6 @@ const deserialize =
(str: string) => { (str: string) => {
try { try {
const r = JSON.parse(str) as T; const r = JSON.parse(str) as T;
console.log("Received: ", r);
fn(r); fn(r);
} catch (e) { } catch (e) {
console.log("Error parsing JSON: ", e); console.log("Error parsing JSON: ", e);

View File

@@ -1,86 +1,67 @@
import { createSignal, Match, Show, Switch } from "solid-js"; import { Accessor, createEffect, Show } from "solid-js";
import { ErrorData, pyApi, SuccessData } from "../api"; import { SuccessData } from "../api";
import { Menu } from "./Menu";
import { setRoute } from "../App";
type MachineDetails = SuccessData<"list_inventory_machines">["data"][string]; type MachineDetails = SuccessData<"list_inventory_machines">["data"][string];
interface MachineListItemProps { interface MachineListItemProps {
name: string; name: string;
info?: MachineDetails; info?: MachineDetails;
nixOnly?: boolean;
} }
type HWInfo = Record<string, SuccessData<"show_machine_hardware_info">["data"]>;
type DeploymentInfo = Record<
string,
SuccessData<"show_machine_deployment_target">["data"]
>;
type MachineErrors = Record<string, ErrorData<"show_machine">["errors"]>;
const [hwInfo, setHwInfo] = createSignal<HWInfo>({});
const [deploymentInfo, setDeploymentInfo] = createSignal<DeploymentInfo>({});
const [errors, setErrors] = createSignal<MachineErrors>({});
// pyApi.show_machine_hardware_info.receive((r) => {
// const { op_key } = r;
// if (r.status === "error") {
// console.error(r.errors);
// if (op_key) {
// setHwInfo((d) => ({ ...d, [op_key]: { system: null } }));
// }
// return;
// }
// if (op_key) {
// setHwInfo((d) => ({ ...d, [op_key]: r.data }));
// }
// });
// pyApi.show_machine_deployment_target.receive((r) => {
// const { op_key } = r;
// if (r.status === "error") {
// console.error(r.errors);
// if (op_key) {
// setDeploymentInfo((d) => ({ ...d, [op_key]: null }));
// }
// return;
// }
// if (op_key) {
// setDeploymentInfo((d) => ({ ...d, [op_key]: r.data }));
// }
// });
export const MachineListItem = (props: MachineListItemProps) => { export const MachineListItem = (props: MachineListItemProps) => {
const { name, info } = props; const { name, info, nixOnly } = props;
return ( return (
<li> <li>
<div class="card card-side m-2 bg-base-100 shadow-lg"> <div class="card card-side m-2 bg-base-200">
<figure class="pl-2"> <figure class="pl-2">
<span class="material-icons content-center text-5xl"> <span
class="material-icons content-center text-5xl"
classList={{
"text-neutral-500": nixOnly,
}}
>
devices_other devices_other
</span> </span>
</figure> </figure>
<div class="card-body flex-row justify-between "> <div class="card-body flex-row justify-between ">
<div class="flex flex-col"> <div class="flex flex-col">
<h2 class="card-title">{name}</h2> <h2
class="card-title"
classList={{
"text-neutral-500": nixOnly,
}}
>
{name}
</h2>
<div class="text-slate-600"> <div class="text-slate-600">
<Show when={info}>{(d) => d()?.description}</Show> <Show when={info}>{(d) => d()?.description}</Show>
</div> </div>
<div class="flex flex-row flex-wrap gap-4 py-2"></div> <div class="flex flex-row flex-wrap gap-4 py-2"></div>
{/* Show only the first error at the bottom */}
<Show when={errors()[name]?.[0]}>
{(error) => (
<div class="badge badge-error py-4">
Error: {error().message}: {error().description}
</div>
)}
</Show>
</div> </div>
<div> <div>
<button class="btn btn-ghost"> <Menu
<span class="material-icons">more_vert</span> popoverid={`menu-${props.name}`}
</button> label={<span class="material-icons">more_vert</span>}
>
<ul class="menu z-[1] w-52 rounded-box bg-base-100 p-2 shadow">
<li>
<a
onClick={() => {
setRoute("machines/edit");
}}
>
Edit
</a>
</li>
<li>
<a>Deploy</a>
</li>
</ul>
</Menu>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,84 @@
import { children, Component, createSignal, type JSX } from "solid-js";
import { useFloating } from "@/src/floating";
import {
autoUpdate,
flip,
hide,
offset,
Placement,
shift,
} from "@floating-ui/dom";
import cx from "classnames";
interface MenuProps {
/**
* Used by the html API to associate the popover with the dispatcher button
*/
popoverid: string;
label: JSX.Element;
children?: JSX.Element;
buttonProps?: JSX.ButtonHTMLAttributes<HTMLButtonElement>;
buttonClass?: string;
/**
* @default "bottom"
*/
placement?: Placement;
}
export const Menu = (props: MenuProps) => {
const c = children(() => props.children);
const [reference, setReference] = createSignal<HTMLElement>();
const [floating, setFloating] = createSignal<HTMLElement>();
// `position` is a reactive object.
const position = useFloating(reference, floating, {
placement: "bottom",
// pass options. Ensure the cleanup function is returned.
whileElementsMounted: (reference, floating, update) =>
autoUpdate(reference, floating, update, {
animationFrame: true,
}),
middleware: [
offset(5),
shift(),
flip(),
hide({
strategy: "referenceHidden",
}),
],
});
return (
<div>
<button
popovertarget={props.popoverid}
popovertargetaction="toggle"
ref={setReference}
class={cx(
"btn btn-ghost btn-outline join-item btn-sm",
props.buttonClass,
)}
{...props.buttonProps}
>
{props.label}
</button>
<div
popover="auto"
id={props.popoverid}
ref={setFloating}
style={{
margin: 0,
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
class="bg-transparent"
>
{c()}
</div>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { activeURI, setRoute } from "../App"; import { activeURI, setRoute } from "../App";
import { callApi } from "../api"; import { callApi } from "../api";
import { Accessor, createEffect, Show } from "solid-js"; import { Accessor, Show } from "solid-js";
interface HeaderProps { interface HeaderProps {
clan_dir: Accessor<string | null>; clan_dir: Accessor<string | null>;
@@ -34,7 +34,14 @@ export const Header = (props: HeaderProps) => {
</span> </span>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<Show when={!query.isFetching && query.data}> <Show when={query.isLoading && !query.data}>
<div class="skeleton mx-4 size-11 rounded-full"></div>
<span class="flex flex-col gap-2">
<div class="skeleton h-3 w-32"></div>
<div class="skeleton h-3 w-40"></div>
</span>
</Show>
<Show when={query.data}>
{(meta) => ( {(meta) => (
<div class="tooltip tooltip-right" data-tip={activeURI()}> <div class="tooltip tooltip-right" data-tip={activeURI()}>
<div class="avatar placeholder online mx-4"> <div class="avatar placeholder online mx-4">
@@ -46,7 +53,7 @@ export const Header = (props: HeaderProps) => {
)} )}
</Show> </Show>
<span class="flex flex-col"> <span class="flex flex-col">
<Show when={!query.isFetching && query.data}> <Show when={query.data}>
{(meta) => [ {(meta) => [
<span class="text-primary">{meta().name}</span>, <span class="text-primary">{meta().name}</span>,
<span class="text-neutral">{meta()?.description}</span>, <span class="text-neutral">{meta()?.description}</span>,

View File

@@ -1,7 +1,7 @@
import { callApi, OperationArgs, pyApi, OperationResponse } from "@/src/api"; import { callApi, OperationArgs, pyApi, OperationResponse } from "@/src/api";
import { activeURI, setRoute } from "@/src/App"; import { activeURI, setRoute } from "@/src/App";
import { createForm, required, reset } from "@modular-forms/solid"; import { createForm, required, reset } from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query"; import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { Match, Switch } from "solid-js"; import { Match, Switch } from "solid-js";
import toast from "solid-toast"; import toast from "solid-toast";
@@ -23,9 +23,7 @@ export function CreateMachine() {
}, },
}); });
const { refetch: refetchMachines } = createQuery(() => ({ const queryClient = useQueryClient();
queryKey: [activeURI(), "list_inventory_machines"],
}));
const handleSubmit = async (values: CreateMachineForm) => { const handleSubmit = async (values: CreateMachineForm) => {
const active_dir = activeURI(); const active_dir = activeURI();
@@ -46,7 +44,10 @@ export function CreateMachine() {
if (response.status === "success") { if (response.status === "success") {
toast.success(`Successfully created ${values.machine.name}`); toast.success(`Successfully created ${values.machine.name}`);
reset(formStore); reset(formStore);
refetchMachines();
queryClient.invalidateQueries({
queryKey: [activeURI(), "list_machines"],
});
setRoute("machines"); setRoute("machines");
} else { } else {
toast.error( toast.error(

View File

@@ -3,43 +3,28 @@ import { activeURI, setRoute } from "@/src/App";
import { callApi, OperationResponse } from "@/src/api"; import { callApi, OperationResponse } from "@/src/api";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem"; import { MachineListItem } from "@/src/components/MachineListItem";
import { createQuery } from "@tanstack/solid-query"; import {
createQueries,
createQuery,
useQueryClient,
} from "@tanstack/solid-query";
type MachinesModel = Extract< type MachinesModel = Extract<
OperationResponse<"list_inventory_machines">, OperationResponse<"list_inventory_machines">,
{ status: "success" } { status: "success" }
>["data"]; >["data"];
type ExtendedMachine = MachinesModel & {
nixOnly: boolean;
};
export const MachineListView: Component = () => { export const MachineListView: Component = () => {
const { const queryClient = useQueryClient();
data: nixosMachines,
isFetching: isLoadingNixos, const inventoryQuery = createQuery<MachinesModel>(() => ({
refetch: refetchNixos, queryKey: [activeURI(), "list_machines", "inventory"],
} = createQuery<string[]>(() => ({ placeholderData: {},
queryKey: [activeURI(), "list_nixos_machines"], enabled: !!activeURI(),
queryFn: async () => {
const uri = activeURI();
if (uri) {
const response = await callApi("list_nixos_machines", {
flake_url: uri,
});
if (response.status === "error") {
toast.error("Failed to fetch data");
} else {
return response.data;
}
}
return [];
},
staleTime: 1000 * 60 * 5,
}));
const {
data: inventoryMachines,
isFetching: isLoadingInventory,
refetch: refetchInventory,
} = createQuery<MachinesModel>(() => ({
queryKey: [activeURI(), "list_inventory_machines"],
initialData: {},
queryFn: async () => { queryFn: async () => {
const uri = activeURI(); const uri = activeURI();
if (uri) { if (uri) {
@@ -54,26 +39,43 @@ export const MachineListView: Component = () => {
} }
return {}; return {};
}, },
staleTime: 1000 * 60 * 5, }));
const nixosQuery = createQuery<string[]>(() => ({
queryKey: [activeURI(), "list_machines", "nixos"],
enabled: !!activeURI(),
placeholderData: [],
queryFn: async () => {
const uri = activeURI();
if (uri) {
const response = await callApi("list_nixos_machines", {
flake_url: uri,
});
if (response.status === "error") {
toast.error("Failed to fetch data");
} else {
return response.data;
}
}
return [];
},
})); }));
const refresh = async () => { const refresh = async () => {
refetchInventory(); queryClient.invalidateQueries({
refetchNixos(); // Invalidates the cache for of all types of machine list at once
queryKey: [activeURI(), "list_machines"],
});
}; };
const unpackedMachines = () => Object.entries(inventoryMachines); const inventoryMachines = () => Object.entries(inventoryQuery.data || {});
const nixOnlyMachines = () => const nixOnlyMachines = () =>
nixosMachines?.filter( nixosQuery.data?.filter(
(name) => !unpackedMachines().some(([key, machine]) => key === name), (name) => !inventoryMachines().some(([key, machine]) => key === name),
); );
createEffect(() => {
console.log(nixOnlyMachines(), unpackedMachines());
});
return ( return (
<div class="max-w-screen-lg"> <div>
<div class="tooltip tooltip-bottom" data-tip="Open Clan"></div> <div class="tooltip tooltip-bottom" data-tip="Open Clan"></div>
<div class="tooltip tooltip-bottom" data-tip="Refresh"> <div class="tooltip tooltip-bottom" data-tip="Refresh">
<button class="btn btn-ghost" onClick={() => refresh()}> <button class="btn btn-ghost" onClick={() => refresh()}>
@@ -86,7 +88,7 @@ export const MachineListView: Component = () => {
</button> </button>
</div> </div>
<Switch> <Switch>
<Match when={isLoadingInventory}> <Match when={inventoryQuery.isLoading}>
{/* Loading skeleton */} {/* Loading skeleton */}
<div> <div>
<div class="card card-side m-2 bg-base-100 shadow-lg"> <div class="card card-side m-2 bg-base-100 shadow-lg">
@@ -104,20 +106,20 @@ export const MachineListView: Component = () => {
</Match> </Match>
<Match <Match
when={ when={
!isLoadingInventory && !inventoryQuery.isLoading &&
unpackedMachines().length === 0 && inventoryMachines().length === 0 &&
nixOnlyMachines()?.length === 0 nixOnlyMachines()?.length === 0
} }
> >
No machines found No machines found
</Match> </Match>
<Match when={!isLoadingInventory}> <Match when={!inventoryQuery.isLoading}>
<ul> <ul>
<For each={unpackedMachines()}> <For each={inventoryMachines()}>
{([name, info]) => <MachineListItem name={name} info={info} />} {([name, info]) => <MachineListItem name={name} info={info} />}
</For> </For>
<For each={nixOnlyMachines()}> <For each={nixOnlyMachines()}>
{(name) => <MachineListItem name={name} />} {(name) => <MachineListItem name={name} nixOnly={true} />}
</For> </For>
</ul> </ul>
</Match> </Match>

View File

@@ -1,10 +1,4 @@
import { callApi } from "@/src/api"; import { callApi } from "@/src/api";
import {
createForm,
required,
setValue,
SubmitHandler,
} from "@modular-forms/solid";
import { import {
activeURI, activeURI,
clanList, clanList,
@@ -12,15 +6,7 @@ import {
setClanList, setClanList,
setRoute, setRoute,
} from "@/src/App"; } from "@/src/App";
import { import { createSignal, For, Match, Setter, Show, Switch } from "solid-js";
createEffect,
createSignal,
For,
Match,
Setter,
Show,
Switch,
} from "solid-js";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { useFloating } from "@/src/floating"; import { useFloating } from "@/src/floating";
import { autoUpdate, flip, hide, offset, shift } from "@floating-ui/dom"; import { autoUpdate, flip, hide, offset, shift } from "@floating-ui/dom";
@@ -156,6 +142,9 @@ const ClanDetails = (props: ClanDetailsProps) => {
</div> </div>
<div class="stat-title">{clan_dir}</div> <div class="stat-title">{clan_dir}</div>
<Show when={details.isLoading}>
<div class="skeleton h-12 w-80" />
</Show>
<Show when={details.isSuccess}> <Show when={details.isSuccess}>
<div class="stat-value">{details.data?.name}</div> <div class="stat-value">{details.data?.name}</div>
</Show> </Show>