mirror of
https://github.com/khairul169/vaulterm.git
synced 2025-04-28 08:39:37 +07:00
feat: add group
This commit is contained in:
parent
b88b04a235
commit
c8e61ed4aa
@ -52,10 +52,9 @@ const InteractiveSession = ({ type, params }: InteractiveSessionProps) => {
|
||||
) : (
|
||||
<Terminal url={termUrl} />
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error("Unknown interactive session type");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default InteractiveSession;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { GetProps, Button as BaseButton, Spinner } from "tamagui";
|
||||
import Icons from "./icons";
|
||||
|
||||
type ButtonProps = GetProps<typeof BaseButton> & {
|
||||
isDisabled?: boolean;
|
||||
@ -16,4 +17,14 @@ const Button = ({ icon, isLoading, isDisabled, ...props }: ButtonProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const BackButton = (props: GetProps<typeof Button>) => (
|
||||
<Button
|
||||
circular
|
||||
bg="$colorTransparent"
|
||||
icon={<Icons name="arrow-left" size={24} />}
|
||||
mr={6}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Button;
|
||||
|
@ -3,9 +3,32 @@ import { GetProps, ScrollView, View, ViewStyle } from "tamagui";
|
||||
|
||||
type GridItem = { key: string };
|
||||
|
||||
type GridViewProps<T extends GridItem> = GetProps<typeof ScrollView> & {
|
||||
type GridViewProps<T extends GridItem> = GetProps<typeof ScrollView> &
|
||||
GridLayoutProps<T>;
|
||||
|
||||
const GridView = <T extends GridItem>({
|
||||
data,
|
||||
renderItem,
|
||||
columns,
|
||||
gap,
|
||||
...props
|
||||
}: GridViewProps<T>) => {
|
||||
return (
|
||||
<ScrollView {...props}>
|
||||
<GridLayout
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
gap={gap}
|
||||
columns={columns}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
type GridLayoutProps<T extends GridItem> = GetProps<typeof View> & {
|
||||
data?: T[] | null;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
gap?: string | number;
|
||||
columns: {
|
||||
xs?: number;
|
||||
sm?: number;
|
||||
@ -15,10 +38,10 @@ type GridViewProps<T extends GridItem> = GetProps<typeof ScrollView> & {
|
||||
};
|
||||
};
|
||||
|
||||
const GridView = <T extends GridItem>({
|
||||
export const GridLayout = <T extends GridItem>({
|
||||
columns,
|
||||
data,
|
||||
renderItem,
|
||||
columns,
|
||||
gap,
|
||||
...props
|
||||
}: GridViewProps<T>) => {
|
||||
@ -33,20 +56,13 @@ const GridView = <T extends GridItem>({
|
||||
}, [columns]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
{...props}
|
||||
contentContainerStyle={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
...(props.contentContainerStyle as object),
|
||||
}}
|
||||
>
|
||||
<View flexDirection="row" flexWrap="wrap" {...props}>
|
||||
{data?.map((item, idx) => (
|
||||
<View key={item.key} p={gap} flexShrink={0} {...basisProps}>
|
||||
{renderItem(item, idx)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -63,6 +63,7 @@ export const MultiTapPressable = ({
|
||||
}}
|
||||
numberOfTaps={numberOfTaps}
|
||||
ref={tapRef}
|
||||
maxDelayMs={120}
|
||||
>
|
||||
<View pressStyle={{ opacity: 0.5 }} {...props} />
|
||||
</TapGestureHandler>
|
||||
|
38
frontend/hooks/useQueryParams.ts
Normal file
38
frontend/hooks/useQueryParams.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export const useQueryParams = <T extends object>() => {
|
||||
const params = useLocalSearchParams() as T;
|
||||
const [history, setHistory] = useState<T[]>([params]);
|
||||
|
||||
const push = useCallback((params: T) => {
|
||||
setHistory((prev) => [...prev, params]);
|
||||
router.setParams(params);
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((params: T) => {
|
||||
setHistory([params]);
|
||||
router.setParams(params);
|
||||
}, []);
|
||||
|
||||
const canGoBack = useMemo(() => history.length > 1, [history]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!canGoBack) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const historyList = [...history];
|
||||
historyList.pop();
|
||||
const prev = historyList[historyList.length - 1];
|
||||
if (!prev) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setHistory(historyList);
|
||||
router.setParams(prev);
|
||||
return true;
|
||||
}, [history, canGoBack]);
|
||||
|
||||
return { params, replace, push, goBack, canGoBack };
|
||||
};
|
@ -58,6 +58,7 @@ export default function LoginPage() {
|
||||
name="username"
|
||||
autoCapitalize="none"
|
||||
onSubmitEditing={onSubmit}
|
||||
autoFocus
|
||||
/>
|
||||
</FormField>
|
||||
<FormField vertical label="Password">
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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"] });
|
||||
},
|
||||
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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
frontend/pages/hosts/schema/query.ts
Normal file
6
frontend/pages/hosts/schema/query.ts
Normal file
@ -0,0 +1,6 @@
|
||||
//
|
||||
|
||||
export type MoveHostPayload = {
|
||||
parentId: string | null;
|
||||
hostId: string | string[];
|
||||
};
|
@ -80,6 +80,7 @@ export default function ServerPage() {
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
placeholder="https://"
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
40
frontend/pages/terminal/new-session-page.tsx
Normal file
40
frontend/pages/terminal/new-session-page.tsx
Normal file
@ -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;
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -23,19 +23,37 @@ func NewRepository(r *Hosts) *Hosts {
|
||||
func (r *Hosts) GetAll(opt GetAllOpt) ([]*models.Host, error) {
|
||||
query := r.db.Order("id DESC")
|
||||
|
||||
if len(opt.ID) > 0 {
|
||||
query = query.Where("hosts.id IN (?)", opt.ID)
|
||||
}
|
||||
|
||||
if opt.TeamID != "" {
|
||||
query = query.Where("hosts.team_id = ?", opt.TeamID)
|
||||
} else {
|
||||
query = query.Where("hosts.owner_id = ? AND hosts.team_id IS NULL", r.User.ID)
|
||||
}
|
||||
|
||||
if opt.ParentID != nil {
|
||||
if *opt.ParentID != "" {
|
||||
query = query.Where("hosts.parent_id = ?", *opt.ParentID)
|
||||
} else {
|
||||
query = query.Where("hosts.parent_id IS NULL")
|
||||
}
|
||||
}
|
||||
|
||||
var rows []*models.Host
|
||||
ret := query.Find(&rows)
|
||||
|
||||
return rows, ret.Error
|
||||
}
|
||||
|
||||
func (r *Hosts) Get(id string) (*models.HostDecrypted, error) {
|
||||
func (r *Hosts) Get(id string) (*models.Host, error) {
|
||||
var host models.Host
|
||||
ret := r.db.Where("hosts.id = ?", id).First(&host)
|
||||
return &host, ret.Error
|
||||
}
|
||||
|
||||
func (r *Hosts) GetWithKeys(id string) (*models.HostDecrypted, error) {
|
||||
var host models.Host
|
||||
ret := r.db.Joins("Key").Joins("AltKey").Where("hosts.id = ?", id).First(&host)
|
||||
if ret.Error != nil {
|
||||
@ -67,3 +85,7 @@ func (r *Hosts) Update(id string, item *models.Host) error {
|
||||
func (r *Hosts) Delete(id string) error {
|
||||
return r.db.Delete(&models.Host{Model: models.Model{ID: id}}).Error
|
||||
}
|
||||
|
||||
func (r *Hosts) SetParentId(id *string, hostIds []string) error {
|
||||
return r.db.Model(&models.Host{}).Where("id IN (?)", hostIds).Update("parent_id", id).Error
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"rul.sh/vaulterm/server/models"
|
||||
@ -17,10 +18,13 @@ func Router(app fiber.Router) {
|
||||
router.Post("/", create)
|
||||
router.Put("/:id", update)
|
||||
router.Delete("/:id", delete)
|
||||
router.Post("/move", move)
|
||||
}
|
||||
|
||||
func getAll(c *fiber.Ctx) error {
|
||||
teamId := c.Query("teamId")
|
||||
parentId := c.Query("parentId")
|
||||
|
||||
user := utils.GetUser(c)
|
||||
repo := NewRepository(&Hosts{User: user})
|
||||
|
||||
@ -28,7 +32,7 @@ func getAll(c *fiber.Ctx) error {
|
||||
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||
}
|
||||
|
||||
rows, err := repo.GetAll(GetAllOpt{TeamID: teamId})
|
||||
rows, err := repo.GetAll(GetAllOpt{TeamID: teamId, ParentID: &parentId})
|
||||
if err != nil {
|
||||
return utils.ResponseError(c, err, 500)
|
||||
}
|
||||
@ -143,3 +147,55 @@ func delete(c *fiber.Ctx) error {
|
||||
"message": "Successfully deleted",
|
||||
})
|
||||
}
|
||||
|
||||
func move(c *fiber.Ctx) error {
|
||||
user := utils.GetUser(c)
|
||||
repo := NewRepository(&Hosts{User: user})
|
||||
|
||||
// validate request
|
||||
var body MoveHostSchema
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return utils.ResponseError(c, err, 500)
|
||||
}
|
||||
if body.HostID == "" {
|
||||
return utils.ResponseError(c, errors.New("invalid request"), 400)
|
||||
}
|
||||
|
||||
// get parent
|
||||
var parentId *string
|
||||
|
||||
if body.ParentID != "" {
|
||||
parent, err := repo.Get(body.ParentID)
|
||||
if err != nil {
|
||||
return utils.ResponseError(c, err, 500)
|
||||
}
|
||||
if !parent.CanWrite(&user.User) {
|
||||
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||
}
|
||||
parentId = &body.ParentID
|
||||
}
|
||||
|
||||
// get hosts
|
||||
hostIds := strings.Split(body.HostID, ",")
|
||||
hosts, err := repo.GetAll(GetAllOpt{TeamID: body.TeamID, ID: hostIds})
|
||||
if err != nil {
|
||||
return utils.ResponseError(c, err, 500)
|
||||
}
|
||||
|
||||
if len(hosts) != len(hostIds) {
|
||||
return utils.ResponseError(c, errors.New("one or more hosts not found"), 400)
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if !host.CanWrite(&user.User) {
|
||||
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||
}
|
||||
}
|
||||
|
||||
// move the hosts to new parent
|
||||
if err := repo.SetParentId(parentId, hostIds); err != nil {
|
||||
return utils.ResponseError(c, err, 500)
|
||||
}
|
||||
|
||||
return c.JSON(true)
|
||||
}
|
||||
|
@ -16,5 +16,13 @@ type CreateHostSchema struct {
|
||||
}
|
||||
|
||||
type GetAllOpt struct {
|
||||
TeamID string
|
||||
TeamID string
|
||||
ParentID *string
|
||||
ID []string
|
||||
}
|
||||
|
||||
type MoveHostSchema struct {
|
||||
TeamID string `json:"teamId"`
|
||||
ParentID string `json:"parentId"`
|
||||
HostID string `json:"hostId"`
|
||||
}
|
||||
|
@ -149,7 +149,13 @@ func getDiskUsage(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- strin
|
||||
return
|
||||
}
|
||||
|
||||
fields := strings.Fields(lines[1])
|
||||
fields := strings.Fields(lines[len(lines)-2])
|
||||
if len(fields) < 5 {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(fields[0], "/") {
|
||||
fields = append([]string{"/"}, fields...)
|
||||
}
|
||||
result <- fmt.Sprintf("\x03%s,%s,%s", fields[1], fields[2], fields[4])
|
||||
}
|
||||
|
||||
|
@ -13,7 +13,7 @@ func HandleStats(c *websocket.Conn) {
|
||||
|
||||
user := utils.GetUserWs(c)
|
||||
hostRepo := hosts.NewRepository(&hosts.Hosts{User: user})
|
||||
data, _ := hostRepo.Get(hostId)
|
||||
data, _ := hostRepo.GetWithKeys(hostId)
|
||||
|
||||
if data == nil || !data.HasAccess(&user.User) {
|
||||
c.WriteMessage(websocket.TextMessage, []byte("Host not found"))
|
||||
|
@ -17,7 +17,7 @@ func HandleTerm(c *websocket.Conn) {
|
||||
|
||||
user := utils.GetUserWs(c)
|
||||
hostRepo := hosts.NewRepository(&hosts.Hosts{User: user})
|
||||
data, err := hostRepo.Get(hostId)
|
||||
data, err := hostRepo.GetWithKeys(hostId)
|
||||
|
||||
if data == nil || !data.HasAccess(&user.User) {
|
||||
log.Printf("Cannot find host! %v\n", err)
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
HostTypeGroup = "group"
|
||||
HostTypeSSH = "ssh"
|
||||
HostTypePVE = "pve"
|
||||
HostTypePVENode = "pve_node"
|
||||
|
@ -9,16 +9,18 @@ import (
|
||||
)
|
||||
|
||||
func GetDataPath(resolveFile string) string {
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
if dataDir != "" {
|
||||
return filepath.Join(dataDir, resolveFile)
|
||||
}
|
||||
|
||||
// Resolve the app directory
|
||||
execPath, err := os.Executable()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
appDir := filepath.Dir(execPath)
|
||||
if resolveFile == "" {
|
||||
return appDir
|
||||
}
|
||||
return filepath.Join(appDir, resolveFile)
|
||||
|
||||
return filepath.Join(cwd, resolveFile)
|
||||
}
|
||||
|
||||
func CheckAndCreateEnvFile() error {
|
||||
|
Loading…
x
Reference in New Issue
Block a user