mirror of
https://github.com/khairul169/vaulterm.git
synced 2025-04-28 16:49:39 +07:00
feat: add hosts management
This commit is contained in:
parent
31c43836f4
commit
d931235fb3
@ -7,15 +7,16 @@ import {
|
|||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { TamaguiProvider, Theme } from "@tamagui/core";
|
import { TamaguiProvider, Theme } from "@tamagui/core";
|
||||||
import useThemeStore from "@/stores/theme";
|
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 { router, usePathname, useRootNavigationState } from "expo-router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { PortalProvider } from "tamagui";
|
||||||
|
import { queryClient } from "@/lib/api";
|
||||||
|
|
||||||
type Props = PropsWithChildren;
|
type Props = PropsWithChildren;
|
||||||
|
|
||||||
const Providers = ({ children }: Props) => {
|
const Providers = ({ children }: Props) => {
|
||||||
const colorScheme = useThemeStore((i) => i.theme);
|
const colorScheme = useThemeStore((i) => i.theme);
|
||||||
const [queryClient] = useState(() => new QueryClient());
|
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
const theme = useMemo(() => {
|
||||||
return colorScheme === "dark"
|
return colorScheme === "dark"
|
||||||
@ -40,9 +41,11 @@ const Providers = ({ children }: Props) => {
|
|||||||
<ThemeProvider value={navTheme}>
|
<ThemeProvider value={navTheme}>
|
||||||
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
|
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
|
||||||
<Theme name="blue">
|
<Theme name="blue">
|
||||||
|
<PortalProvider shouldAddRootHost>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</PortalProvider>
|
||||||
</Theme>
|
</Theme>
|
||||||
</TamaguiProvider>
|
</TamaguiProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
40
frontend/components/ui/form.tsx
Normal file
40
frontend/components/ui/form.tsx
Normal 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;
|
27
frontend/components/ui/input.tsx
Normal file
27
frontend/components/ui/input.tsx
Normal 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;
|
91
frontend/components/ui/modal.tsx
Normal file
91
frontend/components/ui/modal.tsx
Normal 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;
|
@ -1,5 +1,9 @@
|
|||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
|
import { Controller, FieldValues } from "react-hook-form";
|
||||||
import { Select as BaseSelect } from "tamagui";
|
import { Select as BaseSelect } from "tamagui";
|
||||||
|
import { FormFieldBaseProps } from "./utility";
|
||||||
|
import { ErrorMessage } from "./form";
|
||||||
|
import Icons from "./icons";
|
||||||
|
|
||||||
export type SelectItem = {
|
export type SelectItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -50,7 +54,10 @@ const Select = forwardRef<SelectRef, SelectProps>(
|
|||||||
key={item.value}
|
key={item.value}
|
||||||
value={item.value}
|
value={item.value}
|
||||||
index={idx + 1}
|
index={idx + 1}
|
||||||
|
justifyContent="flex-start"
|
||||||
|
gap="$2"
|
||||||
>
|
>
|
||||||
|
{value === item.value && <Icons name="check" size={16} />}
|
||||||
<BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>
|
<BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>
|
||||||
</BaseSelect.Item>
|
</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;
|
export default Select;
|
||||||
|
7
frontend/components/ui/utility.tsx
Normal file
7
frontend/components/ui/utility.tsx
Normal 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>;
|
||||||
|
};
|
26
frontend/hooks/useZForm.ts
Normal file
26
frontend/hooks/useZForm.ts
Normal 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>
|
||||||
|
>;
|
@ -1,3 +1,4 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { ofetch } from "ofetch";
|
import { ofetch } from "ofetch";
|
||||||
|
|
||||||
export const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || ""; //"http://10.0.0.100:3000";
|
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,
|
baseURL: BASE_API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
38
frontend/lib/utils.ts
Normal file
38
frontend/lib/utils.ts
Normal 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 });
|
@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@novnc/novnc": "^1.5.0",
|
"@novnc/novnc": "^1.5.0",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-navigation/bottom-tabs": "7.0.0-rc.36",
|
"@react-navigation/bottom-tabs": "7.0.0-rc.36",
|
||||||
@ -44,6 +45,7 @@
|
|||||||
"ofetch": "^1.4.1",
|
"ofetch": "^1.4.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
"react-hook-form": "^7.53.2",
|
||||||
"react-native": "0.76.1",
|
"react-native": "0.76.1",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-pager-view": "6.4.1",
|
"react-native-pager-view": "6.4.1",
|
||||||
@ -53,6 +55,7 @@
|
|||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "^13.12.2",
|
"react-native-webview": "^13.12.2",
|
||||||
"tamagui": "^1.116.14",
|
"tamagui": "^1.116.14",
|
||||||
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -70,7 +73,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,67 +1,181 @@
|
|||||||
import Icons from "@/components/ui/icons";
|
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 api from "@/lib/api";
|
||||||
|
import { createDisclosure } from "@/lib/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import React from "react";
|
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[] = [
|
const HostForm = () => {
|
||||||
{ label: "SSH", value: "ssh" },
|
const { data } = hostFormModal.use();
|
||||||
{ label: "Proxmox VE", value: "pve" },
|
const form = useZForm(formSchema, data);
|
||||||
{ label: "Incus", value: "incus" },
|
const isEditing = data?.id != null;
|
||||||
];
|
const type = form.watch("type");
|
||||||
|
|
||||||
const HostForm = (props: Props) => {
|
const keys = useKeychains();
|
||||||
const keys = useQuery({
|
const saveMutation = useSaveHost();
|
||||||
queryKey: ["keychains"],
|
|
||||||
queryFn: () => api("/keychains"),
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
select: (i) => i.rows,
|
saveMutation.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
hostFormModal.onClose();
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Modal
|
||||||
<ScrollView contentContainerStyle={{ padding: "$4" }}>
|
disclosure={hostFormModal}
|
||||||
<Label>Hostname</Label>
|
title="Host"
|
||||||
<Input placeholder="IP or hostname..." />
|
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>
|
<FormField label="Hostname">
|
||||||
<Select items={typeOptions} />
|
<InputField form={form} name="host" placeholder="IP or hostname..." />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<Label>Port</Label>
|
<FormField label="Type">
|
||||||
<Input keyboardType="number-pad" placeholder="SSH Port" />
|
<SelectField form={form} name="type" items={typeOptions} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<Label>Label</Label>
|
<FormField label="Port">
|
||||||
<Input placeholder="Label..." />
|
<InputField
|
||||||
|
form={form}
|
||||||
|
name="port"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="SSH Port"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<XStack gap="$3" mt="$3" mb="$1">
|
{type === "pve" && <PVEFormFields form={form} />}
|
||||||
<Label flex={1}>Credentials</Label>
|
{type === "incus" && <IncusFormFields form={form} />}
|
||||||
|
|
||||||
|
<XStack gap="$3">
|
||||||
|
<Label flex={1} h="$3">
|
||||||
|
Credentials
|
||||||
|
</Label>
|
||||||
<Button size="$3" icon={<Icons size={16} name="plus" />}>
|
<Button size="$3" icon={<Icons size={16} name="plus" />}>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<Select
|
<FormField label="User">
|
||||||
placeholder="Username & Password"
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="keyId"
|
||||||
|
placeholder="Select User"
|
||||||
items={keys.data?.map((key: any) => ({
|
items={keys.data?.map((key: any) => ({
|
||||||
label: key.label,
|
label: key.label,
|
||||||
value: key.id,
|
value: key.id,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<Select
|
</FormField>
|
||||||
mt="$3"
|
|
||||||
placeholder="Private Key"
|
{type === "ssh" && (
|
||||||
|
<FormField label="Private Key">
|
||||||
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="altKeyId"
|
||||||
|
placeholder="Select Private Key"
|
||||||
items={keys.data?.map((key: any) => ({
|
items={keys.data?.map((key: any) => ({
|
||||||
label: key.label,
|
label: key.label,
|
||||||
value: key.id,
|
value: key.id,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View p="$4">
|
<XStack p="$4" gap="$4">
|
||||||
<Button icon={<Icons name="content-save" size={18} />}>Save</Button>
|
<Button flex={1} onPress={hostFormModal.onClose} bg="$colorTransparent">
|
||||||
</View>
|
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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -7,6 +7,7 @@ import { MultiTapPressable } from "@/components/ui/pressable";
|
|||||||
import Icons from "@/components/ui/icons";
|
import Icons from "@/components/ui/icons";
|
||||||
import SearchInput from "@/components/ui/search-input";
|
import SearchInput from "@/components/ui/search-input";
|
||||||
import { useTermSession } from "@/stores/terminal-sessions";
|
import { useTermSession } from "@/stores/terminal-sessions";
|
||||||
|
import { hostFormModal } from "./form";
|
||||||
|
|
||||||
const HostsList = () => {
|
const HostsList = () => {
|
||||||
const openSession = useTermSession((i) => i.push);
|
const openSession = useTermSession((i) => i.push);
|
||||||
@ -36,6 +37,10 @@ const HostsList = () => {
|
|||||||
}, [hosts.data, search]);
|
}, [hosts.data, search]);
|
||||||
|
|
||||||
const onOpen = (host: any) => {
|
const onOpen = (host: any) => {
|
||||||
|
hostFormModal.onOpen(host);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenTerminal = (host: any) => {
|
||||||
const session: any = {
|
const session: any = {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
label: host.label,
|
label: host.label,
|
||||||
@ -49,10 +54,6 @@ const HostsList = () => {
|
|||||||
session.params.client = host.metadata?.type === "lxc" ? "xtermjs" : "vnc";
|
session.params.client = host.metadata?.type === "lxc" ? "xtermjs" : "vnc";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (host.type === "incus") {
|
|
||||||
session.params.shell = "bash";
|
|
||||||
}
|
|
||||||
|
|
||||||
openSession(session);
|
openSession(session);
|
||||||
navigation.navigate("terminal" as never);
|
navigation.navigate("terminal" as never);
|
||||||
};
|
};
|
||||||
@ -93,7 +94,8 @@ const HostsList = () => {
|
|||||||
p="$2"
|
p="$2"
|
||||||
group
|
group
|
||||||
numberOfTaps={2}
|
numberOfTaps={2}
|
||||||
onMultiTap={() => onOpen(host)}
|
onMultiTap={() => onOpenTerminal(host)}
|
||||||
|
onTap={() => onOpen(host)}
|
||||||
>
|
>
|
||||||
<Card bordered p="$4">
|
<Card bordered p="$4">
|
||||||
<XStack>
|
<XStack>
|
||||||
@ -107,7 +109,12 @@ const HostsList = () => {
|
|||||||
<Button
|
<Button
|
||||||
circular
|
circular
|
||||||
display="none"
|
display="none"
|
||||||
|
$sm={{ display: "block" }}
|
||||||
$group-hover={{ display: "block" }}
|
$group-hover={{ display: "block" }}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen(host);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icons name="pencil" size={16} />
|
<Icons name="pencil" size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
25
frontend/pages/hosts/hooks/query.ts
Normal file
25
frontend/pages/hosts/hooks/query.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
@ -1,25 +1,31 @@
|
|||||||
import { Button } from "tamagui";
|
import { Button } from "tamagui";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import useThemeStore from "@/stores/theme";
|
|
||||||
import Drawer from "expo-router/drawer";
|
import Drawer from "expo-router/drawer";
|
||||||
import HostsList from "./components/hosts-list";
|
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() {
|
export default function HostsPage() {
|
||||||
const { toggle } = useThemeStore();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Drawer.Screen
|
<Drawer.Screen
|
||||||
options={{
|
options={{
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Button onPress={() => toggle()} mr="$2">
|
<Button
|
||||||
Toggle Theme
|
bg="$colorTransparent"
|
||||||
|
icon={<Icons name="plus" size={24} />}
|
||||||
|
onPress={() => hostFormModal.onOpen(initialValues)}
|
||||||
|
$gtSm={{ mr: "$3" }}
|
||||||
|
>
|
||||||
|
New
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HostsList />
|
<HostsList />
|
||||||
|
<HostForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
82
frontend/pages/hosts/schema/form.ts
Normal file
82
frontend/pages/hosts/schema/form.ts
Normal 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" },
|
||||||
|
];
|
39
frontend/patches/zod.patch
Normal file
39
frontend/patches/zod.patch
Normal 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;
|
36
frontend/pnpm-lock.yaml
generated
36
frontend/pnpm-lock.yaml
generated
@ -8,6 +8,9 @@ patchedDependencies:
|
|||||||
react-native-drawer-layout:
|
react-native-drawer-layout:
|
||||||
hash: ipghvwpiqcl5liuijnfvmjzcvq
|
hash: ipghvwpiqcl5liuijnfvmjzcvq
|
||||||
path: patches/react-native-drawer-layout.patch
|
path: patches/react-native-drawer-layout.patch
|
||||||
|
zod:
|
||||||
|
hash: blxeugurkewr75xivc2c7hodyi
|
||||||
|
path: patches/zod.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@ -16,6 +19,9 @@ importers:
|
|||||||
'@expo/vector-icons':
|
'@expo/vector-icons':
|
||||||
specifier: ^14.0.2
|
specifier: ^14.0.2
|
||||||
version: 14.0.4
|
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':
|
'@novnc/novnc':
|
||||||
specifier: ^1.5.0
|
specifier: ^1.5.0
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
@ -91,6 +97,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 18.3.1
|
specifier: 18.3.1
|
||||||
version: 18.3.1(react@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:
|
react-native:
|
||||||
specifier: 0.76.1
|
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)
|
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:
|
tamagui:
|
||||||
specifier: ^1.116.14
|
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)
|
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:
|
zustand:
|
||||||
specifier: ^5.0.1
|
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))
|
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':
|
'@floating-ui/utils@0.2.8':
|
||||||
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
|
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':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -4636,6 +4653,12 @@ packages:
|
|||||||
react: ^16.6.0 || ^17.0.0 || ^18.0.0
|
react: ^16.6.0 || ^17.0.0 || ^18.0.0
|
||||||
react-dom: ^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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@ -5677,6 +5700,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zod@3.23.8:
|
||||||
|
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||||
|
|
||||||
zustand@5.0.1:
|
zustand@5.0.1:
|
||||||
resolution: {integrity: sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==}
|
resolution: {integrity: sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@ -7048,6 +7074,10 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.8': {}
|
'@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':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
@ -11766,6 +11796,10 @@ snapshots:
|
|||||||
react-fast-compare: 3.2.2
|
react-fast-compare: 3.2.2
|
||||||
shallowequal: 1.1.0
|
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@16.13.1: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
@ -12879,6 +12913,8 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
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)):
|
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:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.12
|
'@types/react': 18.3.12
|
||||||
|
@ -14,7 +14,7 @@ func NewHostsRepository() *Hosts {
|
|||||||
|
|
||||||
func (r *Hosts) GetAll() ([]*models.Host, error) {
|
func (r *Hosts) GetAll() ([]*models.Host, error) {
|
||||||
var rows []*models.Host
|
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
|
return rows, ret.Error
|
||||||
}
|
}
|
||||||
@ -49,6 +49,20 @@ func (r *Hosts) Get(id string) (*GetHostResult, error) {
|
|||||||
return res, ret.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 {
|
func (r *Hosts) Create(item *models.Host) error {
|
||||||
return r.db.Create(item).Error
|
return r.db.Create(item).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Hosts) Update(item *models.Host) error {
|
||||||
|
return r.db.Save(item).Error
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package hosts
|
package hosts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@ -13,6 +14,8 @@ func Router(app *fiber.App) {
|
|||||||
|
|
||||||
router.Get("/", getAll)
|
router.Get("/", getAll)
|
||||||
router.Post("/", create)
|
router.Post("/", create)
|
||||||
|
router.Put("/:id", update)
|
||||||
|
router.Delete("/:id", delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAll(c *fiber.Ctx) error {
|
func getAll(c *fiber.Ctx) error {
|
||||||
@ -34,7 +37,6 @@ func create(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repo := NewHostsRepository()
|
repo := NewHostsRepository()
|
||||||
|
|
||||||
item := &models.Host{
|
item := &models.Host{
|
||||||
Type: body.Type,
|
Type: body.Type,
|
||||||
Label: body.Label,
|
Label: body.Label,
|
||||||
@ -51,3 +53,54 @@ func create(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.Status(http.StatusCreated).JSON(item)
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type IncusWebsocketSession struct {
|
type IncusWebsocketSession struct {
|
||||||
|
Type string `json:"type"` // "qemu" | "lxc"
|
||||||
Instance string `json:"instance"`
|
Instance string `json:"instance"`
|
||||||
Shell string `json:"shell"`
|
Shell string `json:"shell"`
|
||||||
}
|
}
|
||||||
|
@ -8,16 +8,16 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BaseModel struct {
|
type Model struct {
|
||||||
ID string `gorm:"primarykey;type:varchar(26)" json:"id"`
|
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()
|
m.ID = m.GenerateID()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BaseModel) GenerateID() string {
|
func (m *Model) GenerateID() string {
|
||||||
return strings.ToLower(ulid.Make().String())
|
return strings.ToLower(ulid.Make().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Host struct {
|
type Host struct {
|
||||||
BaseModel
|
Model
|
||||||
|
|
||||||
Type string `json:"type" gorm:"not null;index:hosts_type_idx;type:varchar(16)"`
|
Type string `json:"type" gorm:"not null;index:hosts_type_idx;type:varchar(16)"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@ -23,9 +23,9 @@ type Host struct {
|
|||||||
ParentID *string `json:"parentId" gorm:"index:hosts_parent_id_idx;type:varchar(26)"`
|
ParentID *string `json:"parentId" gorm:"index:hosts_parent_id_idx;type:varchar(26)"`
|
||||||
Parent *Host `json:"parent" gorm:"foreignKey:ParentID"`
|
Parent *Host `json:"parent" gorm:"foreignKey:ParentID"`
|
||||||
KeyID *string `json:"keyId" gorm:"index:hosts_key_id_idx"`
|
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"`
|
AltKeyID *string `json:"altKeyId" gorm:"index:hosts_altkey_id_idx"`
|
||||||
AltKey Keychain `gorm:"foreignKey:AltKeyID"`
|
AltKey Keychain `json:"altKey" gorm:"foreignKey:AltKeyID"`
|
||||||
|
|
||||||
Timestamps
|
Timestamps
|
||||||
SoftDeletes
|
SoftDeletes
|
||||||
|
@ -13,7 +13,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Keychain struct {
|
type Keychain struct {
|
||||||
BaseModel
|
Model
|
||||||
|
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Type string `json:"type" gorm:"not null;index:keychains_type_idx;type:varchar(12)"`
|
Type string `json:"type" gorm:"not null;index:keychains_type_idx;type:varchar(12)"`
|
||||||
|
@ -6,7 +6,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
BaseModel
|
Model
|
||||||
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Username string `json:"username" gorm:"unique"`
|
Username string `json:"username" gorm:"unique"`
|
||||||
|
@ -21,7 +21,7 @@ func TestHostsCreate(t *testing.T) {
|
|||||||
test := NewTestWithAuth(t)
|
test := NewTestWithAuth(t)
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"type": "pve",
|
"type": "ssh",
|
||||||
"label": "test ssh",
|
"label": "test ssh",
|
||||||
"host": "10.0.0.102",
|
"host": "10.0.0.102",
|
||||||
"port": 22,
|
"port": 22,
|
||||||
@ -72,3 +72,32 @@ func TestHostsCreate(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusCreated, status)
|
assert.Equal(t, http.StatusCreated, status)
|
||||||
assert.NotNil(t, res["id"])
|
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)
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user