From 0859a86ce0a03016a7dd5f1fe6c781946929f13e Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 11 Aug 2025 11:00:30 +0200 Subject: [PATCH 1/5] ui/api: simplify types in api --- pkgs/clan-app/ui/src/hooks/api.ts | 8 +++----- .../ui/src/workflows/Install/steps/installSteps.tsx | 11 ++++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pkgs/clan-app/ui/src/hooks/api.ts b/pkgs/clan-app/ui/src/hooks/api.ts index f5341c15d..7a1e7b0b5 100644 --- a/pkgs/clan-app/ui/src/hooks/api.ts +++ b/pkgs/clan-app/ui/src/hooks/api.ts @@ -72,7 +72,7 @@ export const callApi = ( const op_key = backendOpts?.op_key ?? crypto.randomUUID(); - const req: BackendSendType = { + const req: BackendSendType = { body: args, header: { ...backendOpts, @@ -83,11 +83,9 @@ export const callApi = ( const result = ( window as unknown as Record< OperationNames, - ( - args: BackendSendType, - ) => Promise> + (args: BackendSendType) => Promise> > - )[method](req) as Promise>; + )[method](req); return { uuid: op_key, diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx index f68e13420..961f1720a 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx @@ -2,6 +2,7 @@ import { Typography } from "@/src/components/Typography/Typography"; import { BackButton, NextButton, StepLayout } from "../../Steps"; import { createForm, + FieldValues, getError, SubmitHandler, valiForm, @@ -12,7 +13,7 @@ import { getStepStore, useStepper } from "@/src/hooks/stepper"; import { InstallSteps, InstallStoreType, PromptValues } from "../install"; import { TextInput } from "@/src/components/Form/TextInput"; import { Alert } from "@/src/components/Alert/Alert"; -import { For, onMount, Show } from "solid-js"; +import { For, Show } from "solid-js"; import { Divider } from "@/src/components/Divider/Divider"; import { Orienter } from "@/src/components/Form/Orienter"; import { Button } from "@/src/components/Button/Button"; @@ -302,19 +303,19 @@ const ConfigureData = () => { ); }; -type PromptGroup = { +interface PromptGroup { name: string; fields: { prompt: Prompt; generator: string; value?: string | null; }[]; -}; +} type Prompt = NonNullable[number]; -type PromptForm = { +interface PromptForm extends FieldValues { promptValues: PromptValues; -}; +} interface PromptsFieldsProps { generators: MachineGenerators; From bb6fab1168da126b603e788e7cae5a94a96cae4b Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 11 Aug 2025 12:23:41 +0200 Subject: [PATCH 2/5] api: init notification queue --- .../clan-app/clan_app/deps/webview/webview.py | 21 ++++++++++++++- .../clan_app/deps/webview/webview_bridge.py | 1 - pkgs/clan-app/ui/index.d.ts | 9 +++++++ pkgs/clan-app/ui/src/hooks/api.ts | 11 ++++++++ pkgs/clan-cli/clan_lib/api/__init__.py | 26 +++++++++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 pkgs/clan-app/ui/index.d.ts diff --git a/pkgs/clan-app/clan_app/deps/webview/webview.py b/pkgs/clan-app/clan_app/deps/webview/webview.py index b2dcc72ef..23651459b 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview.py @@ -1,12 +1,14 @@ import functools import json import logging +import threading from collections.abc import Callable from dataclasses import dataclass, field from enum import IntEnum +from time import sleep 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.log_manager import LogManager @@ -69,6 +71,22 @@ class Webview: if 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 def handle(self) -> Any: """Get the webview handle, creating it if necessary.""" @@ -129,6 +147,7 @@ class Webview: webview=self, middleware_chain=tuple(self._middleware), threads={} ) self._bridge = bridge + return bridge # Legacy methods for compatibility diff --git a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py index 4149b40ab..dd92c468a 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py @@ -25,7 +25,6 @@ class WebviewBridge(ApiBridge): def send_api_response(self, response: BackendResponse) -> None: """Send response back to the webview client.""" - serialized = json.dumps( dataclass_to_dict(response), indent=4, ensure_ascii=False ) diff --git a/pkgs/clan-app/ui/index.d.ts b/pkgs/clan-app/ui/index.d.ts new file mode 100644 index 000000000..ae9aa8315 --- /dev/null +++ b/pkgs/clan-app/ui/index.d.ts @@ -0,0 +1,9 @@ +import { ProcessMessage } from "./src/hooks/api"; + +export {}; + +declare global { + interface Window { + notifyBus: (data: ProcessMessage) => void; + } +} diff --git a/pkgs/clan-app/ui/src/hooks/api.ts b/pkgs/clan-app/ui/src/hooks/api.ts index 7a1e7b0b5..6a5c15b7f 100644 --- a/pkgs/clan-app/ui/src/hooks/api.ts +++ b/pkgs/clan-app/ui/src/hooks/api.ts @@ -99,3 +99,14 @@ export const callApi = ( }, }; }; + +export interface ProcessMessage { + topic: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + origin: string | null; +} + +window.notifyBus = (data) => { + console.debug("Channel function called with data:", data); +}; diff --git a/pkgs/clan-cli/clan_lib/api/__init__.py b/pkgs/clan-cli/clan_lib/api/__init__.py index 62df22996..ffbbe0dac 100644 --- a/pkgs/clan-cli/clan_lib/api/__init__.py +++ b/pkgs/clan-cli/clan_lib/api/__init__.py @@ -5,11 +5,13 @@ from collections.abc import Callable from dataclasses import dataclass from functools import wraps from inspect import Parameter, Signature, signature +from queue import Queue from types import ModuleType from typing import ( Annotated, Any, Literal, + TypedDict, TypeVar, get_type_hints, ) @@ -31,6 +33,30 @@ T = TypeVar("T") 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 class ApiError: message: str From c70c588c1caaa718212000fef746614586af27ee Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 11 Aug 2025 13:48:34 +0200 Subject: [PATCH 3/5] ui/api: init message bus subscriber hooks --- pkgs/clan-app/ui/index.d.ts | 2 +- pkgs/clan-app/ui/src/hooks/api.ts | 11 ---- pkgs/clan-app/ui/src/hooks/notify.ts | 89 ++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 pkgs/clan-app/ui/src/hooks/notify.ts diff --git a/pkgs/clan-app/ui/index.d.ts b/pkgs/clan-app/ui/index.d.ts index ae9aa8315..02786227c 100644 --- a/pkgs/clan-app/ui/index.d.ts +++ b/pkgs/clan-app/ui/index.d.ts @@ -1,4 +1,4 @@ -import { ProcessMessage } from "./src/hooks/api"; +import { ProcessMessage } from "./src/hooks/notify"; export {}; diff --git a/pkgs/clan-app/ui/src/hooks/api.ts b/pkgs/clan-app/ui/src/hooks/api.ts index 6a5c15b7f..7a1e7b0b5 100644 --- a/pkgs/clan-app/ui/src/hooks/api.ts +++ b/pkgs/clan-app/ui/src/hooks/api.ts @@ -99,14 +99,3 @@ export const callApi = ( }, }; }; - -export interface ProcessMessage { - topic: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; - origin: string | null; -} - -window.notifyBus = (data) => { - console.debug("Channel function called with data:", data); -}; diff --git a/pkgs/clan-app/ui/src/hooks/notify.ts b/pkgs/clan-app/ui/src/hooks/notify.ts new file mode 100644 index 000000000..d3af30841 --- /dev/null +++ b/pkgs/clan-app/ui/src/hooks/notify.ts @@ -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 { + filter: (msg: T) => boolean; + callback: (msg: T) => void; +} + +const subscribers: Subscriber[] = []; + +// 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( + filter: (msg: T) => boolean, + callback: (msg: T) => void, +) { + // Cast to shared subscriber type for storage + const sub: Subscriber = { + 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( + filter: (msg: T) => boolean = () => true as boolean, +) { + const [message, setMessage] = createSignal(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((m) => m.origin === origin); +} From 54797dd5f5d35a1ad3159a93e617b88a09db11a7 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 11 Aug 2025 15:00:19 +0200 Subject: [PATCH 4/5] ui/install: hook up notification bus --- .../ui/src/workflows/Install/install.tsx | 13 +++-- .../workflows/Install/steps/installSteps.tsx | 55 +++++++++++++++++-- pkgs/clan-cli/clan_lib/machines/install.py | 23 +++++++- 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/pkgs/clan-app/ui/src/workflows/Install/install.tsx b/pkgs/clan-app/ui/src/workflows/Install/install.tsx index 241edf5d3..e7fb783f9 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/install.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/install.tsx @@ -20,11 +20,12 @@ const InstallStepper = (props: InstallStepperProps) => { const stepSignal = useStepper(); const [store, set] = getStepStore(stepSignal); - onMount(() => { - set("done", props.onDone); - }); - - return ; + return ( + + ); }; export interface InstallModalProps { @@ -53,8 +54,8 @@ export interface InstallStoreType { mainDisk: string; // ...TODO Vars progress: ApiCall<"run_machine_install">; - promptValues: PromptValues; + prepareStep: "disk" | "generators" | "install"; }; done: () => void; } diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx index 961f1720a..156e0ebeb 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx @@ -13,7 +13,7 @@ import { getStepStore, useStepper } from "@/src/hooks/stepper"; import { InstallSteps, InstallStoreType, PromptValues } from "../install"; import { TextInput } from "@/src/components/Form/TextInput"; import { Alert } from "@/src/components/Alert/Alert"; -import { For, Show } from "solid-js"; +import { For, Match, Show, Switch } from "solid-js"; import { Divider } from "@/src/components/Divider/Divider"; import { Orienter } from "@/src/components/Form/Orienter"; import { Button } from "@/src/components/Button/Button"; @@ -28,6 +28,7 @@ import { } from "@/src/hooks/queries"; import { useClanURI } from "@/src/hooks/clan"; import { useApiClient } from "@/src/hooks/ApiClient"; +import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify"; export const InstallHeader = (props: { machineName: string }) => { return ( @@ -450,6 +451,8 @@ const InstallSummary = () => { // Here you would typically trigger the installation process console.log("Installation started"); + stepSignal.setActiveStep("install:progress"); + const setDisk = client.fetch("set_machine_disk_schema", { machine: { flake: { @@ -464,6 +467,10 @@ const InstallSummary = () => { force: true, }); + set("install", (s) => ({ + ...s, + prepareStep: "disk", + })); const diskResult = await setDisk.result; // Wait for the disk schema to be set if (diskResult.status === "error") { console.error("Error setting disk schema:", diskResult.errors); @@ -475,8 +482,11 @@ const InstallSummary = () => { base_dir: clanUri, machine_name: store.install.machineName, }); - stepSignal.setActiveStep("install:progress"); + set("install", (s) => ({ + ...s, + prepareStep: "generators", + })); await runGenerators.result; // Wait for the generators to run const runInstall = client.fetch("run_machine_install", { @@ -494,6 +504,7 @@ const InstallSummary = () => { }); set("install", (s) => ({ ...s, + prepareStep: "install", progress: runInstall, })); @@ -534,6 +545,8 @@ const InstallSummary = () => { ); }; +type InstallTopic = "generators" | "upload-secrets" | "nixos-anywhere"; + const InstallProgress = () => { const stepSignal = useStepper(); const [store, get] = getStepStore(stepSignal); @@ -543,8 +556,12 @@ const InstallProgress = () => { if (progress) { await progress.cancel(); } - store.done(); + stepSignal.previous(); }; + const installState = useNotifyOrigin>( + "run_machine_install", + ); + return (
@@ -556,6 +573,36 @@ const InstallProgress = () => { > Machine is beeing installed + + + + Configuring disk schema ... + + + Provisioning services ... + + + {/* Progress after the run_machine_install api call */} + + + Checking services ... + + + Uploading Credentials ... + + + Running nixos-anywhere ... + + + + + diff --git a/pkgs/clan-cli/clan_lib/machines/install.py b/pkgs/clan-cli/clan_lib/machines/install.py index 4f97b4ac7..a04849d34 100644 --- a/pkgs/clan-cli/clan_lib/machines/install.py +++ b/pkgs/clan-cli/clan_lib/machines/install.py @@ -9,7 +9,7 @@ from clan_cli.facts.generate import generate_facts from clan_cli.machines.hardware import HardwareConfig 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.machines.machines import Machine from clan_lib.nix import nix_config, nix_shell @@ -22,6 +22,20 @@ log = logging.getLogger(__name__) 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 class InstallOptions: 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_vars([machine]) @@ -74,6 +90,9 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: activation_secrets = base_directory / "activation_secrets" upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/") 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_vars_store.populate_dir( machine.name, upload_dir, phases=["activation", "users", "services"] @@ -174,4 +193,6 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: ["nixos-anywhere"], cmd, ) + + notify_install_step("nixos-anywhere") run(cmd, RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True)) From d1f5a8e263292107fe63a014c371c2362cd5f0d0 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 11 Aug 2025 17:00:20 +0200 Subject: [PATCH 5/5] ui/install: set fixed dimensions for modal --- .../ui/src/components/Modal/Modal.module.css | 7 +++-- .../ui/src/workflows/Install/install.tsx | 4 +-- .../src/workflows/Install/steps/Initial.tsx | 16 ++++++------ .../Install/steps/createInstaller.tsx | 26 +++++++++---------- .../workflows/Install/steps/installSteps.tsx | 15 ++++++----- pkgs/clan-app/ui/src/workflows/Steps.tsx | 2 +- 6 files changed, 38 insertions(+), 32 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Modal/Modal.module.css b/pkgs/clan-app/ui/src/components/Modal/Modal.module.css index a2a88096f..5624aa7e0 100644 --- a/pkgs/clan-app/ui/src/components/Modal/Modal.module.css +++ b/pkgs/clan-app/ui/src/components/Modal/Modal.module.css @@ -1,7 +1,9 @@ .modal_content { - @apply min-w-[320px] max-w-[512px]; + @apply min-w-[320px] flex flex-col; @apply rounded-md overflow-hidden; + max-height: calc(100vh - 2rem); + /* todo replace with a theme() color */ 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; @@ -22,7 +24,8 @@ } .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] { @apply p-0; diff --git a/pkgs/clan-app/ui/src/workflows/Install/install.tsx b/pkgs/clan-app/ui/src/workflows/Install/install.tsx index e7fb783f9..518199550 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/install.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/install.tsx @@ -5,8 +5,7 @@ import { StepperProvider, useStepper, } from "@/src/hooks/stepper"; -import { createForm, FieldValues, SubmitHandler } from "@modular-forms/solid"; -import { onMount, Show } from "solid-js"; +import { Show } from "solid-js"; import { Dynamic } from "solid-js/web"; import { initialSteps } from "./steps/Initial"; import { createInstallerSteps } from "./steps/createInstaller"; @@ -85,6 +84,7 @@ export const InstallModal = (props: InstallModalProps) => { return ( { diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/Initial.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/Initial.tsx index 43bc7e25a..8f8354246 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/Initial.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/Initial.tsx @@ -12,8 +12,8 @@ const ChoiceLocalOrRemote = () => {
@@ -33,8 +33,8 @@ const ChoiceLocalOrRemote = () => {
@@ -64,8 +64,8 @@ const ChoiceLocalInstaller = () => {
@@ -85,8 +85,8 @@ const ChoiceLocalInstaller = () => {
diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx index cc78ea09a..3bae7fc1f 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx @@ -149,7 +149,7 @@ const ConfigureImage = () => { let content: Node; return ( -
+ { const stripId = (s: string) => s.split("-")[1] ?? s; return ( - + @@ -317,7 +317,7 @@ const FlashProgress = () => { }; return ( -
+
{ const FlashDone = () => { const stepSignal = useStepper(); return ( -
+
@@ -361,15 +361,15 @@ const FlashDone = () => { title="Remove it and plug it into the machine that you want to install." description="" /> -
- -
+
+
+
); diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx index 156e0ebeb..d413c6fe9 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx @@ -74,7 +74,7 @@ const ConfigureAddress = () => { }; return ( - + @@ -232,7 +232,7 @@ const ConfigureDisk = () => { ); return ( - + @@ -367,7 +367,7 @@ const PromptsFields = (props: PromptsFieldsProps) => { }; return ( - + @@ -563,7 +563,7 @@ const InstallProgress = () => { ); return ( -
+
{ ); }; -const FlashDone = () => { +interface InstallDoneProps { + onDone: () => void; +} +const InstallDone = (props: InstallDoneProps) => { const stepSignal = useStepper(); const [store, get] = getStepStore(stepSignal); @@ -687,7 +690,7 @@ export const installSteps = [ }, { id: "install:done", - content: FlashDone, + content: InstallDone, isSplash: true, }, ] as const; diff --git a/pkgs/clan-app/ui/src/workflows/Steps.tsx b/pkgs/clan-app/ui/src/workflows/Steps.tsx index 986d44485..85f6da171 100644 --- a/pkgs/clan-app/ui/src/workflows/Steps.tsx +++ b/pkgs/clan-app/ui/src/workflows/Steps.tsx @@ -9,7 +9,7 @@ interface StepLayoutProps { } export const StepLayout = (props: StepLayoutProps) => { return ( -
+
{props.body} {props.footer}