mirror of
https://github.com/khairul169/vaulterm.git
synced 2026-09-15 17:03:30 +07:00
feat: add hosts management
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
Reference in New Issue
Block a user