feat: add group

This commit is contained in:
Khairul Hidayat 2024-11-16 23:50:02 +07:00
parent b88b04a235
commit c8e61ed4aa
24 changed files with 534 additions and 86 deletions

View File

@ -52,10 +52,9 @@ const InteractiveSession = ({ type, params }: InteractiveSessionProps) => {
) : ( ) : (
<Terminal url={termUrl} /> <Terminal url={termUrl} />
); );
default:
throw new Error("Unknown interactive session type");
} }
return null;
}; };
export default InteractiveSession; export default InteractiveSession;

View File

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { GetProps, Button as BaseButton, Spinner } from "tamagui"; import { GetProps, Button as BaseButton, Spinner } from "tamagui";
import Icons from "./icons";
type ButtonProps = GetProps<typeof BaseButton> & { type ButtonProps = GetProps<typeof BaseButton> & {
isDisabled?: boolean; 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; export default Button;

View File

@ -3,9 +3,32 @@ import { GetProps, ScrollView, View, ViewStyle } from "tamagui";
type GridItem = { key: string }; 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; data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode; renderItem: (item: T, index: number) => React.ReactNode;
gap?: string | number;
columns: { columns: {
xs?: number; xs?: number;
sm?: 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, data,
renderItem, renderItem,
columns,
gap, gap,
...props ...props
}: GridViewProps<T>) => { }: GridViewProps<T>) => {
@ -33,20 +56,13 @@ const GridView = <T extends GridItem>({
}, [columns]); }, [columns]);
return ( return (
<ScrollView <View flexDirection="row" flexWrap="wrap" {...props}>
{...props}
contentContainerStyle={{
flexDirection: "row",
flexWrap: "wrap",
...(props.contentContainerStyle as object),
}}
>
{data?.map((item, idx) => ( {data?.map((item, idx) => (
<View key={item.key} p={gap} flexShrink={0} {...basisProps}> <View key={item.key} p={gap} flexShrink={0} {...basisProps}>
{renderItem(item, idx)} {renderItem(item, idx)}
</View> </View>
))} ))}
</ScrollView> </View>
); );
}; };

View File

@ -63,6 +63,7 @@ export const MultiTapPressable = ({
}} }}
numberOfTaps={numberOfTaps} numberOfTaps={numberOfTaps}
ref={tapRef} ref={tapRef}
maxDelayMs={120}
> >
<View pressStyle={{ opacity: 0.5 }} {...props} /> <View pressStyle={{ opacity: 0.5 }} {...props} />
</TapGestureHandler> </TapGestureHandler>

View 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 };
};

View File

@ -58,6 +58,7 @@ export default function LoginPage() {
name="username" name="username"
autoCapitalize="none" autoCapitalize="none"
onSubmitEditing={onSubmit} onSubmitEditing={onSubmit}
autoFocus
/> />
</FormField> </FormField>
<FormField vertical label="Password"> <FormField vertical label="Password">

View File

@ -47,22 +47,30 @@ const HostForm = () => {
<InputField f={1} form={form} name="label" placeholder="Label..." /> <InputField f={1} form={form} name="label" placeholder="Label..." />
</FormField> </FormField>
<FormField label="Hostname">
<InputField form={form} name="host" placeholder="IP or hostname..." />
</FormField>
<FormField label="Type"> <FormField label="Type">
<SelectField form={form} name="type" items={typeOptions} /> <SelectField form={form} name="type" items={typeOptions} />
</FormField> </FormField>
<FormField label="Port"> {type !== "group" && (
<InputField <>
form={form} <FormField label="Hostname">
name="port" <InputField
keyboardType="number-pad" form={form}
placeholder="Port" name="host"
/> placeholder="IP or hostname..."
</FormField> />
</FormField>
<FormField label="Port">
<InputField
form={form}
name="port"
keyboardType="number-pad"
placeholder="Port"
/>
</FormField>
</>
)}
{type === "ssh" ? ( {type === "ssh" ? (
<SSHFormFields form={form} /> <SSHFormFields form={form} />

View File

@ -6,12 +6,19 @@ import OSIcons from "@/components/ui/os-icons";
type HostItemProps = { type HostItemProps = {
host: any; host: any;
selected?: boolean;
onMultiTap: () => void; onMultiTap: () => void;
onTap: () => void; onTap?: () => void;
onEdit?: (() => void) | null; onEdit?: (() => void) | null;
}; };
const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => { const HostItem = ({
host,
selected,
onMultiTap,
onTap,
onEdit,
}: HostItemProps) => {
return ( return (
<MultiTapPressable <MultiTapPressable
cursor="pointer" cursor="pointer"
@ -20,15 +27,24 @@ const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => {
onMultiTap={onMultiTap} onMultiTap={onMultiTap}
onTap={onTap} onTap={onTap}
> >
<Card bordered p="$4"> <Card
bordered
p="$4"
borderColor={selected ? "$blue8" : "$borderColor"}
bg={selected ? "$blue3" : undefined}
>
<XStack> <XStack>
<OSIcons {host.type === "group" ? (
name={host.os} <Icons name="package-variant-closed" size={18} mr="$2" mt="$1" />
size={18} ) : (
mr="$2" <OSIcons
mt="$1" name={host.os}
fallback="desktop-classic" size={18}
/> mr="$2"
mt="$1"
fallback="desktop-classic"
/>
)}
<View flex={1}> <View flex={1}>
<Text>{host.label}</Text> <Text>{host.label}</Text>

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 React, { useMemo, useState } from "react";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import SearchInput from "@/components/ui/search-input"; import SearchInput from "@/components/ui/search-input";
import { useTermSession } from "@/stores/terminal-sessions"; import { useTermSession } from "@/stores/terminal-sessions";
import { hostFormModal } from "./form"; import { hostFormModal } from "./form";
import GridView from "@/components/ui/grid-view"; import { GridLayout } from "@/components/ui/grid-view";
import HostItem from "./host-item"; import HostItem from "./host-item";
import { useHosts } from "../hooks/query"; import { useHosts } from "../hooks/query";
type HostsListProps = { type HostsListProps = {
allowEdit?: boolean; 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 openSession = useTermSession((i) => i.push);
const navigation = useNavigation(); const navigation = useNavigation();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const hosts = useHosts(); const { data, isLoading } = useHosts({ parentId });
const hostsList = useMemo(() => { const hostsList = useMemo(() => {
let items = hosts.data || []; let items = data || [];
if (search) { if (search) {
items = items.filter((item: any) => { items = items.filter((item: any) => {
@ -33,7 +43,25 @@ const HostList = ({ allowEdit = true }: HostsListProps) => {
} }
return items.map((i: any) => ({ ...i, key: i.id })); 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) => { const onEdit = (host: any) => {
if (!allowEdit) return; if (!allowEdit) return;
@ -68,29 +96,76 @@ const HostList = ({ allowEdit = true }: HostsListProps) => {
/> />
</View> </View>
{hosts.isLoading ? ( {isLoading ? (
<View alignItems="center" justifyContent="center" flex={1}> <View alignItems="center" justifyContent="center" flex={1}>
<Spinner size="large" /> <Spinner size="large" />
<Text mt="$4">Loading...</Text> <Text mt="$4">Loading...</Text>
</View> </View>
) : ( ) : (
<GridView <ScrollView>
data={hostsList} {groups.length > 0 && (
columns={{ sm: 2, lg: 3, xl: 4 }} <>
contentContainerStyle={{ p: "$2", pt: 0 }} <Text mx="$4">Groups</Text>
gap="$2.5" <ItemList
renderItem={(host: any) => ( data={groups}
<HostItem selected={selected}
host={host} onTap={onSelectedChange ? onSelect : undefined}
onTap={() => {}} onMultiTap={(group) => onParentIdChange?.(group.id)}
onMultiTap={() => onOpenTerminal(host)} onEdit={allowEdit ? onEdit : undefined}
onEdit={allowEdit ? () => onEdit(host) : null} />
/> </>
)} )}
/>
<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); export default React.memo(HostList);

View File

@ -5,12 +5,13 @@ import { useMemo } from "react";
import { useKeychains } from "@/pages/keychains/hooks/query"; import { useKeychains } from "@/pages/keychains/hooks/query";
import queryClient from "@/lib/queryClient"; import queryClient from "@/lib/queryClient";
import { useTeamId } from "@/stores/auth"; import { useTeamId } from "@/stores/auth";
import { MoveHostPayload } from "../schema/query";
export const useHosts = () => { export const useHosts = (params: any = {}) => {
const teamId = useTeamId(); const teamId = useTeamId();
return useQuery({ return useQuery({
queryKey: ["hosts", teamId], queryKey: ["hosts", teamId, params],
queryFn: () => api("/hosts", { params: { teamId } }), queryFn: () => api("/hosts", { params: { teamId, ...params } }),
select: (i) => i.rows, select: (i) => i.rows,
}); });
}; };
@ -40,7 +41,26 @@ export const useSaveHost = () => {
? api(`/hosts/${body.id}`, { method: "PUT", body }) ? api(`/hosts/${body.id}`, { method: "PUT", body })
: api(`/hosts`, { method: "POST", 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["hosts"] }); queryClient.invalidateQueries({ queryKey: ["hosts"] });
}, },

View File

@ -1,5 +1,5 @@
import { Button } from "tamagui"; import { Button, GetProps, useMedia } from "tamagui";
import React from "react"; import React, { useMemo, useState } from "react";
import Drawer from "expo-router/drawer"; import Drawer from "expo-router/drawer";
import HostList from "./components/host-list"; import HostList from "./components/host-list";
import HostForm, { hostFormModal } from "./components/form"; import HostForm, { hostFormModal } from "./components/form";
@ -8,36 +8,152 @@ import { initialValues } from "./schema/form";
import KeyForm from "../keychains/components/form"; import KeyForm from "../keychains/components/form";
import { useUser } from "@/hooks/useUser"; import { useUser } from "@/hooks/useUser";
import { useTeamId } from "@/stores/auth"; 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() { export default function HostsPage() {
const teamId = useTeamId(); const teamId = useTeamId();
const user = useUser(); 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 ( return (
<> <>
<Drawer.Screen <Drawer.Screen
options={{ options={{
headerRight: headerLeft: parentId
!teamId || user?.teamCanWrite(teamId) ? () => <BackButton onPress={onGoBack} />
? () => <AddButton /> : media.gtXs
: undefined, ? () => null
: undefined,
headerTitle:
selected.length > 0 ? `Selected ${selected.length} hosts` : "Hosts",
headerRight: () => actions,
}} }}
/> />
<HostList /> <HostList
parentId={parentId}
onParentIdChange={setParentId}
selected={selected}
onSelectedChange={setSelected}
/>
<HostForm /> <HostForm />
<KeyForm /> <KeyForm />
</> </>
); );
} }
const AddButton = () => ( const AddButton = (props: GetProps<typeof Button>) => (
<Button <Button
bg="$colorTransparent" bg="$colorTransparent"
icon={<Icons name="plus" size={24} />} icon={<Icons name="plus" size={24} />}
onPress={() => hostFormModal.onOpen(initialValues)} $gtSm={{ mr: "$2" }}
$gtSm={{ mr: "$3" }} {...props}
> >
New New
</Button> </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"
/>
</>
);
};

View File

@ -8,6 +8,10 @@ export const baseFormSchema = z.object({
parentId: z.string().ulid().nullish(), parentId: z.string().ulid().nullish(),
}); });
const groupSchema = baseFormSchema.merge(
z.object({ type: z.literal("group") })
);
const hostSchema = baseFormSchema.merge( const hostSchema = baseFormSchema.merge(
z.object({ z.object({
host: hostnameShape(), host: hostnameShape(),
@ -52,7 +56,7 @@ const incusSchema = hostSchema.merge(
export const formSchema = z.discriminatedUnion( export const formSchema = z.discriminatedUnion(
"type", "type",
[sshSchema, pveSchema, incusSchema], [groupSchema, sshSchema, pveSchema, incusSchema],
{ errorMap: () => ({ message: "Invalid host type" }) } { errorMap: () => ({ message: "Invalid host type" }) }
); );
@ -67,6 +71,7 @@ export const initialValues: FormSchema = {
}; };
export const typeOptions: SelectItem[] = [ export const typeOptions: SelectItem[] = [
{ label: "Group", value: "group" },
{ label: "SSH", value: "ssh" }, { label: "SSH", value: "ssh" },
{ label: "Proxmox VE", value: "pve" }, { label: "Proxmox VE", value: "pve" },
{ label: "Incus", value: "incus" }, { label: "Incus", value: "incus" },

View File

@ -0,0 +1,6 @@
//
export type MoveHostPayload = {
parentId: string | null;
hostId: string | string[];
};

View File

@ -80,6 +80,7 @@ export default function ServerPage() {
autoCapitalize="none" autoCapitalize="none"
keyboardType="url" keyboardType="url"
placeholder="https://" placeholder="https://"
onSubmitEditing={onSubmit}
/> />
</FormField> </FormField>

View 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;

View File

@ -4,11 +4,11 @@ import PagerView, { PagerViewRef } from "@/components/ui/pager-view";
import { useTermSession } from "@/stores/terminal-sessions"; import { useTermSession } from "@/stores/terminal-sessions";
import { Button, useMedia } from "tamagui"; import { Button, useMedia } from "tamagui";
import SessionTabs from "./components/session-tabs"; import SessionTabs from "./components/session-tabs";
import HostList from "../hosts/components/host-list";
import Drawer from "expo-router/drawer"; import Drawer from "expo-router/drawer";
import { router } from "expo-router"; import { router } from "expo-router";
import Icons from "@/components/ui/icons"; import Icons from "@/components/ui/icons";
import { useDebounceCallback } from "@/hooks/useDebounce"; import { useDebounceCallback } from "@/hooks/useDebounce";
import NewSessionPage from "./new-session-page";
const TerminalPage = () => { const TerminalPage = () => {
const pagerViewRef = useRef<PagerViewRef>(null!); const pagerViewRef = useRef<PagerViewRef>(null!);
@ -58,7 +58,7 @@ const TerminalPage = () => {
/> />
{sessions.length > 0 && media.gtSm ? <SessionTabs /> : null} {sessions.length > 0 && media.gtSm ? <SessionTabs /> : null}
{!sessions.length ? <HostList allowEdit={false} /> : pagerView} {!sessions.length ? <NewSessionPage /> : pagerView}
</> </>
); );
}; };

View File

@ -23,19 +23,37 @@ func NewRepository(r *Hosts) *Hosts {
func (r *Hosts) GetAll(opt GetAllOpt) ([]*models.Host, error) { func (r *Hosts) GetAll(opt GetAllOpt) ([]*models.Host, error) {
query := r.db.Order("id DESC") query := r.db.Order("id DESC")
if len(opt.ID) > 0 {
query = query.Where("hosts.id IN (?)", opt.ID)
}
if opt.TeamID != "" { if opt.TeamID != "" {
query = query.Where("hosts.team_id = ?", opt.TeamID) query = query.Where("hosts.team_id = ?", opt.TeamID)
} else { } else {
query = query.Where("hosts.owner_id = ? AND hosts.team_id IS NULL", r.User.ID) 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 var rows []*models.Host
ret := query.Find(&rows) ret := query.Find(&rows)
return rows, ret.Error 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 var host models.Host
ret := r.db.Joins("Key").Joins("AltKey").Where("hosts.id = ?", id).First(&host) ret := r.db.Joins("Key").Joins("AltKey").Where("hosts.id = ?", id).First(&host)
if ret.Error != nil { 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 { func (r *Hosts) Delete(id string) error {
return r.db.Delete(&models.Host{Model: models.Model{ID: id}}).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
}

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"rul.sh/vaulterm/server/models" "rul.sh/vaulterm/server/models"
@ -17,10 +18,13 @@ func Router(app fiber.Router) {
router.Post("/", create) router.Post("/", create)
router.Put("/:id", update) router.Put("/:id", update)
router.Delete("/:id", delete) router.Delete("/:id", delete)
router.Post("/move", move)
} }
func getAll(c *fiber.Ctx) error { func getAll(c *fiber.Ctx) error {
teamId := c.Query("teamId") teamId := c.Query("teamId")
parentId := c.Query("parentId")
user := utils.GetUser(c) user := utils.GetUser(c)
repo := NewRepository(&Hosts{User: user}) repo := NewRepository(&Hosts{User: user})
@ -28,7 +32,7 @@ func getAll(c *fiber.Ctx) error {
return utils.ResponseError(c, errors.New("no access"), 403) 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 { if err != nil {
return utils.ResponseError(c, err, 500) return utils.ResponseError(c, err, 500)
} }
@ -143,3 +147,55 @@ func delete(c *fiber.Ctx) error {
"message": "Successfully deleted", "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)
}

View File

@ -16,5 +16,13 @@ type CreateHostSchema struct {
} }
type GetAllOpt 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"`
} }

View File

@ -149,7 +149,13 @@ func getDiskUsage(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- strin
return 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]) result <- fmt.Sprintf("\x03%s,%s,%s", fields[1], fields[2], fields[4])
} }

View File

@ -13,7 +13,7 @@ func HandleStats(c *websocket.Conn) {
user := utils.GetUserWs(c) user := utils.GetUserWs(c)
hostRepo := hosts.NewRepository(&hosts.Hosts{User: user}) hostRepo := hosts.NewRepository(&hosts.Hosts{User: user})
data, _ := hostRepo.Get(hostId) data, _ := hostRepo.GetWithKeys(hostId)
if data == nil || !data.HasAccess(&user.User) { if data == nil || !data.HasAccess(&user.User) {
c.WriteMessage(websocket.TextMessage, []byte("Host not found")) c.WriteMessage(websocket.TextMessage, []byte("Host not found"))

View File

@ -17,7 +17,7 @@ func HandleTerm(c *websocket.Conn) {
user := utils.GetUserWs(c) user := utils.GetUserWs(c)
hostRepo := hosts.NewRepository(&hosts.Hosts{User: user}) hostRepo := hosts.NewRepository(&hosts.Hosts{User: user})
data, err := hostRepo.Get(hostId) data, err := hostRepo.GetWithKeys(hostId)
if data == nil || !data.HasAccess(&user.User) { if data == nil || !data.HasAccess(&user.User) {
log.Printf("Cannot find host! %v\n", err) log.Printf("Cannot find host! %v\n", err)

View File

@ -5,6 +5,7 @@ import (
) )
const ( const (
HostTypeGroup = "group"
HostTypeSSH = "ssh" HostTypeSSH = "ssh"
HostTypePVE = "pve" HostTypePVE = "pve"
HostTypePVENode = "pve_node" HostTypePVENode = "pve_node"

View File

@ -9,16 +9,18 @@ import (
) )
func GetDataPath(resolveFile string) string { func GetDataPath(resolveFile string) string {
dataDir := os.Getenv("DATA_DIR")
if dataDir != "" {
return filepath.Join(dataDir, resolveFile)
}
// Resolve the app directory // Resolve the app directory
execPath, err := os.Executable() cwd, err := os.Getwd()
if err != nil { if err != nil {
return "" return ""
} }
appDir := filepath.Dir(execPath)
if resolveFile == "" { return filepath.Join(cwd, resolveFile)
return appDir
}
return filepath.Join(appDir, resolveFile)
} }
func CheckAndCreateEnvFile() error { func CheckAndCreateEnvFile() error {