diff --git a/frontend/app/_providers.tsx b/frontend/app/_providers.tsx
index 8824aae..ae9a3c3 100644
--- a/frontend/app/_providers.tsx
+++ b/frontend/app/_providers.tsx
@@ -7,15 +7,16 @@ import {
} from "@react-navigation/native";
import { TamaguiProvider, Theme } from "@tamagui/core";
import useThemeStore from "@/stores/theme";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
import { router, usePathname, useRootNavigationState } from "expo-router";
import { useAuthStore } from "@/stores/auth";
+import { PortalProvider } from "tamagui";
+import { queryClient } from "@/lib/api";
type Props = PropsWithChildren;
const Providers = ({ children }: Props) => {
const colorScheme = useThemeStore((i) => i.theme);
- const [queryClient] = useState(() => new QueryClient());
const theme = useMemo(() => {
return colorScheme === "dark"
@@ -40,9 +41,11 @@ const Providers = ({ children }: Props) => {
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/frontend/app/hosts/create.tsx b/frontend/app/hosts/create.tsx
deleted file mode 100644
index db54d54..0000000
--- a/frontend/app/hosts/create.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from "react";
-import { Stack } from "expo-router";
-import HostForm from "@/pages/hosts/components/form";
-
-export default function CreateHostPage() {
- return (
- <>
-
-
- >
- );
-}
diff --git a/frontend/app/hosts/edit.tsx b/frontend/app/hosts/edit.tsx
deleted file mode 100644
index abff198..0000000
--- a/frontend/app/hosts/edit.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from "react";
-import { Stack } from "expo-router";
-import HostForm from "@/pages/hosts/components/form";
-
-export default function EditHostPage() {
- return (
- <>
-
-
- >
- );
-}
diff --git a/frontend/components/ui/form.tsx b/frontend/components/ui/form.tsx
new file mode 100644
index 0000000..c436c64
--- /dev/null
+++ b/frontend/components/ui/form.tsx
@@ -0,0 +1,40 @@
+import React, { ComponentPropsWithoutRef } from "react";
+import { Label, Text, View, XStack } from "tamagui";
+
+type FormFieldProps = ComponentPropsWithoutRef & {
+ label?: string;
+ htmlFor?: string;
+};
+
+const FormField = ({ label, htmlFor, ...props }: FormFieldProps) => {
+ return (
+
+
+
+ {props.children}
+
+
+ );
+};
+
+type ErrorMessageProps = ComponentPropsWithoutRef & {
+ error?: unknown | null;
+};
+
+export const ErrorMessage = ({ error, ...props }: ErrorMessageProps) => {
+ if (!error) {
+ return null;
+ }
+
+ const message = (error as any)?.message || "Something went wrong";
+
+ return (
+
+ {message}
+
+ );
+};
+
+export default FormField;
diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx
new file mode 100644
index 0000000..e4101b0
--- /dev/null
+++ b/frontend/components/ui/input.tsx
@@ -0,0 +1,27 @@
+import { Controller, FieldValues } from "react-hook-form";
+import { FormFieldBaseProps } from "./utility";
+import { Input, View } from "tamagui";
+import { ComponentPropsWithoutRef } from "react";
+import { ErrorMessage } from "./form";
+
+type InputFieldProps = FormFieldBaseProps &
+ ComponentPropsWithoutRef;
+
+export const InputField = ({
+ form,
+ name,
+ ...props
+}: InputFieldProps) => (
+ (
+ <>
+
+
+ >
+ )}
+ />
+);
+
+export default Input;
diff --git a/frontend/components/ui/modal.tsx b/frontend/components/ui/modal.tsx
new file mode 100644
index 0000000..e5dcf49
--- /dev/null
+++ b/frontend/components/ui/modal.tsx
@@ -0,0 +1,91 @@
+import { Adapt, Button, Dialog, Sheet, Text, View } from "tamagui";
+import React from "react";
+import { createDisclosure } from "@/lib/utils";
+import Icons from "./icons";
+
+type ModalProps = {
+ disclosure: ReturnType>;
+ title?: string;
+ description?: string;
+ children?: React.ReactNode;
+ width?: number | string;
+};
+
+const Modal = ({
+ disclosure,
+ children,
+ title,
+ description,
+ width = 512,
+}: ModalProps) => {
+ const { open, onOpenChange } = disclosure.use();
+
+ return (
+
+ );
+};
+
+export default Modal;
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx
index 38f2b92..0891e61 100644
--- a/frontend/components/ui/select.tsx
+++ b/frontend/components/ui/select.tsx
@@ -1,5 +1,9 @@
import React, { forwardRef } from "react";
+import { Controller, FieldValues } from "react-hook-form";
import { Select as BaseSelect } from "tamagui";
+import { FormFieldBaseProps } from "./utility";
+import { ErrorMessage } from "./form";
+import Icons from "./icons";
export type SelectItem = {
label: string;
@@ -50,7 +54,10 @@ const Select = forwardRef(
key={item.value}
value={item.value}
index={idx + 1}
+ justifyContent="flex-start"
+ gap="$2"
>
+ {value === item.value && }
{item.label}
))}
@@ -62,4 +69,24 @@ const Select = forwardRef(
}
);
+type SelectFieldProps = FormFieldBaseProps &
+ SelectProps;
+
+export const SelectField = ({
+ form,
+ name,
+ ...props
+}: SelectFieldProps) => (
+ (
+ <>
+
+
+ >
+ )}
+ />
+);
+
export default Select;
diff --git a/frontend/components/ui/utility.tsx b/frontend/components/ui/utility.tsx
new file mode 100644
index 0000000..18b85f5
--- /dev/null
+++ b/frontend/components/ui/utility.tsx
@@ -0,0 +1,7 @@
+import { UseZFormReturn } from "@/hooks/useZForm";
+import { FieldPath, FieldValues } from "react-hook-form";
+
+export type FormFieldBaseProps = {
+ form: UseZFormReturn;
+ name: FieldPath;
+};
diff --git a/frontend/hooks/useZForm.ts b/frontend/hooks/useZForm.ts
new file mode 100644
index 0000000..7e6e764
--- /dev/null
+++ b/frontend/hooks/useZForm.ts
@@ -0,0 +1,26 @@
+import { z } from "zod";
+import { FieldValues, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect } from "react";
+
+export const useZForm = (
+ schema: z.ZodSchema,
+ value?: Partial | null
+) => {
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: value as never,
+ });
+
+ useEffect(() => {
+ if (value) {
+ form.reset(value as never);
+ }
+ }, [value]);
+
+ return form;
+};
+
+export type UseZFormReturn = ReturnType<
+ typeof useZForm
+>;
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 0c3fe91..21f91ba 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -1,3 +1,4 @@
+import { QueryClient } from "@tanstack/react-query";
import { ofetch } from "ofetch";
export const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || ""; //"http://10.0.0.100:3000";
@@ -7,4 +8,6 @@ const api = ofetch.create({
baseURL: BASE_API_URL,
});
+export const queryClient = new QueryClient();
+
export default api;
diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts
new file mode 100644
index 0000000..8f416a3
--- /dev/null
+++ b/frontend/lib/utils.ts
@@ -0,0 +1,38 @@
+import { z } from "zod";
+import { createStore, useStore } from "zustand";
+
+export const createDisclosure = (data?: T | null) => {
+ const store = createStore(() => ({
+ open: false,
+ data: data,
+ }));
+
+ const onOpen = (data?: T | null) => {
+ store.setState({ open: true, data });
+ };
+
+ const onClose = () => {
+ store.setState({ open: false });
+ };
+
+ const onOpenChange = (open: boolean) => {
+ store.setState({ open });
+ };
+
+ const use = () => {
+ const state = useStore(store);
+ return { ...state, onOpen, onClose, onOpenChange };
+ };
+
+ return { store, use, onOpen, onClose, onOpenChange };
+};
+
+const hostnameRegex =
+ /^(?:(?:[a-zA-Z0-9-]{1,63}\.?)+[a-zA-Z]{0,63}|(?:\d{1,3}\.){3}\d{1,3}|(?:[a-fA-F0-9:]+:+)+[a-fA-F0-9]+)$/;
+
+export const isHostnameOrIP = (value?: string | null) => {
+ return hostnameRegex.test(value || "");
+};
+
+export const hostnameShape = (message: string = "Invalid hostname") =>
+ z.string().refine(isHostnameOrIP, { message });
diff --git a/frontend/package.json b/frontend/package.json
index 9df1ab5..ca4d02d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,6 +19,7 @@
},
"dependencies": {
"@expo/vector-icons": "^14.0.2",
+ "@hookform/resolvers": "^3.9.1",
"@novnc/novnc": "^1.5.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "7.0.0-rc.36",
@@ -44,6 +45,7 @@
"ofetch": "^1.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
+ "react-hook-form": "^7.53.2",
"react-native": "0.76.1",
"react-native-gesture-handler": "~2.20.2",
"react-native-pager-view": "6.4.1",
@@ -53,6 +55,7 @@
"react-native-web": "~0.19.13",
"react-native-webview": "^13.12.2",
"tamagui": "^1.116.14",
+ "zod": "^3.23.8",
"zustand": "^5.0.1"
},
"devDependencies": {
@@ -70,7 +73,8 @@
"private": true,
"pnpm": {
"patchedDependencies": {
- "react-native-drawer-layout": "patches/react-native-drawer-layout.patch"
+ "react-native-drawer-layout": "patches/react-native-drawer-layout.patch",
+ "zod": "patches/zod.patch"
}
}
}
\ No newline at end of file
diff --git a/frontend/pages/hosts/components/form.tsx b/frontend/pages/hosts/components/form.tsx
index 7acf8c4..aca6272 100644
--- a/frontend/pages/hosts/components/form.tsx
+++ b/frontend/pages/hosts/components/form.tsx
@@ -1,67 +1,181 @@
import Icons from "@/components/ui/icons";
-import Select, { SelectItem } from "@/components/ui/select";
+import Modal from "@/components/ui/modal";
+import { SelectField } from "@/components/ui/select";
+import { useZForm, UseZFormReturn } from "@/hooks/useZForm";
import api from "@/lib/api";
+import { createDisclosure } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import React from "react";
-import { Button, Input, Label, ScrollView, Text, View, XStack } from "tamagui";
+import { Button, Label, ScrollView, XStack } from "tamagui";
+import {
+ FormSchema,
+ formSchema,
+ incusTypes,
+ pveTypes,
+ typeOptions,
+} from "../schema/form";
+import { InputField } from "@/components/ui/input";
+import FormField from "@/components/ui/form";
+import { useKeychains, useSaveHost } from "../hooks/query";
-type Props = {};
+export const hostFormModal = createDisclosure();
-const typeOptions: SelectItem[] = [
- { label: "SSH", value: "ssh" },
- { label: "Proxmox VE", value: "pve" },
- { label: "Incus", value: "incus" },
-];
+const HostForm = () => {
+ const { data } = hostFormModal.use();
+ const form = useZForm(formSchema, data);
+ const isEditing = data?.id != null;
+ const type = form.watch("type");
-const HostForm = (props: Props) => {
- const keys = useQuery({
- queryKey: ["keychains"],
- queryFn: () => api("/keychains"),
- select: (i) => i.rows,
+ const keys = useKeychains();
+ const saveMutation = useSaveHost();
+
+ const onSubmit = form.handleSubmit((values) => {
+ saveMutation.mutate(values, {
+ onSuccess: () => {
+ hostFormModal.onClose();
+ form.reset();
+ },
+ });
});
return (
- <>
-
-
-
+
+
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+ {type === "pve" && }
+ {type === "incus" && }
+
+
+
}>
Add
-
-
- }>Save
-
+
+
+ }
+ onPress={onSubmit}
+ >
+ Save
+
+
+
+ );
+};
+
+type MiscFormFieldProps = {
+ form: UseZFormReturn;
+};
+
+const PVEFormFields = ({ form }: MiscFormFieldProps) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+const IncusFormFields = ({ form }: MiscFormFieldProps) => {
+ const type = form.watch("metadata.type");
+
+ return (
+ <>
+
+
+
+
+
+
+ {type === "lxc" && (
+
+
+
+ )}
>
);
};
diff --git a/frontend/pages/hosts/components/hosts-list.tsx b/frontend/pages/hosts/components/hosts-list.tsx
index 27e455b..a620547 100644
--- a/frontend/pages/hosts/components/hosts-list.tsx
+++ b/frontend/pages/hosts/components/hosts-list.tsx
@@ -7,6 +7,7 @@ import { MultiTapPressable } from "@/components/ui/pressable";
import Icons from "@/components/ui/icons";
import SearchInput from "@/components/ui/search-input";
import { useTermSession } from "@/stores/terminal-sessions";
+import { hostFormModal } from "./form";
const HostsList = () => {
const openSession = useTermSession((i) => i.push);
@@ -36,6 +37,10 @@ const HostsList = () => {
}, [hosts.data, search]);
const onOpen = (host: any) => {
+ hostFormModal.onOpen(host);
+ };
+
+ const onOpenTerminal = (host: any) => {
const session: any = {
id: host.id,
label: host.label,
@@ -49,10 +54,6 @@ const HostsList = () => {
session.params.client = host.metadata?.type === "lxc" ? "xtermjs" : "vnc";
}
- if (host.type === "incus") {
- session.params.shell = "bash";
- }
-
openSession(session);
navigation.navigate("terminal" as never);
};
@@ -93,7 +94,8 @@ const HostsList = () => {
p="$2"
group
numberOfTaps={2}
- onMultiTap={() => onOpen(host)}
+ onMultiTap={() => onOpenTerminal(host)}
+ onTap={() => onOpen(host)}
>
@@ -107,7 +109,12 @@ const HostsList = () => {
diff --git a/frontend/pages/hosts/hooks/query.ts b/frontend/pages/hosts/hooks/query.ts
new file mode 100644
index 0000000..d7cfc3c
--- /dev/null
+++ b/frontend/pages/hosts/hooks/query.ts
@@ -0,0 +1,25 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { FormSchema } from "../schema/form";
+import api, { queryClient } from "@/lib/api";
+
+export const useKeychains = () => {
+ return useQuery({
+ queryKey: ["keychains"],
+ queryFn: () => api("/keychains"),
+ select: (i) => i.rows,
+ });
+};
+
+export const useSaveHost = () => {
+ return useMutation({
+ mutationFn: async (body: FormSchema) => {
+ return body.id
+ ? api(`/hosts/${body.id}`, { method: "PUT", body })
+ : api(`/hosts`, { method: "POST", body });
+ },
+ onError: (e) => console.error(e),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["hosts"] });
+ },
+ });
+};
diff --git a/frontend/pages/hosts/page.tsx b/frontend/pages/hosts/page.tsx
index 93cebf1..d766c94 100644
--- a/frontend/pages/hosts/page.tsx
+++ b/frontend/pages/hosts/page.tsx
@@ -1,25 +1,31 @@
import { Button } from "tamagui";
import React from "react";
-import useThemeStore from "@/stores/theme";
import Drawer from "expo-router/drawer";
import HostsList from "./components/hosts-list";
+import HostForm, { hostFormModal } from "./components/form";
+import Icons from "@/components/ui/icons";
+import { initialValues } from "./schema/form";
export default function HostsPage() {
- const { toggle } = useThemeStore();
-
return (
<>
(
-
),
}}
/>
+
>
);
}
diff --git a/frontend/pages/hosts/schema/form.ts b/frontend/pages/hosts/schema/form.ts
new file mode 100644
index 0000000..18bbc4b
--- /dev/null
+++ b/frontend/pages/hosts/schema/form.ts
@@ -0,0 +1,82 @@
+import { SelectItem } from "@/components/ui/select";
+import { hostnameShape } from "@/lib/utils";
+import { z } from "zod";
+
+export const baseFormSchema = z.object({
+ id: z.string().nullish(),
+ label: z.string().min(1, { message: "Label is required" }),
+ parentId: z.string().ulid().nullish(),
+});
+
+const hostSchema = baseFormSchema.merge(
+ z.object({
+ host: hostnameShape(),
+ port: z.coerce
+ .number({ message: "Invalid port" })
+ .min(1, { message: "Port is required" }),
+ })
+);
+
+const sshSchema = hostSchema.merge(
+ z.object({
+ type: z.literal("ssh"),
+ keyId: z.string().ulid({ message: "SSH key is required" }),
+ altKeyId: z.string().ulid().nullish(),
+ })
+);
+
+const pveSchema = hostSchema.merge(
+ z.object({
+ type: z.literal("pve"),
+ keyId: z.string().ulid({ message: "PVE User is required" }),
+ metadata: z.object({
+ node: z.string().min(1, { message: "PVE node is required" }),
+ type: z.enum(["lxc", "qemu"]),
+ vmid: z.string().min(1, { message: "VMID is required" }),
+ }),
+ })
+);
+
+const incusSchema = hostSchema.merge(
+ z.object({
+ type: z.literal("incus"),
+ keyId: z.string().ulid({ message: "Incus cert is required" }),
+ metadata: z.object({
+ type: z.enum(["lxc", "qemu"]),
+ instance: z.string().min(1, { message: "Instance name is required" }),
+ shell: z.string().nullish(),
+ }),
+ })
+);
+
+export const formSchema = z.discriminatedUnion(
+ "type",
+ [sshSchema, pveSchema, incusSchema],
+ { errorMap: () => ({ message: "Invalid host type" }) }
+);
+
+export type FormSchema = z.infer;
+
+export const initialValues: FormSchema = {
+ type: "ssh",
+ host: "",
+ port: 22,
+ label: "",
+ keyId: "",
+};
+
+export const typeOptions: SelectItem[] = [
+ { label: "SSH", value: "ssh" },
+ { label: "Proxmox VE", value: "pve" },
+ { label: "Incus", value: "incus" },
+];
+
+export const pveTypes: SelectItem[] = [
+ { label: "Virtual Machine", value: "qemu" },
+ { label: "Container", value: "lxc" },
+];
+
+export const incusTypes: SelectItem[] = [
+ { label: "Container", value: "lxc" },
+ { label: "Virtual Machine", value: "qemu" },
+];
diff --git a/frontend/patches/zod.patch b/frontend/patches/zod.patch
new file mode 100644
index 0000000..981ff8b
--- /dev/null
+++ b/frontend/patches/zod.patch
@@ -0,0 +1,39 @@
+diff --git a/lib/index.mjs b/lib/index.mjs
+index 56bfb8f948c05032d5250123cc5c1a59a53ba8ff..b81c18ead97d3bc371f8f1f1fb48fdc2cedfb68b 100644
+--- a/lib/index.mjs
++++ b/lib/index.mjs
+@@ -870,7 +870,7 @@ class ZodType {
+ }
+ const cuidRegex = /^c[^\s-]{8,}$/i;
+ const cuid2Regex = /^[0-9a-z]+$/;
+-const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
++const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
+ // const uuidRegex =
+ // /^([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}|00000000-0000-0000-0000-000000000000)$/i;
+ const uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i;
+diff --git a/lib/index.umd.js b/lib/index.umd.js
+index 5b15271fa65f0a31288578d0e35e3f2e17d4d741..a4477ddf06bacd8280f68c8d79fed15ad94e22d8 100644
+--- a/lib/index.umd.js
++++ b/lib/index.umd.js
+@@ -876,7 +876,7 @@
+ }
+ const cuidRegex = /^c[^\s-]{8,}$/i;
+ const cuid2Regex = /^[0-9a-z]+$/;
+- const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
++ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
+ // const uuidRegex =
+ // /^([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}|00000000-0000-0000-0000-000000000000)$/i;
+ const uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i;
+diff --git a/lib/types.js b/lib/types.js
+index 0eb943a350832a548f0c7c1b4bb43a4207ba44fd..f41bd264f0e32cc319dcad7ef76814870d67dc95 100644
+--- a/lib/types.js
++++ b/lib/types.js
+@@ -341,7 +341,7 @@ exports.Schema = ZodType;
+ exports.ZodSchema = ZodType;
+ const cuidRegex = /^c[^\s-]{8,}$/i;
+ const cuid2Regex = /^[0-9a-z]+$/;
+-const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
++const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
+ // const uuidRegex =
+ // /^([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}|00000000-0000-0000-0000-000000000000)$/i;
+ const uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i;
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index be61f5a..55c5dd7 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -8,6 +8,9 @@ patchedDependencies:
react-native-drawer-layout:
hash: ipghvwpiqcl5liuijnfvmjzcvq
path: patches/react-native-drawer-layout.patch
+ zod:
+ hash: blxeugurkewr75xivc2c7hodyi
+ path: patches/zod.patch
importers:
@@ -16,6 +19,9 @@ importers:
'@expo/vector-icons':
specifier: ^14.0.2
version: 14.0.4
+ '@hookform/resolvers':
+ specifier: ^3.9.1
+ version: 3.9.1(react-hook-form@7.53.2(react@18.3.1))
'@novnc/novnc':
specifier: ^1.5.0
version: 1.5.0
@@ -91,6 +97,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
+ react-hook-form:
+ specifier: ^7.53.2
+ version: 7.53.2(react@18.3.1)
react-native:
specifier: 0.76.1
version: 0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1)
@@ -118,6 +127,9 @@ importers:
tamagui:
specifier: ^1.116.14
version: 1.116.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)
+ zod:
+ specifier: ^3.23.8
+ version: 3.23.8(patch_hash=blxeugurkewr75xivc2c7hodyi)
zustand:
specifier: ^5.0.1
version: 5.0.1(@types/react@18.3.12)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1))
@@ -1159,6 +1171,11 @@ packages:
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
+ '@hookform/resolvers@3.9.1':
+ resolution: {integrity: sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==}
+ peerDependencies:
+ react-hook-form: ^7.0.0
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -4636,6 +4653,12 @@ packages:
react: ^16.6.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0
+ react-hook-form@7.53.2:
+ resolution: {integrity: sha512-YVel6fW5sOeedd1524pltpHX+jgU2u3DSDtXEaBORNdqiNrsX/nUI/iGXONegttg0mJVnfrIkiV0cmTU6Oo2xw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5677,6 +5700,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ zod@3.23.8:
+ resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
+
zustand@5.0.1:
resolution: {integrity: sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==}
engines: {node: '>=12.20.0'}
@@ -7048,6 +7074,10 @@ snapshots:
'@floating-ui/utils@0.2.8': {}
+ '@hookform/resolvers@3.9.1(react-hook-form@7.53.2(react@18.3.1))':
+ dependencies:
+ react-hook-form: 7.53.2(react@18.3.1)
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -11766,6 +11796,10 @@ snapshots:
react-fast-compare: 3.2.2
shallowequal: 1.1.0
+ react-hook-form@7.53.2(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
react-is@16.13.1: {}
react-is@18.3.1: {}
@@ -12879,6 +12913,8 @@ snapshots:
yocto-queue@0.1.0: {}
+ zod@3.23.8(patch_hash=blxeugurkewr75xivc2c7hodyi): {}
+
zustand@5.0.1(@types/react@18.3.12)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)):
optionalDependencies:
'@types/react': 18.3.12
diff --git a/server/app/hosts/repository.go b/server/app/hosts/repository.go
index 51cd60a..4e5811d 100644
--- a/server/app/hosts/repository.go
+++ b/server/app/hosts/repository.go
@@ -14,7 +14,7 @@ func NewHostsRepository() *Hosts {
func (r *Hosts) GetAll() ([]*models.Host, error) {
var rows []*models.Host
- ret := r.db.Order("created_at DESC").Find(&rows)
+ ret := r.db.Order("id DESC").Find(&rows)
return rows, ret.Error
}
@@ -49,6 +49,20 @@ func (r *Hosts) Get(id string) (*GetHostResult, error) {
return res, ret.Error
}
+func (r *Hosts) Exists(id string) (bool, error) {
+ var count int64
+ ret := r.db.Model(&models.Host{}).Where("id = ?", id).Count(&count)
+ return count > 0, ret.Error
+}
+
+func (r *Hosts) Delete(id string) error {
+ return r.db.Delete(&models.Host{Model: models.Model{ID: id}}).Error
+}
+
func (r *Hosts) Create(item *models.Host) error {
return r.db.Create(item).Error
}
+
+func (r *Hosts) Update(item *models.Host) error {
+ return r.db.Save(item).Error
+}
diff --git a/server/app/hosts/router.go b/server/app/hosts/router.go
index db6602b..d185876 100644
--- a/server/app/hosts/router.go
+++ b/server/app/hosts/router.go
@@ -1,6 +1,7 @@
package hosts
import (
+ "fmt"
"net/http"
"github.com/gofiber/fiber/v2"
@@ -13,6 +14,8 @@ func Router(app *fiber.App) {
router.Get("/", getAll)
router.Post("/", create)
+ router.Put("/:id", update)
+ router.Delete("/:id", delete)
}
func getAll(c *fiber.Ctx) error {
@@ -34,7 +37,6 @@ func create(c *fiber.Ctx) error {
}
repo := NewHostsRepository()
-
item := &models.Host{
Type: body.Type,
Label: body.Label,
@@ -51,3 +53,54 @@ func create(c *fiber.Ctx) error {
return c.Status(http.StatusCreated).JSON(item)
}
+
+func update(c *fiber.Ctx) error {
+ var body CreateHostSchema
+ if err := c.BodyParser(&body); err != nil {
+ return utils.ResponseError(c, err, 500)
+ }
+
+ repo := NewHostsRepository()
+
+ id := c.Params("id")
+ exist, _ := repo.Exists(id)
+ if !exist {
+ return utils.ResponseError(c, fmt.Errorf("host %s not found", id), 404)
+ }
+
+ item := &models.Host{
+ Model: models.Model{ID: id},
+ Type: body.Type,
+ Label: body.Label,
+ Host: body.Host,
+ Port: body.Port,
+ Metadata: body.Metadata,
+ ParentID: body.ParentID,
+ KeyID: body.KeyID,
+ AltKeyID: body.AltKeyID,
+ }
+ if err := repo.Update(item); err != nil {
+ return utils.ResponseError(c, err, 500)
+ }
+
+ return c.JSON(item)
+}
+
+func delete(c *fiber.Ctx) error {
+ repo := NewHostsRepository()
+
+ id := c.Params("id")
+ exist, _ := repo.Exists(id)
+ if !exist {
+ return utils.ResponseError(c, fmt.Errorf("host %s not found", id), 404)
+ }
+
+ if err := repo.Delete(id); err != nil {
+ return utils.ResponseError(c, err, 500)
+ }
+
+ return c.JSON(fiber.Map{
+ "status": "ok",
+ "message": "Successfully deleted",
+ })
+}
diff --git a/server/app/ws/term_incus.go b/server/app/ws/term_incus.go
index 84ceab4..20b8871 100644
--- a/server/app/ws/term_incus.go
+++ b/server/app/ws/term_incus.go
@@ -13,6 +13,7 @@ import (
)
type IncusWebsocketSession struct {
+ Type string `json:"type"` // "qemu" | "lxc"
Instance string `json:"instance"`
Shell string `json:"shell"`
}
diff --git a/server/models/base_model.go b/server/models/base_model.go
index 5032a6d..fb48eea 100644
--- a/server/models/base_model.go
+++ b/server/models/base_model.go
@@ -8,16 +8,16 @@ import (
"gorm.io/gorm"
)
-type BaseModel struct {
+type Model struct {
ID string `gorm:"primarykey;type:varchar(26)" json:"id"`
}
-func (m *BaseModel) BeforeCreate(tx *gorm.DB) error {
+func (m *Model) BeforeCreate(tx *gorm.DB) error {
m.ID = m.GenerateID()
return nil
}
-func (m *BaseModel) GenerateID() string {
+func (m *Model) GenerateID() string {
return strings.ToLower(ulid.Make().String())
}
diff --git a/server/models/host.go b/server/models/host.go
index e289e99..af8e2f0 100644
--- a/server/models/host.go
+++ b/server/models/host.go
@@ -12,7 +12,7 @@ const (
)
type Host struct {
- BaseModel
+ Model
Type string `json:"type" gorm:"not null;index:hosts_type_idx;type:varchar(16)"`
Label string `json:"label"`
@@ -23,9 +23,9 @@ type Host struct {
ParentID *string `json:"parentId" gorm:"index:hosts_parent_id_idx;type:varchar(26)"`
Parent *Host `json:"parent" gorm:"foreignKey:ParentID"`
KeyID *string `json:"keyId" gorm:"index:hosts_key_id_idx"`
- Key Keychain `gorm:"foreignKey:KeyID"`
+ Key Keychain `json:"key" gorm:"foreignKey:KeyID"`
AltKeyID *string `json:"altKeyId" gorm:"index:hosts_altkey_id_idx"`
- AltKey Keychain `gorm:"foreignKey:AltKeyID"`
+ AltKey Keychain `json:"altKey" gorm:"foreignKey:AltKeyID"`
Timestamps
SoftDeletes
diff --git a/server/models/keychain.go b/server/models/keychain.go
index f4cdaa6..47262f1 100644
--- a/server/models/keychain.go
+++ b/server/models/keychain.go
@@ -13,7 +13,7 @@ const (
)
type Keychain struct {
- BaseModel
+ Model
Label string `json:"label"`
Type string `json:"type" gorm:"not null;index:keychains_type_idx;type:varchar(12)"`
diff --git a/server/models/user.go b/server/models/user.go
index e97ce52..d77bc83 100644
--- a/server/models/user.go
+++ b/server/models/user.go
@@ -6,7 +6,7 @@ const (
)
type User struct {
- BaseModel
+ Model
Name string `json:"name"`
Username string `json:"username" gorm:"unique"`
diff --git a/server/tests/hosts_test.go b/server/tests/hosts_test.go
index 6389245..6adbdf4 100644
--- a/server/tests/hosts_test.go
+++ b/server/tests/hosts_test.go
@@ -21,7 +21,7 @@ func TestHostsCreate(t *testing.T) {
test := NewTestWithAuth(t)
data := map[string]interface{}{
- "type": "pve",
+ "type": "ssh",
"label": "test ssh",
"host": "10.0.0.102",
"port": 22,
@@ -72,3 +72,32 @@ func TestHostsCreate(t *testing.T) {
assert.Equal(t, http.StatusCreated, status)
assert.NotNil(t, res["id"])
}
+
+func TestHostsUpdate(t *testing.T) {
+ test := NewTestWithAuth(t)
+
+ id := "01jc3v9w609f8e2wzw60amv195"
+ data := map[string]interface{}{
+ "type": "ssh",
+ "label": "test ssh update",
+ "host": "10.0.0.102",
+ "port": 22,
+ "keyId": "01jc3wkctzqrcz8qhwynr4p9pe",
+ }
+
+ res, status, err := test.Fetch("PUT", "/hosts/"+id, &FetchOptions{Body: data})
+
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, status)
+ assert.NotNil(t, res["id"])
+}
+
+func TestHostsDelete(t *testing.T) {
+ test := NewTestWithAuth(t)
+
+ id := "01jc3v9w609f8e2wzw60amv195"
+ _, status, err := test.Fetch("DELETE", "/hosts/"+id, nil)
+
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, status)
+}