Merge branch 'main' into Qubasa-Qubasa-main

This commit is contained in:
Mic92
2023-09-06 15:40:21 +00:00
50 changed files with 1701 additions and 677 deletions

View File

@@ -1,355 +1,7 @@
"use client";
import React, { ReactNode, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
MenuItem,
Select,
Step,
StepLabel,
Stepper,
Typography,
} from "@mui/material";
import {
Control,
Controller,
Form,
useForm,
UseFormWatch,
} from "react-hook-form";
import { DashboardCard } from "@/components/card";
import Info from "@mui/icons-material/Info";
import { Check, Usb } from "@mui/icons-material";
import toast from "react-hot-toast";
import { buffer } from "stream/consumers";
import { CreateMachineForm } from "@/components/createMachineForm";
type StepId = "select" | "create" | "install";
type Step = {
id: StepId;
label: string;
children?: ReactNode;
};
const steps: Step[] = [
{
id: "select",
label: "Image",
},
{
id: "create",
label: "Customize new template",
},
{
id: "install",
label: "Install",
},
];
const serverImagesData = [
{
id: "1",
name: "Cassies Gaming PC",
},
{
id: "2",
name: "Ivern office",
},
{
id: "3",
name: "Dad's working pc",
},
{
id: "4",
name: "Sisters's pony preset",
},
];
interface StepContentProps {
id: StepId;
control: Control<FormValues>;
watch: UseFormWatch<FormValues>;
}
const StepContent = (props: StepContentProps) => {
const { id, control, watch } = props;
const [hasWebUsb, setHasWebUsb] = useState<boolean>(false);
useEffect(() => {
setHasWebUsb(Boolean(navigator?.usb));
}, []);
const content: Record<StepId, ReactNode> = {
select: (
<div>
<div className="">
<Typography component="div" variant="overline" className="h-full">
Select an image
</Typography>
<Controller
name="image"
control={control}
render={({ field }) => (
<Select
{...field}
defaultValue={control._defaultValues.image}
fullWidth
>
{imageOptions.map(({ id, label }) => (
<MenuItem key={id} value={id}>
{label}
</MenuItem>
))}
</Select>
)}
/>
<div className="w-full py-4">
<DashboardCard title={<Info />}>
<div className="w-full py-2">
<Typography className="pb-4">
{watch("image") === "new"
? `You selected the option to create a new system image. Configure your predefined options, such as programs, clans, etc. in
the following steps.`
: `You selected the option to reuse an existing system image. Please select one
from the list below`}
</Typography>
{watch("image") === "existing" && (
<Controller
name="source"
control={control}
render={({ field }) => (
<Select
{...field}
defaultValue={control._defaultValues.source}
fullWidth
>
{serverImagesData.map(({ id, name }) => (
<MenuItem key={id} value={id}>
{name}
</MenuItem>
))}
</Select>
)}
/>
)}
</div>
</DashboardCard>
</div>
</div>
</div>
),
create: (
<div className="flex w-full flex-col">
<div className="my-3 w-full p-4">
Formular generated from nix flake jsonschema
</div>
</div>
),
install: (
<div className="flex w-full justify-center">
<Button
color="secondary"
type="submit"
startIcon={<Usb />}
variant="contained"
>
{hasWebUsb ? "Flash USB Device" : "Download installer image"}
</Button>
</div>
),
};
return (
<div className="mt-4 flex p-4">
<div className="flex w-full flex-col">
<Typography
component="div"
variant="overline"
className="flex w-full justify-center"
>
{watch("image") == "new"
? "Create system template"
: "Choose existing"}
</Typography>
<div className="my-3 w-full p-4">{content[id]}</div>
</div>
</div>
);
};
type FormValues = {
image: ImageOption;
source: string;
};
type ImageOption = "new" | "existing";
type ImageOptions = {
id: ImageOption;
label: string;
}[];
const imageOptions: ImageOptions = [
{
id: "new",
label: "New image",
},
{
id: "existing",
label: "Previously created image",
},
];
const defaultValues: FormValues = {
image: "new",
source: serverImagesData[0].id,
};
export default function AddNode() {
const { handleSubmit, control, watch, reset, formState } =
useForm<FormValues>({
defaultValues,
});
const [activeStep, setActiveStep] = useState<number>(0);
const [usb, setUsb] = useState<USB | undefined>(undefined);
useEffect(() => {
setUsb(navigator?.usb);
}, []);
const handleNext = () => {
if (activeStep < visibleSteps.length - 1) {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
};
const handleBack = () => {
if (activeStep > 0) {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
}
};
const handleReset = () => {
setActiveStep(0);
reset();
};
async function onSubmit(data: any) {
console.log({ data }, "To be submitted");
if (usb) {
let device;
try {
device = await usb.requestDevice({
filters: [{}],
});
toast.success(`Connected to '${device.productName}'`);
} catch (error) {
console.log({ error });
toast.error("Couldn't connect to usb device");
}
if (device) {
// await device.open();
// await device.selectConfiguration(1);
// await device.claimInterface(0);
// const data = new Uint8Array([1, 2, 3]);
// device.transferOut(2, data);
}
} else {
//Offer the image as download
const blob = new Blob(["data"]);
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
a.href = url;
a.download = "image.iso";
a.click();
}
return true;
}
const imageValue = watch("image");
const visibleSteps = useMemo(
() =>
steps.filter((s) => {
if (imageValue == "existing" && s.id == "create") {
return false;
}
return true;
}),
[imageValue],
);
// console.log({})
const currentStep = visibleSteps.at(activeStep);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ width: "100%" }}>
<Stepper activeStep={activeStep} color="secondary">
{visibleSteps.map(({ label }, index) => {
const stepProps: { completed?: boolean } = {};
const labelProps: {
optional?: React.ReactNode;
} = {};
return (
<Step
sx={{
".MuiStepIcon-root.Mui-active": {
color: "secondary.main",
},
".MuiStepIcon-root.Mui-completed": {
color: "secondary.main",
},
}}
key={label}
{...stepProps}
>
<StepLabel {...labelProps}>{label}</StepLabel>
</Step>
);
})}
</Stepper>
{activeStep === visibleSteps.length ? (
<>
<Typography variant="h5" sx={{ mt: 2, mb: 1 }}>
Image succesfully downloaded
</Typography>
<Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
<Box sx={{ flex: "1 1 auto" }} />
<Button color="secondary" onClick={handleReset}>
Reset
</Button>
</Box>
</>
) : (
<>
{currentStep && (
<StepContent
id={currentStep.id}
control={control}
watch={watch}
/>
)}
<Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
<Button
color="secondary"
disabled={activeStep === 0}
onClick={handleBack}
sx={{ mr: 1 }}
>
Back
</Button>
<Box sx={{ flex: "1 1 auto" }} />
{activeStep !== visibleSteps.length - 1 && (
<Button onClick={handleNext} color="secondary">
{activeStep <= visibleSteps.length - 1 && "Next"}
</Button>
)}
{activeStep === visibleSteps.length - 1 && (
<Button color="secondary" onClick={handleReset}>
Reset
</Button>
)}
</Box>
</>
)}
</Box>
</form>
);
export default function CreateMachine() {
return <CreateMachineForm />;
}

View File

@@ -0,0 +1,164 @@
"use client";
import { useGetMachineSchema } from "@/api/default/default";
import { Check, Error } from "@mui/icons-material";
import {
Box,
Button,
LinearProgress,
List,
ListItem,
ListItemIcon,
ListItemText,
Paper,
Typography,
} from "@mui/material";
import { IChangeEvent, FormProps } from "@rjsf/core";
import { Form } from "@rjsf/mui";
import validator from "@rjsf/validator-ajv8";
import toast from "react-hot-toast";
import { JSONSchema7 } from "json-schema";
import { useMemo, useRef } from "react";
import { FormStepContentProps } from "./interfaces";
import {
ErrorListProps,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
TranslatableString,
} from "@rjsf/utils";
interface PureCustomConfigProps extends FormStepContentProps {
schema: JSONSchema7;
initialValues: any;
}
export function CustomConfig(props: FormStepContentProps) {
const { formHooks } = props;
const { data, isLoading, error } = useGetMachineSchema("mama");
const schema = useMemo(() => {
if (!isLoading && !error?.message && data?.data) {
return data?.data.schema;
}
return {};
}, [data, isLoading, error]);
const initialValues = useMemo(
() =>
Object.entries(schema?.properties || {}).reduce((acc, [key, value]) => {
/*@ts-ignore*/
const init: any = value?.default;
if (init) {
return {
...acc,
[key]: init,
};
}
return acc;
}, {}),
[schema],
);
return isLoading ? (
<LinearProgress variant="indeterminate" />
) : error?.message ? (
<div>{error?.message}</div>
) : (
<PureCustomConfig
formHooks={formHooks}
initialValues={initialValues}
schema={schema}
/>
);
}
function ErrorList<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({ errors, registry }: ErrorListProps<T, S, F>) {
const { translateString } = registry;
return (
<Paper elevation={0}>
<Box mb={2} p={2}>
<Typography variant="h6">
{translateString(TranslatableString.ErrorsLabel)}
</Typography>
<List dense={true}>
{errors.map((error, i: number) => {
return (
<ListItem key={i}>
<ListItemIcon>
<Error color="error" />
</ListItemIcon>
<ListItemText primary={error.stack} />
</ListItem>
);
})}
</List>
</Box>
</Paper>
);
}
function PureCustomConfig(props: PureCustomConfigProps) {
const { schema, initialValues, formHooks } = props;
const { setValue, watch } = formHooks;
console.log({ schema });
const configData = watch("config") as IChangeEvent<any>;
console.log({ configData });
const setConfig = (data: IChangeEvent<any>) => {
console.log({ data });
setValue("config", data);
};
const formRef = useRef<any>();
const validate = () => {
const isValid: boolean = formRef?.current?.validateForm();
console.log({ isValid }, formRef.current);
if (!isValid) {
formHooks.setError("config", {
message: "invalid config",
});
toast.error(
"Configuration is invalid. Please check the highlighted fields for details.",
);
} else {
formHooks.clearErrors("config");
toast.success("Config seems valid");
}
};
return (
<Form
ref={formRef}
onChange={setConfig}
formData={configData.formData}
acceptcharset="utf-8"
schema={schema}
validator={validator}
liveValidate={true}
templates={{
// ObjectFieldTemplate:
ErrorListTemplate: ErrorList,
ButtonTemplates: {
SubmitButton: (props) => (
<div className="flex w-full items-center justify-center">
<Button
onClick={validate}
startIcon={<Check />}
variant="outlined"
color="secondary"
>
Validate
</Button>
</div>
),
},
}}
/>
);
}

View File

@@ -0,0 +1,160 @@
import {
Box,
Button,
MobileStepper,
Step,
StepLabel,
Stepper,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import React, { ReactNode, useState } from "react";
import { useForm, UseFormReturn } from "react-hook-form";
import { CustomConfig } from "./customConfig";
import { CreateMachineForm, FormStep } from "./interfaces";
const SC = (props: { children: ReactNode }) => {
return <>{props.children}</>;
};
export function CreateMachineForm() {
const formHooks = useForm<CreateMachineForm>({
defaultValues: {
name: "",
config: {},
},
});
const { handleSubmit, control, watch, reset, formState } = formHooks;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [activeStep, setActiveStep] = useState<number>(0);
const steps: FormStep[] = [
{
id: "template",
label: "Template",
content: <div></div>,
},
{
id: "modules",
label: "Modules",
content: <div></div>,
},
{
id: "config",
label: "Customize",
content: <CustomConfig formHooks={formHooks} />,
},
{
id: "save",
label: "Save",
content: <div></div>,
},
];
const handleNext = () => {
if (activeStep < steps.length - 1) {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
};
const handleBack = () => {
if (activeStep > 0) {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
}
};
const handleReset = () => {
setActiveStep(0);
reset();
};
const currentStep = steps.at(activeStep);
async function onSubmit(data: any) {
console.log({ data }, "Aggregated Data; creating machine from");
}
const BackButton = () => (
<Button
color="secondary"
disabled={activeStep === 0}
onClick={handleBack}
sx={{ mr: 1 }}
>
Back
</Button>
);
const NextButton = () => (
<>
{activeStep !== steps.length - 1 && (
<Button
disabled={!formHooks.formState.isValid}
onClick={handleNext}
color="secondary"
>
{activeStep <= steps.length - 1 && "Next"}
</Button>
)}
{activeStep === steps.length - 1 && (
<Button color="secondary" onClick={handleReset}>
Reset
</Button>
)}
</>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ width: "100%" }}>
{isMobile && (
<MobileStepper
activeStep={activeStep}
color="secondary"
backButton={<BackButton />}
nextButton={<NextButton />}
steps={steps.length}
/>
)}
{!isMobile && (
<Stepper activeStep={activeStep} color="secondary">
{steps.map(({ label }, index) => {
const stepProps: { completed?: boolean } = {};
const labelProps: {
optional?: React.ReactNode;
} = {};
return (
<Step
sx={{
".MuiStepIcon-root.Mui-active": {
color: "secondary.main",
},
".MuiStepIcon-root.Mui-completed": {
color: "secondary.main",
},
}}
key={label}
{...stepProps}
>
<StepLabel {...labelProps}>{label}</StepLabel>
</Step>
);
})}
</Stepper>
)}
{/* <CustomConfig formHooks={formHooks} /> */}
{/* The step Content */}
{currentStep && currentStep.content}
{/* Desktop step controls */}
{!isMobile && (
<Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
<BackButton />
<Box sx={{ flex: "1 1 auto" }} />
<NextButton />
</Box>
)}
</Box>
</form>
);
}

View File

@@ -0,0 +1,23 @@
import { ReactElement, ReactNode } from "react";
import { UseFormReturn } from "react-hook-form";
export type StepId = "template" | "modules" | "config" | "save";
export type CreateMachineForm = {
name: string;
config: any;
};
export type FormHooks = UseFormReturn<CreateMachineForm>;
export type FormStep = {
id: StepId;
label: string;
content: FormStepContent;
};
export interface FormStepContentProps {
formHooks: FormHooks;
}
export type FormStepContent = ReactElement<FormStepContentProps>;

View File

@@ -0,0 +1,88 @@
import { RJSFSchema } from "@rjsf/utils";
export const schema: RJSFSchema = {
properties: {
bloatware: {
properties: {
age: {
default: 42,
description: "The age of the user",
type: "integer",
},
isAdmin: {
default: false,
description: "Is the user an admin?",
type: "boolean",
},
kernelModules: {
default: ["nvme", "xhci_pci", "ahci"],
description: "A list of enabled kernel modules",
items: {
type: "string",
},
type: "array",
},
name: {
default: "John Doe",
description: "The name of the user",
type: "string",
},
services: {
properties: {
opt: {
default: "foo",
description: "A submodule option",
type: "string",
},
},
type: "object",
},
userIds: {
additionalProperties: {
type: "integer",
},
default: {
albrecht: 3,
horst: 1,
peter: 2,
},
description: "Some attributes",
type: "object",
},
},
type: "object",
},
networking: {
properties: {
zerotier: {
properties: {
controller: {
properties: {
enable: {
default: false,
description:
"Whether to enable turn this machine into the networkcontroller.",
type: "boolean",
},
public: {
default: false,
description:
"everyone can join a public network without having the administrator to accept\n",
type: "boolean",
},
},
type: "object",
},
networkId: {
description: "zerotier networking id\n",
type: "string",
},
},
required: ["networkId"],
type: "object",
},
},
type: "object",
},
},
type: "object",
};

View File

@@ -0,0 +1,111 @@
import { RJSFSchema } from "@rjsf/utils";
export const schema: RJSFSchema = {
type: "object",
properties: {
name: {
type: "string",
default: "John-nixi",
description: "The name of the machine",
},
age: {
type: "integer",
default: 42,
description: "The age of the user",
maximum: 40,
},
role: {
enum: ["New York", "Amsterdam", "Hong Kong"],
description: "Role of the user",
},
kernelModules: {
type: "array",
items: {
type: "string",
},
default: ["nvme", "xhci_pci", "ahci"],
description: "A list of enabled kernel modules",
},
userIds: {
type: "array",
items: {
type: "object",
properties: {
user: {
type: "string",
},
id: {
type: "integer",
},
},
},
default: [
{
user: "John",
id: 12,
},
],
description: "Some attributes",
},
xdg: {
type: "object",
properties: {
portal: {
type: "object",
properties: {
xdgOpenUsePortal: {
type: "boolean",
default: false,
},
enable: {
type: "boolean",
default: false,
},
lxqt: {
type: "object",
properties: {
enable: {
type: "boolean",
default: false,
},
styles: {
type: "array",
items: {
type: "string",
},
},
},
},
extraPortals: {
type: "array",
items: {
type: "string",
},
},
wlr: {
type: "object",
properties: {
enable: {
type: "boolean",
default: false,
},
settings: {
type: "object",
default: {
screencast: {
output_name: "HDMI-A-1",
max_fps: 30,
exec_before: "disable_notifications.sh",
exec_after: "enable_notifications.sh",
chooser_type: "simple",
chooser_cmd: "${pkgs.slurp}/bin/slurp -f %o -or",
},
},
},
},
},
},
},
},
},
},
};