Merge pull request 'api: init notification queue' (#4678) from ui-notify into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4678
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
from time import sleep
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from clan_lib.api import MethodRegistry
|
from clan_lib.api import MethodRegistry, message_queue
|
||||||
from clan_lib.api.tasks import WebThread
|
from clan_lib.api.tasks import WebThread
|
||||||
from clan_lib.log_manager import LogManager
|
from clan_lib.log_manager import LogManager
|
||||||
|
|
||||||
@@ -69,6 +71,22 @@ class Webview:
|
|||||||
if self.size:
|
if self.size:
|
||||||
self.set_size(self.size)
|
self.set_size(self.size)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.setup_notify() # Start the notification loop
|
||||||
|
|
||||||
|
def setup_notify(self) -> None:
|
||||||
|
def loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = message_queue.get() # Blocks until available
|
||||||
|
js_code = f"window.notifyBus({json.dumps(msg)});"
|
||||||
|
self.eval(js_code)
|
||||||
|
except Exception as e:
|
||||||
|
print("Bridge notify error:", e)
|
||||||
|
sleep(0.01) # avoid busy loop
|
||||||
|
|
||||||
|
threading.Thread(target=loop, daemon=True).start()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def handle(self) -> Any:
|
def handle(self) -> Any:
|
||||||
"""Get the webview handle, creating it if necessary."""
|
"""Get the webview handle, creating it if necessary."""
|
||||||
@@ -129,6 +147,7 @@ class Webview:
|
|||||||
webview=self, middleware_chain=tuple(self._middleware), threads={}
|
webview=self, middleware_chain=tuple(self._middleware), threads={}
|
||||||
)
|
)
|
||||||
self._bridge = bridge
|
self._bridge = bridge
|
||||||
|
|
||||||
return bridge
|
return bridge
|
||||||
|
|
||||||
# Legacy methods for compatibility
|
# Legacy methods for compatibility
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class WebviewBridge(ApiBridge):
|
|||||||
|
|
||||||
def send_api_response(self, response: BackendResponse) -> None:
|
def send_api_response(self, response: BackendResponse) -> None:
|
||||||
"""Send response back to the webview client."""
|
"""Send response back to the webview client."""
|
||||||
|
|
||||||
serialized = json.dumps(
|
serialized = json.dumps(
|
||||||
dataclass_to_dict(response), indent=4, ensure_ascii=False
|
dataclass_to_dict(response), indent=4, ensure_ascii=False
|
||||||
)
|
)
|
||||||
|
|||||||
9
pkgs/clan-app/ui/index.d.ts
vendored
Normal file
9
pkgs/clan-app/ui/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ProcessMessage } from "./src/hooks/notify";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
notifyBus: (data: ProcessMessage) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
.modal_content {
|
.modal_content {
|
||||||
@apply min-w-[320px] max-w-[512px];
|
@apply min-w-[320px] flex flex-col;
|
||||||
@apply rounded-md overflow-hidden;
|
@apply rounded-md overflow-hidden;
|
||||||
|
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
|
||||||
/* todo replace with a theme() color */
|
/* todo replace with a theme() color */
|
||||||
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
|
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
|
||||||
@apply border border-def-3 rounded-bl-md rounded-br-md;
|
@apply border border-def-3 rounded-bl-md rounded-br-md;
|
||||||
@@ -22,7 +24,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal_body {
|
.modal_body {
|
||||||
@apply rounded-b-md p-6 pt-4 bg-def-1;
|
overflow-y: auto;
|
||||||
|
@apply rounded-b-md p-6 pt-4 bg-def-1 flex-grow;
|
||||||
|
|
||||||
&[data-no-padding] {
|
&[data-no-padding] {
|
||||||
@apply p-0;
|
@apply p-0;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const callApi = <K extends OperationNames>(
|
|||||||
|
|
||||||
const op_key = backendOpts?.op_key ?? crypto.randomUUID();
|
const op_key = backendOpts?.op_key ?? crypto.randomUUID();
|
||||||
|
|
||||||
const req: BackendSendType<OperationNames> = {
|
const req: BackendSendType<K> = {
|
||||||
body: args,
|
body: args,
|
||||||
header: {
|
header: {
|
||||||
...backendOpts,
|
...backendOpts,
|
||||||
@@ -83,11 +83,9 @@ export const callApi = <K extends OperationNames>(
|
|||||||
const result = (
|
const result = (
|
||||||
window as unknown as Record<
|
window as unknown as Record<
|
||||||
OperationNames,
|
OperationNames,
|
||||||
(
|
(args: BackendSendType<K>) => Promise<BackendReturnType<K>>
|
||||||
args: BackendSendType<OperationNames>,
|
|
||||||
) => Promise<BackendReturnType<OperationNames>>
|
|
||||||
>
|
>
|
||||||
)[method](req) as Promise<BackendReturnType<K>>;
|
)[method](req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uuid: op_key,
|
uuid: op_key,
|
||||||
|
|||||||
89
pkgs/clan-app/ui/src/hooks/notify.ts
Normal file
89
pkgs/clan-app/ui/src/hooks/notify.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { createSignal, onCleanup } from "solid-js";
|
||||||
|
import { OperationNames } from "./api";
|
||||||
|
|
||||||
|
export interface ProcessMessage<
|
||||||
|
TData = unknown,
|
||||||
|
TTopic extends string = string,
|
||||||
|
> {
|
||||||
|
topic: TTopic;
|
||||||
|
data: TData;
|
||||||
|
origin: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subscriber<T extends ProcessMessage> {
|
||||||
|
filter: (msg: T) => boolean;
|
||||||
|
callback: (msg: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers: Subscriber<ProcessMessage>[] = [];
|
||||||
|
|
||||||
|
// Declare the global function on the window type
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
notifyBus: (msg: ProcessMessage) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notifyBus = (msg: ProcessMessage) => {
|
||||||
|
console.debug("notifyBus called with:", msg);
|
||||||
|
for (const sub of subscribers) {
|
||||||
|
try {
|
||||||
|
if (sub.filter(msg)) {
|
||||||
|
sub.callback(msg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Subscriber threw an error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to any message
|
||||||
|
*
|
||||||
|
* Returns a function to unsubsribe itself
|
||||||
|
*
|
||||||
|
* consider using useNotify for reactive usage on solidjs
|
||||||
|
*/
|
||||||
|
export function _subscribeNotify<T extends ProcessMessage>(
|
||||||
|
filter: (msg: T) => boolean,
|
||||||
|
callback: (msg: T) => void,
|
||||||
|
) {
|
||||||
|
// Cast to shared subscriber type for storage
|
||||||
|
const sub: Subscriber<ProcessMessage> = {
|
||||||
|
filter: filter as (msg: ProcessMessage) => boolean,
|
||||||
|
callback: callback as (msg: ProcessMessage) => void,
|
||||||
|
};
|
||||||
|
subscribers.push(sub);
|
||||||
|
return () => {
|
||||||
|
const idx = subscribers.indexOf(sub);
|
||||||
|
if (idx >= 0) subscribers.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a reactive signal that tracks a message by the given filter predicate
|
||||||
|
* The signal has the value of the last message where filter was true
|
||||||
|
* null in case no message was recieved yet
|
||||||
|
*/
|
||||||
|
export function useNotify<T extends ProcessMessage = ProcessMessage>(
|
||||||
|
filter: (msg: T) => boolean = () => true as boolean,
|
||||||
|
) {
|
||||||
|
const [message, setMessage] = createSignal<T | null>(null);
|
||||||
|
|
||||||
|
const unsubscribe = _subscribeNotify(filter, (msg) => setMessage(() => msg));
|
||||||
|
|
||||||
|
onCleanup(unsubscribe);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks any message that was sent from this api origin
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function useNotifyOrigin<
|
||||||
|
T extends ProcessMessage = ProcessMessage,
|
||||||
|
K extends OperationNames = OperationNames,
|
||||||
|
>(origin: K) {
|
||||||
|
return useNotify<T>((m) => m.origin === origin);
|
||||||
|
}
|
||||||
@@ -5,8 +5,7 @@ import {
|
|||||||
StepperProvider,
|
StepperProvider,
|
||||||
useStepper,
|
useStepper,
|
||||||
} from "@/src/hooks/stepper";
|
} from "@/src/hooks/stepper";
|
||||||
import { createForm, FieldValues, SubmitHandler } from "@modular-forms/solid";
|
import { Show } from "solid-js";
|
||||||
import { onMount, Show } from "solid-js";
|
|
||||||
import { Dynamic } from "solid-js/web";
|
import { Dynamic } from "solid-js/web";
|
||||||
import { initialSteps } from "./steps/Initial";
|
import { initialSteps } from "./steps/Initial";
|
||||||
import { createInstallerSteps } from "./steps/createInstaller";
|
import { createInstallerSteps } from "./steps/createInstaller";
|
||||||
@@ -20,11 +19,12 @@ const InstallStepper = (props: InstallStepperProps) => {
|
|||||||
const stepSignal = useStepper<InstallSteps>();
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
|
|
||||||
onMount(() => {
|
return (
|
||||||
set("done", props.onDone);
|
<Dynamic
|
||||||
});
|
component={stepSignal.currentStep().content}
|
||||||
|
onDone={props.onDone}
|
||||||
return <Dynamic component={stepSignal.currentStep().content} />;
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface InstallModalProps {
|
export interface InstallModalProps {
|
||||||
@@ -53,8 +53,8 @@ export interface InstallStoreType {
|
|||||||
mainDisk: string;
|
mainDisk: string;
|
||||||
// ...TODO Vars
|
// ...TODO Vars
|
||||||
progress: ApiCall<"run_machine_install">;
|
progress: ApiCall<"run_machine_install">;
|
||||||
|
|
||||||
promptValues: PromptValues;
|
promptValues: PromptValues;
|
||||||
|
prepareStep: "disk" | "generators" | "install";
|
||||||
};
|
};
|
||||||
done: () => void;
|
done: () => void;
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,7 @@ export const InstallModal = (props: InstallModalProps) => {
|
|||||||
return (
|
return (
|
||||||
<StepperProvider stepper={stepper}>
|
<StepperProvider stepper={stepper}>
|
||||||
<Modal
|
<Modal
|
||||||
|
class="h-[30rem] w-screen max-w-3xl"
|
||||||
mount={props.mount}
|
mount={props.mount}
|
||||||
title="Install machine"
|
title="Install machine"
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const ChoiceLocalOrRemote = () => {
|
|||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<div class="flex flex-col justify-center gap-1 px-1">
|
<div class="flex flex-col justify-center gap-1 px-1">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="label"
|
hierarchy="body"
|
||||||
size="xs"
|
size="default"
|
||||||
weight="bold"
|
weight="bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
@@ -33,8 +33,8 @@ const ChoiceLocalOrRemote = () => {
|
|||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<div class="flex flex-col justify-center gap-1 px-1">
|
<div class="flex flex-col justify-center gap-1 px-1">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="label"
|
hierarchy="body"
|
||||||
size="xs"
|
size="default"
|
||||||
weight="bold"
|
weight="bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
@@ -64,8 +64,8 @@ const ChoiceLocalInstaller = () => {
|
|||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<div class="flex flex-col justify-center gap-1 px-1">
|
<div class="flex flex-col justify-center gap-1 px-1">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="label"
|
hierarchy="body"
|
||||||
size="xs"
|
size="default"
|
||||||
weight="bold"
|
weight="bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
@@ -85,8 +85,8 @@ const ChoiceLocalInstaller = () => {
|
|||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<div class="flex flex-col justify-center gap-1 px-1">
|
<div class="flex flex-col justify-center gap-1 px-1">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="label"
|
hierarchy="body"
|
||||||
size="xs"
|
size="default"
|
||||||
weight="bold"
|
weight="bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const ConfigureImage = () => {
|
|||||||
let content: Node;
|
let content: Node;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<div
|
<div
|
||||||
@@ -241,7 +241,7 @@ const ChooseDisk = () => {
|
|||||||
|
|
||||||
const stripId = (s: string) => s.split("-")[1] ?? s;
|
const stripId = (s: string) => s.split("-")[1] ?? s;
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -317,7 +317,7 @@ const FlashProgress = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-60 w-full flex-col items-center justify-end bg-inv-4">
|
<div class="flex size-full h-60 flex-col items-center justify-end bg-inv-4">
|
||||||
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="title"
|
hierarchy="title"
|
||||||
@@ -343,7 +343,7 @@ const FlashProgress = () => {
|
|||||||
const FlashDone = () => {
|
const FlashDone = () => {
|
||||||
const stepSignal = useStepper<InstallSteps>();
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
return (
|
return (
|
||||||
<div class="flex w-full flex-col items-center bg-inv-4">
|
<div class="flex size-full flex-col items-center justify-between bg-inv-4">
|
||||||
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
|
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
|
||||||
<div class="rounded-full bg-semantic-success-4">
|
<div class="rounded-full bg-semantic-success-4">
|
||||||
<Icon icon="Checkmark" class="size-9" />
|
<Icon icon="Checkmark" class="size-9" />
|
||||||
@@ -361,7 +361,8 @@ const FlashDone = () => {
|
|||||||
title="Remove it and plug it into the machine that you want to install."
|
title="Remove it and plug it into the machine that you want to install."
|
||||||
description=""
|
description=""
|
||||||
/>
|
/>
|
||||||
<div class="mt-3 flex w-full justify-end">
|
</div>
|
||||||
|
<div class="flex w-full justify-end p-6">
|
||||||
<Button
|
<Button
|
||||||
hierarchy="primary"
|
hierarchy="primary"
|
||||||
endIcon="ArrowRight"
|
endIcon="ArrowRight"
|
||||||
@@ -371,7 +372,6 @@ const FlashDone = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Typography } from "@/src/components/Typography/Typography";
|
|||||||
import { BackButton, NextButton, StepLayout } from "../../Steps";
|
import { BackButton, NextButton, StepLayout } from "../../Steps";
|
||||||
import {
|
import {
|
||||||
createForm,
|
createForm,
|
||||||
|
FieldValues,
|
||||||
getError,
|
getError,
|
||||||
SubmitHandler,
|
SubmitHandler,
|
||||||
valiForm,
|
valiForm,
|
||||||
@@ -12,7 +13,7 @@ import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
|||||||
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
|
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
import { Alert } from "@/src/components/Alert/Alert";
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
import { For, onMount, Show } from "solid-js";
|
import { For, Match, Show, Switch } from "solid-js";
|
||||||
import { Divider } from "@/src/components/Divider/Divider";
|
import { Divider } from "@/src/components/Divider/Divider";
|
||||||
import { Orienter } from "@/src/components/Form/Orienter";
|
import { Orienter } from "@/src/components/Form/Orienter";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from "@/src/hooks/queries";
|
} from "@/src/hooks/queries";
|
||||||
import { useClanURI } from "@/src/hooks/clan";
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
|
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
|
||||||
|
|
||||||
export const InstallHeader = (props: { machineName: string }) => {
|
export const InstallHeader = (props: { machineName: string }) => {
|
||||||
return (
|
return (
|
||||||
@@ -72,7 +74,7 @@ const ConfigureAddress = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -230,7 +232,7 @@ const ConfigureDisk = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -302,19 +304,19 @@ const ConfigureData = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type PromptGroup = {
|
interface PromptGroup {
|
||||||
name: string;
|
name: string;
|
||||||
fields: {
|
fields: {
|
||||||
prompt: Prompt;
|
prompt: Prompt;
|
||||||
generator: string;
|
generator: string;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
}[];
|
}[];
|
||||||
};
|
}
|
||||||
|
|
||||||
type Prompt = NonNullable<MachineGenerators[number]["prompts"]>[number];
|
type Prompt = NonNullable<MachineGenerators[number]["prompts"]>[number];
|
||||||
type PromptForm = {
|
interface PromptForm extends FieldValues {
|
||||||
promptValues: PromptValues;
|
promptValues: PromptValues;
|
||||||
};
|
}
|
||||||
|
|
||||||
interface PromptsFieldsProps {
|
interface PromptsFieldsProps {
|
||||||
generators: MachineGenerators;
|
generators: MachineGenerators;
|
||||||
@@ -365,7 +367,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -449,6 +451,8 @@ const InstallSummary = () => {
|
|||||||
// Here you would typically trigger the installation process
|
// Here you would typically trigger the installation process
|
||||||
console.log("Installation started");
|
console.log("Installation started");
|
||||||
|
|
||||||
|
stepSignal.setActiveStep("install:progress");
|
||||||
|
|
||||||
const setDisk = client.fetch("set_machine_disk_schema", {
|
const setDisk = client.fetch("set_machine_disk_schema", {
|
||||||
machine: {
|
machine: {
|
||||||
flake: {
|
flake: {
|
||||||
@@ -463,6 +467,10 @@ const InstallSummary = () => {
|
|||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
set("install", (s) => ({
|
||||||
|
...s,
|
||||||
|
prepareStep: "disk",
|
||||||
|
}));
|
||||||
const diskResult = await setDisk.result; // Wait for the disk schema to be set
|
const diskResult = await setDisk.result; // Wait for the disk schema to be set
|
||||||
if (diskResult.status === "error") {
|
if (diskResult.status === "error") {
|
||||||
console.error("Error setting disk schema:", diskResult.errors);
|
console.error("Error setting disk schema:", diskResult.errors);
|
||||||
@@ -474,8 +482,11 @@ const InstallSummary = () => {
|
|||||||
base_dir: clanUri,
|
base_dir: clanUri,
|
||||||
machine_name: store.install.machineName,
|
machine_name: store.install.machineName,
|
||||||
});
|
});
|
||||||
stepSignal.setActiveStep("install:progress");
|
|
||||||
|
|
||||||
|
set("install", (s) => ({
|
||||||
|
...s,
|
||||||
|
prepareStep: "generators",
|
||||||
|
}));
|
||||||
await runGenerators.result; // Wait for the generators to run
|
await runGenerators.result; // Wait for the generators to run
|
||||||
|
|
||||||
const runInstall = client.fetch("run_machine_install", {
|
const runInstall = client.fetch("run_machine_install", {
|
||||||
@@ -493,6 +504,7 @@ const InstallSummary = () => {
|
|||||||
});
|
});
|
||||||
set("install", (s) => ({
|
set("install", (s) => ({
|
||||||
...s,
|
...s,
|
||||||
|
prepareStep: "install",
|
||||||
progress: runInstall,
|
progress: runInstall,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -533,6 +545,8 @@ const InstallSummary = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InstallTopic = "generators" | "upload-secrets" | "nixos-anywhere";
|
||||||
|
|
||||||
const InstallProgress = () => {
|
const InstallProgress = () => {
|
||||||
const stepSignal = useStepper<InstallSteps>();
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
@@ -542,10 +556,14 @@ const InstallProgress = () => {
|
|||||||
if (progress) {
|
if (progress) {
|
||||||
await progress.cancel();
|
await progress.cancel();
|
||||||
}
|
}
|
||||||
store.done();
|
stepSignal.previous();
|
||||||
};
|
};
|
||||||
|
const installState = useNotifyOrigin<ProcessMessage<unknown, InstallTopic>>(
|
||||||
|
"run_machine_install",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-60 w-full flex-col items-center justify-end bg-inv-4">
|
<div class="flex size-full h-60 flex-col items-center justify-end bg-inv-4">
|
||||||
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="title"
|
hierarchy="title"
|
||||||
@@ -555,6 +573,36 @@ const InstallProgress = () => {
|
|||||||
>
|
>
|
||||||
Machine is beeing installed
|
Machine is beeing installed
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="default"
|
||||||
|
class="py-2"
|
||||||
|
color="secondary"
|
||||||
|
inverted
|
||||||
|
>
|
||||||
|
<Switch fallback={"Waiting for preparation to start..."}>
|
||||||
|
<Match when={store.install.prepareStep === "disk"}>
|
||||||
|
Configuring disk schema ...
|
||||||
|
</Match>
|
||||||
|
<Match when={store.install.prepareStep === "generators"}>
|
||||||
|
Provisioning services ...
|
||||||
|
</Match>
|
||||||
|
<Match when={store.install.prepareStep === "install"}>
|
||||||
|
{/* Progress after the run_machine_install api call */}
|
||||||
|
<Switch fallback={"Waiting for installation to start..."}>
|
||||||
|
<Match when={installState()?.topic === "generators"}>
|
||||||
|
Checking services ...
|
||||||
|
</Match>
|
||||||
|
<Match when={installState()?.topic === "upload-secrets"}>
|
||||||
|
Uploading Credentials ...
|
||||||
|
</Match>
|
||||||
|
<Match when={installState()?.topic === "nixos-anywhere"}>
|
||||||
|
Running nixos-anywhere ...
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Typography>
|
||||||
<LoadingBar />
|
<LoadingBar />
|
||||||
<Button
|
<Button
|
||||||
hierarchy="primary"
|
hierarchy="primary"
|
||||||
@@ -569,7 +617,10 @@ const InstallProgress = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FlashDone = () => {
|
interface InstallDoneProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
const InstallDone = (props: InstallDoneProps) => {
|
||||||
const stepSignal = useStepper<InstallSteps>();
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
|
|
||||||
@@ -592,7 +643,7 @@ const FlashDone = () => {
|
|||||||
hierarchy="primary"
|
hierarchy="primary"
|
||||||
endIcon="Close"
|
endIcon="Close"
|
||||||
size="s"
|
size="s"
|
||||||
onClick={() => store.done()}
|
onClick={() => props.onDone()}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
@@ -639,7 +690,7 @@ export const installSteps = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "install:done",
|
id: "install:done",
|
||||||
content: FlashDone,
|
content: InstallDone,
|
||||||
isSplash: true,
|
isSplash: true,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface StepLayoutProps {
|
|||||||
}
|
}
|
||||||
export const StepLayout = (props: StepLayoutProps) => {
|
export const StepLayout = (props: StepLayoutProps) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex size-full grow flex-col justify-between gap-6">
|
||||||
{props.body}
|
{props.body}
|
||||||
{props.footer}
|
{props.footer}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from inspect import Parameter, Signature, signature
|
from inspect import Parameter, Signature, signature
|
||||||
|
from queue import Queue
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import (
|
from typing import (
|
||||||
Annotated,
|
Annotated,
|
||||||
Any,
|
Any,
|
||||||
Literal,
|
Literal,
|
||||||
|
TypedDict,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
get_type_hints,
|
get_type_hints,
|
||||||
)
|
)
|
||||||
@@ -31,6 +33,30 @@ T = TypeVar("T")
|
|||||||
ResponseDataType = TypeVar("ResponseDataType")
|
ResponseDataType = TypeVar("ResponseDataType")
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessMessage(TypedDict):
|
||||||
|
"""
|
||||||
|
Represents a message to be sent to the UI.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
- topic: The topic of the message, used to identify the type of message.
|
||||||
|
- data: The data to be sent with the message.
|
||||||
|
- origin: The API operation that this message is related to, if applicable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
topic: str
|
||||||
|
data: Any
|
||||||
|
origin: str | None
|
||||||
|
|
||||||
|
|
||||||
|
message_queue: Queue[ProcessMessage] = Queue()
|
||||||
|
"""
|
||||||
|
A global message queue for sending messages to the UI
|
||||||
|
This can be used to send notifications or messages to the UI. Before returning a response.
|
||||||
|
|
||||||
|
The clan-app imports the queue as clan_lib.api.message_queue and subscribes to it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ApiError:
|
class ApiError:
|
||||||
message: str
|
message: str
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from clan_cli.facts.generate import generate_facts
|
|||||||
from clan_cli.machines.hardware import HardwareConfig
|
from clan_cli.machines.hardware import HardwareConfig
|
||||||
from clan_cli.vars.generate import generate_vars
|
from clan_cli.vars.generate import generate_vars
|
||||||
|
|
||||||
from clan_lib.api import API
|
from clan_lib.api import API, message_queue
|
||||||
from clan_lib.cmd import Log, RunOpts, run
|
from clan_lib.cmd import Log, RunOpts, run
|
||||||
from clan_lib.machines.machines import Machine
|
from clan_lib.machines.machines import Machine
|
||||||
from clan_lib.nix import nix_config, nix_shell
|
from clan_lib.nix import nix_config, nix_shell
|
||||||
@@ -22,6 +22,20 @@ log = logging.getLogger(__name__)
|
|||||||
BuildOn = Literal["auto", "local", "remote"]
|
BuildOn = Literal["auto", "local", "remote"]
|
||||||
|
|
||||||
|
|
||||||
|
Step = Literal["generators", "upload-secrets", "nixos-anywhere"]
|
||||||
|
|
||||||
|
|
||||||
|
def notify_install_step(current: Step) -> None:
|
||||||
|
message_queue.put(
|
||||||
|
{
|
||||||
|
"topic": current,
|
||||||
|
"data": None,
|
||||||
|
# MUST be set the to api function name, while technically you can set any origin, this is a bad idea.
|
||||||
|
"origin": "run_machine_install",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class InstallOptions:
|
class InstallOptions:
|
||||||
machine: Machine
|
machine: Machine
|
||||||
@@ -64,6 +78,8 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify the UI about what we are doing
|
||||||
|
notify_install_step("generators")
|
||||||
generate_facts([machine])
|
generate_facts([machine])
|
||||||
generate_vars([machine])
|
generate_vars([machine])
|
||||||
|
|
||||||
@@ -74,6 +90,9 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
|
|||||||
activation_secrets = base_directory / "activation_secrets"
|
activation_secrets = base_directory / "activation_secrets"
|
||||||
upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/")
|
upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/")
|
||||||
upload_dir.mkdir(parents=True)
|
upload_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Notify the UI about what we are doing
|
||||||
|
notify_install_step("upload-secrets")
|
||||||
machine.secret_facts_store.upload(upload_dir)
|
machine.secret_facts_store.upload(upload_dir)
|
||||||
machine.secret_vars_store.populate_dir(
|
machine.secret_vars_store.populate_dir(
|
||||||
machine.name, upload_dir, phases=["activation", "users", "services"]
|
machine.name, upload_dir, phases=["activation", "users", "services"]
|
||||||
@@ -174,4 +193,6 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
|
|||||||
["nixos-anywhere"],
|
["nixos-anywhere"],
|
||||||
cmd,
|
cmd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notify_install_step("nixos-anywhere")
|
||||||
run(cmd, RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True))
|
run(cmd, RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True))
|
||||||
|
|||||||
Reference in New Issue
Block a user