feat: add hosts management

This commit is contained in:
Khairul Hidayat 2024-11-09 10:33:07 +00:00
parent 31c43836f4
commit d931235fb3
27 changed files with 742 additions and 94 deletions

View File

@ -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) => {
<ThemeProvider value={navTheme}>
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
<Theme name="blue">
<PortalProvider shouldAddRootHost>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</PortalProvider>
</Theme>
</TamaguiProvider>
</ThemeProvider>

View File

@ -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 (
<>
<Stack.Screen options={{ title: "Add Host" }} />
<HostForm />
</>
);
}

View File

@ -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 (
<>
<Stack.Screen options={{ title: "Edit Host" }} />
<HostForm />
</>
);
}

View File

@ -0,0 +1,40 @@
import React, { ComponentPropsWithoutRef } from "react";
import { Label, Text, View, XStack } from "tamagui";
type FormFieldProps = ComponentPropsWithoutRef<typeof XStack> & {
label?: string;
htmlFor?: string;
};
const FormField = ({ label, htmlFor, ...props }: FormFieldProps) => {
return (
<XStack alignItems="flex-start" {...props}>
<Label htmlFor={htmlFor} w={120} $xs={{ w: 100 }}>
{label}
</Label>
<View w="auto" flex={1}>
{props.children}
</View>
</XStack>
);
};
type ErrorMessageProps = ComponentPropsWithoutRef<typeof Text> & {
error?: unknown | null;
};
export const ErrorMessage = ({ error, ...props }: ErrorMessageProps) => {
if (!error) {
return null;
}
const message = (error as any)?.message || "Something went wrong";
return (
<Text color="red" fontSize="$3" mt="$1" {...props}>
{message}
</Text>
);
};
export default FormField;

View File

@ -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<T extends FieldValues> = FormFieldBaseProps<T> &
ComponentPropsWithoutRef<typeof Input>;
export const InputField = <T extends FieldValues>({
form,
name,
...props
}: InputFieldProps<T>) => (
<Controller
control={form.control}
name={name}
render={({ field, fieldState }) => (
<>
<Input {...field} {...props} />
<ErrorMessage error={fieldState.error} />
</>
)}
/>
);
export default Input;

View File

@ -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<typeof createDisclosure<any>>;
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 (
<Dialog open={open} onOpenChange={onOpenChange} modal>
<Adapt when="sm">
<Sheet
animation="quick"
zIndex={999}
modal
dismissOnSnapToBottom
disableDrag
>
<Sheet.Frame>
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay
animation="quicker"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
zIndex={0}
/>
</Sheet>
</Adapt>
<Dialog.Portal zIndex={999}>
<Dialog.Overlay
key="overlay"
animation="quickest"
opacity={0.5}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Dialog.Content
bordered
key="content"
elevate
animateOnly={["transform", "opacity"]}
animation={["quickest", { opacity: { overshootClamping: true } }]}
enterStyle={{ x: 0, opacity: 0, scale: 0.95 }}
exitStyle={{ x: 0, opacity: 0, scale: 0.98 }}
p="$1"
width="90%"
maxWidth={width}
height="90%"
maxHeight={600}
>
<View p="$4">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
<Dialog.Close asChild>
<Button
position="absolute"
top="$2"
right="$2"
size="$3"
bg="$colorTransparent"
circular
icon={<Icons name="close" size={16} />}
/>
</Dialog.Close>
</View>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog>
);
};
export default Modal;

View File

@ -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<SelectRef, SelectProps>(
key={item.value}
value={item.value}
index={idx + 1}
justifyContent="flex-start"
gap="$2"
>
{value === item.value && <Icons name="check" size={16} />}
<BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
@ -62,4 +69,24 @@ const Select = forwardRef<SelectRef, SelectProps>(
}
);
type SelectFieldProps<T extends FieldValues> = FormFieldBaseProps<T> &
SelectProps;
export const SelectField = <T extends FieldValues>({
form,
name,
...props
}: SelectFieldProps<T>) => (
<Controller
control={form.control}
name={name}
render={({ field, fieldState }) => (
<>
<Select w="auto" f={1} {...field} {...props} />
<ErrorMessage error={fieldState.error} />
</>
)}
/>
);
export default Select;

View File

@ -0,0 +1,7 @@
import { UseZFormReturn } from "@/hooks/useZForm";
import { FieldPath, FieldValues } from "react-hook-form";
export type FormFieldBaseProps<T extends FieldValues> = {
form: UseZFormReturn<T>;
name: FieldPath<T>;
};

View File

@ -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 = <T extends FieldValues>(
schema: z.ZodSchema<T>,
value?: Partial<T> | null
) => {
const form = useForm<T>({
resolver: zodResolver(schema),
defaultValues: value as never,
});
useEffect(() => {
if (value) {
form.reset(value as never);
}
}, [value]);
return form;
};
export type UseZFormReturn<T extends FieldValues> = ReturnType<
typeof useZForm<T>
>;

View File

@ -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;

38
frontend/lib/utils.ts Normal file
View File

@ -0,0 +1,38 @@
import { z } from "zod";
import { createStore, useStore } from "zustand";
export const createDisclosure = <T>(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 });

View File

@ -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"
}
}
}

View File

@ -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<FormSchema>();
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 (
<>
<ScrollView contentContainerStyle={{ padding: "$4" }}>
<Label>Hostname</Label>
<Input placeholder="IP or hostname..." />
<Modal
disclosure={hostFormModal}
title="Host"
description={`${isEditing ? "Edit" : "Add new"} host.`}
>
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
<FormField label="Label">
<InputField f={1} form={form} name="label" placeholder="Label..." />
</FormField>
<Label>Type</Label>
<Select items={typeOptions} />
<FormField label="Hostname">
<InputField form={form} name="host" placeholder="IP or hostname..." />
</FormField>
<Label>Port</Label>
<Input keyboardType="number-pad" placeholder="SSH Port" />
<FormField label="Type">
<SelectField form={form} name="type" items={typeOptions} />
</FormField>
<Label>Label</Label>
<Input placeholder="Label..." />
<FormField label="Port">
<InputField
form={form}
name="port"
keyboardType="number-pad"
placeholder="SSH Port"
/>
</FormField>
<XStack gap="$3" mt="$3" mb="$1">
<Label flex={1}>Credentials</Label>
{type === "pve" && <PVEFormFields form={form} />}
{type === "incus" && <IncusFormFields form={form} />}
<XStack gap="$3">
<Label flex={1} h="$3">
Credentials
</Label>
<Button size="$3" icon={<Icons size={16} name="plus" />}>
Add
</Button>
</XStack>
<Select
placeholder="Username & Password"
<FormField label="User">
<SelectField
form={form}
name="keyId"
placeholder="Select User"
items={keys.data?.map((key: any) => ({
label: key.label,
value: key.id,
}))}
/>
<Select
mt="$3"
placeholder="Private Key"
</FormField>
{type === "ssh" && (
<FormField label="Private Key">
<SelectField
form={form}
name="altKeyId"
placeholder="Select Private Key"
items={keys.data?.map((key: any) => ({
label: key.label,
value: key.id,
}))}
/>
</FormField>
)}
</ScrollView>
<View p="$4">
<Button icon={<Icons name="content-save" size={18} />}>Save</Button>
</View>
<XStack p="$4" gap="$4">
<Button flex={1} onPress={hostFormModal.onClose} bg="$colorTransparent">
Cancel
</Button>
<Button
flex={1}
icon={<Icons name="content-save" size={18} />}
onPress={onSubmit}
>
Save
</Button>
</XStack>
</Modal>
);
};
type MiscFormFieldProps = {
form: UseZFormReturn<FormSchema>;
};
const PVEFormFields = ({ form }: MiscFormFieldProps) => {
return (
<>
<FormField label="Node">
<InputField form={form} name="metadata.node" placeholder="pve" />
</FormField>
<FormField label="Type">
<SelectField
form={form}
name="metadata.type"
placeholder="Select Type"
items={pveTypes}
/>
</FormField>
<FormField label="VMID">
<InputField
form={form}
name="metadata.vmid"
keyboardType="number-pad"
placeholder="VMID"
/>
</FormField>
</>
);
};
const IncusFormFields = ({ form }: MiscFormFieldProps) => {
const type = form.watch("metadata.type");
return (
<>
<FormField label="Type">
<SelectField
form={form}
name="metadata.type"
placeholder="Select Type"
items={incusTypes}
/>
</FormField>
<FormField label="Instance ID">
<InputField
form={form}
name="metadata.instance"
placeholder="myinstance"
/>
</FormField>
{type === "lxc" && (
<FormField label="Shell">
<InputField form={form} name="metadata.shell" placeholder="bash" />
</FormField>
)}
</>
);
};

View File

@ -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)}
>
<Card bordered p="$4">
<XStack>
@ -107,7 +109,12 @@ const HostsList = () => {
<Button
circular
display="none"
$sm={{ display: "block" }}
$group-hover={{ display: "block" }}
onPress={(e) => {
e.stopPropagation();
onOpen(host);
}}
>
<Icons name="pencil" size={16} />
</Button>

View File

@ -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"] });
},
});
};

View File

@ -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 (
<>
<Drawer.Screen
options={{
headerRight: () => (
<Button onPress={() => toggle()} mr="$2">
Toggle Theme
<Button
bg="$colorTransparent"
icon={<Icons name="plus" size={24} />}
onPress={() => hostFormModal.onOpen(initialValues)}
$gtSm={{ mr: "$3" }}
>
New
</Button>
),
}}
/>
<HostsList />
<HostForm />
</>
);
}

View File

@ -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<typeof formSchema>;
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" },
];

View File

@ -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;

View File

@ -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

View File

@ -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
}

View File

@ -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",
})
}

View File

@ -13,6 +13,7 @@ import (
)
type IncusWebsocketSession struct {
Type string `json:"type"` // "qemu" | "lxc"
Instance string `json:"instance"`
Shell string `json:"shell"`
}

View File

@ -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())
}

View File

@ -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

View File

@ -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)"`

View File

@ -6,7 +6,7 @@ const (
)
type User struct {
BaseModel
Model
Name string `json:"name"`
Username string `json:"username" gorm:"unique"`

View File

@ -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)
}