Merge pull request 'clan-app: 3d UI Remove unused files and exports' (#4029) from Qubasa/clan-core:ui_minimize-3d into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4029
This commit is contained in:
@@ -3,6 +3,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import tailwind from "eslint-plugin-tailwindcss";
|
import tailwind from "eslint-plugin-tailwindcss";
|
||||||
import pluginQuery from "@tanstack/eslint-plugin-query";
|
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||||
import { globalIgnores } from "eslint/config";
|
import { globalIgnores } from "eslint/config";
|
||||||
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
|
|
||||||
const config = tseslint.config(
|
const config = tseslint.config(
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
@@ -12,6 +13,9 @@ const config = tseslint.config(
|
|||||||
...tailwind.configs["flat/recommended"],
|
...tailwind.configs["flat/recommended"],
|
||||||
globalIgnores(["src/types/index.d.ts"]),
|
globalIgnores(["src/types/index.d.ts"]),
|
||||||
{
|
{
|
||||||
|
plugins: {
|
||||||
|
"unused-imports": unusedImports,
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"tailwindcss/no-contradicting-classname": [
|
"tailwindcss/no-contradicting-classname": [
|
||||||
"error",
|
"error",
|
||||||
@@ -29,6 +33,7 @@ const config = tseslint.config(
|
|||||||
// TODO: make this more strict by removing later
|
// TODO: make this more strict by removing later
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"unused-imports/no-unused-imports": "warn",
|
||||||
"@typescript-eslint/no-non-null-assertion": "off",
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
19
pkgs/clan-app/ui/knip.json
Normal file
19
pkgs/clan-app/ui/knip.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"ignore": [
|
||||||
|
"gtk.webview.js",
|
||||||
|
"stylelint.config.js",
|
||||||
|
"util.ts",
|
||||||
|
"src/components/v2/**",
|
||||||
|
"api/**",
|
||||||
|
"tailwind/**"
|
||||||
|
],
|
||||||
|
"ignoreDependencies": [
|
||||||
|
"@babel/plugin-syntax-import-attributes",
|
||||||
|
"@storybook/addon-viewport",
|
||||||
|
"@typescript-eslint/parser",
|
||||||
|
"@vitest/coverage-v8",
|
||||||
|
"http-server",
|
||||||
|
"playwright",
|
||||||
|
"wait-on"
|
||||||
|
]
|
||||||
|
}
|
||||||
1211
pkgs/clan-app/ui/package-lock.json
generated
1211
pkgs/clan-app/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,10 @@
|
|||||||
"build": "npm run check && npm run test && vite build && npm run convert-html",
|
"build": "npm run check && npm run test && vite build && npm run convert-html",
|
||||||
"convert-html": "node gtk.webview.js",
|
"convert-html": "node gtk.webview.js",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"check": "tsc --noEmit --skipLibCheck && eslint ./src",
|
"check": "tsc --noEmit --skipLibCheck && eslint ./src --fix",
|
||||||
"test": "vitest run --project unit --typecheck",
|
"test": "vitest run --project unit --typecheck",
|
||||||
"storybook": "storybook",
|
"storybook": "storybook",
|
||||||
|
"knip": "knip --fix",
|
||||||
"storybook-build": "storybook build",
|
"storybook-build": "storybook build",
|
||||||
"storybook-dev": "storybook dev -p 6006",
|
"storybook-dev": "storybook dev -p 6006",
|
||||||
"test-storybook": "vitest run --project storybook",
|
"test-storybook": "vitest run --project storybook",
|
||||||
@@ -30,7 +31,6 @@
|
|||||||
"@storybook/addon-viewport": "^9.0.8",
|
"@storybook/addon-viewport": "^9.0.8",
|
||||||
"@storybook/addon-vitest": "^9.0.8",
|
"@storybook/addon-vitest": "^9.0.8",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"@types/json-schema": "^7.0.15",
|
|
||||||
"@types/node": "^22.15.19",
|
"@types/node": "^22.15.19",
|
||||||
"@types/three": "^0.176.0",
|
"@types/three": "^0.176.0",
|
||||||
"@typescript-eslint/parser": "^8.32.1",
|
"@typescript-eslint/parser": "^8.32.1",
|
||||||
@@ -41,8 +41,10 @@
|
|||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.17.0",
|
"eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
|
"knip": "^5.61.2",
|
||||||
"playwright": "~1.52.0",
|
"playwright": "~1.52.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"postcss-url": "^10.1.3",
|
"postcss-url": "^10.1.3",
|
||||||
@@ -67,11 +69,7 @@
|
|||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
"@tanstack/eslint-plugin-query": "^5.51.12",
|
"@tanstack/eslint-plugin-query": "^5.51.12",
|
||||||
"@tanstack/solid-query": "^5.76.0",
|
"@tanstack/solid-query": "^5.76.0",
|
||||||
"corvu": "^0.7.1",
|
|
||||||
"material-icons": "^1.13.12",
|
|
||||||
"nanoid": "^5.0.7",
|
|
||||||
"solid-js": "^1.9.7",
|
"solid-js": "^1.9.7",
|
||||||
"solid-markdown": "^2.0.13",
|
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"three": "^0.176.0"
|
"three": "^0.176.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
|
||||||
import type {
|
|
||||||
ComputePositionConfig,
|
|
||||||
ComputePositionReturn,
|
|
||||||
ReferenceElement,
|
|
||||||
} from "@floating-ui/dom";
|
|
||||||
import { computePosition } from "@floating-ui/dom";
|
|
||||||
|
|
||||||
export interface UseFloatingOptions<
|
|
||||||
R extends ReferenceElement,
|
|
||||||
F extends HTMLElement,
|
|
||||||
> extends Partial<ComputePositionConfig> {
|
|
||||||
whileElementsMounted?: (
|
|
||||||
reference: R,
|
|
||||||
floating: F,
|
|
||||||
update: () => void,
|
|
||||||
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
|
||||||
void | (() => void);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
|
|
||||||
x?: number | null;
|
|
||||||
y?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseFloatingResult extends UseFloatingState {
|
|
||||||
update(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
|
||||||
reference: () => R | undefined | null,
|
|
||||||
floating: () => F | undefined | null,
|
|
||||||
options?: UseFloatingOptions<R, F>,
|
|
||||||
): UseFloatingResult {
|
|
||||||
const placement = () => options?.placement ?? "bottom";
|
|
||||||
const strategy = () => options?.strategy ?? "absolute";
|
|
||||||
|
|
||||||
const [data, setData] = createSignal<UseFloatingState>({
|
|
||||||
x: null,
|
|
||||||
y: null,
|
|
||||||
placement: placement(),
|
|
||||||
strategy: strategy(),
|
|
||||||
middlewareData: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [error, setError] = createSignal<{ value: unknown } | undefined>();
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const currentError = error();
|
|
||||||
if (currentError) {
|
|
||||||
throw currentError.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const version = createMemo(() => {
|
|
||||||
reference();
|
|
||||||
floating();
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
function update() {
|
|
||||||
const currentReference = reference();
|
|
||||||
const currentFloating = floating();
|
|
||||||
|
|
||||||
if (currentReference && currentFloating) {
|
|
||||||
const capturedVersion = version();
|
|
||||||
computePosition(currentReference, currentFloating, {
|
|
||||||
middleware: options?.middleware,
|
|
||||||
placement: placement(),
|
|
||||||
strategy: strategy(),
|
|
||||||
}).then(
|
|
||||||
(currentData) => {
|
|
||||||
// Check if it's still valid
|
|
||||||
if (capturedVersion === version()) {
|
|
||||||
setData(currentData);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
setError(err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const currentReference = reference();
|
|
||||||
const currentFloating = floating();
|
|
||||||
|
|
||||||
placement();
|
|
||||||
strategy();
|
|
||||||
|
|
||||||
if (currentReference && currentFloating) {
|
|
||||||
if (options?.whileElementsMounted) {
|
|
||||||
const cleanup = options.whileElementsMounted(
|
|
||||||
currentReference,
|
|
||||||
currentFloating,
|
|
||||||
update,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cleanup) {
|
|
||||||
onCleanup(cleanup);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
get x() {
|
|
||||||
return data().x;
|
|
||||||
},
|
|
||||||
get y() {
|
|
||||||
return data().y;
|
|
||||||
},
|
|
||||||
get placement() {
|
|
||||||
return data().placement;
|
|
||||||
},
|
|
||||||
get strategy() {
|
|
||||||
return data().strategy;
|
|
||||||
},
|
|
||||||
get middlewareData() {
|
|
||||||
return data().middlewareData;
|
|
||||||
},
|
|
||||||
update,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { JSX } from "solid-js";
|
|
||||||
interface LabelProps {
|
|
||||||
label: JSX.Element;
|
|
||||||
required?: boolean;
|
|
||||||
}
|
|
||||||
export const Label = (props: LabelProps) => (
|
|
||||||
<span
|
|
||||||
class=" block"
|
|
||||||
classList={{
|
|
||||||
"after:ml-0.5 after:text-primary after:content-['*']": props.required,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
@@ -3,6 +3,6 @@ import { JSX } from "solid-js";
|
|||||||
interface FormSectionProps {
|
interface FormSectionProps {
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
}
|
}
|
||||||
export const FormSection = (props: FormSectionProps) => {
|
const FormSection = (props: FormSectionProps) => {
|
||||||
return <div class="p-2">{props.children}</div>;
|
return <div class="p-2">{props.children}</div>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
import {
|
|
||||||
createUniqueId,
|
|
||||||
createSignal,
|
|
||||||
Show,
|
|
||||||
type JSX,
|
|
||||||
For,
|
|
||||||
createMemo,
|
|
||||||
Accessor,
|
|
||||||
} from "solid-js";
|
|
||||||
import { Portal } from "solid-js/web";
|
|
||||||
import { useFloating } from "../base";
|
|
||||||
import { autoUpdate, flip, hide, offset, shift, size } from "@floating-ui/dom";
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
import {
|
|
||||||
InputBase,
|
|
||||||
InputError,
|
|
||||||
InputLabel,
|
|
||||||
InputLabelProps,
|
|
||||||
} from "@/src/components/inputBase";
|
|
||||||
import { FieldLayout } from "./layout";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { useContext } from "corvu/dialog";
|
|
||||||
|
|
||||||
export interface Option {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectInputpProps {
|
|
||||||
value: string[] | string;
|
|
||||||
selectProps?: JSX.InputHTMLAttributes<HTMLSelectElement>;
|
|
||||||
options: Option[];
|
|
||||||
label: JSX.Element;
|
|
||||||
labelProps?: InputLabelProps;
|
|
||||||
helperText?: JSX.Element;
|
|
||||||
error?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: string;
|
|
||||||
inlineLabel?: JSX.Element;
|
|
||||||
class?: string;
|
|
||||||
adornment?: {
|
|
||||||
position: "start" | "end";
|
|
||||||
content: JSX.Element;
|
|
||||||
};
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
multiple?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
portalRef?: Accessor<HTMLElement | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectInput(props: SelectInputpProps) {
|
|
||||||
const dialogContext = (dialogContextId?: string) =>
|
|
||||||
useContext(dialogContextId);
|
|
||||||
|
|
||||||
const _id = createUniqueId();
|
|
||||||
|
|
||||||
const [reference, setReference] = createSignal<HTMLElement>();
|
|
||||||
const [floating, setFloating] = createSignal<HTMLElement>();
|
|
||||||
|
|
||||||
// `position` is a reactive object.
|
|
||||||
const position = useFloating(reference, floating, {
|
|
||||||
placement: "bottom-start",
|
|
||||||
|
|
||||||
// pass options. Ensure the cleanup function is returned.
|
|
||||||
whileElementsMounted: (reference, floating, update) =>
|
|
||||||
autoUpdate(reference, floating, update, {
|
|
||||||
animationFrame: true,
|
|
||||||
}),
|
|
||||||
middleware: [
|
|
||||||
size({
|
|
||||||
apply({ rects, elements }) {
|
|
||||||
Object.assign(elements.floating.style, {
|
|
||||||
minWidth: `${rects.reference.width}px`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 2 }),
|
|
||||||
shift(),
|
|
||||||
flip(),
|
|
||||||
hide({
|
|
||||||
strategy: "referenceHidden",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create values list
|
|
||||||
const getValues = createMemo(() => {
|
|
||||||
return Array.isArray(props.value)
|
|
||||||
? (props.value as string[])
|
|
||||||
: typeof props.value === "string"
|
|
||||||
? [props.value]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// const getSingleValue = createMemo(() => {
|
|
||||||
// const values = getValues();
|
|
||||||
// return values.length > 0 ? values[0] : "";
|
|
||||||
// });
|
|
||||||
|
|
||||||
const handleClickOption = (opt: Option) => {
|
|
||||||
if (!props.multiple) {
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
props.selectProps.onInput({
|
|
||||||
currentTarget: {
|
|
||||||
value: opt.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let currValues = getValues();
|
|
||||||
|
|
||||||
if (currValues.includes(opt.value)) {
|
|
||||||
currValues = currValues.filter((o) => o !== opt.value);
|
|
||||||
} else {
|
|
||||||
currValues.push(opt.value);
|
|
||||||
}
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
props.selectProps.onInput({
|
|
||||||
currentTarget: {
|
|
||||||
options: currValues.map((value) => ({
|
|
||||||
value,
|
|
||||||
selected: true,
|
|
||||||
disabled: false,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FieldLayout
|
|
||||||
error={props.error && <InputError error={props.error} />}
|
|
||||||
label={
|
|
||||||
<InputLabel
|
|
||||||
description={""}
|
|
||||||
required={props.required}
|
|
||||||
{...props.labelProps}
|
|
||||||
>
|
|
||||||
{props.label}
|
|
||||||
</InputLabel>
|
|
||||||
}
|
|
||||||
field={
|
|
||||||
<InputBase
|
|
||||||
error={!!props.error}
|
|
||||||
disabled={props.disabled}
|
|
||||||
required={props.required}
|
|
||||||
class="!justify-start"
|
|
||||||
divRef={setReference}
|
|
||||||
inputElem={
|
|
||||||
<button
|
|
||||||
// TODO: Keyboard acessibililty
|
|
||||||
// Currently the popover only opens with onClick
|
|
||||||
// Options are not selectable with keyboard
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onClick={() => {
|
|
||||||
const popover = document.getElementById(_id);
|
|
||||||
if (popover) {
|
|
||||||
popover.togglePopover(); // Show or hide the popover
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center gap-2"
|
|
||||||
formnovalidate
|
|
||||||
// TODO: Use native popover once Webkit supports it within <form>
|
|
||||||
// popovertarget={_id}
|
|
||||||
// popovertargetaction="toggle"
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.adornment && props.adornment.position === "start"}
|
|
||||||
>
|
|
||||||
{props.adornment?.content}
|
|
||||||
</Show>
|
|
||||||
{props.inlineLabel}
|
|
||||||
<div class="flex cursor-default flex-row gap-2">
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
getValues() &&
|
|
||||||
getValues.length !== 1 &&
|
|
||||||
getValues()[0] !== ""
|
|
||||||
}
|
|
||||||
fallback={props.placeholder}
|
|
||||||
>
|
|
||||||
<For each={getValues()} fallback={"Select"}>
|
|
||||||
{(item) => (
|
|
||||||
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
|
|
||||||
{item}
|
|
||||||
<Show when={props.multiple}>
|
|
||||||
<button
|
|
||||||
class=""
|
|
||||||
type="button"
|
|
||||||
onClick={(_e) => {
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
props.selectProps.onInput({
|
|
||||||
currentTarget: {
|
|
||||||
options: getValues()
|
|
||||||
.filter((o) => o !== item)
|
|
||||||
.map((value) => ({
|
|
||||||
value,
|
|
||||||
selected: true,
|
|
||||||
disabled: false,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show
|
|
||||||
when={props.adornment && props.adornment.position === "end"}
|
|
||||||
>
|
|
||||||
{props.adornment?.content}
|
|
||||||
</Show>
|
|
||||||
<Icon size={12} icon="CaretDown" class="ml-auto mr-2" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Portal
|
|
||||||
mount={
|
|
||||||
props.portalRef ? props.portalRef() || document.body : document.body
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id={_id}
|
|
||||||
popover
|
|
||||||
ref={setFloating}
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
position: position.strategy,
|
|
||||||
top: `${position.y ?? 0}px`,
|
|
||||||
left: `${position.x ?? 0}px`,
|
|
||||||
}}
|
|
||||||
class="z-[1000] shadow"
|
|
||||||
>
|
|
||||||
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll">
|
|
||||||
<Show when={!props.loading} fallback={"Loading ...."}>
|
|
||||||
<For each={props.options}>
|
|
||||||
{(opt) => (
|
|
||||||
<>
|
|
||||||
<li>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="!justify-start"
|
|
||||||
onClick={() => handleClickOption(opt)}
|
|
||||||
disabled={opt.disabled}
|
|
||||||
classList={{
|
|
||||||
active:
|
|
||||||
!opt.disabled && getValues().includes(opt.value),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
InputVariant,
|
InputVariant,
|
||||||
} from "@/src/components/inputBase";
|
} from "@/src/components/inputBase";
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import { FieldLayout } from "./layout";
|
import { FieldLayout } from "./layout";
|
||||||
|
|
||||||
interface TextInputProps {
|
interface TextInputProps {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { JSX } from "solid-js";
|
|
||||||
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
|
|
||||||
interface FieldsetProps {
|
|
||||||
legend?: string;
|
|
||||||
children: JSX.Element;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Fieldset(props: FieldsetProps) {
|
|
||||||
return (
|
|
||||||
<fieldset class="flex flex-col gap-y-2.5">
|
|
||||||
{props.legend && (
|
|
||||||
<div class="px-2">
|
|
||||||
<Typography
|
|
||||||
hierarchy="body"
|
|
||||||
tag="p"
|
|
||||||
size="s"
|
|
||||||
color="primary"
|
|
||||||
weight="medium"
|
|
||||||
>
|
|
||||||
{props.legend}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div class="flex flex-col gap-y-3 rounded-md border border-secondary-200 bg-secondary-50 p-5">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,928 +0,0 @@
|
|||||||
import {
|
|
||||||
createForm,
|
|
||||||
Field,
|
|
||||||
FieldArray,
|
|
||||||
FieldValues,
|
|
||||||
FormStore,
|
|
||||||
pattern,
|
|
||||||
ResponseData,
|
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
insert,
|
|
||||||
SubmitHandler,
|
|
||||||
reset,
|
|
||||||
remove,
|
|
||||||
move,
|
|
||||||
} from "@modular-forms/solid";
|
|
||||||
import { JSONSchema7, JSONSchema7Type } from "json-schema";
|
|
||||||
import { TextInput } from "../fields/TextInput";
|
|
||||||
import { createEffect, For, JSX, Match, Show, Switch } from "solid-js";
|
|
||||||
import cx from "classnames";
|
|
||||||
import { Label } from "../base/label";
|
|
||||||
import { SelectInput } from "../fields/Select";
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
|
|
||||||
function generateDefaults(schema: JSONSchema7): unknown {
|
|
||||||
switch (schema.type) {
|
|
||||||
case "string":
|
|
||||||
return ""; // Default value for string
|
|
||||||
|
|
||||||
case "number":
|
|
||||||
case "integer":
|
|
||||||
return 0; // Default value for number/integer
|
|
||||||
|
|
||||||
case "boolean":
|
|
||||||
return false; // Default value for boolean
|
|
||||||
|
|
||||||
case "array":
|
|
||||||
return []; // Default empty array if no items schema or items is true/false
|
|
||||||
|
|
||||||
case "object": {
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
if (schema.properties) {
|
|
||||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
|
||||||
if (typeof propSchema === "boolean") {
|
|
||||||
obj[key] = false;
|
|
||||||
} else {
|
|
||||||
// if (schema.required schema.required.includes(key))
|
|
||||||
obj[key] = generateDefaults(propSchema);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null; // Default for unknown types or nulls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormProps {
|
|
||||||
schema: JSONSchema7;
|
|
||||||
initialValues?: NonNullable<unknown>;
|
|
||||||
handleSubmit?: SubmitHandler<NonNullable<unknown>>;
|
|
||||||
initialPath?: string[];
|
|
||||||
components?: {
|
|
||||||
before?: JSX.Element;
|
|
||||||
after?: JSX.Element;
|
|
||||||
};
|
|
||||||
readonly?: boolean;
|
|
||||||
formProps?: JSX.InputHTMLAttributes<HTMLFormElement>;
|
|
||||||
errorContext?: string;
|
|
||||||
resetOnSubmit?: boolean;
|
|
||||||
}
|
|
||||||
export const DynForm = (props: FormProps) => {
|
|
||||||
const [formStore, { Field, Form: ModuleForm }] = createForm({
|
|
||||||
initialValues: props.initialValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
|
|
||||||
values,
|
|
||||||
event,
|
|
||||||
) => {
|
|
||||||
console.log("Submitting form values", values, props.errorContext);
|
|
||||||
props.handleSubmit?.(values, event);
|
|
||||||
// setValue(formStore, "root", null);
|
|
||||||
if (props.resetOnSubmit) {
|
|
||||||
console.log("Resetting form", values, props.initialValues);
|
|
||||||
reset(formStore);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log("FormStore", formStore);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */}
|
|
||||||
<ModuleForm {...props.formProps} onSubmit={handleSubmit}>
|
|
||||||
{props.components?.before}
|
|
||||||
<SchemaFields
|
|
||||||
schema={props.schema}
|
|
||||||
Field={Field}
|
|
||||||
formStore={formStore}
|
|
||||||
path={props.initialPath || []}
|
|
||||||
readonly={!!props.readonly}
|
|
||||||
parent={props.schema}
|
|
||||||
/>
|
|
||||||
{props.components?.after}
|
|
||||||
</ModuleForm>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UnsupportedProps {
|
|
||||||
schema: JSONSchema7;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Unsupported = (props: UnsupportedProps) => (
|
|
||||||
<div>
|
|
||||||
{props.error && <div class="font-bold text-error-700">{props.error}</div>}
|
|
||||||
<span>
|
|
||||||
Invalid or unsupported schema entry of type:{" "}
|
|
||||||
<b>{JSON.stringify(props.schema.type)}</b>
|
|
||||||
</span>
|
|
||||||
<pre>
|
|
||||||
<code>{JSON.stringify(props.schema, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface SchemaFieldsProps<T extends FieldValues, R extends ResponseData> {
|
|
||||||
formStore: FormStore<T, R>;
|
|
||||||
Field: typeof Field<T, R, never>;
|
|
||||||
schema: JSONSchema7;
|
|
||||||
path: string[];
|
|
||||||
readonly: boolean;
|
|
||||||
parent: JSONSchema7;
|
|
||||||
}
|
|
||||||
export function SchemaFields<T extends FieldValues, R extends ResponseData>(
|
|
||||||
props: SchemaFieldsProps<T, R>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
|
||||||
{/* Simple types */}
|
|
||||||
<Match when={props.schema.type === "boolean"}>bool</Match>
|
|
||||||
|
|
||||||
<Match when={props.schema.type === "integer"}>
|
|
||||||
<StringField {...props} schema={props.schema} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.schema.type === "number"}>
|
|
||||||
<StringField {...props} schema={props.schema} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.schema.type === "string"}>
|
|
||||||
<StringField {...props} schema={props.schema} />
|
|
||||||
</Match>
|
|
||||||
{/* Composed types */}
|
|
||||||
<Match when={props.schema.type === "array"}>
|
|
||||||
<ArrayFields {...props} schema={props.schema} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.schema.type === "object"}>
|
|
||||||
<ObjectFields {...props} schema={props.schema} />
|
|
||||||
</Match>
|
|
||||||
{/* Empty / Null */}
|
|
||||||
<Match when={props.schema.type === "null"}>
|
|
||||||
Dont know how to rendner InputType null
|
|
||||||
<Unsupported schema={props.schema} />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StringField<T extends FieldValues, R extends ResponseData>(
|
|
||||||
props: SchemaFieldsProps<T, R>,
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
props.schema.type !== "string" &&
|
|
||||||
props.schema.type !== "number" &&
|
|
||||||
props.schema.type !== "integer"
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<span class="text-error-700">
|
|
||||||
Error cannot render the following as String input.
|
|
||||||
<Unsupported schema={props.schema} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { Field } = props;
|
|
||||||
|
|
||||||
const validate = props.schema.pattern
|
|
||||||
? pattern(
|
|
||||||
new RegExp(props.schema.pattern),
|
|
||||||
`String should follow pattern ${props.schema.pattern}`,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
label: props.schema.title || props.path.join("."),
|
|
||||||
required:
|
|
||||||
props.parent.required &&
|
|
||||||
props.parent.required.some(
|
|
||||||
(r) => r === props.path[props.path.length - 1],
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const readonly = !!props.readonly;
|
|
||||||
return (
|
|
||||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
|
||||||
<Match
|
|
||||||
when={props.schema.type === "number" || props.schema.type === "integer"}
|
|
||||||
>
|
|
||||||
{(s) => (
|
|
||||||
<Field
|
|
||||||
// @ts-expect-error: We dont know dynamic names while type checking
|
|
||||||
name={props.path.join(".")}
|
|
||||||
validate={validate}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
inputProps={{
|
|
||||||
...fieldProps,
|
|
||||||
inputmode: "numeric",
|
|
||||||
pattern: "[0-9.]*",
|
|
||||||
readonly,
|
|
||||||
}}
|
|
||||||
{...commonProps}
|
|
||||||
value={(field.value as unknown as string) || ""}
|
|
||||||
error={field.error}
|
|
||||||
// required
|
|
||||||
// altLabel="Leave empty to accept the default"
|
|
||||||
// helperText="Configure how dude connects"
|
|
||||||
// error="Something is wrong now"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match when={props.schema.enum}>
|
|
||||||
{(_enumSchemas) => (
|
|
||||||
<Field
|
|
||||||
// @ts-expect-error: We dont know dynamic names while type checking
|
|
||||||
name={props.path.join(".")}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<OnlyStringItems itemspec={props.schema}>
|
|
||||||
{(options) => (
|
|
||||||
<SelectInput
|
|
||||||
error={field.error}
|
|
||||||
// altLabel={props.schema.title}
|
|
||||||
label={props.path.join(".")}
|
|
||||||
helperText={props.schema.description}
|
|
||||||
value={field.value || []}
|
|
||||||
options={options.map((o) => ({
|
|
||||||
value: o,
|
|
||||||
label: o,
|
|
||||||
}))}
|
|
||||||
selectProps={fieldProps}
|
|
||||||
required={!!props.schema.minItems}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</OnlyStringItems>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match when={props.schema.writeOnly && props.schema}>
|
|
||||||
{(s) => (
|
|
||||||
<Field
|
|
||||||
// @ts-expect-error: We dont know dynamic names while type checking
|
|
||||||
name={props.path.join(".")}
|
|
||||||
validate={validate}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<TextInput
|
|
||||||
inputProps={{ ...fieldProps, readonly }}
|
|
||||||
value={field.value as unknown as string}
|
|
||||||
// type="password"
|
|
||||||
error={field.error}
|
|
||||||
{...commonProps}
|
|
||||||
// required
|
|
||||||
// altLabel="Leave empty to accept the default"
|
|
||||||
// helperText="Configure how dude connects"
|
|
||||||
// error="Something is wrong now"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
{/* TODO: when is it a normal string input? */}
|
|
||||||
<Match when={props.schema}>
|
|
||||||
{(s) => (
|
|
||||||
<Field
|
|
||||||
// @ts-expect-error: We dont know dynamic names while type checking
|
|
||||||
name={props.path.join(".")}
|
|
||||||
validate={validate}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<TextInput
|
|
||||||
inputProps={{ ...fieldProps, readonly }}
|
|
||||||
value={field.value as unknown as string}
|
|
||||||
error={field.error}
|
|
||||||
{...commonProps}
|
|
||||||
// placeholder="foobar"
|
|
||||||
// inlineLabel={
|
|
||||||
// <div class="label">
|
|
||||||
// <span class=""></span>
|
|
||||||
// </div>
|
|
||||||
// }
|
|
||||||
// required
|
|
||||||
// altLabel="Leave empty to accept the default"
|
|
||||||
// helperText="Configure how dude connects"
|
|
||||||
// error="Something is wrong now"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OptionSchemaProps {
|
|
||||||
itemSpec: JSONSchema7Type;
|
|
||||||
}
|
|
||||||
export function OptionSchema(props: OptionSchemaProps) {
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
fallback={<option class="text-error-700">Item spec unhandled</option>}
|
|
||||||
>
|
|
||||||
<Match when={typeof props.itemSpec === "string" && props.itemSpec}>
|
|
||||||
{(o) => <option>{o()}</option>}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValueDisplayProps<T extends FieldValues, R extends ResponseData>
|
|
||||||
extends SchemaFieldsProps<T, R> {
|
|
||||||
children: JSX.Element;
|
|
||||||
listFieldName: string;
|
|
||||||
idx: number;
|
|
||||||
of: number;
|
|
||||||
}
|
|
||||||
export function ListValueDisplay<T extends FieldValues, R extends ResponseData>(
|
|
||||||
props: ValueDisplayProps<T, R>,
|
|
||||||
) {
|
|
||||||
const removeItem = (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
remove(
|
|
||||||
props.formStore,
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
props.listFieldName,
|
|
||||||
{ at: props.idx },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const moveItemBy = (dir: number) => (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
move(
|
|
||||||
props.formStore,
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
props.listFieldName,
|
|
||||||
{ from: props.idx, to: props.idx + dir },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const topMost = () => props.idx === props.of - 1;
|
|
||||||
const bottomMost = () => props.idx === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="w-full border-b border-secondary-200 px-2 pb-4">
|
|
||||||
<div class="flex w-full items-center gap-2">
|
|
||||||
{props.children}
|
|
||||||
<div class="ml-4 min-w-fit">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="s"
|
|
||||||
type="button"
|
|
||||||
onClick={moveItemBy(1)}
|
|
||||||
disabled={topMost()}
|
|
||||||
startIcon={<Icon icon="ArrowBottom" />}
|
|
||||||
class="h-12"
|
|
||||||
></Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="s"
|
|
||||||
onClick={moveItemBy(-1)}
|
|
||||||
disabled={bottomMost()}
|
|
||||||
class="h-12"
|
|
||||||
startIcon={<Icon icon="ArrowTop" />}
|
|
||||||
></Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="s"
|
|
||||||
class="h-12"
|
|
||||||
startIcon={<Icon icon="Trash" />}
|
|
||||||
onClick={removeItem}
|
|
||||||
></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const findDuplicates = (arr: unknown[]) => {
|
|
||||||
const seen = new Set();
|
|
||||||
const duplicates: number[] = [];
|
|
||||||
|
|
||||||
arr.forEach((obj, idx) => {
|
|
||||||
const serializedObj = JSON.stringify(obj);
|
|
||||||
|
|
||||||
if (seen.has(serializedObj)) {
|
|
||||||
duplicates.push(idx);
|
|
||||||
} else {
|
|
||||||
seen.add(serializedObj);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return duplicates;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface OnlyStringItems {
|
|
||||||
children: (items: string[]) => JSX.Element;
|
|
||||||
itemspec: JSONSchema7;
|
|
||||||
}
|
|
||||||
const OnlyStringItems = (props: OnlyStringItems) => {
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
Array.isArray(props.itemspec.enum) &&
|
|
||||||
typeof props.itemspec.type === "string" &&
|
|
||||||
props.itemspec
|
|
||||||
}
|
|
||||||
fallback={
|
|
||||||
<Unsupported
|
|
||||||
schema={props.itemspec}
|
|
||||||
error="Unsupported array item type"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{props.children(props.itemspec.enum as string[])}
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ArrayFields<T extends FieldValues, R extends ResponseData>(
|
|
||||||
props: SchemaFieldsProps<T, R>,
|
|
||||||
) {
|
|
||||||
if (props.schema.type !== "array") {
|
|
||||||
return (
|
|
||||||
<span class="text-error-700">
|
|
||||||
Error cannot render the following as array.
|
|
||||||
<Unsupported schema={props.schema} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { Field } = props;
|
|
||||||
|
|
||||||
const listFieldName = props.path.join(".");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
!Array.isArray(props.schema.items) &&
|
|
||||||
typeof props.schema.items === "object" &&
|
|
||||||
props.schema.items
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(itemsSchema) => (
|
|
||||||
<>
|
|
||||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
|
||||||
<Match when={itemsSchema().type === "array"}>
|
|
||||||
<Unsupported
|
|
||||||
schema={props.schema}
|
|
||||||
error="Array of Array is not supported yet."
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match
|
|
||||||
when={itemsSchema().type === "string" && itemsSchema().enum}
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
name={listFieldName}
|
|
||||||
// @ts-expect-error: type is known due to schema
|
|
||||||
type="string[]"
|
|
||||||
validateOn="touched"
|
|
||||||
revalidateOn="touched"
|
|
||||||
validate={() => {
|
|
||||||
let error = "";
|
|
||||||
const values: unknown[] = getValues(
|
|
||||||
props.formStore,
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
listFieldName,
|
|
||||||
// @ts-expect-error: assumption based on the behavior of selectInput
|
|
||||||
)?.strings?.selection;
|
|
||||||
console.log("vali", { values });
|
|
||||||
if (props.schema.uniqueItems) {
|
|
||||||
const duplicates = findDuplicates(values);
|
|
||||||
if (duplicates.length) {
|
|
||||||
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
props.schema.maxItems &&
|
|
||||||
values.length > props.schema.maxItems
|
|
||||||
) {
|
|
||||||
error = `You can only select up to ${props.schema.maxItems} items`;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
props.schema.minItems &&
|
|
||||||
values.length < props.schema.minItems
|
|
||||||
) {
|
|
||||||
error = `Please select at least ${props.schema.minItems} items.`;
|
|
||||||
}
|
|
||||||
return error;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<OnlyStringItems itemspec={itemsSchema()}>
|
|
||||||
{(options) => (
|
|
||||||
<SelectInput
|
|
||||||
multiple
|
|
||||||
error={field.error}
|
|
||||||
// altLabel={props.schema.title}
|
|
||||||
label={listFieldName}
|
|
||||||
helperText={props.schema.description}
|
|
||||||
value={field.value || ""}
|
|
||||||
options={options.map((o) => ({
|
|
||||||
value: o,
|
|
||||||
label: o,
|
|
||||||
}))}
|
|
||||||
selectProps={fieldProps}
|
|
||||||
required={!!props.schema.minItems}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</OnlyStringItems>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Match>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
itemsSchema().type === "string" ||
|
|
||||||
itemsSchema().type === "object"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* !Important: Register the parent field to gain access to array items*/}
|
|
||||||
<FieldArray
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
name={listFieldName}
|
|
||||||
of={props.formStore}
|
|
||||||
validateOn="touched"
|
|
||||||
revalidateOn="touched"
|
|
||||||
validate={() => {
|
|
||||||
let error = "";
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
const values: unknown[] = getValues(
|
|
||||||
props.formStore,
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
listFieldName,
|
|
||||||
);
|
|
||||||
if (props.schema.uniqueItems) {
|
|
||||||
const duplicates = findDuplicates(values);
|
|
||||||
if (duplicates.length) {
|
|
||||||
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
props.schema.maxItems &&
|
|
||||||
values.length > props.schema.maxItems
|
|
||||||
) {
|
|
||||||
error = `You can only add up to ${props.schema.maxItems} items`;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
props.schema.minItems &&
|
|
||||||
values.length < props.schema.minItems
|
|
||||||
) {
|
|
||||||
error = `Please add at least ${props.schema.minItems} items.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return error;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(fieldArray) => (
|
|
||||||
<>
|
|
||||||
{/* Render existing items */}
|
|
||||||
<For
|
|
||||||
each={fieldArray.items}
|
|
||||||
fallback={
|
|
||||||
// Empty list
|
|
||||||
<span class="text-neutral-500">
|
|
||||||
No {itemsSchema().title || "entries"} yet.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(item, idx) => (
|
|
||||||
<ListValueDisplay
|
|
||||||
{...props}
|
|
||||||
listFieldName={listFieldName}
|
|
||||||
idx={idx()}
|
|
||||||
of={fieldArray.items.length}
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
// @ts-expect-error: field names are not know ahead of time
|
|
||||||
name={`${listFieldName}.${idx()}`}
|
|
||||||
>
|
|
||||||
{(f, fp) => (
|
|
||||||
<>
|
|
||||||
<DynForm
|
|
||||||
formProps={{
|
|
||||||
class: cx("w-full"),
|
|
||||||
}}
|
|
||||||
resetOnSubmit={true}
|
|
||||||
schema={itemsSchema()}
|
|
||||||
initialValues={
|
|
||||||
itemsSchema().type === "object"
|
|
||||||
? f.value
|
|
||||||
: { "": f.value }
|
|
||||||
}
|
|
||||||
readonly={true}
|
|
||||||
></DynForm>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</ListValueDisplay>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={fieldArray.error}>
|
|
||||||
<span class="font-bold text-error-700">
|
|
||||||
{fieldArray.error}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Add new item */}
|
|
||||||
<DynForm
|
|
||||||
formProps={{
|
|
||||||
class: cx("px-2 w-full"),
|
|
||||||
}}
|
|
||||||
schema={{
|
|
||||||
...itemsSchema(),
|
|
||||||
title: itemsSchema().title || "thing",
|
|
||||||
}}
|
|
||||||
initialPath={["root"]}
|
|
||||||
// Reset the input field for list items
|
|
||||||
resetOnSubmit={true}
|
|
||||||
initialValues={{
|
|
||||||
root: generateDefaults(itemsSchema()),
|
|
||||||
}}
|
|
||||||
// Button for adding new items
|
|
||||||
components={{
|
|
||||||
before: (
|
|
||||||
<div class="flex w-full justify-end pb-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
type="submit"
|
|
||||||
endIcon={<Icon size={14} icon={"Plus"} />}
|
|
||||||
class="capitalize"
|
|
||||||
>
|
|
||||||
Add {itemsSchema().title}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
// Add the new item to the FieldArray
|
|
||||||
handleSubmit={(values, event) => {
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
const prev: unknown[] = getValues(
|
|
||||||
props.formStore,
|
|
||||||
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
listFieldName,
|
|
||||||
);
|
|
||||||
if (itemsSchema().type === "object") {
|
|
||||||
const newIdx = prev.length;
|
|
||||||
setValue(
|
|
||||||
props.formStore,
|
|
||||||
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
`${listFieldName}.${newIdx}`,
|
|
||||||
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
values.root,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
insert(props.formStore, listFieldName, {
|
|
||||||
// @ts-expect-error: listFieldName is not known ahead of time
|
|
||||||
value: values.root,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FieldArray>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ObjectFieldPropertyLabelProps {
|
|
||||||
schema: JSONSchema7;
|
|
||||||
fallback: JSX.Element;
|
|
||||||
}
|
|
||||||
export function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) {
|
|
||||||
return (
|
|
||||||
<Switch fallback={props.fallback}>
|
|
||||||
{/* @ts-expect-error: $exportedModuleInfo should exist since we export it */}
|
|
||||||
<Match when={props.schema?.$exportedModuleInfo?.path}>
|
|
||||||
{(path) => path()[path().length - 1]}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ObjectFields<T extends FieldValues, R extends ResponseData>(
|
|
||||||
props: SchemaFieldsProps<T, R>,
|
|
||||||
) {
|
|
||||||
if (props.schema.type !== "object") {
|
|
||||||
return (
|
|
||||||
<span class="text-error-700">
|
|
||||||
Error cannot render the following as Object
|
|
||||||
<Unsupported schema={props.schema} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldName = props.path.join(".");
|
|
||||||
const { Field } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
fallback={
|
|
||||||
<Unsupported
|
|
||||||
schema={props.schema}
|
|
||||||
error="Dont know how to render objectFields"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Match
|
|
||||||
when={!props.schema.additionalProperties && props.schema.properties}
|
|
||||||
>
|
|
||||||
{(properties) => (
|
|
||||||
<For each={Object.entries(properties())}>
|
|
||||||
{([propName, propSchema]) => (
|
|
||||||
<div
|
|
||||||
// eslint-disable-next-line tailwindcss/no-custom-classname
|
|
||||||
class={cx(
|
|
||||||
"w-full grid grid-cols-1 gap-4 justify-items-start",
|
|
||||||
`p-${props.path.length * 2}`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Label
|
|
||||||
label={propName}
|
|
||||||
required={props.schema.required?.some((r) => r === propName)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{typeof propSchema === "object" && (
|
|
||||||
<SchemaFields
|
|
||||||
{...props}
|
|
||||||
schema={propSchema}
|
|
||||||
path={[...props.path, propName]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{typeof propSchema === "boolean" && (
|
|
||||||
<span class="text-error-700">
|
|
||||||
Schema: Object of Boolean not supported
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
{/* Objects where people can define their own keys
|
|
||||||
- Trivial Key-value pairs. Where the value is a string a number or a list of strings (trivial select).
|
|
||||||
- Non-trivial Key-value pairs. Where the value is an object or a list
|
|
||||||
*/}
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
typeof props.schema.additionalProperties === "object" &&
|
|
||||||
props.schema.additionalProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(additionalPropertiesSchema) => (
|
|
||||||
<Switch
|
|
||||||
fallback={
|
|
||||||
<Unsupported
|
|
||||||
schema={additionalPropertiesSchema()}
|
|
||||||
error="type of additionalProperties not supported yet"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Non-trivival cases */}
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
additionalPropertiesSchema().type === "object" &&
|
|
||||||
additionalPropertiesSchema()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(itemSchema) => (
|
|
||||||
<Field
|
|
||||||
// Important!: Register the object field to gain access to the dynamic object properties
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
name={fieldName}
|
|
||||||
>
|
|
||||||
{(objectField, fp) => (
|
|
||||||
<>
|
|
||||||
<For
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<label class="">
|
|
||||||
No{" "}
|
|
||||||
<ObjectFieldPropertyLabel
|
|
||||||
schema={itemSchema()}
|
|
||||||
fallback={"No entries"}
|
|
||||||
/>{" "}
|
|
||||||
yet.
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
each={Object.entries(objectField.value || {})}
|
|
||||||
>
|
|
||||||
{([key, relatedValue]) => (
|
|
||||||
<Field
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
name={`${fieldName}.${key}`}
|
|
||||||
>
|
|
||||||
{(f, fp) => (
|
|
||||||
<div class="w-full border-l-4 border-gray-300 pl-4">
|
|
||||||
<DynForm
|
|
||||||
formProps={{
|
|
||||||
class: cx("w-full"),
|
|
||||||
}}
|
|
||||||
schema={itemSchema()}
|
|
||||||
initialValues={f.value}
|
|
||||||
components={{
|
|
||||||
before: (
|
|
||||||
<div class="flex w-full">
|
|
||||||
<span class="text-xl font-semibold">
|
|
||||||
{key}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="ml-auto"
|
|
||||||
size="s"
|
|
||||||
type="button"
|
|
||||||
onClick={(_e) => {
|
|
||||||
const copy = {
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
...objectField.value,
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
||||||
delete copy[key];
|
|
||||||
setValue(
|
|
||||||
props.formStore,
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
`${fieldName}`,
|
|
||||||
copy,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="Trash" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
{/* Replace this with a normal input ?*/}
|
|
||||||
<DynForm
|
|
||||||
formProps={{
|
|
||||||
class: cx("w-full"),
|
|
||||||
}}
|
|
||||||
resetOnSubmit={true}
|
|
||||||
initialValues={{ "": "" }}
|
|
||||||
schema={{
|
|
||||||
type: "string",
|
|
||||||
title: `Entry title or key`,
|
|
||||||
}}
|
|
||||||
handleSubmit={(values, event) => {
|
|
||||||
setValue(
|
|
||||||
props.formStore,
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
`${fieldName}`,
|
|
||||||
// @ts-expect-error: fieldName is not known ahead of time
|
|
||||||
{ ...objectField.value, [values[""]]: {} },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
additionalPropertiesSchema().type === "array" &&
|
|
||||||
additionalPropertiesSchema()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(itemSchema) => (
|
|
||||||
<Unsupported
|
|
||||||
schema={itemSchema()}
|
|
||||||
error="dynamic arrays are not implemented yet"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
{/* TODO: Trivial cases */}
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import schema from "@/api/API.json" with { type: "json" };
|
|
||||||
import { API, Error as ApiError } from "@/api/API";
|
import { API, Error as ApiError } from "@/api/API";
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { Schema as Inventory } from "@/api/Inventory";
|
import { Schema as Inventory } from "@/api/Inventory";
|
||||||
import { toast, Toast } from "solid-toast";
|
import { toast } from "solid-toast";
|
||||||
import {
|
import {
|
||||||
ErrorToastComponent,
|
ErrorToastComponent,
|
||||||
CancelToastComponent,
|
CancelToastComponent,
|
||||||
} from "@/src/components/toast";
|
} from "@/src/components/toast";
|
||||||
export type OperationNames = keyof API;
|
type OperationNames = keyof API;
|
||||||
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
||||||
export type OperationResponse<T extends OperationNames> = API[T]["return"];
|
export type OperationResponse<T extends OperationNames> = API[T]["return"];
|
||||||
|
|
||||||
export type ApiEnvelope<T> =
|
type ApiEnvelope<T> =
|
||||||
| {
|
| {
|
||||||
status: "success";
|
status: "success";
|
||||||
data: T;
|
data: T;
|
||||||
@@ -19,10 +17,10 @@ export type ApiEnvelope<T> =
|
|||||||
}
|
}
|
||||||
| ApiError;
|
| ApiError;
|
||||||
|
|
||||||
export type Services = NonNullable<Inventory["services"]>;
|
type Services = NonNullable<Inventory["services"]>;
|
||||||
export type ServiceNames = keyof Services;
|
type ServiceNames = keyof Services;
|
||||||
export type ClanService<T extends ServiceNames> = Services[T];
|
type ClanService<T extends ServiceNames> = Services[T];
|
||||||
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
|
type ClanServiceInstance<T extends ServiceNames> = NonNullable<
|
||||||
Services[T]
|
Services[T]
|
||||||
>[string];
|
>[string];
|
||||||
|
|
||||||
@@ -30,17 +28,17 @@ export type SuccessQuery<T extends OperationNames> = Extract<
|
|||||||
OperationResponse<T>,
|
OperationResponse<T>,
|
||||||
{ status: "success" }
|
{ status: "success" }
|
||||||
>;
|
>;
|
||||||
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
||||||
|
|
||||||
export type ErrorQuery<T extends OperationNames> = Extract<
|
type ErrorQuery<T extends OperationNames> = Extract<
|
||||||
OperationResponse<T>,
|
OperationResponse<T>,
|
||||||
{ status: "error" }
|
{ status: "error" }
|
||||||
>;
|
>;
|
||||||
export type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
|
type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
|
||||||
|
|
||||||
export type ClanOperations = Record<OperationNames, (str: string) => void>;
|
type ClanOperations = Record<OperationNames, (str: string) => void>;
|
||||||
|
|
||||||
export interface GtkResponse<T> {
|
interface GtkResponse<T> {
|
||||||
result: T;
|
result: T;
|
||||||
op_key: string;
|
op_key: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { callApi } from ".";
|
|
||||||
import { Schema as Inventory } from "@/api/Inventory";
|
|
||||||
|
|
||||||
export const instance_name = (machine_name: string) =>
|
|
||||||
`${machine_name}_wifi_0` as const;
|
|
||||||
|
|
||||||
export async function get_iwd_service(base_path: string, machine_name: string) {
|
|
||||||
const r = await callApi("get_inventory", {
|
|
||||||
flake: { identifier: base_path },
|
|
||||||
}).promise;
|
|
||||||
if (r.status == "error") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// @FIXME: Clean this up once we implement the feature
|
|
||||||
// @ts-expect-error: This doesn't check currently
|
|
||||||
const inventory: Inventory = r.data;
|
|
||||||
|
|
||||||
const instance_key = instance_name(machine_name);
|
|
||||||
return inventory.services?.iwd?.[instance_key] || null;
|
|
||||||
}
|
|
||||||
@@ -119,11 +119,11 @@ export const ApiTester = () => {
|
|||||||
<Show
|
<Show
|
||||||
when={showSuggestions() && filteredEndpoints().length > 0}
|
when={showSuggestions() && filteredEndpoints().length > 0}
|
||||||
>
|
>
|
||||||
<ul class="absolute z-10 w-full bg-white border border-gray-300 rounded mt-1 max-h-60 overflow-y-auto shadow-lg">
|
<ul class="absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border border-gray-300 bg-white shadow-lg">
|
||||||
<For each={filteredEndpoints()}>
|
<For each={filteredEndpoints()}>
|
||||||
{(ep) => (
|
{(ep) => (
|
||||||
<li
|
<li
|
||||||
class="p-2 hover:bg-gray-100 cursor-pointer"
|
class="cursor-pointer p-2 hover:bg-gray-100"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setValue(formStore, "endpoint", ep);
|
setValue(formStore, "endpoint", ep);
|
||||||
@@ -142,13 +142,13 @@ export const ApiTester = () => {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field name="payload">
|
<Field name="payload">
|
||||||
{(field, fieldProps) => (
|
{(field, fieldProps) => (
|
||||||
<div class="flex flex-col my-2">
|
<div class="my-2 flex flex-col">
|
||||||
<label class="mb-1 font-medium" for="payload-textarea">
|
<label class="mb-1 font-medium" for="payload-textarea">
|
||||||
payload
|
payload
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="payload-textarea"
|
id="payload-textarea"
|
||||||
class="border rounded p-2 text-sm min-h-[120px] resize-y focus:outline-none focus:ring-2 focus:ring-blue-400"
|
class="min-h-[120px] resize-y rounded border p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
placeholder={`{\n "key": "value"\n}`}
|
placeholder={`{\n "key": "value"\n}`}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
{...fieldProps}
|
{...fieldProps}
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ const sizeFont: Record<Size, string> = {
|
|||||||
s: cx("text-[0.75rem]"),
|
s: cx("text-[0.75rem]"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ButtonProps
|
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: Variants;
|
variant?: Variants;
|
||||||
size?: Size;
|
size?: Size;
|
||||||
children?: JSX.Element;
|
children?: JSX.Element;
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import cx from "classnames";
|
|
||||||
import { createMemo, JSX, Show, splitProps } from "solid-js";
|
|
||||||
|
|
||||||
export interface FileInputProps {
|
|
||||||
ref: (element: HTMLInputElement) => void;
|
|
||||||
name: string;
|
|
||||||
value?: File[] | File;
|
|
||||||
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
|
||||||
onClick: JSX.EventHandler<HTMLInputElement, Event>;
|
|
||||||
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
|
||||||
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
|
||||||
accept?: string;
|
|
||||||
required?: boolean;
|
|
||||||
multiple?: boolean;
|
|
||||||
class?: string;
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
helperText?: string;
|
|
||||||
placeholder?: JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File input field that users can click or drag files into. Various
|
|
||||||
* decorations can be displayed in or around the field to communicate the entry
|
|
||||||
* requirements.
|
|
||||||
*/
|
|
||||||
export function FileInput(props: FileInputProps) {
|
|
||||||
// Split input element props
|
|
||||||
const [, inputProps] = splitProps(props, [
|
|
||||||
"class",
|
|
||||||
"value",
|
|
||||||
"label",
|
|
||||||
"error",
|
|
||||||
"placeholder",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create file list
|
|
||||||
const getFiles = createMemo(() =>
|
|
||||||
props.value
|
|
||||||
? Array.isArray(props.value)
|
|
||||||
? props.value
|
|
||||||
: [props.value]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={cx(" w-full", props.class)}>
|
|
||||||
<div class="">
|
|
||||||
<span
|
|
||||||
class=" block"
|
|
||||||
classList={{
|
|
||||||
"after:ml-0.5 after:text-primary after:content-['*']":
|
|
||||||
props.required,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Show when={props.helperText}>
|
|
||||||
<span class=" m-1">{props.helperText}</span>
|
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
class={cx(
|
|
||||||
"relative flex min-h-[96px] w-full items-center justify-center rounded-2xl border-[3px] border-dashed p-8 text-center focus-within:ring-4 md:min-h-[112px] md:text-lg lg:min-h-[128px] lg:p-10 lg:text-xl",
|
|
||||||
!getFiles().length && "text-slate-500",
|
|
||||||
props.error
|
|
||||||
? "border-red-500/25 focus-within:border-red-500/50 focus-within:ring-red-500/10 hover:border-red-500/40 dark:border-red-400/25 dark:focus-within:border-red-400/50 dark:focus-within:ring-red-400/10 dark:hover:border-red-400/40"
|
|
||||||
: "border-slate-200 focus-within:border-sky-500/50 focus-within:ring-sky-500/10 hover:border-slate-300 dark:border-slate-800 dark:focus-within:border-sky-400/50 dark:focus-within:ring-sky-400/10 dark:hover:border-slate-700",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={getFiles().length}
|
|
||||||
fallback={
|
|
||||||
props.placeholder || (
|
|
||||||
<>Click to select file{props.multiple && "s"}</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Selected file{props.multiple && "s"}:{" "}
|
|
||||||
{getFiles()
|
|
||||||
.map(({ name }) => name)
|
|
||||||
.join(", ")}
|
|
||||||
</Show>
|
|
||||||
<input
|
|
||||||
{...inputProps}
|
|
||||||
// Disable drag n drop
|
|
||||||
onDrop={(e) => e.preventDefault()}
|
|
||||||
class="absolute size-full cursor-pointer opacity-0"
|
|
||||||
type="file"
|
|
||||||
id={props.name}
|
|
||||||
aria-invalid={!!props.error}
|
|
||||||
aria-errormessage={`${props.name}-error`}
|
|
||||||
/>
|
|
||||||
{props.error && (
|
|
||||||
<span class=" font-bold text-error-700">{props.error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { children, 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";
|
|
||||||
import { Button } from "./Button/Button";
|
|
||||||
|
|
||||||
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
|
|
||||||
variant="ghost"
|
|
||||||
size="s"
|
|
||||||
popovertarget={props.popoverid}
|
|
||||||
popovertargetaction="toggle"
|
|
||||||
ref={setReference}
|
|
||||||
class={cx("", 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
|
|
||||||
import { Show } from "solid-js";
|
|
||||||
import { type JSX } from "solid-js";
|
|
||||||
import cx from "classnames";
|
|
||||||
|
|
||||||
interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
|
|
||||||
formStore: FormStore<T, R>;
|
|
||||||
value: string;
|
|
||||||
options: JSX.Element;
|
|
||||||
selectProps: JSX.HTMLAttributes<HTMLSelectElement>;
|
|
||||||
label: JSX.Element;
|
|
||||||
error?: string;
|
|
||||||
required?: boolean;
|
|
||||||
topRightLabel?: JSX.Element;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectInput<T extends FieldValues, R extends ResponseData>(
|
|
||||||
props: SelectInputProps<T, R>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
class={cx(" w-full", props.class)}
|
|
||||||
aria-disabled={props.formStore.submitting}
|
|
||||||
>
|
|
||||||
<div class="">
|
|
||||||
<span
|
|
||||||
class=" block"
|
|
||||||
classList={{
|
|
||||||
"after:ml-0.5 after:text-primary after:content-['*']":
|
|
||||||
props.required,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
<Show when={props.topRightLabel}>
|
|
||||||
<span class="">{props.topRightLabel}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
{...props.selectProps}
|
|
||||||
required={props.required}
|
|
||||||
class="w-full"
|
|
||||||
value={props.value}
|
|
||||||
>
|
|
||||||
{props.options}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{props.error && (
|
|
||||||
<span class=" font-bold text-error-700">{props.error}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import "./css/sidebar.css";
|
|||||||
import Icon, { IconVariant } from "../icon";
|
import Icon, { IconVariant } from "../icon";
|
||||||
import { clanMetaQuery } from "@/src/queries/clan-meta";
|
import { clanMetaQuery } from "@/src/queries/clan-meta";
|
||||||
|
|
||||||
export const SidebarSection = (props: {
|
const SidebarSection = (props: {
|
||||||
title: string;
|
title: string;
|
||||||
icon: IconVariant;
|
icon: IconVariant;
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
div.tag-list {
|
|
||||||
@apply flex flex-wrap gap-2;
|
|
||||||
|
|
||||||
span.tag {
|
|
||||||
@apply w-fit rounded-full px-3 py-2 bg-inv-4 fg-inv-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Component, For } from "solid-js";
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import "./TagList.css";
|
|
||||||
|
|
||||||
export interface TagListProps {
|
|
||||||
values: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TagList: Component<TagListProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="tag-list">
|
|
||||||
<For each={props.values}>
|
|
||||||
{(tag) => (
|
|
||||||
<Typography hierarchy="label" size="s" inverted={true} class="tag">
|
|
||||||
{tag}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,7 @@ import { Dynamic } from "solid-js/web";
|
|||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import "./css/typography.css";
|
import "./css/typography.css";
|
||||||
|
|
||||||
export type Hierarchy = "body" | "title" | "headline" | "label";
|
type Hierarchy = "body" | "title" | "headline" | "label";
|
||||||
type Color = "primary" | "secondary" | "tertiary";
|
type Color = "primary" | "secondary" | "tertiary";
|
||||||
type Weight = "normal" | "medium" | "bold";
|
type Weight = "normal" | "medium" | "bold";
|
||||||
type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
|
type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
.accordion {
|
|
||||||
@apply flex flex-col gap-y-5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion__title {
|
|
||||||
@apply flex h-5 cursor-pointer items-center justify-end gap-x-0.5 px-1 font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion__body {
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { createSignal, JSX, Show } from "solid-js";
|
|
||||||
import Icon from "../icon";
|
|
||||||
import { Button } from "../Button/Button";
|
|
||||||
import cx from "classnames";
|
|
||||||
import "./accordion.css";
|
|
||||||
|
|
||||||
interface AccordionProps {
|
|
||||||
title: string;
|
|
||||||
children: JSX.Element;
|
|
||||||
class?: string;
|
|
||||||
initiallyOpen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Accordion(props: AccordionProps) {
|
|
||||||
const [isOpen, setIsOpen] = createSignal(props.initiallyOpen ?? false);
|
|
||||||
return (
|
|
||||||
<div class={cx(`accordion`, props.class)} tabindex="0">
|
|
||||||
<div onClick={() => setIsOpen(!isOpen())} class="accordion__title">
|
|
||||||
<Show
|
|
||||||
when={isOpen()}
|
|
||||||
fallback={
|
|
||||||
<Button
|
|
||||||
endIcon={<Icon size={12} icon={"CaretDown"} />}
|
|
||||||
variant="ghost"
|
|
||||||
size="s"
|
|
||||||
>
|
|
||||||
{props.title}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
endIcon={<Icon size={12} icon={"CaretUp"} />}
|
|
||||||
variant="ghost"
|
|
||||||
size="s"
|
|
||||||
>
|
|
||||||
{props.title}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={isOpen()}>
|
|
||||||
<div class="accordion__body">{props.children}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { JSX } from "solid-js";
|
|
||||||
import cx from "classnames";
|
|
||||||
import Icon, { IconVariant } from "../icon";
|
|
||||||
import { Typography } from "../Typography";
|
|
||||||
|
|
||||||
interface BadgeProps {
|
|
||||||
color: keyof typeof colorMap;
|
|
||||||
children: JSX.Element;
|
|
||||||
icon?: IconVariant;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorMap = {
|
|
||||||
primary: cx("bg-primary-800 text-primary-100"),
|
|
||||||
secondary: cx("bg-secondary-800 text-secondary-100"),
|
|
||||||
blue: "bg-blue-100 text-blue-800",
|
|
||||||
gray: "bg-gray-100 text-gray-800",
|
|
||||||
green: "bg-green-100 text-green-800",
|
|
||||||
orange: "bg-orange-100 text-orange-800",
|
|
||||||
red: "bg-red-100 text-red-800",
|
|
||||||
yellow: "bg-yellow-100 text-yellow-800",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Badge = (props: BadgeProps) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={cx(
|
|
||||||
"flex px-4 py-2 rounded-sm justify-center items-center gap-1",
|
|
||||||
colorMap[props.color],
|
|
||||||
props.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.icon && <Icon icon={props.icon} class="size-4" />}
|
|
||||||
<Typography hierarchy="label" size="default" color="inherit">
|
|
||||||
{props.children}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { FileInput, type FileInputProps } from "@/src/components/FileInput"; // Assuming FileInput can take a ref and has onClick
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import Fieldset from "@/src/Form/fieldset";
|
|
||||||
import Icon from "@/src/components/icon"; // For displaying file icons
|
|
||||||
import { callApi } from "@/src/api";
|
|
||||||
import type { FieldValues } from "@modular-forms/solid";
|
|
||||||
import { Show, For, type Component, type JSX } from "solid-js";
|
|
||||||
|
|
||||||
// Types for the file dialog options passed to callApi
|
|
||||||
interface FileRequestFilter {
|
|
||||||
patterns: string[];
|
|
||||||
mime_types?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileDialogOptions {
|
|
||||||
title: string;
|
|
||||||
filters?: FileRequestFilter;
|
|
||||||
initial_folder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Props for the CustomFileField component
|
|
||||||
interface FileSelectorOpts<TFieldName extends string> {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
Field: any; // The Field component from createForm
|
|
||||||
name: TFieldName; // Name of the form field (e.g., "sshKeys", "profilePicture")
|
|
||||||
label: string; // Legend for Fieldset or main label for the input
|
|
||||||
description?: string | JSX.Element; // Optional description text
|
|
||||||
multiple?: boolean; // True if multiple files can be selected, false for single file
|
|
||||||
fileDialogOptions: FileDialogOptions; // Configuration for the custom file dialog
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
of: any;
|
|
||||||
// Optional props for styling
|
|
||||||
inputClass?: string;
|
|
||||||
fileListClass?: string;
|
|
||||||
// You can add more specific props like `validate` if you want to pass them to Field
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FileSelectorField: Component<FileSelectorOpts<string>> = (
|
|
||||||
props,
|
|
||||||
) => {
|
|
||||||
const {
|
|
||||||
Field,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
multiple = false,
|
|
||||||
fileDialogOptions,
|
|
||||||
inputClass,
|
|
||||||
fileListClass,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
// Ref to the underlying HTMLInputElement (assuming FileInput forwards refs or is simple)
|
|
||||||
let actualInputElement: HTMLInputElement | undefined;
|
|
||||||
|
|
||||||
const openAndSetFiles = async (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!actualInputElement) {
|
|
||||||
console.error(
|
|
||||||
"CustomFileField: Input element ref is not set. Cannot proceed.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
const mode = multiple ? "open_multiple_files" : "open_file";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await callApi("open_file", {
|
|
||||||
file_request: {
|
|
||||||
title: fileDialogOptions.title,
|
|
||||||
mode: mode,
|
|
||||||
filters: fileDialogOptions.filters,
|
|
||||||
initial_folder: fileDialogOptions.initial_folder,
|
|
||||||
},
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (
|
|
||||||
response.status === "success" &&
|
|
||||||
response.data &&
|
|
||||||
Array.isArray(response.data)
|
|
||||||
) {
|
|
||||||
(response.data as string[]).forEach((filename) => {
|
|
||||||
// Create File objects. Content is empty as we only have paths.
|
|
||||||
// Type might be generic or derived if possible.
|
|
||||||
dataTransfer.items.add(
|
|
||||||
new File([], filename, { type: "application/octet-stream" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else if (response.status === "error") {
|
|
||||||
// Consider using a toast or other user notification for API errors
|
|
||||||
console.error("Error from open_file API:", response.errors);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to call open_file API:", error);
|
|
||||||
// Consider using a toast here
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the FileList on the actual input element
|
|
||||||
Object.defineProperty(actualInputElement, "files", {
|
|
||||||
value: dataTransfer.files,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dispatch an 'input' event so modular-forms updates its state
|
|
||||||
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
|
|
||||||
actualInputElement.dispatchEvent(inputEvent);
|
|
||||||
|
|
||||||
// Optionally, dispatch 'change' if your forms setup relies more on it
|
|
||||||
// const changeEvent = new Event("change", { bubbles: true, cancelable: true });
|
|
||||||
// actualInputElement.dispatchEvent(changeEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fieldset legend={label}>
|
|
||||||
{description &&
|
|
||||||
(typeof description === "string" ? (
|
|
||||||
<Typography hierarchy="body" size="s" weight="medium" class="mb-2">
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
description
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Field name={name} type={multiple ? "File[]" : "File"}>
|
|
||||||
{(
|
|
||||||
field: { value: File | File[]; error?: string },
|
|
||||||
fieldProps: Record<string, unknown>,
|
|
||||||
) => (
|
|
||||||
<>
|
|
||||||
{/*
|
|
||||||
This FileInput component should be clickable.
|
|
||||||
Its 'ref' needs to point to the actual <input type="file"> element.
|
|
||||||
If FileInput is complex, it might need an 'inputRef' prop or similar.
|
|
||||||
*/}
|
|
||||||
<FileInput
|
|
||||||
{...(fieldProps as unknown as FileInputProps)} // Spread modular-forms props
|
|
||||||
ref={(el: HTMLInputElement) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(fieldProps as any).ref(el); // Pass ref to modular-forms
|
|
||||||
actualInputElement = el; // Capture for local use
|
|
||||||
}}
|
|
||||||
class={inputClass}
|
|
||||||
multiple={multiple}
|
|
||||||
// The onClick here triggers our custom dialog logic
|
|
||||||
onClick={openAndSetFiles}
|
|
||||||
// The 'value' prop for a file input is not for displaying selected files directly.
|
|
||||||
// We'll display them below. FileInput might show placeholder text.
|
|
||||||
// value={undefined} // Explicitly not setting value from field.value here
|
|
||||||
error={field.error} // Display error from modular-forms
|
|
||||||
/>
|
|
||||||
{field.error && (
|
|
||||||
<Typography hierarchy="body" size="xs" class="mt-1">
|
|
||||||
{field.error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Display the list of selected files */}
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
field.value &&
|
|
||||||
(multiple
|
|
||||||
? (field.value as File[]).length > 0
|
|
||||||
: field.value instanceof File)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class={`mt-2 space-y-1 ${fileListClass || ""}`}>
|
|
||||||
<For
|
|
||||||
each={
|
|
||||||
multiple
|
|
||||||
? (field.value as File[])
|
|
||||||
: field.value instanceof File
|
|
||||||
? [field.value as File]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(file) => (
|
|
||||||
<div class="flex items-center justify-between rounded border p-2 text-sm border-def-1">
|
|
||||||
<span class="truncate" title={file.name}>
|
|
||||||
{file.name}
|
|
||||||
</span>
|
|
||||||
{/* A remove button per file is complex with FileList & modular-forms.
|
|
||||||
For now, clearing all files is simpler (e.g., via FileInput's own clear).
|
|
||||||
Or, the user re-selects files to change the selection. */}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import cx from "classnames";
|
|
||||||
import { JSX } from "solid-js";
|
|
||||||
import Icon, { IconVariant } from "../icon";
|
|
||||||
|
|
||||||
interface GroupProps {
|
|
||||||
children: JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Group = (props: GroupProps) => (
|
|
||||||
<div class="flex flex-col gap-8 rounded-md border px-4 py-5 bg-def-2 border-def-2">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SectionVariant = "attention" | "danger";
|
|
||||||
|
|
||||||
interface SectionHeaderProps {
|
|
||||||
variant: SectionVariant;
|
|
||||||
headline: JSX.Element;
|
|
||||||
}
|
|
||||||
const variantColorsMap: Record<SectionVariant, string> = {
|
|
||||||
attention: cx("bg-[#9BD8F2] fg-def-1"),
|
|
||||||
danger: cx("bg-semantic-2 fg-semantic-2"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const variantIconColorsMap: Record<SectionVariant, string> = {
|
|
||||||
attention: cx("fg-def-1"),
|
|
||||||
danger: cx("fg-semantic-3"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const variantIconMap: Record<SectionVariant, IconVariant> = {
|
|
||||||
attention: "Attention",
|
|
||||||
danger: "Warning",
|
|
||||||
};
|
|
||||||
|
|
||||||
// SectionHeader component
|
|
||||||
export const SectionHeader = (props: SectionHeaderProps) => (
|
|
||||||
<div
|
|
||||||
class={cx(
|
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2",
|
|
||||||
variantColorsMap[props.variant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
<Icon
|
|
||||||
icon={variantIconMap[props.variant]}
|
|
||||||
class={cx("size-5", variantIconColorsMap[props.variant])}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{props.headline}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Section component
|
|
||||||
interface SectionProps {
|
|
||||||
children: JSX.Element;
|
|
||||||
}
|
|
||||||
export const Section = (props: SectionProps) => (
|
|
||||||
<div class="flex flex-col gap-3">{props.children}</div>
|
|
||||||
);
|
|
||||||
@@ -99,8 +99,7 @@ export const InputBase = (props: InputBaseProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface InputLabelProps
|
interface InputLabelProps extends JSX.LabelHTMLAttributes<HTMLLabelElement> {
|
||||||
extends JSX.LabelHTMLAttributes<HTMLLabelElement> {
|
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
.machine-item {
|
|
||||||
@apply col-span-1 flex flex-col items-center;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
padding: theme(padding.2);
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.machine-item__thumb-wrapper {
|
|
||||||
position: relative;
|
|
||||||
padding: theme(padding.4);
|
|
||||||
|
|
||||||
border-radius: theme(borderRadius.md);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.machine-item__thumb {
|
|
||||||
@apply rounded-md bg-secondary-100 border border-secondary-200;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
z-index: 20;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
transition: transform 0.24s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.machine-item__header {
|
|
||||||
@apply flex flex-col justify-center items-center;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
z-index: 20;
|
|
||||||
|
|
||||||
transition: transform 0.18s 0.04s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.machine-item__pseudo {
|
|
||||||
@apply bg-secondary-50;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 1px solid theme(borderColor.secondary.100);
|
|
||||||
border-radius: theme(borderRadius.md);
|
|
||||||
|
|
||||||
transition:
|
|
||||||
transform 0.16s ease-in-out,
|
|
||||||
opacity 0.08s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.machine-item:hover {
|
|
||||||
& .machine-item__pseudo {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .machine-item__thumb {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
|
||||||
0 8px 20px rgba(0, 0, 0, 0.15),
|
|
||||||
0 12px 40px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .machine-item__header {
|
|
||||||
transform: translateY(4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.machine-item:not(:hover) .machine-item__pseudo {
|
|
||||||
transform: scale(0.94);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { createSignal, Setter } from "solid-js";
|
|
||||||
import { callApi, SuccessQuery } from "../../api";
|
|
||||||
|
|
||||||
import { A, useNavigate } from "@solidjs/router";
|
|
||||||
import { RndThumbnail } from "../noiseThumbnail";
|
|
||||||
|
|
||||||
import { Filter } from "../../routes/machines";
|
|
||||||
import { Typography } from "../Typography";
|
|
||||||
import "./css/index.css";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
|
|
||||||
|
|
||||||
interface MachineListItemProps {
|
|
||||||
name: string;
|
|
||||||
info?: MachineDetails;
|
|
||||||
nixOnly?: boolean;
|
|
||||||
setFilter: Setter<Filter>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MachineListItem = (props: MachineListItemProps) => {
|
|
||||||
const { name, info, nixOnly } = props;
|
|
||||||
|
|
||||||
// Bootstrapping
|
|
||||||
const [installing, setInstalling] = createSignal<boolean>(false);
|
|
||||||
|
|
||||||
// Later only updates
|
|
||||||
const [updating, setUpdating] = createSignal<boolean>(false);
|
|
||||||
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
if (!info?.deploy?.targetHost || installing()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active_clan = activeClanURI();
|
|
||||||
if (!active_clan) {
|
|
||||||
console.error("No active clan selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!info?.deploy?.targetHost) {
|
|
||||||
console.error(
|
|
||||||
"Machine does not have a target host. Specify where the machine should be deployed.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target_host = await callApi("get_host", {
|
|
||||||
field: "targetHost",
|
|
||||||
flake: { identifier: active_clan },
|
|
||||||
name: name,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (target_host.status == "error") {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target_host.data === null) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target_host.data!.data) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstalling(true);
|
|
||||||
await callApi("install_machine", {
|
|
||||||
opts: {
|
|
||||||
machine: {
|
|
||||||
name: name,
|
|
||||||
flake: {
|
|
||||||
identifier: active_clan,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
no_reboot: true,
|
|
||||||
debug: true,
|
|
||||||
nix_options: [],
|
|
||||||
password: null,
|
|
||||||
},
|
|
||||||
target_host: target_host.data!.data,
|
|
||||||
}).promise.finally(() => setInstalling(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
if (!info?.deploy?.targetHost || installing()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active_clan = activeClanURI();
|
|
||||||
if (!active_clan) {
|
|
||||||
console.error("No active clan selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!info?.deploy.targetHost) {
|
|
||||||
console.error(
|
|
||||||
"Machine does not have a target host. Specify where the machine should be deployed.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUpdating(true);
|
|
||||||
|
|
||||||
const target_host = await callApi("get_host", {
|
|
||||||
field: "targetHost",
|
|
||||||
flake: { identifier: active_clan },
|
|
||||||
name: name,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (target_host.status == "error") {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target_host.data === null) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target_host.data!.data) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const build_host = await callApi("get_host", {
|
|
||||||
field: "buildHost",
|
|
||||||
flake: { identifier: active_clan },
|
|
||||||
name: name,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (build_host.status == "error") {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (build_host.data === null) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await callApi("deploy_machine", {
|
|
||||||
machine: {
|
|
||||||
name: name,
|
|
||||||
flake: {
|
|
||||||
identifier: active_clan,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
target_host: target_host.data!.data,
|
|
||||||
build_host: build_host.data?.data || null,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
setUpdating(false);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div class="machine-item">
|
|
||||||
<A href={`/machines/${name}`}>
|
|
||||||
<div class="machine-item__thumb-wrapper">
|
|
||||||
<div class="machine-item__thumb">
|
|
||||||
<RndThumbnail name={name} width={100} height={100} />
|
|
||||||
</div>
|
|
||||||
<div class="machine-item__pseudo" />
|
|
||||||
</div>
|
|
||||||
<header class="machine-item__header">
|
|
||||||
<Typography
|
|
||||||
class="text-center"
|
|
||||||
hierarchy="body"
|
|
||||||
size="s"
|
|
||||||
weight="bold"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Typography>
|
|
||||||
</header>
|
|
||||||
</A>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import Dialog from "corvu/dialog";
|
|
||||||
import { createSignal, JSX } from "solid-js";
|
|
||||||
import { Button } from "../Button/Button";
|
|
||||||
import Icon from "../icon";
|
|
||||||
import cx from "classnames";
|
|
||||||
|
|
||||||
interface ModalProps {
|
|
||||||
open: boolean | undefined;
|
|
||||||
handleClose: () => void;
|
|
||||||
title: string;
|
|
||||||
children: JSX.Element;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
export const Modal = (props: ModalProps) => {
|
|
||||||
const [dragging, setDragging] = createSignal(false);
|
|
||||||
const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
let dialogRef: HTMLDivElement;
|
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
|
||||||
setDragging(true);
|
|
||||||
const rect = dialogRef.getBoundingClientRect();
|
|
||||||
setStartOffset({
|
|
||||||
x: e.clientX - rect.left,
|
|
||||||
y: e.clientY - rect.top,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (dragging()) {
|
|
||||||
let newTop = e.clientY - startOffset().y;
|
|
||||||
let newLeft = e.clientX - startOffset().x;
|
|
||||||
|
|
||||||
if (newTop < 0) {
|
|
||||||
newTop = 0;
|
|
||||||
}
|
|
||||||
if (newLeft < 0) {
|
|
||||||
newLeft = 0;
|
|
||||||
}
|
|
||||||
dialogRef.style.top = `${newTop}px`;
|
|
||||||
dialogRef.style.left = `${newLeft}px`;
|
|
||||||
|
|
||||||
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
|
|
||||||
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => setDragging(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={props.open} trapFocus={true}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay
|
|
||||||
class="fixed inset-0 z-50 bg-black/50"
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog.Content
|
|
||||||
class={cx(
|
|
||||||
"overflow-hidden absolute left-1/3 top-1/3 z-50 min-w-[560px] rounded-md border border-def-4 focus-visible:outline-none",
|
|
||||||
props.class,
|
|
||||||
)}
|
|
||||||
classList={{
|
|
||||||
"!cursor-grabbing": dragging(),
|
|
||||||
[cx("scale-[101%] transition-transform")]: dragging(),
|
|
||||||
}}
|
|
||||||
ref={(el) => {
|
|
||||||
dialogRef = el;
|
|
||||||
}}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseDown={(e: MouseEvent) => {
|
|
||||||
e.stopPropagation(); // Prevent backdrop drag conflict
|
|
||||||
}}
|
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()} // Prevent backdrop click closing
|
|
||||||
>
|
|
||||||
<Dialog.Label
|
|
||||||
as="div"
|
|
||||||
class="flex w-full justify-center border-b-2 px-4 py-2 align-middle bg-def-3 border-def-4"
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-full cursor-move flex-col gap-px py-1 "
|
|
||||||
classList={{
|
|
||||||
"!cursor-grabbing": dragging(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
</div>
|
|
||||||
<span class="mx-2 select-none whitespace-nowrap">
|
|
||||||
{props.title}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="flex w-full cursor-move flex-col gap-px py-1 "
|
|
||||||
classList={{
|
|
||||||
"!cursor-grabbing": dragging(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
<hr class="h-px w-full border-none bg-secondary-300" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute right-1 top-2 pl-1 bg-def-3">
|
|
||||||
<Button
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
tabIndex={-1}
|
|
||||||
class="size-4"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => props.handleClose()}
|
|
||||||
size="s"
|
|
||||||
startIcon={<Icon icon={"Close"} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Dialog.Label>
|
|
||||||
<Dialog.Description
|
|
||||||
class="flex max-h-[90vh] flex-col overflow-y-hidden bg-def-1"
|
|
||||||
as="div"
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { For } from "solid-js";
|
|
||||||
|
|
||||||
function mulberry32(seed: number) {
|
|
||||||
return function () {
|
|
||||||
let t = (seed += 0x6d2b79f5);
|
|
||||||
t = Math.imul(t ^ (t >>> 15), 1 | t);
|
|
||||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateCellColor(
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
grid: number[][],
|
|
||||||
rand: () => number,
|
|
||||||
) {
|
|
||||||
const rows = grid.length;
|
|
||||||
const cols = grid[0].length;
|
|
||||||
|
|
||||||
// Get the values of neighboring cells
|
|
||||||
const neighbors = [
|
|
||||||
grid[y][(x - 1 + cols) % cols], // Left
|
|
||||||
grid[y][(x + 1) % cols], // Right
|
|
||||||
grid[(y - 1 + rows) % rows][x], // Top
|
|
||||||
grid[(y + 1) % rows][x], // Bottom
|
|
||||||
];
|
|
||||||
|
|
||||||
const makeValue = (threshold: number, wanted: number) =>
|
|
||||||
rand() > threshold ? Math.floor(rand() * 50) : wanted;
|
|
||||||
|
|
||||||
// Calculate the sum of neighbors
|
|
||||||
const neighborSum = neighbors.reduce((sum, val) => sum + val, 0);
|
|
||||||
// Introduce a hard cutoff for fewer intermediate values
|
|
||||||
if (neighborSum < 1) {
|
|
||||||
// Mostly dark squares
|
|
||||||
// return Math.floor(rand() * 50); // Darker square
|
|
||||||
return makeValue(0.9, Math.floor(rand() * 50));
|
|
||||||
} else if (neighborSum >= 3) {
|
|
||||||
// Mostly bright squares
|
|
||||||
// return Math.floor(200 + rand() * 55); // Bright square
|
|
||||||
return makeValue(0.9, Math.floor(200 + rand() * 55));
|
|
||||||
} else {
|
|
||||||
// Rare intermediate values
|
|
||||||
return makeValue(0.4, Math.floor(100 + rand() * 50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePatternedImage(seed: number, width = 300, height = 150) {
|
|
||||||
const rand = mulberry32(seed);
|
|
||||||
const rowSize = 1 + Math.floor((rand() * width) / 10);
|
|
||||||
const colSize = 1 + Math.floor((rand() * height) / 10);
|
|
||||||
const cols = Math.floor(width / colSize);
|
|
||||||
const rows = Math.floor(height / rowSize);
|
|
||||||
|
|
||||||
// Initialize a 2D grid with random values (0 or 1)
|
|
||||||
const grid = Array.from({ length: rows }, () =>
|
|
||||||
Array.from({ length: cols }, () => (rand() > 0.5 ? 1 : 0)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("Could not get 2d context");
|
|
||||||
}
|
|
||||||
|
|
||||||
const centerX = width / 2;
|
|
||||||
const centerY = height / 2;
|
|
||||||
const totalCells = rows * cols;
|
|
||||||
for (let i = 0; i < totalCells; i++) {
|
|
||||||
// Calculate polar coordinates
|
|
||||||
const angle = (i / totalCells) * Math.PI * 2 * rand() * 360; // Increase the spiral's density
|
|
||||||
const radius = Math.sqrt(i) * rand() * 4; // Controls how tightly the spiral is packed
|
|
||||||
|
|
||||||
// Convert polar to Cartesian coordinates
|
|
||||||
const x = Math.floor(centerX + radius * Math.cos(angle));
|
|
||||||
const y = Math.floor(centerY + radius * Math.sin(angle));
|
|
||||||
|
|
||||||
// Find grid coordinates
|
|
||||||
const col = Math.floor(x / colSize);
|
|
||||||
const row = Math.floor(y / rowSize);
|
|
||||||
|
|
||||||
// Ensure the cell is within bounds
|
|
||||||
if (col >= 0 && col < cols && row >= 0 && row < rows) {
|
|
||||||
const colorValue = calculateCellColor(col, row, grid, rand);
|
|
||||||
ctx.fillStyle = `rgb(${colorValue}, ${colorValue}, ${colorValue})`;
|
|
||||||
ctx.fillRect(x, y, colSize, rowSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return canvas.toDataURL();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RndThumbnailProps {
|
|
||||||
name: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
export const RndThumbnail = (props: RndThumbnailProps) => {
|
|
||||||
const seed = () =>
|
|
||||||
Array.from(props.name).reduce((acc, char) => acc + char.charCodeAt(0), 0); // Seed from name
|
|
||||||
const imageSrc = () =>
|
|
||||||
generatePatternedImage(seed(), props.width, props.height);
|
|
||||||
|
|
||||||
return <img src={imageSrc()} alt={props.name} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RndThumbnailShow = () => {
|
|
||||||
const names = ["hsjobeki", "mic92", "lassulus", "D", "A", "D", "B", "C"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="grid grid-cols-4">
|
|
||||||
<For each={names}>{(name) => <RndThumbnail name={name} />}</For>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -68,7 +68,7 @@ const WarningIcon: Component = () => (
|
|||||||
|
|
||||||
// --- Base Props and Styles ---
|
// --- Base Props and Styles ---
|
||||||
|
|
||||||
export interface BaseToastProps {
|
interface BaseToastProps {
|
||||||
t: Toast;
|
t: Toast;
|
||||||
message: string;
|
message: string;
|
||||||
onCancel?: () => void; // Optional custom function on X click
|
onCancel?: () => void; // Optional custom function on X click
|
||||||
@@ -254,7 +254,7 @@ export const CancelToastComponent: Component<BaseToastProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Warning Toast
|
// Warning Toast
|
||||||
export const WarningToastComponent: Component<BaseToastProps> = (props) => {
|
const WarningToastComponent: Component<BaseToastProps> = (props) => {
|
||||||
let timeoutId: number | undefined;
|
let timeoutId: number | undefined;
|
||||||
const [clicked, setClicked] = createSignal(false);
|
const [clicked, setClicked] = createSignal(false);
|
||||||
const [exiting, setExiting] = createSignal(false);
|
const [exiting, setExiting] = createSignal(false);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@import "material-icons/iconfont/filled.css";
|
/* Material icons removed - using custom icons */
|
||||||
/* List of icons: https://marella.me/material-icons/demo/ */
|
|
||||||
/* @import url(./components/Typography/css/typography.css); */
|
/* @import url(./components/Typography/css/typography.css); */
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createContext, createEffect, JSX, useContext } from "solid-js";
|
import { createContext, createEffect, JSX, useContext } from "solid-js";
|
||||||
import { callApi } from "@/src/api";
|
|
||||||
import {
|
import {
|
||||||
activeClanURI,
|
activeClanURI,
|
||||||
addClanURI,
|
addClanURI,
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import type {
|
|||||||
} from "@floating-ui/dom";
|
} from "@floating-ui/dom";
|
||||||
import { computePosition } from "@floating-ui/dom";
|
import { computePosition } from "@floating-ui/dom";
|
||||||
|
|
||||||
export interface UseFloatingOptions<
|
interface UseFloatingOptions<R extends ReferenceElement, F extends HTMLElement>
|
||||||
R extends ReferenceElement,
|
extends Partial<ComputePositionConfig> {
|
||||||
F extends HTMLElement,
|
|
||||||
> extends Partial<ComputePositionConfig> {
|
|
||||||
whileElementsMounted?: (
|
whileElementsMounted?: (
|
||||||
reference: R,
|
reference: R,
|
||||||
floating: F,
|
floating: F,
|
||||||
@@ -23,7 +21,7 @@ interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
|
|||||||
y?: number | null;
|
y?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseFloatingResult extends UseFloatingState {
|
interface UseFloatingResult extends UseFloatingState {
|
||||||
update(): void;
|
update(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const registerClan = async () => {
|
|||||||
* Opens the custom file dialog
|
* Opens the custom file dialog
|
||||||
* Returns a native FileList to allow interaction with the native input type="file"
|
* Returns a native FileList to allow interaction with the native input type="file"
|
||||||
*/
|
*/
|
||||||
export const selectSshKeys = async (): Promise<FileList> => {
|
const selectSshKeys = async (): Promise<FileList> => {
|
||||||
const dataTransfer = new DataTransfer();
|
const dataTransfer = new DataTransfer();
|
||||||
|
|
||||||
const response = await callApi("open_file", {
|
const response = await callApi("open_file", {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@import "material-icons/iconfont/filled.css";
|
/* Material icons removed - using custom icons */
|
||||||
/* List of icons: https://marella.me/material-icons/demo/ */
|
|
||||||
/* @import url(./components/Typography/css/typography.css); */
|
/* @import url(./components/Typography/css/typography.css); */
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
|
|||||||
@@ -4,24 +4,15 @@ import { Navigate, RouteDefinition, Router } from "@solidjs/router";
|
|||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||||
import {
|
|
||||||
CreateMachine,
|
|
||||||
MachineDetails,
|
|
||||||
MachineListView,
|
|
||||||
} from "./routes/machines";
|
|
||||||
import { Layout } from "./layout/layout";
|
import { Layout } from "./layout/layout";
|
||||||
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
|
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
|
||||||
import { Flash } from "./routes/flash/view";
|
|
||||||
import { HostList } from "./routes/hosts/view";
|
import { HostList } from "./routes/hosts/view";
|
||||||
import { Welcome } from "./routes/welcome";
|
import { Welcome } from "./routes/welcome";
|
||||||
import { Toaster } from "solid-toast";
|
import { Toaster } from "solid-toast";
|
||||||
import { ModuleList } from "./routes/modules/list";
|
|
||||||
import { ModuleDetails } from "./routes/modules/details";
|
|
||||||
import { ModuleDetails as AddModule } from "./routes/modules/add";
|
|
||||||
import { ApiTester } from "./api_test";
|
import { ApiTester } from "./api_test";
|
||||||
import { IconVariant } from "./components/icon";
|
import { IconVariant } from "./components/icon";
|
||||||
import { Components } from "./routes/components";
|
import { Components } from "./routes/components";
|
||||||
import { VarsPage } from "./routes/machines/install/vars-step";
|
|
||||||
import { ThreePlayground } from "./three";
|
import { ThreePlayground } from "./three";
|
||||||
import { ClanProvider } from "./contexts/clan";
|
import { ClanProvider } from "./contexts/clan";
|
||||||
|
|
||||||
@@ -54,35 +45,7 @@ export const routes: AppRoute[] = [
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
component: () => <Navigate href="/machines" />,
|
component: () => <Navigate href="/machines" />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/machines",
|
|
||||||
label: "Machines",
|
|
||||||
icon: "Grid",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
label: "Overview",
|
|
||||||
component: () => <MachineListView />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/create",
|
|
||||||
label: "Create",
|
|
||||||
component: () => <CreateMachine />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/:id",
|
|
||||||
label: "Details",
|
|
||||||
hidden: true,
|
|
||||||
component: () => <MachineDetails />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/:id/vars",
|
|
||||||
label: "Vars",
|
|
||||||
hidden: true,
|
|
||||||
component: () => <VarsPage />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/clans",
|
path: "/clans",
|
||||||
label: "Clans",
|
label: "Clans",
|
||||||
@@ -107,42 +70,7 @@ export const routes: AppRoute[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/modules",
|
|
||||||
label: "Modules",
|
|
||||||
icon: "Search",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
label: "App Store",
|
|
||||||
component: () => <ModuleList />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "details/:id",
|
|
||||||
label: "Details",
|
|
||||||
hidden: true,
|
|
||||||
component: () => <ModuleDetails />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/add/:id",
|
|
||||||
label: "Details",
|
|
||||||
hidden: true,
|
|
||||||
component: () => <AddModule />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/tools",
|
|
||||||
label: "Tools",
|
|
||||||
icon: "Folder",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "/flash",
|
|
||||||
label: "Flash Installer",
|
|
||||||
component: () => <Flash />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/welcome",
|
path: "/welcome",
|
||||||
label: "",
|
label: "",
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/solid-query";
|
|
||||||
import { callApi } from "../api";
|
|
||||||
import toast from "solid-toast";
|
|
||||||
|
|
||||||
export interface ModulesFilter {
|
|
||||||
features: string[];
|
|
||||||
}
|
|
||||||
export const createModulesQuery = (
|
|
||||||
uri: string | undefined,
|
|
||||||
filter?: ModulesFilter,
|
|
||||||
) =>
|
|
||||||
useQuery(() => ({
|
|
||||||
queryKey: [uri, "list_modules"],
|
|
||||||
placeholderData: {
|
|
||||||
localModules: {},
|
|
||||||
modulesPerSource: {},
|
|
||||||
},
|
|
||||||
enabled: !!uri,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (uri) {
|
|
||||||
const response = await callApi("list_modules", {
|
|
||||||
base_path: uri,
|
|
||||||
}).promise;
|
|
||||||
if (response.status === "error") {
|
|
||||||
console.error("Failed to fetch data");
|
|
||||||
} else {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
localModules: {},
|
|
||||||
modulesPerSource: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const tagsQuery = (uri: string | undefined) =>
|
|
||||||
useQuery<string[]>(() => ({
|
|
||||||
queryKey: [uri, "tags"],
|
|
||||||
placeholderData: [],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!uri) return [];
|
|
||||||
|
|
||||||
const response = await callApi("get_inventory", {
|
|
||||||
flake: { identifier: uri },
|
|
||||||
}).promise;
|
|
||||||
if (response.status === "error") {
|
|
||||||
console.error("Failed to fetch data");
|
|
||||||
} else {
|
|
||||||
const machines = response.data.machines || {};
|
|
||||||
const tags = Object.values(machines).flatMap((m) => m.tags || []);
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const machinesQuery = (uri: string | undefined) =>
|
|
||||||
useQuery<string[]>(() => ({
|
|
||||||
queryKey: [uri, "machines"],
|
|
||||||
placeholderData: [],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!uri) return [];
|
|
||||||
|
|
||||||
const response = await callApi("get_inventory", {
|
|
||||||
flake: { identifier: uri },
|
|
||||||
}).promise;
|
|
||||||
if (response.status === "error") {
|
|
||||||
console.error("Failed to fetch data");
|
|
||||||
} else {
|
|
||||||
const machines = response.data.machines || {};
|
|
||||||
return Object.keys(machines);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
import { callApi, ClanServiceInstance, SuccessQuery } from "@/src/api";
|
import { callApi, SuccessQuery } from "@/src/api";
|
||||||
import { useParams } from "@solidjs/router";
|
import { useParams } from "@solidjs/router";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/solid-query";
|
import { useQueryClient } from "@tanstack/solid-query";
|
||||||
import { createSignal, For, Match, Switch } from "solid-js";
|
import { Match, Switch } from "solid-js";
|
||||||
import {
|
import { createForm, required, SubmitHandler } from "@modular-forms/solid";
|
||||||
createForm,
|
|
||||||
FieldValues,
|
|
||||||
getValue,
|
|
||||||
getValues,
|
|
||||||
required,
|
|
||||||
setValue,
|
|
||||||
SubmitHandler,
|
|
||||||
} from "@modular-forms/solid";
|
|
||||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
import { Button } from "../../components/Button/Button";
|
import { Button } from "../../components/Button/Button";
|
||||||
import Icon from "@/src/components/icon";
|
import Icon from "@/src/components/icon";
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
export const colors = () => {
|
|
||||||
return (
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
|
||||||
<div class="h-10 w-20 bg-red-500">red</div>
|
|
||||||
<div class="h-10 w-20 bg-green-500">green</div>
|
|
||||||
<div class="h-10 w-20 bg-blue-500">blue</div>
|
|
||||||
<div class="h-10 w-20 bg-yellow-500">yellow</div>
|
|
||||||
<div class="h-10 w-20 bg-purple-500">purple</div>
|
|
||||||
<div class="h-10 w-20 bg-cyan-500">cyan</div>
|
|
||||||
<div class="h-10 w-20 bg-pink-500">pink</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { callApi } from "@/src/api";
|
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
|
||||||
|
|
||||||
export const Deploy = () => {
|
|
||||||
return <div>Deloy view</div>;
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { callApi } from "@/src/api";
|
|
||||||
import { useQuery } from "@tanstack/solid-query";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
export function DiskView() {
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const query = useQuery(() => ({
|
|
||||||
queryKey: ["disk", activeClanURI()],
|
|
||||||
queryFn: async () => {
|
|
||||||
const currUri = activeClanURI();
|
|
||||||
if (currUri) {
|
|
||||||
// Example of calling an API
|
|
||||||
const result = await callApi("get_inventory", {
|
|
||||||
flake: { identifier: currUri },
|
|
||||||
}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Configure Disk</h1>
|
|
||||||
<p>
|
|
||||||
Select machine then configure the disk. Required before installing for
|
|
||||||
the first time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
import { callApi } from "@/src/api";
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
// Icon is used in CustomFileField, ensure it's available or remove if not needed there
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import { Header } from "@/src/layout/header";
|
|
||||||
import { SelectInput } from "@/src/Form/fields/Select";
|
|
||||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
|
||||||
import {
|
|
||||||
createForm,
|
|
||||||
required,
|
|
||||||
FieldValues,
|
|
||||||
setValue,
|
|
||||||
getValue,
|
|
||||||
getValues,
|
|
||||||
} from "@modular-forms/solid";
|
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
|
||||||
import { createEffect, createSignal, For, Show } from "solid-js"; // For, Show might not be needed directly here now
|
|
||||||
import toast from "solid-toast";
|
|
||||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
|
||||||
import { InputLabel } from "@/src/components/inputBase";
|
|
||||||
import { Modal } from "@/src/components/modal";
|
|
||||||
import Fieldset from "@/src/Form/fieldset"; // Still used for other fieldsets
|
|
||||||
import Accordion from "@/src/components/accordion";
|
|
||||||
|
|
||||||
// Import the new generic component
|
|
||||||
import {
|
|
||||||
FileSelectorField,
|
|
||||||
type FileDialogOptions,
|
|
||||||
} from "@/src/components/fileSelect"; // Adjust path
|
|
||||||
|
|
||||||
interface Wifi extends FieldValues {
|
|
||||||
ssid: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FlashFormValues extends FieldValues {
|
|
||||||
machine: {
|
|
||||||
devicePath: string;
|
|
||||||
flake: string;
|
|
||||||
};
|
|
||||||
disk: string;
|
|
||||||
language: string;
|
|
||||||
keymap: string;
|
|
||||||
wifi: Wifi[];
|
|
||||||
sshKeys: File[]; // This field will use CustomFileField
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Flash = () => {
|
|
||||||
const [formStore, { Form, Field }] = createForm<FlashFormValues>({
|
|
||||||
initialValues: {
|
|
||||||
machine: {
|
|
||||||
flake: "git+https://git.clan.lol/clan/clan-core",
|
|
||||||
devicePath: "flash-installer",
|
|
||||||
},
|
|
||||||
language: "en_US.UTF-8",
|
|
||||||
keymap: "en",
|
|
||||||
// sshKeys: [] // Initial value for sshKeys (optional, modular-forms handles undefined)
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ==== WIFI NETWORK (logic remains the same) ==== */
|
|
||||||
const [wifiNetworks, setWifiNetworks] = createSignal<Wifi[]>([]);
|
|
||||||
const [passwordVisibility, setPasswordVisibility] = createSignal<boolean[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
createEffect(() => {
|
|
||||||
const formWifi = getValue(formStore, "wifi");
|
|
||||||
if (formWifi !== undefined) {
|
|
||||||
setWifiNetworks(formWifi as Wifi[]);
|
|
||||||
setPasswordVisibility(new Array(formWifi.length).fill(false));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const addWifiNetwork = () => {
|
|
||||||
setWifiNetworks((c) => {
|
|
||||||
const res = [...c, { ssid: "", password: "" }];
|
|
||||||
setValue(formStore, "wifi", res);
|
|
||||||
return res;
|
|
||||||
});
|
|
||||||
setPasswordVisibility((c) => [...c, false]);
|
|
||||||
};
|
|
||||||
const removeWifiNetwork = (index: number) => {
|
|
||||||
const updatedNetworks = wifiNetworks().filter((_, i) => i !== index);
|
|
||||||
setWifiNetworks(updatedNetworks);
|
|
||||||
const updatedVisibility = passwordVisibility().filter(
|
|
||||||
(_, i) => i !== index,
|
|
||||||
);
|
|
||||||
setPasswordVisibility(updatedVisibility);
|
|
||||||
setValue(formStore, "wifi", updatedNetworks);
|
|
||||||
};
|
|
||||||
const togglePasswordVisibility = (index: number) => {
|
|
||||||
const updatedVisibility = [...passwordVisibility()];
|
|
||||||
updatedVisibility[index] = !updatedVisibility[index];
|
|
||||||
setPasswordVisibility(updatedVisibility);
|
|
||||||
};
|
|
||||||
/* ==== END OF WIFI NETWORK ==== */
|
|
||||||
|
|
||||||
const deviceQuery = createQuery(() => ({
|
|
||||||
queryKey: ["block_devices"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await callApi("show_block_devices", {}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 1, // 1 minutes
|
|
||||||
}));
|
|
||||||
|
|
||||||
const keymapQuery = createQuery(() => ({
|
|
||||||
queryKey: ["list_keymaps"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await callApi("list_possible_keymaps", {}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
staleTime: Infinity,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const langQuery = createQuery(() => ({
|
|
||||||
queryKey: ["list_languages"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await callApi("list_possible_languages", {}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
staleTime: Infinity,
|
|
||||||
}));
|
|
||||||
// Define the options for the SSH key file dialog
|
|
||||||
const sshKeyDialogOptions: FileDialogOptions = {
|
|
||||||
title: "Select SSH Public Key(s)",
|
|
||||||
filters: { patterns: ["*.pub"] },
|
|
||||||
initial_folder: "~/.ssh",
|
|
||||||
};
|
|
||||||
|
|
||||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
|
||||||
const [isFlashing, setFlashing] = createSignal(false);
|
|
||||||
|
|
||||||
const handleSubmit = (values: FlashFormValues) => {
|
|
||||||
// Basic check for sshKeys, could add to modular-forms validation
|
|
||||||
if (!values.sshKeys || values.sshKeys.length === 0) {
|
|
||||||
toast.error("Please select at least one SSH key.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfirmOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
const values = getValues(formStore) as FlashFormValues;
|
|
||||||
// Additional check, though handleSubmit should catch it
|
|
||||||
if (!values.sshKeys || values.sshKeys.length === 0) {
|
|
||||||
toast.error("SSH keys are missing. Cannot proceed with flash.");
|
|
||||||
setConfirmOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFlashing(true);
|
|
||||||
console.log("Confirmed flash:", values);
|
|
||||||
try {
|
|
||||||
await toast.promise(
|
|
||||||
callApi("flash_machine", {
|
|
||||||
machine: {
|
|
||||||
name: values.machine.devicePath,
|
|
||||||
flake: {
|
|
||||||
identifier: values.machine.flake,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mode: "format",
|
|
||||||
disks: [{ name: "main", device: values.disk }],
|
|
||||||
system_config: {
|
|
||||||
language: values.language,
|
|
||||||
keymap: values.keymap,
|
|
||||||
// Ensure sshKeys is correctly mapped (File[] to string[])
|
|
||||||
ssh_keys_path: values.sshKeys.map((file) => file.name),
|
|
||||||
},
|
|
||||||
dry_run: false,
|
|
||||||
write_efi_boot_entries: false,
|
|
||||||
debug: false,
|
|
||||||
graphical: true,
|
|
||||||
}).promise,
|
|
||||||
{
|
|
||||||
error: (errors) => `Error flashing disk: ${errors}`,
|
|
||||||
loading: "Flashing ... This may take up to 15minutes.",
|
|
||||||
success: "Disk flashed successfully",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(`Error could not flash disk: ${error}`);
|
|
||||||
} finally {
|
|
||||||
setFlashing(false);
|
|
||||||
}
|
|
||||||
setConfirmOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header title="Flash installer" />
|
|
||||||
<Modal
|
|
||||||
open={confirmOpen() || isFlashing()}
|
|
||||||
handleClose={() => !isFlashing() && setConfirmOpen(false)}
|
|
||||||
title="Confirm"
|
|
||||||
>
|
|
||||||
{/* ... Modal content as before ... */}
|
|
||||||
<div class="flex flex-col gap-4 p-4">
|
|
||||||
<div class="flex flex-col justify-between rounded-sm border p-4 align-middle text-red-900 border-def-2">
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
weight="medium"
|
|
||||||
size="default"
|
|
||||||
class="flex-wrap break-words pr-4"
|
|
||||||
>
|
|
||||||
Warning: All data will be lost.
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
weight="bold"
|
|
||||||
size="default"
|
|
||||||
class="flex-wrap break-words pr-4"
|
|
||||||
>
|
|
||||||
Selected disk: '{getValue(formStore, "disk")}'
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full justify-between">
|
|
||||||
<Button
|
|
||||||
disabled={isFlashing()}
|
|
||||||
variant="light"
|
|
||||||
onClick={() => setConfirmOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button disabled={isFlashing()} onClick={handleConfirm}>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<div class="w-full self-stretch p-8">
|
|
||||||
<Form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
|
|
||||||
>
|
|
||||||
<FileSelectorField
|
|
||||||
Field={Field}
|
|
||||||
name="sshKeys" // Corresponds to FlashFormValues.sshKeys
|
|
||||||
label="Authorized SSH Keys"
|
|
||||||
description="Provide your SSH public key(s) for secure, passwordless connections. (.pub files)"
|
|
||||||
multiple={true} // Allow multiple SSH keys
|
|
||||||
fileDialogOptions={sshKeyDialogOptions}
|
|
||||||
of={Array<File>}
|
|
||||||
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
|
|
||||||
// e.g. validate={[required("At least one SSH key is required.")]}
|
|
||||||
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Fieldset legend="General">
|
|
||||||
<Field name="disk" validate={[required("This field is required")]}>
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<SelectInput
|
|
||||||
loading={deviceQuery.isFetching}
|
|
||||||
selectProps={props}
|
|
||||||
label="Flash Disk"
|
|
||||||
labelProps={{
|
|
||||||
labelAction: (
|
|
||||||
<Button
|
|
||||||
disabled={isFlashing()}
|
|
||||||
class="ml-auto"
|
|
||||||
variant="ghost"
|
|
||||||
size="s"
|
|
||||||
type="button"
|
|
||||||
startIcon={<Icon icon="Update" />}
|
|
||||||
onClick={() => deviceQuery.refetch()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
value={field.value || ""}
|
|
||||||
error={field.error}
|
|
||||||
required
|
|
||||||
placeholder="Select a drive"
|
|
||||||
options={
|
|
||||||
deviceQuery.data?.blockdevices.map((d) => ({
|
|
||||||
value: d.path,
|
|
||||||
label: `${d.path} -- ${d.size} bytes`,
|
|
||||||
})) || []
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
|
|
||||||
<Fieldset legend="Network Settings">
|
|
||||||
{/* ... Network settings as before ... */}
|
|
||||||
<FieldLayout
|
|
||||||
label={<InputLabel>Networks</InputLabel>}
|
|
||||||
field={
|
|
||||||
<div class="flex w-full justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="s"
|
|
||||||
variant="light"
|
|
||||||
onClick={addWifiNetwork}
|
|
||||||
startIcon={<Icon size={12} icon="Plus" />}
|
|
||||||
>
|
|
||||||
WiFi Network
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* TODO: You would render the actual WiFi input fields here using a <For> loop over wifiNetworks() signal */}
|
|
||||||
</Fieldset>
|
|
||||||
|
|
||||||
<Accordion title="Advanced">
|
|
||||||
<Fieldset>
|
|
||||||
<Field
|
|
||||||
name="machine.flake"
|
|
||||||
validate={[required("This field is required")]}
|
|
||||||
>
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
label="Source (flake URL)"
|
|
||||||
value={String(field.value)}
|
|
||||||
error={field.error}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
name="machine.devicePath"
|
|
||||||
validate={[required("This field is required")]}
|
|
||||||
>
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
label="Image Name (attribute name)"
|
|
||||||
value={String(field.value)}
|
|
||||||
error={field.error}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<FieldLayout
|
|
||||||
label={
|
|
||||||
<InputLabel help="Computed reference">Source Url</InputLabel>
|
|
||||||
}
|
|
||||||
field={
|
|
||||||
<InputLabel>
|
|
||||||
{getValue(formStore, "machine.flake") +
|
|
||||||
"#" +
|
|
||||||
getValue(formStore, "machine.devicePath")}
|
|
||||||
</InputLabel>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<hr class="mb-6"></hr>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
name="language"
|
|
||||||
validate={[required("This field is required")]}
|
|
||||||
>
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<SelectInput
|
|
||||||
selectProps={props}
|
|
||||||
label="Language"
|
|
||||||
value={String(field.value)}
|
|
||||||
error={field.error}
|
|
||||||
required
|
|
||||||
loading={langQuery.isLoading}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
label: "en_US.UTF-8",
|
|
||||||
value: "en_US.UTF-8",
|
|
||||||
},
|
|
||||||
...(langQuery.data?.map((lang) => ({
|
|
||||||
label: lang,
|
|
||||||
value: lang,
|
|
||||||
})) || []),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
name="keymap"
|
|
||||||
validate={[required("This field is required")]}
|
|
||||||
>
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<SelectInput
|
|
||||||
selectProps={props}
|
|
||||||
label="Keymap"
|
|
||||||
value={String(field.value)}
|
|
||||||
error={field.error}
|
|
||||||
required
|
|
||||||
loading={keymapQuery.isLoading}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
label: "en",
|
|
||||||
value: "en",
|
|
||||||
},
|
|
||||||
...(keymapQuery.data?.map((keymap) => ({
|
|
||||||
label: keymap,
|
|
||||||
value: keymap,
|
|
||||||
})) || []),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<div class="mt-2 flex justify-end pt-2">
|
|
||||||
<Button
|
|
||||||
class="self-end"
|
|
||||||
type="submit"
|
|
||||||
disabled={formStore.submitting || isFlashing()}
|
|
||||||
startIcon={
|
|
||||||
formStore.submitting || isFlashing() ? (
|
|
||||||
<Icon icon="Load" />
|
|
||||||
) : (
|
|
||||||
<Icon icon="Flash" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formStore.submitting || isFlashing()
|
|
||||||
? "Flashing..."
|
|
||||||
: "Flash Installer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { RndThumbnail } from "@/src/components/noiseThumbnail";
|
|
||||||
import cx from "classnames";
|
|
||||||
interface AvatarProps {
|
|
||||||
name?: string;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
export const MachineAvatar = (props: AvatarProps) => {
|
|
||||||
return (
|
|
||||||
<figure>
|
|
||||||
<div class="">
|
|
||||||
<div
|
|
||||||
class={cx(
|
|
||||||
"rounded-lg border p-2 bg-def-1 border-def-3 h-fit",
|
|
||||||
props.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RndThumbnail name={props.name || ""} height={120} width={220} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { callApi, OperationArgs } from "@/src/api";
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
|
||||||
import { Header } from "@/src/layout/header";
|
|
||||||
import { createForm, required, reset } from "@modular-forms/solid";
|
|
||||||
import { useNavigate } from "@solidjs/router";
|
|
||||||
import { useQueryClient } from "@tanstack/solid-query";
|
|
||||||
import { Match, Switch } from "solid-js";
|
|
||||||
import toast from "solid-toast";
|
|
||||||
import { MachineAvatar } from "./avatar";
|
|
||||||
import { DynForm } from "@/src/Form/form";
|
|
||||||
import Fieldset from "@/src/Form/fieldset";
|
|
||||||
import Accordion from "@/src/components/accordion";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
type CreateMachineForm = OperationArgs<"create_machine">;
|
|
||||||
|
|
||||||
export function CreateMachine() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const [formStore, { Form, Field }] = createForm<CreateMachineForm>({
|
|
||||||
initialValues: {
|
|
||||||
opts: {
|
|
||||||
clan_dir: {
|
|
||||||
identifier: activeClanURI() || "",
|
|
||||||
},
|
|
||||||
machine: {
|
|
||||||
tags: ["all"],
|
|
||||||
deploy: {
|
|
||||||
targetHost: "",
|
|
||||||
},
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handleSubmit = async (values: CreateMachineForm) => {
|
|
||||||
const active_dir = activeClanURI();
|
|
||||||
if (!active_dir) {
|
|
||||||
toast.error("Open a clan to create the machine within");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("submitting", values);
|
|
||||||
|
|
||||||
const response = await callApi("create_machine", {
|
|
||||||
opts: {
|
|
||||||
...values.opts,
|
|
||||||
clan_dir: {
|
|
||||||
identifier: active_dir,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (response.status === "success") {
|
|
||||||
toast.success(`Successfully created ${values.opts.machine.name}`);
|
|
||||||
reset(formStore);
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: [active_dir, "list_machines"],
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate("/machines");
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
`Error: ${response.errors[0].message}. Machine ${values.opts.machine.name} could not be created`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header title="Create Machine" />
|
|
||||||
<div class="flex w-full p-4">
|
|
||||||
<div class="mt-4 w-full self-stretch px-8">
|
|
||||||
<Form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
name="opts.machine.name"
|
|
||||||
validate={[required("This field is required")]}
|
|
||||||
>
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<div class="mb-4 flex justify-center border-b pb-4">
|
|
||||||
<MachineAvatar name={field.value} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Fieldset legend="General">
|
|
||||||
<Field
|
|
||||||
name="opts.machine.name"
|
|
||||||
validate={[required("This field is required")]}
|
|
||||||
>
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
value={`${field.value}`}
|
|
||||||
label={"name"}
|
|
||||||
error={field.error}
|
|
||||||
required
|
|
||||||
placeholder="New_machine"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field name="opts.machine.description">
|
|
||||||
{(field, props) => (
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
value={`${field.value}`}
|
|
||||||
label={"description"}
|
|
||||||
error={field.error}
|
|
||||||
placeholder="My awesome machine"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
|
|
||||||
<Fieldset legend="Tags">
|
|
||||||
<Field name="opts.machine.tags" type="string[]">
|
|
||||||
{(field, props) => (
|
|
||||||
<div class="p-2">
|
|
||||||
<DynForm
|
|
||||||
initialValues={{ tags: ["all"] }}
|
|
||||||
schema={{
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
tags: {
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
title: "Tag",
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
uniqueItems: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
<Accordion title="Advanced">
|
|
||||||
<Fieldset>
|
|
||||||
<Field name="opts.machine.deploy.targetHost">
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
value={`${field.value}`}
|
|
||||||
label={"Target"}
|
|
||||||
error={field.error}
|
|
||||||
placeholder="e.g. 192.168.188.64"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={formStore.submitting}
|
|
||||||
startIcon={
|
|
||||||
formStore.submitting ? (
|
|
||||||
<Icon icon="Load" />
|
|
||||||
) : (
|
|
||||||
<Icon icon="Plus" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch fallback={<>Creating</>}>
|
|
||||||
<Match when={!formStore.submitting}>Create</Match>
|
|
||||||
</Switch>
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,804 +0,0 @@
|
|||||||
import { callApi, SuccessData } from "@/src/api";
|
|
||||||
import {
|
|
||||||
createForm,
|
|
||||||
FieldValues,
|
|
||||||
getValue,
|
|
||||||
getValues,
|
|
||||||
setValue,
|
|
||||||
} from "@modular-forms/solid";
|
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/solid-query";
|
|
||||||
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
|
|
||||||
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
|
||||||
import Accordion from "@/src/components/accordion";
|
|
||||||
import toast from "solid-toast";
|
|
||||||
import { MachineAvatar } from "./avatar";
|
|
||||||
import { Header } from "@/src/layout/header";
|
|
||||||
import { InputLabel } from "@/src/components/inputBase";
|
|
||||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
|
||||||
import { Modal } from "@/src/components/modal";
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import { HardwareValues, HWStep } from "./install/hardware-step";
|
|
||||||
import { DiskStep, DiskValues } from "./install/disk-step";
|
|
||||||
import { SummaryStep } from "./install/summary-step";
|
|
||||||
import cx from "classnames";
|
|
||||||
import { VarsStep, VarsValues } from "./install/vars-step";
|
|
||||||
import Fieldset from "@/src/Form/fieldset";
|
|
||||||
import {
|
|
||||||
type FileDialogOptions,
|
|
||||||
FileSelectorField,
|
|
||||||
} from "@/src/components/fileSelect";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
import { TagList } from "@/src/components/TagList/TagList";
|
|
||||||
|
|
||||||
type MachineFormInterface = MachineData & {
|
|
||||||
sshKey?: File;
|
|
||||||
disk?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MachineData = SuccessData<"get_machine_details">;
|
|
||||||
|
|
||||||
const steps: Record<StepIdx, string> = {
|
|
||||||
"1": "Hardware detection",
|
|
||||||
"2": "Disk schema",
|
|
||||||
"3": "Credentials & Data",
|
|
||||||
"4": "Installation",
|
|
||||||
};
|
|
||||||
|
|
||||||
type StepIdx = keyof AllStepsValues;
|
|
||||||
|
|
||||||
export interface AllStepsValues extends FieldValues {
|
|
||||||
"1": HardwareValues;
|
|
||||||
"2": DiskValues;
|
|
||||||
"3": VarsValues;
|
|
||||||
"4": NonNullable<unknown>;
|
|
||||||
sshKey?: File;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoadingBar = () => (
|
|
||||||
<div
|
|
||||||
class="h-3 w-80 overflow-hidden rounded-[3px] border-2 border-def-1"
|
|
||||||
style={{
|
|
||||||
background: `repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
#ccc,
|
|
||||||
#ccc 8px,
|
|
||||||
#eee 8px,
|
|
||||||
#eee 16px
|
|
||||||
)`,
|
|
||||||
animation: "slide 25s linear infinite",
|
|
||||||
"background-size": "200% 100%",
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
);
|
|
||||||
|
|
||||||
function sleep(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InstallMachineProps {
|
|
||||||
name?: string;
|
|
||||||
machine: MachineData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InstallMachine = (props: InstallMachineProps) => {
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const curr = activeClanURI();
|
|
||||||
const { name } = props;
|
|
||||||
if (!curr || !name) {
|
|
||||||
return <span>No Clan selected</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [formStore, { Form, Field }] = createForm<AllStepsValues>();
|
|
||||||
|
|
||||||
const [isDone, setIsDone] = createSignal<boolean>(false);
|
|
||||||
const [isInstalling, setIsInstalling] = createSignal<boolean>(false);
|
|
||||||
const [progressText, setProgressText] = createSignal<string>();
|
|
||||||
|
|
||||||
const handleInstall = async (values: AllStepsValues) => {
|
|
||||||
console.log("Installing", values);
|
|
||||||
const curr_uri = activeClanURI();
|
|
||||||
|
|
||||||
const target = values["1"].target;
|
|
||||||
const diskValues = values["2"];
|
|
||||||
|
|
||||||
if (!curr_uri) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!props.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsInstalling(true);
|
|
||||||
|
|
||||||
// props.machine.disk_
|
|
||||||
const shouldRunDisk =
|
|
||||||
JSON.stringify(props.machine.disk_schema?.placeholders) !==
|
|
||||||
JSON.stringify(diskValues.placeholders);
|
|
||||||
|
|
||||||
if (shouldRunDisk) {
|
|
||||||
setProgressText("Setting up disk ... (1/5)");
|
|
||||||
const disk_response = await callApi("set_machine_disk_schema", {
|
|
||||||
machine: {
|
|
||||||
flake: { identifier: curr_uri },
|
|
||||||
name: props.name,
|
|
||||||
},
|
|
||||||
placeholders: diskValues.placeholders,
|
|
||||||
schema_name: diskValues.schema,
|
|
||||||
force: true,
|
|
||||||
}).promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgressText("Installing machine ... (2/5)");
|
|
||||||
|
|
||||||
const target_host = await callApi("get_host", {
|
|
||||||
field: "targetHost",
|
|
||||||
flake: { identifier: curr_uri },
|
|
||||||
name: props.name,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (target_host.status == "error") {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target_host.data === null) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target_host.data!.data) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const installPromise = callApi("install_machine", {
|
|
||||||
opts: {
|
|
||||||
machine: {
|
|
||||||
name: props.name,
|
|
||||||
flake: {
|
|
||||||
identifier: curr_uri,
|
|
||||||
},
|
|
||||||
private_key: values.sshKey?.name,
|
|
||||||
},
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
target_host: target_host.data!.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Next step
|
|
||||||
await sleep(10 * 1000);
|
|
||||||
setProgressText("Building machine ... (3/5)");
|
|
||||||
await sleep(10 * 1000);
|
|
||||||
setProgressText("Formatting remote disk ... (4/5)");
|
|
||||||
await sleep(10 * 1000);
|
|
||||||
setProgressText("Copying system ... (5/5)");
|
|
||||||
await sleep(20 * 1000);
|
|
||||||
setProgressText("Rebooting remote system ... ");
|
|
||||||
await sleep(10 * 1000);
|
|
||||||
|
|
||||||
const installResponse = await installPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [step, setStep] = createSignal<StepIdx>("1");
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
console.log("Next");
|
|
||||||
setStep((c) => `${+c + 1}` as StepIdx);
|
|
||||||
};
|
|
||||||
const handlePrev = () => {
|
|
||||||
console.log("Next");
|
|
||||||
setStep((c) => `${+c - 1}` as StepIdx);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Footer = () => (
|
|
||||||
<div class="flex justify-between p-4">
|
|
||||||
<Button
|
|
||||||
startIcon={<Icon icon="ArrowLeft" />}
|
|
||||||
variant="light"
|
|
||||||
type="button"
|
|
||||||
onClick={handlePrev}
|
|
||||||
disabled={step() === "1"}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
endIcon={<Icon icon="ArrowRight" />}
|
|
||||||
type="submit"
|
|
||||||
// IMPORTANT: The step itself will try to submit and call the next step
|
|
||||||
// onClick={(e: Event) => handleNext()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
fallback={
|
|
||||||
<Form
|
|
||||||
onSubmit={handleInstall}
|
|
||||||
class="relative top-0 flex h-full flex-col gap-0"
|
|
||||||
>
|
|
||||||
{/* Register each step as form field */}
|
|
||||||
{/* @ts-expect-error: object type is not statically supported */}
|
|
||||||
<Field name="1">{(field, fieldProps) => <></>}</Field>
|
|
||||||
{/* @ts-expect-error: object type is not statically supported */}
|
|
||||||
<Field name="2">{(field, fieldProps) => <></>}</Field>
|
|
||||||
|
|
||||||
{/* Modal Header */}
|
|
||||||
<div class="select-none px-6 py-2">
|
|
||||||
<Typography hierarchy="label" size="default">
|
|
||||||
Install:{" "}
|
|
||||||
</Typography>
|
|
||||||
<Typography hierarchy="label" size="default" weight="bold">
|
|
||||||
{props.name}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
{/* Stepper header */}
|
|
||||||
<div class="flex items-center justify-evenly gap-2 border py-3 bg-def-3 border-def-2">
|
|
||||||
<For each={Object.entries(steps)}>
|
|
||||||
{([idx, label]) => (
|
|
||||||
<div class="flex flex-col items-center gap-3 fg-def-1">
|
|
||||||
<Typography
|
|
||||||
classList={{
|
|
||||||
[cx("bg-inv-4 fg-inv-1")]: idx === step(),
|
|
||||||
[cx("bg-def-4 fg-def-1")]: idx < step(),
|
|
||||||
}}
|
|
||||||
color="inherit"
|
|
||||||
hierarchy="label"
|
|
||||||
size="default"
|
|
||||||
weight="bold"
|
|
||||||
class="flex size-6 items-center justify-center rounded-full text-center align-middle bg-def-1"
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={idx >= step()}
|
|
||||||
fallback={<Icon icon="Checkmark" class="size-5" />}
|
|
||||||
>
|
|
||||||
{idx}
|
|
||||||
</Show>
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
color="inherit"
|
|
||||||
hierarchy="label"
|
|
||||||
size="xs"
|
|
||||||
weight="medium"
|
|
||||||
class="text-center align-top fg-def-3"
|
|
||||||
classList={{
|
|
||||||
[cx("!fg-def-1")]: idx == step(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<Switch fallback={"Undefined content. This Step seems to not exist."}>
|
|
||||||
<Match when={step() === "1"}>
|
|
||||||
<HWStep
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
machine_id={props.name}
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
dir={activeClanURI()}
|
|
||||||
handleNext={(data) => {
|
|
||||||
const prev = getValue(formStore, "1");
|
|
||||||
setValue(formStore, "1", { ...prev, ...data });
|
|
||||||
handleNext();
|
|
||||||
}}
|
|
||||||
initial={
|
|
||||||
getValue(formStore, "1") || {
|
|
||||||
target: props.machine.machine.deploy?.targetHost || "",
|
|
||||||
report: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
footer={<Footer />}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={step() === "2"}>
|
|
||||||
<DiskStep
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
machine_id={props.name}
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
dir={activeClanURI()}
|
|
||||||
footer={<Footer />}
|
|
||||||
handleNext={(data) => {
|
|
||||||
const prev = getValue(formStore, "2");
|
|
||||||
setValue(formStore, "2", { ...prev, ...data });
|
|
||||||
handleNext();
|
|
||||||
}}
|
|
||||||
// @ts-expect-error: The placeholder type is to wide
|
|
||||||
initial={{
|
|
||||||
...props.machine.disk_schema,
|
|
||||||
...getValue(formStore, "2"),
|
|
||||||
initialized: !!props.machine.disk_schema,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={step() === "3"}>
|
|
||||||
<VarsStep
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
machine_id={props.name}
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
dir={activeClanURI()}
|
|
||||||
handleNext={(data) => {
|
|
||||||
const prev = getValue(formStore, "3");
|
|
||||||
setValue(formStore, "3", { ...prev, ...data });
|
|
||||||
handleNext();
|
|
||||||
}}
|
|
||||||
initial={getValue(formStore, "3") || {}}
|
|
||||||
footer={<Footer />}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={step() === "4"}>
|
|
||||||
<SummaryStep
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
machine_id={props.name}
|
|
||||||
// @ts-expect-error: This cannot be undefined in this context.
|
|
||||||
dir={activeClanURI()}
|
|
||||||
handleNext={() => handleNext()}
|
|
||||||
// @ts-expect-error: This cannot be known.
|
|
||||||
initial={getValues(formStore)}
|
|
||||||
footer={
|
|
||||||
<div class="flex justify-between p-4">
|
|
||||||
<Button
|
|
||||||
startIcon={<Icon icon="ArrowLeft" />}
|
|
||||||
variant="light"
|
|
||||||
type="button"
|
|
||||||
onClick={handlePrev}
|
|
||||||
disabled={step() === "1"}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button startIcon={<Icon icon="Flash" />}>Install</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Form>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Match when={isInstalling()}>
|
|
||||||
<div class="flex h-96 w-[40rem] flex-col fg-inv-1">
|
|
||||||
<div class="flex w-full gap-1 p-4 bg-inv-4">
|
|
||||||
<Typography
|
|
||||||
color="inherit"
|
|
||||||
hierarchy="label"
|
|
||||||
size="default"
|
|
||||||
weight="medium"
|
|
||||||
>
|
|
||||||
Install:
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
color="inherit"
|
|
||||||
hierarchy="label"
|
|
||||||
size="default"
|
|
||||||
weight="bold"
|
|
||||||
>
|
|
||||||
{props.name}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div class="flex h-full flex-col items-center gap-3 px-4 py-8 bg-inv-4 fg-inv-1">
|
|
||||||
<Icon icon="ClanIcon" viewBox="0 0 72 89" class="size-20" />
|
|
||||||
{isDone() && <LoadingBar />}
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
size="default"
|
|
||||||
weight="medium"
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
{progressText()}
|
|
||||||
</Typography>
|
|
||||||
<Button onClick={() => setIsInstalling(false)}>Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MachineDetailsProps {
|
|
||||||
initialData: MachineData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MachineForm = (props: MachineDetailsProps) => {
|
|
||||||
const [formStore, { Form, Field }] =
|
|
||||||
// TODO: retrieve the correct initial values from API
|
|
||||||
createForm<MachineFormInterface>({
|
|
||||||
initialValues: props.initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetHost = () => getValue(formStore, "machine.deploy.targetHost");
|
|
||||||
const machineName = () =>
|
|
||||||
getValue(formStore, "machine.name") || props.initialData.machine.name;
|
|
||||||
|
|
||||||
const [installModalOpen, setInstallModalOpen] = createSignal(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const handleSubmit = async (values: MachineFormInterface) => {
|
|
||||||
console.log("submitting", values);
|
|
||||||
|
|
||||||
const curr_uri = activeClanURI();
|
|
||||||
if (!curr_uri) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const machine_response = await callApi("set_machine", {
|
|
||||||
machine: {
|
|
||||||
name: props.initialData.machine.name || "My machine",
|
|
||||||
flake: {
|
|
||||||
identifier: curr_uri,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
...values.machine,
|
|
||||||
// TODO: Remove this workaround
|
|
||||||
tags: Array.from(
|
|
||||||
values.machine.tags || props.initialData.machine.tags || [],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}).promise;
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: [curr_uri, "machine", machineName(), "get_machine_details"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generatorsQuery = useQuery(() => ({
|
|
||||||
queryKey: [activeClanURI(), machineName(), "generators"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const machine_name = machineName();
|
|
||||||
const base_dir = activeClanURI();
|
|
||||||
if (!machine_name || !base_dir) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const result = await callApi("get_generators_closure", {
|
|
||||||
base_dir: base_dir,
|
|
||||||
machine_name: machine_name,
|
|
||||||
}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleUpdateButton = async () => {
|
|
||||||
await generatorsQuery.refetch();
|
|
||||||
|
|
||||||
if (
|
|
||||||
generatorsQuery.data?.some((generator) => generator.prompts?.length !== 0)
|
|
||||||
) {
|
|
||||||
navigate(`/machines/${machineName()}/vars?action=update`);
|
|
||||||
} else {
|
|
||||||
handleUpdate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isUpdating, setIsUpdating] = createSignal(false);
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
if (isUpdating()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const curr_uri = activeClanURI();
|
|
||||||
if (!curr_uri) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const machine = machineName();
|
|
||||||
if (!machine) {
|
|
||||||
toast.error("Machine is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = targetHost();
|
|
||||||
|
|
||||||
const active_clan = activeClanURI();
|
|
||||||
if (!active_clan) {
|
|
||||||
console.error("No active clan selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target_host = await callApi("get_host", {
|
|
||||||
field: "targetHost",
|
|
||||||
flake: { identifier: active_clan },
|
|
||||||
name: machine,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (target_host.status == "error") {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target_host.data === null) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target_host.data!.data) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const build_host = await callApi("get_host", {
|
|
||||||
field: "buildHost",
|
|
||||||
flake: { identifier: active_clan },
|
|
||||||
name: machine,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (build_host.status == "error") {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (build_host.data === null) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
|
||||||
const r = await callApi("deploy_machine", {
|
|
||||||
machine: {
|
|
||||||
name: machine,
|
|
||||||
flake: {
|
|
||||||
identifier: curr_uri,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
target_host: target_host.data!.data,
|
|
||||||
build_host: build_host.data!.data,
|
|
||||||
}).promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const action = searchParams.action;
|
|
||||||
console.log({ action });
|
|
||||||
if (action === "update") {
|
|
||||||
setSearchParams({ action: undefined });
|
|
||||||
handleUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="sticky top-0 flex items-center justify-end gap-2 border-b border-secondary-100 bg-secondary-50 px-4 py-2">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-fit" data-tip="Machine must be online">
|
|
||||||
{/* <Button
|
|
||||||
class="w-full"
|
|
||||||
size="s"
|
|
||||||
// disabled={!online()}
|
|
||||||
onClick={() => {
|
|
||||||
setInstallModalOpen(true);
|
|
||||||
}}
|
|
||||||
endIcon={<Icon icon="Flash" />}
|
|
||||||
>
|
|
||||||
Install
|
|
||||||
</Button> */}
|
|
||||||
</div>
|
|
||||||
{/* <Typography hierarchy="label" size="default">
|
|
||||||
Installs the system for the first time. Used to bootstrap the
|
|
||||||
remote device.
|
|
||||||
</Typography> */}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="button-group flex">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
class="w-full"
|
|
||||||
size="s"
|
|
||||||
onClick={() => {
|
|
||||||
setInstallModalOpen(true);
|
|
||||||
}}
|
|
||||||
endIcon={<Icon size={14} icon="Flash" />}
|
|
||||||
>
|
|
||||||
Install
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
class="w-full"
|
|
||||||
size="s"
|
|
||||||
onClick={() => handleUpdateButton()}
|
|
||||||
endIcon={<Icon size={12} icon="Update" />}
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
class="w-full"
|
|
||||||
size="s"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/machines/${machineName()}/vars`);
|
|
||||||
}}
|
|
||||||
endIcon={<Icon size={12} icon="Folder" />}
|
|
||||||
>
|
|
||||||
Credentials
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class=" w-fit" data-tip="Machine must be online"></div>
|
|
||||||
{/* <Typography hierarchy="label" size="default">
|
|
||||||
Update the system if changes should be synced after the
|
|
||||||
installation process.
|
|
||||||
</Typography> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<span class="mb-2 flex w-full justify-center">
|
|
||||||
<MachineAvatar name={machineName()} />
|
|
||||||
</span>
|
|
||||||
<Form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
|
|
||||||
>
|
|
||||||
<Fieldset legend="General">
|
|
||||||
<Field name="machine.name">
|
|
||||||
{(field, props) => (
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
label="Name"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
error={field.error}
|
|
||||||
class="col-span-2"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field name="machine.description">
|
|
||||||
{(field, props) => (
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
label="Description"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
error={field.error}
|
|
||||||
class="col-span-2"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field name="machine.tags" type="string[]">
|
|
||||||
{(field, props) => (
|
|
||||||
<div class="grid grid-cols-10 items-center">
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
size="default"
|
|
||||||
weight="bold"
|
|
||||||
class="col-span-5"
|
|
||||||
>
|
|
||||||
Tags{" "}
|
|
||||||
</Typography>
|
|
||||||
<div class="col-span-5 justify-self-end">
|
|
||||||
{/* alphabetically sort the tags */}
|
|
||||||
<TagList values={[...(field.value || [])].sort()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
|
|
||||||
<Typography hierarchy={"body"} size={"s"}>
|
|
||||||
<Fieldset legend="Hardware">
|
|
||||||
<Field name="hw_config">
|
|
||||||
{(field, props) => (
|
|
||||||
<FieldLayout
|
|
||||||
label={<InputLabel>Hardware Configuration</InputLabel>}
|
|
||||||
field={<span>{field.value || "None"}</span>}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<hr />
|
|
||||||
<Field name="disk_schema.schema_name">
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<FieldLayout
|
|
||||||
label={<InputLabel>Disk schema</InputLabel>}
|
|
||||||
field={<span>{field.value || "None"}</span>}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Fieldset>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Accordion title="Connection Settings">
|
|
||||||
<Fieldset>
|
|
||||||
<Field name="machine.deploy.targetHost">
|
|
||||||
{(field, props) => (
|
|
||||||
<TextInput
|
|
||||||
inputProps={props}
|
|
||||||
label="Target Host"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
error={field.error}
|
|
||||||
class="col-span-2"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<FileSelectorField
|
|
||||||
Field={Field}
|
|
||||||
of={Array<File>}
|
|
||||||
multiple={true}
|
|
||||||
name="sshKeys" // Corresponds to FlashFormValues.sshKeys
|
|
||||||
label="SSH Private Key"
|
|
||||||
description="Provide your SSH private key for secure, passwordless connections."
|
|
||||||
fileDialogOptions={
|
|
||||||
{
|
|
||||||
title: "Select SSH Keys",
|
|
||||||
initial_folder: "~/.ssh",
|
|
||||||
} as FileDialogOptions
|
|
||||||
}
|
|
||||||
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
|
|
||||||
// e.g. validate={[required("At least one SSH key is required.")]}
|
|
||||||
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
|
|
||||||
/>
|
|
||||||
</Fieldset>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{
|
|
||||||
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={formStore.submitting || !formStore.dirty}
|
|
||||||
>
|
|
||||||
Update edits
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
}
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={`Install machine`}
|
|
||||||
open={installModalOpen()}
|
|
||||||
handleClose={() => setInstallModalOpen(false)}
|
|
||||||
class="min-w-[600px]"
|
|
||||||
>
|
|
||||||
<InstallMachine name={machineName()} machine={props.initialData} />
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MachineDetails = () => {
|
|
||||||
const params = useParams();
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const genericQuery = useQuery(() => ({
|
|
||||||
queryKey: [activeClanURI(), "machine", params.id, "get_machine_details"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const curr = activeClanURI();
|
|
||||||
if (curr) {
|
|
||||||
const result = await callApi("get_machine_details", {
|
|
||||||
machine: {
|
|
||||||
flake: {
|
|
||||||
identifier: curr,
|
|
||||||
},
|
|
||||||
name: params.id,
|
|
||||||
},
|
|
||||||
}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header title={`${params.id} machine`} showBack />
|
|
||||||
<Show when={genericQuery.data} fallback={<span class=""></span>}>
|
|
||||||
{(data) => (
|
|
||||||
<>
|
|
||||||
<MachineForm initialData={data()} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./details";
|
|
||||||
export * from "./create";
|
|
||||||
export * from "./list";
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { callApi } from "@/src/api";
|
|
||||||
import {
|
|
||||||
createForm,
|
|
||||||
SubmitHandler,
|
|
||||||
validate,
|
|
||||||
required,
|
|
||||||
FieldValues,
|
|
||||||
} from "@modular-forms/solid";
|
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
|
||||||
import { StepProps } from "./hardware-step";
|
|
||||||
import { SelectInput } from "@/src/Form/fields/Select";
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import { Group } from "@/src/components/group";
|
|
||||||
import { useContext } from "corvu/dialog";
|
|
||||||
|
|
||||||
export interface DiskValues extends FieldValues {
|
|
||||||
placeholders: {
|
|
||||||
mainDisk: string;
|
|
||||||
};
|
|
||||||
schema: string;
|
|
||||||
initialized: boolean;
|
|
||||||
}
|
|
||||||
export const DiskStep = (props: StepProps<DiskValues>) => {
|
|
||||||
const [formStore, { Form, Field }] = createForm<DiskValues>({
|
|
||||||
initialValues: { ...props.initial, schema: "single-disk" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<DiskValues> = async (values, event) => {
|
|
||||||
console.log("Submit Disk", { values });
|
|
||||||
const valid = await validate(formStore);
|
|
||||||
console.log("Valid", valid);
|
|
||||||
if (!valid) return;
|
|
||||||
props.handleNext(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const diskSchemaQuery = createQuery(() => ({
|
|
||||||
queryKey: [props.dir, props.machine_id, "disk_schemas"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await callApi("get_disk_schemas", {
|
|
||||||
machine: {
|
|
||||||
flake: {
|
|
||||||
identifier: props.dir,
|
|
||||||
},
|
|
||||||
name: props.machine_id,
|
|
||||||
},
|
|
||||||
}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const modalContext = useContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
class="flex flex-col gap-6"
|
|
||||||
noValidate={false}
|
|
||||||
>
|
|
||||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
|
||||||
<div class="flex h-full flex-col gap-6 p-4">
|
|
||||||
<span class="flex flex-col gap-4">
|
|
||||||
<Field
|
|
||||||
name="schema"
|
|
||||||
validate={required("Schema must be provided")}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<>
|
|
||||||
<Typography
|
|
||||||
hierarchy="body"
|
|
||||||
size="default"
|
|
||||||
weight="bold"
|
|
||||||
class="capitalize"
|
|
||||||
>
|
|
||||||
{(field.value || "No schema selected")
|
|
||||||
.split("-")
|
|
||||||
.join(" ")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
hierarchy="body"
|
|
||||||
size="xs"
|
|
||||||
weight="medium"
|
|
||||||
class="underline"
|
|
||||||
>
|
|
||||||
Change schema
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</span>
|
|
||||||
<Group>
|
|
||||||
{props.initial?.initialized &&
|
|
||||||
"Disk has been initialized already"}
|
|
||||||
<Field
|
|
||||||
name="placeholders.mainDisk"
|
|
||||||
validate={
|
|
||||||
!props.initial?.initialized
|
|
||||||
? required("Disk must be provided")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<SelectInput
|
|
||||||
loading={diskSchemaQuery.isFetching}
|
|
||||||
options={
|
|
||||||
diskSchemaQuery.data?.["single-disk"].placeholders[
|
|
||||||
"mainDisk"
|
|
||||||
].options?.map((o) => ({ label: o, value: o })) || [
|
|
||||||
{ label: "No options", value: "" },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
error={field.error}
|
|
||||||
label="Main Disk"
|
|
||||||
value={field.value || ""}
|
|
||||||
placeholder="Select a disk"
|
|
||||||
selectProps={fieldProps}
|
|
||||||
required={!props.initial?.initialized}
|
|
||||||
portalRef={modalContext.contentRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{props.footer}
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import { callApi } from "@/src/api";
|
|
||||||
import { Button } from "../../../components/Button/Button";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { InputError, InputLabel } from "@/src/components/inputBase";
|
|
||||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
|
||||||
import {
|
|
||||||
createForm,
|
|
||||||
FieldValues,
|
|
||||||
getValue,
|
|
||||||
required,
|
|
||||||
setValue,
|
|
||||||
submit,
|
|
||||||
SubmitHandler,
|
|
||||||
validate,
|
|
||||||
} from "@modular-forms/solid";
|
|
||||||
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
|
|
||||||
import { TextInput } from "@/src/Form/fields";
|
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
|
||||||
import { Badge } from "@/src/components/badge";
|
|
||||||
import { Group } from "@/src/components/group";
|
|
||||||
import {
|
|
||||||
type FileDialogOptions,
|
|
||||||
FileSelectorField,
|
|
||||||
} from "@/src/components/fileSelect";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
export type HardwareValues = FieldValues & {
|
|
||||||
report: boolean;
|
|
||||||
target: string;
|
|
||||||
sshKey?: File;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface StepProps<T> {
|
|
||||||
machine_id: string;
|
|
||||||
dir: string;
|
|
||||||
handleNext: (data: T) => void;
|
|
||||||
footer: JSX.Element;
|
|
||||||
initial?: T;
|
|
||||||
}
|
|
||||||
export const HWStep = (props: StepProps<HardwareValues>) => {
|
|
||||||
const [formStore, { Form, Field }] = createForm<HardwareValues>({
|
|
||||||
initialValues: (props.initial as HardwareValues) || {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<HardwareValues> = async (values, event) => {
|
|
||||||
console.log("Submit Hardware", { values });
|
|
||||||
const valid = await validate(formStore);
|
|
||||||
console.log("Valid", valid);
|
|
||||||
if (!valid) return;
|
|
||||||
props.handleNext(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
|
||||||
|
|
||||||
const hwReportQuery = createQuery(() => ({
|
|
||||||
queryKey: [props.dir, props.machine_id, "hw_report"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await callApi("show_machine_hardware_config", {
|
|
||||||
machine: {
|
|
||||||
flake: {
|
|
||||||
identifier: props.dir,
|
|
||||||
},
|
|
||||||
name: props.machine_id,
|
|
||||||
},
|
|
||||||
}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
// Workaround to set the form state
|
|
||||||
createEffect(() => {
|
|
||||||
const report = hwReportQuery.data;
|
|
||||||
if (report === "nixos-facter" || report === "nixos-generate-config") {
|
|
||||||
setValue(formStore, "report", true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const generateReport = async (e: Event) => {
|
|
||||||
const curr_uri = activeClanURI();
|
|
||||||
if (!curr_uri) return;
|
|
||||||
|
|
||||||
await validate(formStore, "target");
|
|
||||||
const target = getValue(formStore, "target");
|
|
||||||
const sshFile = getValue(formStore, "sshKey") as File | undefined;
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
console.error("Target is not set");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active_clan = activeClanURI();
|
|
||||||
if (!active_clan) {
|
|
||||||
console.error("No active clan selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target_host = await callApi("get_host", {
|
|
||||||
field: "targetHost",
|
|
||||||
flake: { identifier: active_clan },
|
|
||||||
name: props.machine_id,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
if (target_host.status == "error") {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target_host.data === null) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target_host.data!.data) {
|
|
||||||
console.error("No target host found for the machine");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = await callApi("generate_machine_hardware_info", {
|
|
||||||
opts: {
|
|
||||||
machine: {
|
|
||||||
name: props.machine_id,
|
|
||||||
private_key: sshFile?.name,
|
|
||||||
flake: {
|
|
||||||
identifier: curr_uri,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
backend: "nixos-facter",
|
|
||||||
},
|
|
||||||
target_host: target_host.data!.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: refresh the machine details
|
|
||||||
|
|
||||||
hwReportQuery.refetch();
|
|
||||||
submit(formStore);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit} class="flex flex-col gap-6">
|
|
||||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
|
||||||
<div class="flex h-full flex-col gap-6 p-4">
|
|
||||||
<Group>
|
|
||||||
<Field name="target" validate={required("Target must be provided")}>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<TextInput
|
|
||||||
error={field.error}
|
|
||||||
variant="ghost"
|
|
||||||
label="Target ip"
|
|
||||||
value={field.value || ""}
|
|
||||||
inputProps={fieldProps}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<FileSelectorField
|
|
||||||
Field={Field}
|
|
||||||
of={File}
|
|
||||||
multiple={false}
|
|
||||||
name="sshKey" // Corresponds to FlashFormValues.sshKeys
|
|
||||||
label="SSH Private Key"
|
|
||||||
description="Provide your SSH private key for secure, passwordless connections."
|
|
||||||
fileDialogOptions={
|
|
||||||
{
|
|
||||||
title: "Select SSH Keys",
|
|
||||||
initial_folder: "~/.ssh",
|
|
||||||
} as FileDialogOptions
|
|
||||||
}
|
|
||||||
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
|
|
||||||
// e.g. validate={[required("At least one SSH key is required.")]}
|
|
||||||
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group>
|
|
||||||
<Field
|
|
||||||
name="report"
|
|
||||||
type="boolean"
|
|
||||||
validate={required("Report must be generated")}
|
|
||||||
>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<FieldLayout
|
|
||||||
error={field.error && <InputError error={field.error} />}
|
|
||||||
label={
|
|
||||||
<InputLabel
|
|
||||||
required
|
|
||||||
help="Detect hardware specific drivers from target ip"
|
|
||||||
>
|
|
||||||
Hardware report
|
|
||||||
</InputLabel>
|
|
||||||
}
|
|
||||||
field={
|
|
||||||
<Switch>
|
|
||||||
<Match when={hwReportQuery.isLoading}>
|
|
||||||
<div>Loading...</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={hwReportQuery.error}>
|
|
||||||
<div>Error...</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={hwReportQuery.data}>
|
|
||||||
{(data) => (
|
|
||||||
<>
|
|
||||||
<Switch>
|
|
||||||
<Match when={data() === "none"}>
|
|
||||||
<Badge color="red" icon="Attention">
|
|
||||||
No report
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isGenerating()}
|
|
||||||
startIcon={<Icon icon="Report" />}
|
|
||||||
class="w-full"
|
|
||||||
onClick={generateReport}
|
|
||||||
>
|
|
||||||
Run hardware detection
|
|
||||||
</Button>
|
|
||||||
</Match>
|
|
||||||
<Match when={data() === "nixos-facter"}>
|
|
||||||
<Badge color="primary" icon="Checkmark">
|
|
||||||
Report detected
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isGenerating()}
|
|
||||||
startIcon={<Icon icon="Report" />}
|
|
||||||
class="w-full"
|
|
||||||
onClick={generateReport}
|
|
||||||
>
|
|
||||||
Re-run hardware detection
|
|
||||||
</Button>
|
|
||||||
</Match>
|
|
||||||
<Match when={data() === "nixos-generate-config"}>
|
|
||||||
<Badge color="primary" icon="Checkmark">
|
|
||||||
Legacy Report detected
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isGenerating()}
|
|
||||||
startIcon={<Icon icon="Report" />}
|
|
||||||
class="w-full"
|
|
||||||
onClick={generateReport}
|
|
||||||
>
|
|
||||||
Replace hardware detection
|
|
||||||
</Button>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{props.footer}
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { StepProps } from "./hardware-step";
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
|
||||||
import { InputLabel } from "@/src/components/inputBase";
|
|
||||||
import { Group, Section, SectionHeader } from "@/src/components/group";
|
|
||||||
import { AllStepsValues } from "../details";
|
|
||||||
import { Badge } from "@/src/components/badge";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
|
|
||||||
export const SummaryStep = (props: StepProps<AllStepsValues>) => {
|
|
||||||
const hwValues = () => props.initial?.["1"];
|
|
||||||
const diskValues = () => props.initial?.["2"];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
|
||||||
<div class="flex h-full flex-col gap-6 p-4">
|
|
||||||
<Section>
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
size="xs"
|
|
||||||
weight="medium"
|
|
||||||
class="uppercase"
|
|
||||||
>
|
|
||||||
Hardware Report
|
|
||||||
</Typography>
|
|
||||||
<Group>
|
|
||||||
<FieldLayout
|
|
||||||
label={<InputLabel>Detected</InputLabel>}
|
|
||||||
field={
|
|
||||||
hwValues()?.report ? (
|
|
||||||
<Badge color="green" class="w-fit">
|
|
||||||
<Icon icon="Checkmark" color="inherit" />
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge color="red" class="w-fit">
|
|
||||||
<Icon icon="Warning" color="inherit" />
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></FieldLayout>
|
|
||||||
<FieldLayout
|
|
||||||
label={<InputLabel>Target</InputLabel>}
|
|
||||||
field={
|
|
||||||
<Typography hierarchy="body" size="xs" weight="bold">
|
|
||||||
{hwValues()?.target}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
></FieldLayout>
|
|
||||||
</Group>
|
|
||||||
</Section>
|
|
||||||
<Section>
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
size="xs"
|
|
||||||
weight="medium"
|
|
||||||
class="uppercase"
|
|
||||||
>
|
|
||||||
Disk Configuration
|
|
||||||
</Typography>
|
|
||||||
<Group>
|
|
||||||
<FieldLayout
|
|
||||||
label={<InputLabel>Disk Layout</InputLabel>}
|
|
||||||
field={
|
|
||||||
<Typography hierarchy="body" size="xs" weight="bold">
|
|
||||||
{diskValues()?.schema}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
></FieldLayout>
|
|
||||||
<hr class="h-px w-full border-none bg-def-acc-3"></hr>
|
|
||||||
<FieldLayout
|
|
||||||
label={<InputLabel>Main Disk</InputLabel>}
|
|
||||||
field={
|
|
||||||
<Typography hierarchy="body" size="xs" weight="bold">
|
|
||||||
{diskValues()?.placeholders.mainDisk}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
></FieldLayout>
|
|
||||||
</Group>
|
|
||||||
</Section>
|
|
||||||
<SectionHeader
|
|
||||||
variant="danger"
|
|
||||||
headline={
|
|
||||||
<span>
|
|
||||||
<Typography
|
|
||||||
hierarchy="body"
|
|
||||||
size="s"
|
|
||||||
weight="bold"
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
Setup your device.
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
hierarchy="body"
|
|
||||||
size="s"
|
|
||||||
weight="medium"
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
This will erase the disk and bootstrap fresh.
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{props.footer}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import { callApi, SuccessData } from "@/src/api";
|
|
||||||
import {
|
|
||||||
createForm,
|
|
||||||
FieldValues,
|
|
||||||
SubmitHandler,
|
|
||||||
validate,
|
|
||||||
} from "@modular-forms/solid";
|
|
||||||
import { createQuery, useQueryClient } from "@tanstack/solid-query";
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import { Group } from "@/src/components/group";
|
|
||||||
import { For, JSX, Match, Show, Switch } from "solid-js";
|
|
||||||
import { TextInput } from "@/src/Form/fields";
|
|
||||||
import toast from "solid-toast";
|
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
|
|
||||||
import { StepProps } from "./hardware-step";
|
|
||||||
import { BackButton } from "@/src/components/BackButton";
|
|
||||||
import { Button } from "../../../components/Button/Button";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
export type VarsValues = FieldValues & Record<string, Record<string, string>>;
|
|
||||||
|
|
||||||
export interface VarsFormProps {
|
|
||||||
machine_id: string;
|
|
||||||
dir: string;
|
|
||||||
handleSubmit: SubmitHandler<VarsValues>;
|
|
||||||
generators: SuccessData<"get_generators_closure">;
|
|
||||||
footer: JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VarsForm = (props: VarsFormProps) => {
|
|
||||||
const [formStore, { Form, Field }] = createForm<VarsValues>({});
|
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {
|
|
||||||
console.log("Submit Vars", { values });
|
|
||||||
// sanitize the values back (replace __dot__)
|
|
||||||
// This hack is needed because we are using "." in the keys of the form
|
|
||||||
const sanitizedValues = Object.fromEntries(
|
|
||||||
Object.entries(values).map(([key, value]) => [
|
|
||||||
key.replaceAll("__dot__", "."),
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(value).map(([k, v]) => [
|
|
||||||
k.replaceAll("__dot__", "."),
|
|
||||||
v,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
) as VarsValues;
|
|
||||||
const valid = await validate(formStore);
|
|
||||||
if (!valid) {
|
|
||||||
toast.error("Please fill all required fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
props.handleSubmit(sanitizedValues, event);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
class="flex h-full flex-col gap-6"
|
|
||||||
noValidate={false}
|
|
||||||
>
|
|
||||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
|
||||||
<div class="flex h-full flex-col gap-6 p-4">
|
|
||||||
<Show
|
|
||||||
when={props.generators.length > 0}
|
|
||||||
fallback="No credentials needed"
|
|
||||||
>
|
|
||||||
<For each={props.generators}>
|
|
||||||
{(generator) => (
|
|
||||||
<Group>
|
|
||||||
<Typography hierarchy="label" size="default">
|
|
||||||
{generator.name}
|
|
||||||
</Typography>
|
|
||||||
<div>
|
|
||||||
Bound to module (shared):{" "}
|
|
||||||
{generator.share ? "True" : "False"}
|
|
||||||
</div>
|
|
||||||
<For each={generator.prompts}>
|
|
||||||
{(prompt) => (
|
|
||||||
<Group>
|
|
||||||
<Typography hierarchy="label" size="s">
|
|
||||||
{!prompt.previous_value ? "Required" : "Optional"}
|
|
||||||
</Typography>
|
|
||||||
<Typography hierarchy="label" size="s">
|
|
||||||
{prompt.name}
|
|
||||||
</Typography>
|
|
||||||
<Field
|
|
||||||
// Avoid nesting issue in case of a "."
|
|
||||||
name={`${generator.name.replaceAll(
|
|
||||||
".",
|
|
||||||
"__dot__",
|
|
||||||
)}.${prompt.name.replaceAll(".", "__dot__")}`}
|
|
||||||
>
|
|
||||||
{(field, props) => (
|
|
||||||
<Switch
|
|
||||||
fallback={
|
|
||||||
<TextInput
|
|
||||||
inputProps={{
|
|
||||||
...props,
|
|
||||||
type:
|
|
||||||
prompt.prompt_type === "hidden"
|
|
||||||
? "password"
|
|
||||||
: "text",
|
|
||||||
}}
|
|
||||||
label={prompt.description}
|
|
||||||
value={prompt.previous_value ?? ""}
|
|
||||||
error={field.error}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
prompt.prompt_type === "multiline" ||
|
|
||||||
prompt.prompt_type === "multiline-hidden"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
{...props}
|
|
||||||
class="h-32 w-full rounded-md border border-gray-300 p-2"
|
|
||||||
placeholder={prompt.description}
|
|
||||||
value={prompt.previous_value ?? ""}
|
|
||||||
name={prompt.description}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{props.footer}
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type VarsStepProps = StepProps<VarsValues> & {
|
|
||||||
fullClosure?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VarsStep = (props: VarsStepProps) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const generatorsQuery = createQuery(() => ({
|
|
||||||
queryKey: [props.dir, props.machine_id, "generators", props.fullClosure],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await callApi("get_generators_closure", {
|
|
||||||
base_dir: props.dir,
|
|
||||||
machine_name: props.machine_id,
|
|
||||||
full_closure: props.fullClosure,
|
|
||||||
}).promise;
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {
|
|
||||||
const loading_toast = toast.loading("Generating vars...");
|
|
||||||
if (generatorsQuery.data === undefined) {
|
|
||||||
toast.error("Error fetching data");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await callApi("generate_vars_for_machine", {
|
|
||||||
machine_name: props.machine_id,
|
|
||||||
base_dir: props.dir,
|
|
||||||
generators: generatorsQuery.data.map((generator) => generator.name),
|
|
||||||
all_prompt_values: values,
|
|
||||||
}).promise;
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [props.dir, props.machine_id, "generators"],
|
|
||||||
});
|
|
||||||
toast.dismiss(loading_toast);
|
|
||||||
if (result.status === "error") {
|
|
||||||
toast.error(result.errors[0].message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.status === "success") {
|
|
||||||
toast.success("Vars saved successfully");
|
|
||||||
}
|
|
||||||
props.handleNext(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch>
|
|
||||||
<Match when={generatorsQuery.isLoading}>Loading ...</Match>
|
|
||||||
<Match when={generatorsQuery.data}>
|
|
||||||
{(generators) => (
|
|
||||||
<VarsForm
|
|
||||||
machine_id={props.machine_id}
|
|
||||||
dir={props.dir}
|
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
generators={generators()}
|
|
||||||
footer={props.footer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VarsPage = () => {
|
|
||||||
const params = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const handleNext = (values: VarsValues) => {
|
|
||||||
if (searchParams?.action === "update") {
|
|
||||||
navigate(`/machines/${params.id}?action=update`);
|
|
||||||
} else {
|
|
||||||
navigate(-1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const fullClosure = searchParams?.full_closure !== undefined;
|
|
||||||
const footer = (
|
|
||||||
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
// disabled={formStore.submitting || !formStore.dirty}
|
|
||||||
>
|
|
||||||
Update edits
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div class="p-1">
|
|
||||||
{/* BackButton and header */}
|
|
||||||
<BackButton />
|
|
||||||
<div class="p-2">
|
|
||||||
<h3 class="text-2xl">{params.id}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VarsStep component */}
|
|
||||||
<Show when={activeClanURI()}>
|
|
||||||
{(uri) => (
|
|
||||||
<VarsStep
|
|
||||||
machine_id={params.id}
|
|
||||||
dir={uri()}
|
|
||||||
handleNext={handleNext}
|
|
||||||
footer={footer}
|
|
||||||
fullClosure={fullClosure}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import { type Component, createSignal, For, Match, Switch } from "solid-js";
|
|
||||||
import { callApi, OperationResponse } from "@/src/api";
|
|
||||||
import { MachineListItem } from "@/src/components/machine-list-item";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/solid-query";
|
|
||||||
import { useNavigate } from "@solidjs/router";
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { Header } from "@/src/layout/header";
|
|
||||||
import { makePersisted } from "@solid-primitives/storage";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
type MachinesModel = Extract<
|
|
||||||
OperationResponse<"list_machines">,
|
|
||||||
{ status: "success" }
|
|
||||||
>["data"];
|
|
||||||
|
|
||||||
export interface Filter {
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MachineListView: Component = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [filter, setFilter] = createSignal<Filter>({ tags: [] });
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
|
|
||||||
const inventoryQuery = useQuery<MachinesModel>(() => ({
|
|
||||||
queryKey: [activeClanURI(), "list_machines"],
|
|
||||||
placeholderData: {},
|
|
||||||
enabled: !!activeClanURI(),
|
|
||||||
queryFn: async () => {
|
|
||||||
console.log("fetching inventory", activeClanURI());
|
|
||||||
const uri = activeClanURI();
|
|
||||||
if (uri) {
|
|
||||||
const response = await callApi("list_machines", {
|
|
||||||
flake: {
|
|
||||||
identifier: uri,
|
|
||||||
},
|
|
||||||
}).promise;
|
|
||||||
console.log("response", response);
|
|
||||||
if (response.status === "error") {
|
|
||||||
console.error("Failed to fetch data");
|
|
||||||
} else {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
const clanURI = activeClanURI();
|
|
||||||
|
|
||||||
// do nothing if there is no active URI
|
|
||||||
if (!clanURI) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("refreshing", clanURI);
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
// Invalidates the cache for of all types of machine list at once
|
|
||||||
queryKey: [clanURI, "list_machines"],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const inventoryMachines = () =>
|
|
||||||
Object.entries(inventoryQuery.data || {}).filter((e) => {
|
|
||||||
const hasAllTags = filter().tags.every((tag) => e[1].tags?.includes(tag));
|
|
||||||
|
|
||||||
return hasAllTags;
|
|
||||||
});
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [view, setView] = makePersisted(createSignal<"list" | "grid">("grid"), {
|
|
||||||
name: "machines_view",
|
|
||||||
storage: localStorage,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header
|
|
||||||
title="Your Machines"
|
|
||||||
toolbar={
|
|
||||||
<>
|
|
||||||
<span class="" data-tip="Reload">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="s"
|
|
||||||
onClick={() => refresh()}
|
|
||||||
startIcon={<Icon icon="Update" />}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="button-group">
|
|
||||||
<Button
|
|
||||||
onclick={() => setView("list")}
|
|
||||||
variant={view() == "list" ? "dark" : "light"}
|
|
||||||
size="s"
|
|
||||||
startIcon={<Icon icon="List" />}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onclick={() => setView("grid")}
|
|
||||||
variant={view() == "grid" ? "dark" : "light"}
|
|
||||||
size="s"
|
|
||||||
startIcon={<Icon icon="Grid" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate("create")}
|
|
||||||
size="s"
|
|
||||||
variant="light"
|
|
||||||
startIcon={<Icon size={14} icon="Plus" />}
|
|
||||||
>
|
|
||||||
New Machine
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div class="my-1 flex w-full gap-2 p-2">
|
|
||||||
<For each={filter().tags.sort()}>
|
|
||||||
{(tag) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setFilter((prev) => {
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
tags: prev.tags.filter((t) => t !== tag),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<Switch>
|
|
||||||
<Match when={inventoryQuery.isLoading}>
|
|
||||||
{/* Loading skeleton */}
|
|
||||||
<div class="grid grid-cols-4"></div>
|
|
||||||
<div class="machine-item-loader">
|
|
||||||
<div class="machine-item-loader__thumb-wrapper">
|
|
||||||
<div class="machine-item-loader__thumb">
|
|
||||||
<div class="machine-item-loader__loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="machine-item-loader__headline">
|
|
||||||
<div class="machine-item-loader__loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="machine-item-loader">
|
|
||||||
<div class="machine-item-loader__thumb-wrapper">
|
|
||||||
<div class="machine-item-loader__thumb">
|
|
||||||
<div class="machine-item-loader__loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="machine-item-loader__headline">
|
|
||||||
<div class="machine-item-loader__loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="machine-item-loader">
|
|
||||||
<div class="machine-item-loader__thumb-wrapper">
|
|
||||||
<div class="machine-item-loader__thumb">
|
|
||||||
<div class="machine-item-loader__loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="machine-item-loader__headline">
|
|
||||||
<div class="machine-item-loader__loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match
|
|
||||||
when={!inventoryQuery.isLoading && inventoryMachines().length === 0}
|
|
||||||
>
|
|
||||||
<div class="mt-8 flex w-full flex-col items-center justify-center gap-2">
|
|
||||||
<span class="text-lg">
|
|
||||||
No machines defined yet. Click below to define one.
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
class="size-28 overflow-hidden p-2"
|
|
||||||
onClick={() => navigate("/machines/create")}
|
|
||||||
>
|
|
||||||
<span class="material-icons text-6xl font-light">add</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={!inventoryQuery.isLoading}>
|
|
||||||
<div
|
|
||||||
class="my-4 grid gap-6 p-6"
|
|
||||||
classList={{
|
|
||||||
"grid-cols-1": view() === "list",
|
|
||||||
"grid-cols-4": view() === "grid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={inventoryMachines()}>
|
|
||||||
{([name, info]) => (
|
|
||||||
<MachineListItem
|
|
||||||
name={name}
|
|
||||||
info={info}
|
|
||||||
setFilter={setFilter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { BackButton } from "@/src/components/BackButton";
|
|
||||||
import { createModulesQuery, machinesQuery, tagsQuery } from "@/src/queries";
|
|
||||||
import { useParams } from "@solidjs/router";
|
|
||||||
import { For, Match, Switch } from "solid-js";
|
|
||||||
import { ModuleInfo } from "./list";
|
|
||||||
import { createForm, FieldValues, SubmitHandler } from "@modular-forms/solid";
|
|
||||||
import { SelectInput } from "@/src/Form/fields/Select";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
export const ModuleDetails = () => {
|
|
||||||
const params = useParams();
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
const modulesQuery = createModulesQuery(activeClanURI());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="p-1">
|
|
||||||
<BackButton />
|
|
||||||
<div class="p-2">
|
|
||||||
<h3 class="text-2xl">{params.id}</h3>
|
|
||||||
{/* <Switch>
|
|
||||||
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
|
|
||||||
{(d) => <AddModule data={d()[1]} id={d()[0]} />}
|
|
||||||
</Match>
|
|
||||||
</Switch> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AddModuleProps {
|
|
||||||
data: ModuleInfo;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddModule = (props: AddModuleProps) => {
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
const tags = tagsQuery(activeClanURI());
|
|
||||||
const machines = machinesQuery(activeClanURI());
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>Add to your clan</div>
|
|
||||||
<Switch fallback="loading">
|
|
||||||
<Match when={tags.data}>
|
|
||||||
{(tags) => (
|
|
||||||
<For each={Object.keys(props.data.roles)}>
|
|
||||||
{(role) => (
|
|
||||||
<>
|
|
||||||
<div class="text-neutral-600">{role}s</div>
|
|
||||||
<RoleForm
|
|
||||||
avilableTags={tags()}
|
|
||||||
availableMachines={machines.data || []}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RoleFormData extends FieldValues {
|
|
||||||
machines: string[];
|
|
||||||
tags: string[];
|
|
||||||
test: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoleFormProps {
|
|
||||||
avilableTags: string[];
|
|
||||||
availableMachines: string[];
|
|
||||||
}
|
|
||||||
const RoleForm = (props: RoleFormProps) => {
|
|
||||||
const [formStore, { Field, Form }] = createForm<RoleFormData>({
|
|
||||||
// initialValues: {
|
|
||||||
// machines: ["hugo", "bruno"],
|
|
||||||
// tags: ["network", "backup"],
|
|
||||||
// },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<RoleFormData> = (values) => {
|
|
||||||
console.log(values);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<Field name="machines" type="string[]">
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<SelectInput
|
|
||||||
error={field.error}
|
|
||||||
label={"Machines"}
|
|
||||||
value={field.value || []}
|
|
||||||
options={props.availableMachines.map((o) => ({
|
|
||||||
value: o,
|
|
||||||
label: o,
|
|
||||||
}))}
|
|
||||||
multiple
|
|
||||||
selectProps={fieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field name="tags" type="string[]">
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<SelectInput
|
|
||||||
error={field.error}
|
|
||||||
label={"Tags"}
|
|
||||||
value={field.value || []}
|
|
||||||
options={props.avilableTags.map((o) => ({
|
|
||||||
value: o,
|
|
||||||
label: o,
|
|
||||||
}))}
|
|
||||||
multiple
|
|
||||||
selectProps={fieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import { BackButton } from "@/src/components/BackButton";
|
|
||||||
import { createModulesQuery } from "@/src/queries";
|
|
||||||
import { useNavigate, useParams } from "@solidjs/router";
|
|
||||||
import { createEffect, For, Match, Switch } from "solid-js";
|
|
||||||
import { ModuleInfo } from "./list";
|
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
|
||||||
import { JSONSchema7 } from "json-schema";
|
|
||||||
import { SubmitHandler } from "@modular-forms/solid";
|
|
||||||
import { DynForm } from "@/src/Form/form";
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
import { activeClanURI } from "@/src/stores/clan";
|
|
||||||
|
|
||||||
export const ModuleDetails = () => {
|
|
||||||
const params = useParams();
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
const modulesQuery = createModulesQuery(activeClanURI());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="p-1">
|
|
||||||
<BackButton />
|
|
||||||
<div class="p-2">
|
|
||||||
<h3 class="text-2xl">{params.id}</h3>
|
|
||||||
{/* <Switch>
|
|
||||||
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
|
|
||||||
{(d) => <Details data={d()[1]} id={d()[0]} />}
|
|
||||||
</Match>
|
|
||||||
</Switch> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function deepMerge(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
obj1: Record<string, any>,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
obj2: Record<string, any>,
|
|
||||||
) {
|
|
||||||
const result = { ...obj1 };
|
|
||||||
|
|
||||||
for (const key in obj2) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(obj2, key)) {
|
|
||||||
if (obj2[key] instanceof Object && obj1[key] instanceof Object) {
|
|
||||||
result[key] = deepMerge(obj1[key], obj2[key]);
|
|
||||||
} else {
|
|
||||||
result[key] = obj2[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailsProps {
|
|
||||||
data: ModuleInfo;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
const Details = (props: DetailsProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const add = async () => {
|
|
||||||
navigate(`/modules/add/${props.id}`);
|
|
||||||
// const uri = activeURI();
|
|
||||||
// if (!uri) return;
|
|
||||||
// const res = await callApi("get_inventory", { base_path: uri });
|
|
||||||
// if (res.status === "error") {
|
|
||||||
// toast.error("Failed to fetch inventory");
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// const inventory = res.data;
|
|
||||||
// const newInventory = deepMerge(inventory, {
|
|
||||||
// services: {
|
|
||||||
// [props.id]: {
|
|
||||||
// default: {
|
|
||||||
// enabled: false,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// callApi("set_inventory", {
|
|
||||||
// flake_dir: uri,
|
|
||||||
// inventory: newInventory,
|
|
||||||
// message: `Add module: ${props.id} in 'default' instance`,
|
|
||||||
// });
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
{/* TODO: bring this feature back */}
|
|
||||||
{/* <article class="prose">{props.data.description}</article> */}
|
|
||||||
{/* <span class="">Categories</span> */}
|
|
||||||
<div>
|
|
||||||
{/* TODO: bring this feature back */}
|
|
||||||
{/* <For each={props.data.categories}>
|
|
||||||
{(c) => <div class=" m-1">{c}</div>}
|
|
||||||
</For> */}
|
|
||||||
</div>
|
|
||||||
<span class="">Roles</span>
|
|
||||||
<div>
|
|
||||||
<For each={Object.keys(props.data.roles)}>
|
|
||||||
{(r) => <div class=" m-1">{r}</div>}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<div class="p-2">
|
|
||||||
{/* TODO: bring this feature back */}
|
|
||||||
{/* <SolidMarkdown>{props.data.readme}</SolidMarkdown> */}
|
|
||||||
</div>
|
|
||||||
<div class="my-2 flex w-full gap-2">
|
|
||||||
<Button variant="light" onClick={add} startIcon={<Icon icon="Plus" />}>
|
|
||||||
Add to Clan
|
|
||||||
</Button>
|
|
||||||
{/* Add -> Select (required) roles, assign Machine */}
|
|
||||||
</div>
|
|
||||||
<ModuleForm id={props.id} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModuleSchemasType = Record<string, Record<string, JSONSchema7>>;
|
|
||||||
|
|
||||||
const Unsupported = (props: { schema: JSONSchema7; what: string }) => (
|
|
||||||
<div>
|
|
||||||
Cannot render {props.what}
|
|
||||||
<pre>
|
|
||||||
<code>{JSON.stringify(props.schema, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
function removeTrailingS(str: string) {
|
|
||||||
// Check if the last character is "s" or "S"
|
|
||||||
if (str.endsWith("s") || str.endsWith("S")) {
|
|
||||||
return str.slice(0, -1); // Remove the last character
|
|
||||||
}
|
|
||||||
return str; // Return unchanged if no trailing "s"
|
|
||||||
}
|
|
||||||
interface SchemaFormProps {
|
|
||||||
title: string;
|
|
||||||
schema: JSONSchema7;
|
|
||||||
path: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModuleForm = (props: { id: string }) => {
|
|
||||||
// TODO: Fetch the synced schema for all the modules at runtime
|
|
||||||
// We use static schema file at build time for now. (Different versions might have different schema at runtime)
|
|
||||||
const schemaQuery = createQuery(() => ({
|
|
||||||
queryKey: [activeClanURI(), "modules_schema"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const moduleSchema = await import(
|
|
||||||
"../../../api/modules_schemas.json"
|
|
||||||
).then((m) => m.default as ModuleSchemasType);
|
|
||||||
|
|
||||||
return moduleSchema;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log("Schema Query", schemaQuery.data?.[props.id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
|
|
||||||
values,
|
|
||||||
event,
|
|
||||||
) => {
|
|
||||||
console.log("Submitted form values", values);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="ModuleForm">
|
|
||||||
<Switch fallback={"No Schema found"}>
|
|
||||||
<Match when={schemaQuery.isLoading}>Loading...</Match>
|
|
||||||
<Match when={schemaQuery.data?.[props.id]}>
|
|
||||||
{(rolesSchemas) => (
|
|
||||||
<>
|
|
||||||
Configure this module
|
|
||||||
<For each={Object.entries(rolesSchemas())}>
|
|
||||||
{([role, schema]) => (
|
|
||||||
<div class="my-2">
|
|
||||||
<h4 class="text-xl">{role}</h4>
|
|
||||||
<DynForm
|
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
schema={schema}
|
|
||||||
components={{
|
|
||||||
after: <Button>Submit</Button>,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { SuccessData } from "@/src/api";
|
|
||||||
import { Button } from "../../components/Button/Button";
|
|
||||||
import { Header } from "@/src/layout/header";
|
|
||||||
import { createModulesQuery } from "@/src/queries";
|
|
||||||
import { A, useNavigate } from "@solidjs/router";
|
|
||||||
import { createSignal, For, Match, Switch } from "solid-js";
|
|
||||||
import { Typography } from "@/src/components/Typography";
|
|
||||||
import { Menu } from "@/src/components/Menu";
|
|
||||||
import { makePersisted } from "@solid-primitives/storage";
|
|
||||||
import { useQueryClient } from "@tanstack/solid-query";
|
|
||||||
import cx from "classnames";
|
|
||||||
import Icon from "@/src/components/icon";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
|
||||||
|
|
||||||
export type ModuleInfo = SuccessData<"list_modules">["localModules"][string];
|
|
||||||
|
|
||||||
interface CategoryProps {
|
|
||||||
categories: string[];
|
|
||||||
}
|
|
||||||
const Categories = (props: CategoryProps) => {
|
|
||||||
return (
|
|
||||||
<span class="inline-flex h-full align-middle">
|
|
||||||
<Typography hierarchy="label" size="default" class="w-16" tag="div">
|
|
||||||
Categories:
|
|
||||||
</Typography>
|
|
||||||
{props.categories.map((category) => (
|
|
||||||
<Typography hierarchy="label" size="default">
|
|
||||||
{category}
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RolesProps {
|
|
||||||
roles: Record<string, null>;
|
|
||||||
}
|
|
||||||
const Roles = (props: RolesProps) => {
|
|
||||||
return (
|
|
||||||
<div class="inline-flex h-full align-middle">
|
|
||||||
<Typography hierarchy="label" size="default" class="w-16" tag="div">
|
|
||||||
Type:
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{Object.keys(props.roles).map((role) => (
|
|
||||||
<Typography hierarchy="label" size="default">
|
|
||||||
{role}
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ModuleItem = (props: {
|
|
||||||
name: string;
|
|
||||||
info: ModuleInfo;
|
|
||||||
source: string;
|
|
||||||
class?: string;
|
|
||||||
}) => {
|
|
||||||
const { name, info } = props;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={cx(
|
|
||||||
"col-span-1 flex flex-col border-b border-secondary-200 pb-4 gap-2",
|
|
||||||
props.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header class="flex flex-row items-center justify-between">
|
|
||||||
<div class="flex flex-col gap-0">
|
|
||||||
<A href={`/modules/details/${props.source}/${info.manifest.name}`}>
|
|
||||||
<div class="">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
{/* <Categories categories={info.categories} /> */}
|
|
||||||
<Typography hierarchy="title" size="m" weight="medium">
|
|
||||||
{info.manifest.name}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</A>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<Typography hierarchy="body" size="xs">
|
|
||||||
{info.manifest.description}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Menu popoverid={`menu-${props.name}`} label={<Icon icon={"More"} />}>
|
|
||||||
<ul class="z-[1] w-52 bg-slate-100 p-2 shadow">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/modules/details/${name}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Configure
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Menu>
|
|
||||||
</header>
|
|
||||||
<Roles roles={info.roles || {}} />
|
|
||||||
<div class="w-full">
|
|
||||||
<Categories categories={info.manifest.categories} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ModuleList = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { activeClanURI } = useClanContext();
|
|
||||||
const modulesQuery = createModulesQuery(activeClanURI(), {
|
|
||||||
features: ["inventory"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [view, setView] = makePersisted(createSignal<"list" | "grid">("list"), {
|
|
||||||
name: "modules_view",
|
|
||||||
storage: localStorage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
const clanURI = activeClanURI();
|
|
||||||
|
|
||||||
// do nothing if there is no active URI
|
|
||||||
if (!clanURI) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
// Invalidates the cache for of all types of machine list at once
|
|
||||||
queryKey: [clanURI, "list_modules"],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header
|
|
||||||
title="App Store"
|
|
||||||
toolbar={
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="s"
|
|
||||||
onClick={() => refresh()}
|
|
||||||
startIcon={<Icon icon="Update" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="button-group">
|
|
||||||
<Button
|
|
||||||
onclick={() => setView("list")}
|
|
||||||
variant={view() == "list" ? "dark" : "light"}
|
|
||||||
size="s"
|
|
||||||
startIcon={<Icon icon="List" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onclick={() => setView("grid")}
|
|
||||||
variant={view() == "grid" ? "dark" : "light"}
|
|
||||||
size="s"
|
|
||||||
startIcon={<Icon icon="Grid" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Switch fallback="Error">
|
|
||||||
<Match when={modulesQuery.isFetching}>Loading....</Match>
|
|
||||||
<Match when={modulesQuery.data}>
|
|
||||||
{(modules) => (
|
|
||||||
<div
|
|
||||||
class="grid gap-6 p-6"
|
|
||||||
classList={{
|
|
||||||
"grid-cols-1": view() === "list",
|
|
||||||
"grid-cols-2": view() === "grid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={Object.entries(modules().modulesPerSource)}>
|
|
||||||
{([sourceName, v]) => (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Typography size="default" hierarchy="label">
|
|
||||||
{sourceName}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<For each={Object.entries(v)}>
|
|
||||||
{([moduleName, moduleInfo]) => (
|
|
||||||
<ModuleItem
|
|
||||||
source={sourceName}
|
|
||||||
info={moduleInfo}
|
|
||||||
name={moduleName}
|
|
||||||
class={view() == "grid" ? cx("max-w-md") : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<div>{"localModules"}</div>
|
|
||||||
<For each={Object.entries(modules().localModules)}>
|
|
||||||
{([moduleName, moduleInfo]) => (
|
|
||||||
<ModuleItem
|
|
||||||
source={"localModules"}
|
|
||||||
info={moduleInfo}
|
|
||||||
name={moduleName}
|
|
||||||
class={view() == "grid" ? cx("max-w-md") : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -80,7 +80,6 @@ const removeClanURI = (uri: string) => {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
store,
|
store,
|
||||||
setStore,
|
|
||||||
activeClanURI,
|
activeClanURI,
|
||||||
setActiveClanURI,
|
setActiveClanURI,
|
||||||
clanURIs,
|
clanURIs,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js";
|
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Button } from "./components/Button/Button";
|
import { Button } from "./components/Button/Button";
|
||||||
import Icon from "./components/icon";
|
import Icon from "./components/icon";
|
||||||
|
|||||||
Reference in New Issue
Block a user