feat: add group

This commit is contained in:
2024-11-16 23:50:34 +07:00
parent b88b04a235
commit c8e61ed4aa
24 changed files with 534 additions and 86 deletions
+1
View File
@@ -58,6 +58,7 @@ export default function LoginPage() {
name="username"
autoCapitalize="none"
onSubmitEditing={onSubmit}
autoFocus
/>
</FormField>
<FormField vertical label="Password">
+20 -12
View File
@@ -47,22 +47,30 @@ const HostForm = () => {
<InputField f={1} form={form} name="label" placeholder="Label..." />
</FormField>
<FormField label="Hostname">
<InputField form={form} name="host" placeholder="IP or hostname..." />
</FormField>
<FormField label="Type">
<SelectField form={form} name="type" items={typeOptions} />
</FormField>
<FormField label="Port">
<InputField
form={form}
name="port"
keyboardType="number-pad"
placeholder="Port"
/>
</FormField>
{type !== "group" && (
<>
<FormField label="Hostname">
<InputField
form={form}
name="host"
placeholder="IP or hostname..."
/>
</FormField>
<FormField label="Port">
<InputField
form={form}
name="port"
keyboardType="number-pad"
placeholder="Port"
/>
</FormField>
</>
)}
{type === "ssh" ? (
<SSHFormFields form={form} />
+26 -10
View File
@@ -6,12 +6,19 @@ import OSIcons from "@/components/ui/os-icons";
type HostItemProps = {
host: any;
selected?: boolean;
onMultiTap: () => void;
onTap: () => void;
onTap?: () => void;
onEdit?: (() => void) | null;
};
const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => {
const HostItem = ({
host,
selected,
onMultiTap,
onTap,
onEdit,
}: HostItemProps) => {
return (
<MultiTapPressable
cursor="pointer"
@@ -20,15 +27,24 @@ const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => {
onMultiTap={onMultiTap}
onTap={onTap}
>
<Card bordered p="$4">
<Card
bordered
p="$4"
borderColor={selected ? "$blue8" : "$borderColor"}
bg={selected ? "$blue3" : undefined}
>
<XStack>
<OSIcons
name={host.os}
size={18}
mr="$2"
mt="$1"
fallback="desktop-classic"
/>
{host.type === "group" ? (
<Icons name="package-variant-closed" size={18} mr="$2" mt="$1" />
) : (
<OSIcons
name={host.os}
size={18}
mr="$2"
mt="$1"
fallback="desktop-classic"
/>
)}
<View flex={1}>
<Text>{host.label}</Text>
+95 -20
View File
@@ -1,26 +1,36 @@
import { View, Text, Spinner } from "tamagui";
import { View, Text, Spinner, ScrollView } from "tamagui";
import React, { useMemo, useState } from "react";
import { useNavigation } from "expo-router";
import SearchInput from "@/components/ui/search-input";
import { useTermSession } from "@/stores/terminal-sessions";
import { hostFormModal } from "./form";
import GridView from "@/components/ui/grid-view";
import { GridLayout } from "@/components/ui/grid-view";
import HostItem from "./host-item";
import { useHosts } from "../hooks/query";
type HostsListProps = {
allowEdit?: boolean;
parentId?: string | null;
onParentIdChange?: (id: string | null) => void;
selected?: string[];
onSelectedChange?: (ids: string[]) => void;
};
const HostList = ({ allowEdit = true }: HostsListProps) => {
const HostList = ({
allowEdit = true,
parentId,
onParentIdChange,
selected = [],
onSelectedChange,
}: HostsListProps) => {
const openSession = useTermSession((i) => i.push);
const navigation = useNavigation();
const [search, setSearch] = useState("");
const hosts = useHosts();
const { data, isLoading } = useHosts({ parentId });
const hostsList = useMemo(() => {
let items = hosts.data || [];
let items = data || [];
if (search) {
items = items.filter((item: any) => {
@@ -33,7 +43,25 @@ const HostList = ({ allowEdit = true }: HostsListProps) => {
}
return items.map((i: any) => ({ ...i, key: i.id }));
}, [hosts.data, search]);
}, [data, search]);
const groups = useMemo(
() => hostsList.filter((i: any) => i.type === "group"),
[hostsList]
);
const hosts = useMemo(
() => hostsList.filter((i: any) => i.type !== "group"),
[hostsList]
);
const onSelect = (host: any) => {
if (selected.includes(host.id)) {
onSelectedChange?.(selected.filter((i) => i !== host.id));
} else {
onSelectedChange?.([...selected, host.id]);
}
};
const onEdit = (host: any) => {
if (!allowEdit) return;
@@ -68,29 +96,76 @@ const HostList = ({ allowEdit = true }: HostsListProps) => {
/>
</View>
{hosts.isLoading ? (
{isLoading ? (
<View alignItems="center" justifyContent="center" flex={1}>
<Spinner size="large" />
<Text mt="$4">Loading...</Text>
</View>
) : (
<GridView
data={hostsList}
columns={{ sm: 2, lg: 3, xl: 4 }}
contentContainerStyle={{ p: "$2", pt: 0 }}
gap="$2.5"
renderItem={(host: any) => (
<HostItem
host={host}
onTap={() => {}}
onMultiTap={() => onOpenTerminal(host)}
onEdit={allowEdit ? () => onEdit(host) : null}
/>
<ScrollView>
{groups.length > 0 && (
<>
<Text mx="$4">Groups</Text>
<ItemList
data={groups}
selected={selected}
onTap={onSelectedChange ? onSelect : undefined}
onMultiTap={(group) => onParentIdChange?.(group.id)}
onEdit={allowEdit ? onEdit : undefined}
/>
</>
)}
/>
<Text mx="$4">Hosts</Text>
{!hosts.length && (
<Text mx="$4" fontSize="$3" mt="$2">
No hosts found
</Text>
)}
<ItemList
data={hosts}
selected={selected}
onTap={onSelectedChange ? onSelect : undefined}
onMultiTap={onOpenTerminal}
onEdit={allowEdit ? onEdit : undefined}
/>
</ScrollView>
)}
</>
);
};
type ItemListProps = {
data?: any[];
selected?: string[];
onTap?: (host: any) => void;
onMultiTap?: (host: any) => void;
onEdit?: (host: any) => void;
};
const ItemList = ({
data,
selected,
onTap,
onMultiTap,
onEdit,
}: ItemListProps) => (
<GridLayout
data={data}
columns={{ sm: 2, lg: 3, xl: 4 }}
padding="$2"
gap="$2.5"
renderItem={(host: any) => (
<HostItem
host={host}
selected={selected?.includes(host.id)}
onTap={onEdit ? () => onTap?.(host) : undefined}
onMultiTap={() => onMultiTap?.(host)}
onEdit={onEdit ? () => onEdit?.(host) : undefined}
/>
)}
/>
);
export default React.memo(HostList);
+24 -4
View File
@@ -5,12 +5,13 @@ import { useMemo } from "react";
import { useKeychains } from "@/pages/keychains/hooks/query";
import queryClient from "@/lib/queryClient";
import { useTeamId } from "@/stores/auth";
import { MoveHostPayload } from "../schema/query";
export const useHosts = () => {
export const useHosts = (params: any = {}) => {
const teamId = useTeamId();
return useQuery({
queryKey: ["hosts", teamId],
queryFn: () => api("/hosts", { params: { teamId } }),
queryKey: ["hosts", teamId, params],
queryFn: () => api("/hosts", { params: { teamId, ...params } }),
select: (i) => i.rows,
});
};
@@ -40,7 +41,26 @@ export const useSaveHost = () => {
? api(`/hosts/${body.id}`, { method: "PUT", body })
: api(`/hosts`, { method: "POST", body });
},
onError: (e) => console.error(e),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["hosts"] });
},
});
};
export const useMoveHost = () => {
const teamId = useTeamId();
return useMutation({
mutationFn: (data: MoveHostPayload) => {
const hostId = Array.isArray(data.hostId)
? data.hostId.join(",")
: data.hostId;
return api("/hosts/move", {
method: "POST",
body: { teamId, parentId: data.parentId, hostId },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["hosts"] });
},
+126 -10
View File
@@ -1,5 +1,5 @@
import { Button } from "tamagui";
import React from "react";
import { Button, GetProps, useMedia } from "tamagui";
import React, { useMemo, useState } from "react";
import Drawer from "expo-router/drawer";
import HostList from "./components/host-list";
import HostForm, { hostFormModal } from "./components/form";
@@ -8,36 +8,152 @@ import { initialValues } from "./schema/form";
import KeyForm from "../keychains/components/form";
import { useUser } from "@/hooks/useUser";
import { useTeamId } from "@/stores/auth";
import { useMoveHost } from "./hooks/query";
import { useQueryParams } from "@/hooks/useQueryParams";
import { BackButton } from "@/components/ui/button";
type Params = {
parentId?: string | null;
};
export default function HostsPage() {
const teamId = useTeamId();
const user = useUser();
const [selected, setSelected] = useState<string[]>([]);
const queryParams = useQueryParams<Params>();
const parentId = queryParams.params?.parentId;
const media = useMedia();
const setParentId = (id: string | null) => {
queryParams.push({ parentId: id || "" });
};
const onGoBack = () => {
if (!queryParams.goBack()) {
queryParams.replace({ parentId: "" });
}
};
const actions = useMemo(() => {
if (selected?.length > 0) {
return (
<HostsActions
selected={selected}
parentId={parentId}
onClear={() => setSelected([])}
/>
);
}
if (!teamId || user?.teamCanWrite(teamId)) {
return (
<AddButton
onPress={() => hostFormModal.onOpen({ ...initialValues, parentId })}
/>
);
}
return null;
}, [teamId, user, parentId]);
return (
<>
<Drawer.Screen
options={{
headerRight:
!teamId || user?.teamCanWrite(teamId)
? () => <AddButton />
: undefined,
headerLeft: parentId
? () => <BackButton onPress={onGoBack} />
: media.gtXs
? () => null
: undefined,
headerTitle:
selected.length > 0 ? `Selected ${selected.length} hosts` : "Hosts",
headerRight: () => actions,
}}
/>
<HostList />
<HostList
parentId={parentId}
onParentIdChange={setParentId}
selected={selected}
onSelectedChange={setSelected}
/>
<HostForm />
<KeyForm />
</>
);
}
const AddButton = () => (
const AddButton = (props: GetProps<typeof Button>) => (
<Button
bg="$colorTransparent"
icon={<Icons name="plus" size={24} />}
onPress={() => hostFormModal.onOpen(initialValues)}
$gtSm={{ mr: "$3" }}
$gtSm={{ mr: "$2" }}
{...props}
>
New
</Button>
);
type HostsActionsProps = {
selected: string[];
parentId?: string | null;
onClear: () => void;
};
const actionMode = {
CUT: 1,
};
const HostsActions = ({
selected,
parentId = null,
onClear,
}: HostsActionsProps) => {
const [curMode, setCurMode] = useState(0);
const move = useMoveHost();
const onReset = () => {
setCurMode(0);
onClear();
};
const onMoveAction = () => {
move.mutate({ parentId, hostId: selected }, { onSuccess: onReset });
};
return (
<>
{curMode === actionMode.CUT ? (
<Button
key="paste"
circular
icon={<Icons name="content-paste" size={20} />}
bg="$colorTransparent"
onPress={onMoveAction}
/>
) : (
<Button
key="cut"
circular
icon={<Icons name="content-cut" size={20} />}
bg="$colorTransparent"
onPress={() => setCurMode(actionMode.CUT)}
/>
)}
{/* <Button
circular
icon={<Icons name="trash-can" size={24} />}
bg="$colorTransparent"
/> */}
<Button
key="close"
circular
icon={<Icons name="close" size={24} />}
bg="$colorTransparent"
onPress={onReset}
mr="$2"
/>
</>
);
};
+6 -1
View File
@@ -8,6 +8,10 @@ export const baseFormSchema = z.object({
parentId: z.string().ulid().nullish(),
});
const groupSchema = baseFormSchema.merge(
z.object({ type: z.literal("group") })
);
const hostSchema = baseFormSchema.merge(
z.object({
host: hostnameShape(),
@@ -52,7 +56,7 @@ const incusSchema = hostSchema.merge(
export const formSchema = z.discriminatedUnion(
"type",
[sshSchema, pveSchema, incusSchema],
[groupSchema, sshSchema, pveSchema, incusSchema],
{ errorMap: () => ({ message: "Invalid host type" }) }
);
@@ -67,6 +71,7 @@ export const initialValues: FormSchema = {
};
export const typeOptions: SelectItem[] = [
{ label: "Group", value: "group" },
{ label: "SSH", value: "ssh" },
{ label: "Proxmox VE", value: "pve" },
{ label: "Incus", value: "incus" },
+6
View File
@@ -0,0 +1,6 @@
//
export type MoveHostPayload = {
parentId: string | null;
hostId: string | string[];
};
+1
View File
@@ -80,6 +80,7 @@ export default function ServerPage() {
autoCapitalize="none"
keyboardType="url"
placeholder="https://"
onSubmitEditing={onSubmit}
/>
</FormField>
@@ -0,0 +1,40 @@
import { View, Text } from "react-native";
import React from "react";
import HostList from "../hosts/components/host-list";
import { useQueryParams } from "@/hooks/useQueryParams";
import { BackButton } from "@/components/ui/button";
import { XStack } from "tamagui";
type Params = {
parentId?: string | null;
};
const NewSessionPage = () => {
const queryParams = useQueryParams<Params>();
const parentId = queryParams.params?.parentId;
const setParentId = (id: string | null) => {
queryParams.push({ parentId: id || "" });
};
const onGoBack = () => {
if (!queryParams.goBack()) {
queryParams.replace({ parentId: "" });
}
};
return (
<>
<XStack alignItems="center" h="$6">
{parentId ? <BackButton onPress={onGoBack} /> : null}
</XStack>
<HostList
allowEdit={false}
parentId={parentId}
onParentIdChange={setParentId}
/>
</>
);
};
export default NewSessionPage;
+2 -2
View File
@@ -4,11 +4,11 @@ import PagerView, { PagerViewRef } from "@/components/ui/pager-view";
import { useTermSession } from "@/stores/terminal-sessions";
import { Button, useMedia } from "tamagui";
import SessionTabs from "./components/session-tabs";
import HostList from "../hosts/components/host-list";
import Drawer from "expo-router/drawer";
import { router } from "expo-router";
import Icons from "@/components/ui/icons";
import { useDebounceCallback } from "@/hooks/useDebounce";
import NewSessionPage from "./new-session-page";
const TerminalPage = () => {
const pagerViewRef = useRef<PagerViewRef>(null!);
@@ -58,7 +58,7 @@ const TerminalPage = () => {
/>
{sessions.length > 0 && media.gtSm ? <SessionTabs /> : null}
{!sessions.length ? <HostList allowEdit={false} /> : pagerView}
{!sessions.length ? <NewSessionPage /> : pagerView}
</>
);
};