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