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";
|
||||
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">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
<PortalProvider shouldAddRootHost>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</PortalProvider>
|
||||
</Theme>
|
||||
</TamaguiProvider>
|
||||
</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 { 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;
|
||||
|
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";
|
||||
|
||||
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
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": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
items={keys.data?.map((key: any) => ({
|
||||
label: key.label,
|
||||
value: key.id,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
mt="$3"
|
||||
placeholder="Private Key"
|
||||
items={keys.data?.map((key: any) => ({
|
||||
label: key.label,
|
||||
value: key.id,
|
||||
}))}
|
||||
/>
|
||||
<FormField label="User">
|
||||
<SelectField
|
||||
form={form}
|
||||
name="keyId"
|
||||
placeholder="Select User"
|
||||
items={keys.data?.map((key: any) => ({
|
||||
label: key.label,
|
||||
value: key.id,
|
||||
}))}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
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 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
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:
|
||||
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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type IncusWebsocketSession struct {
|
||||
Type string `json:"type"` // "qemu" | "lxc"
|
||||
Instance string `json:"instance"`
|
||||
Shell string `json:"shell"`
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)"`
|
||||
|
@ -6,7 +6,7 @@ const (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
BaseModel
|
||||
Model
|
||||
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username" gorm:"unique"`
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user