feat: add hosts management

This commit is contained in:
2024-11-09 10:33:07 +00:00
parent 31c43836f4
commit d931235fb3
27 changed files with 742 additions and 94 deletions
+157 -43
View File
@@ -1,67 +1,181 @@
import Icons from "@/components/ui/icons";
import Select, { SelectItem } from "@/components/ui/select";
import Modal from "@/components/ui/modal";
import { SelectField } from "@/components/ui/select";
import { useZForm, UseZFormReturn } from "@/hooks/useZForm";
import api from "@/lib/api";
import { createDisclosure } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { Button, Input, Label, ScrollView, Text, View, XStack } from "tamagui";
import { Button, Label, ScrollView, XStack } from "tamagui";
import {
FormSchema,
formSchema,
incusTypes,
pveTypes,
typeOptions,
} from "../schema/form";
import { InputField } from "@/components/ui/input";
import FormField from "@/components/ui/form";
import { useKeychains, useSaveHost } from "../hooks/query";
type Props = {};
export const hostFormModal = createDisclosure<FormSchema>();
const typeOptions: SelectItem[] = [
{ label: "SSH", value: "ssh" },
{ label: "Proxmox VE", value: "pve" },
{ label: "Incus", value: "incus" },
];
const HostForm = () => {
const { data } = hostFormModal.use();
const form = useZForm(formSchema, data);
const isEditing = data?.id != null;
const type = form.watch("type");
const HostForm = (props: Props) => {
const keys = useQuery({
queryKey: ["keychains"],
queryFn: () => api("/keychains"),
select: (i) => i.rows,
const keys = useKeychains();
const saveMutation = useSaveHost();
const onSubmit = form.handleSubmit((values) => {
saveMutation.mutate(values, {
onSuccess: () => {
hostFormModal.onClose();
form.reset();
},
});
});
return (
<>
<ScrollView contentContainerStyle={{ padding: "$4" }}>
<Label>Hostname</Label>
<Input placeholder="IP or hostname..." />
<Modal
disclosure={hostFormModal}
title="Host"
description={`${isEditing ? "Edit" : "Add new"} host.`}
>
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
<FormField label="Label">
<InputField f={1} form={form} name="label" placeholder="Label..." />
</FormField>
<Label>Type</Label>
<Select items={typeOptions} />
<FormField label="Hostname">
<InputField form={form} name="host" placeholder="IP or hostname..." />
</FormField>
<Label>Port</Label>
<Input keyboardType="number-pad" placeholder="SSH Port" />
<FormField label="Type">
<SelectField form={form} name="type" items={typeOptions} />
</FormField>
<Label>Label</Label>
<Input placeholder="Label..." />
<FormField label="Port">
<InputField
form={form}
name="port"
keyboardType="number-pad"
placeholder="SSH Port"
/>
</FormField>
<XStack gap="$3" mt="$3" mb="$1">
<Label flex={1}>Credentials</Label>
{type === "pve" && <PVEFormFields form={form} />}
{type === "incus" && <IncusFormFields form={form} />}
<XStack gap="$3">
<Label flex={1} h="$3">
Credentials
</Label>
<Button size="$3" icon={<Icons size={16} name="plus" />}>
Add
</Button>
</XStack>
<Select
placeholder="Username & Password"
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>
)}
</>
);
};
+12 -5
View File
@@ -7,6 +7,7 @@ import { MultiTapPressable } from "@/components/ui/pressable";
import Icons from "@/components/ui/icons";
import SearchInput from "@/components/ui/search-input";
import { useTermSession } from "@/stores/terminal-sessions";
import { hostFormModal } from "./form";
const HostsList = () => {
const openSession = useTermSession((i) => i.push);
@@ -36,6 +37,10 @@ const HostsList = () => {
}, [hosts.data, search]);
const onOpen = (host: any) => {
hostFormModal.onOpen(host);
};
const onOpenTerminal = (host: any) => {
const session: any = {
id: host.id,
label: host.label,
@@ -49,10 +54,6 @@ const HostsList = () => {
session.params.client = host.metadata?.type === "lxc" ? "xtermjs" : "vnc";
}
if (host.type === "incus") {
session.params.shell = "bash";
}
openSession(session);
navigation.navigate("terminal" as never);
};
@@ -93,7 +94,8 @@ const HostsList = () => {
p="$2"
group
numberOfTaps={2}
onMultiTap={() => onOpen(host)}
onMultiTap={() => onOpenTerminal(host)}
onTap={() => onOpen(host)}
>
<Card bordered p="$4">
<XStack>
@@ -107,7 +109,12 @@ const HostsList = () => {
<Button
circular
display="none"
$sm={{ display: "block" }}
$group-hover={{ display: "block" }}
onPress={(e) => {
e.stopPropagation();
onOpen(host);
}}
>
<Icons name="pencil" size={16} />
</Button>
+25
View File
@@ -0,0 +1,25 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { FormSchema } from "../schema/form";
import api, { queryClient } from "@/lib/api";
export const useKeychains = () => {
return useQuery({
queryKey: ["keychains"],
queryFn: () => api("/keychains"),
select: (i) => i.rows,
});
};
export const useSaveHost = () => {
return useMutation({
mutationFn: async (body: FormSchema) => {
return body.id
? api(`/hosts/${body.id}`, { method: "PUT", body })
: api(`/hosts`, { method: "POST", body });
},
onError: (e) => console.error(e),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["hosts"] });
},
});
};
+11 -5
View File
@@ -1,25 +1,31 @@
import { Button } from "tamagui";
import React from "react";
import useThemeStore from "@/stores/theme";
import Drawer from "expo-router/drawer";
import HostsList from "./components/hosts-list";
import HostForm, { hostFormModal } from "./components/form";
import Icons from "@/components/ui/icons";
import { initialValues } from "./schema/form";
export default function HostsPage() {
const { toggle } = useThemeStore();
return (
<>
<Drawer.Screen
options={{
headerRight: () => (
<Button onPress={() => toggle()} mr="$2">
Toggle Theme
<Button
bg="$colorTransparent"
icon={<Icons name="plus" size={24} />}
onPress={() => hostFormModal.onOpen(initialValues)}
$gtSm={{ mr: "$3" }}
>
New
</Button>
),
}}
/>
<HostsList />
<HostForm />
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { SelectItem } from "@/components/ui/select";
import { hostnameShape } from "@/lib/utils";
import { z } from "zod";
export const baseFormSchema = z.object({
id: z.string().nullish(),
label: z.string().min(1, { message: "Label is required" }),
parentId: z.string().ulid().nullish(),
});
const hostSchema = baseFormSchema.merge(
z.object({
host: hostnameShape(),
port: z.coerce
.number({ message: "Invalid port" })
.min(1, { message: "Port is required" }),
})
);
const sshSchema = hostSchema.merge(
z.object({
type: z.literal("ssh"),
keyId: z.string().ulid({ message: "SSH key is required" }),
altKeyId: z.string().ulid().nullish(),
})
);
const pveSchema = hostSchema.merge(
z.object({
type: z.literal("pve"),
keyId: z.string().ulid({ message: "PVE User is required" }),
metadata: z.object({
node: z.string().min(1, { message: "PVE node is required" }),
type: z.enum(["lxc", "qemu"]),
vmid: z.string().min(1, { message: "VMID is required" }),
}),
})
);
const incusSchema = hostSchema.merge(
z.object({
type: z.literal("incus"),
keyId: z.string().ulid({ message: "Incus cert is required" }),
metadata: z.object({
type: z.enum(["lxc", "qemu"]),
instance: z.string().min(1, { message: "Instance name is required" }),
shell: z.string().nullish(),
}),
})
);
export const formSchema = z.discriminatedUnion(
"type",
[sshSchema, pveSchema, incusSchema],
{ errorMap: () => ({ message: "Invalid host type" }) }
);
export type FormSchema = z.infer<typeof formSchema>;
export const initialValues: FormSchema = {
type: "ssh",
host: "",
port: 22,
label: "",
keyId: "",
};
export const typeOptions: SelectItem[] = [
{ label: "SSH", value: "ssh" },
{ label: "Proxmox VE", value: "pve" },
{ label: "Incus", value: "incus" },
];
export const pveTypes: SelectItem[] = [
{ label: "Virtual Machine", value: "qemu" },
{ label: "Container", value: "lxc" },
];
export const incusTypes: SelectItem[] = [
{ label: "Container", value: "lxc" },
{ label: "Virtual Machine", value: "qemu" },
];