mirror of
https://github.com/khairul169/vaulterm.git
synced 2025-04-28 08:39:37 +07:00
feat: add tags
This commit is contained in:
parent
95f9f76edd
commit
b0932c39b1
18
frontend/components/ui/badge.tsx
Normal file
18
frontend/components/ui/badge.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { GetProps, Text, View } from "tamagui";
|
||||||
|
|
||||||
|
type BadgeProps = GetProps<typeof View>;
|
||||||
|
|
||||||
|
const Badge = ({ children, ...props }: BadgeProps) => {
|
||||||
|
return (
|
||||||
|
<View px={5} py={1} flexShrink={0} bg="$blue6" borderRadius="$3" {...props}>
|
||||||
|
{typeof children === "string" ? (
|
||||||
|
<Text fontSize="$2">{children}</Text>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Badge;
|
@ -56,7 +56,7 @@ export const GridLayout = <T extends GridItem>({
|
|||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View flexDirection="row" flexWrap="wrap" {...props}>
|
<View flexDirection="row" flexWrap="wrap" alignItems="stretch" {...props}>
|
||||||
{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)}
|
||||||
|
@ -27,17 +27,10 @@ const Modal = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange} modal>
|
<Dialog open={open} onOpenChange={onOpenChange} modal>
|
||||||
<Adapt when="sm" platform="touch">
|
<Adapt when="sm" platform="touch">
|
||||||
<Sheet
|
<Sheet animation="quick" zIndex={999} modal dismissOnSnapToBottom>
|
||||||
animation="quick"
|
|
||||||
zIndex={999}
|
|
||||||
modal
|
|
||||||
dismissOnSnapToBottom
|
|
||||||
snapPoints={[40, 60, 0]}
|
|
||||||
// disableDrag
|
|
||||||
>
|
|
||||||
<Sheet.Overlay
|
<Sheet.Overlay
|
||||||
opacity={0.1}
|
opacity={0.1}
|
||||||
animation="quick"
|
animation="quickest"
|
||||||
enterStyle={{ opacity: 0 }}
|
enterStyle={{ opacity: 0 }}
|
||||||
exitStyle={{ opacity: 0 }}
|
exitStyle={{ opacity: 0 }}
|
||||||
zIndex={0}
|
zIndex={0}
|
||||||
@ -52,7 +45,7 @@ const Modal = ({
|
|||||||
<Dialog.Overlay
|
<Dialog.Overlay
|
||||||
key="overlay"
|
key="overlay"
|
||||||
animation="quickest"
|
animation="quickest"
|
||||||
opacity={0.5}
|
opacity={0.2}
|
||||||
enterStyle={{ opacity: 0 }}
|
enterStyle={{ opacity: 0 }}
|
||||||
exitStyle={{ opacity: 0 }}
|
exitStyle={{ opacity: 0 }}
|
||||||
/>
|
/>
|
||||||
|
257
frontend/components/ui/select-multiple.tsx
Normal file
257
frontend/components/ui/select-multiple.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import React, { forwardRef, useMemo, useState } from "react";
|
||||||
|
import { Controller, FieldValues } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
Adapt,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Input,
|
||||||
|
ListItem,
|
||||||
|
ScrollView,
|
||||||
|
Sheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "tamagui";
|
||||||
|
import { FormFieldBaseProps } from "./utility";
|
||||||
|
import { ErrorMessage } from "./form";
|
||||||
|
import Icons from "./icons";
|
||||||
|
|
||||||
|
export type SelectItem = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectMultipleProps = React.ComponentPropsWithoutRef<typeof ListItem> & {
|
||||||
|
items?: SelectItem[] | null;
|
||||||
|
value?: string[];
|
||||||
|
defaultValue?: string[];
|
||||||
|
onChange?: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
isCreatable?: boolean;
|
||||||
|
onCreate?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectMultipleRef = React.ElementRef<typeof ListItem>;
|
||||||
|
|
||||||
|
const SelectMultiple = forwardRef<SelectMultipleRef, SelectMultipleProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select...",
|
||||||
|
isCreatable = false,
|
||||||
|
onCreate,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const itemList = useMemo(() => {
|
||||||
|
if (!items?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = [...items];
|
||||||
|
|
||||||
|
const searchText = search.toLowerCase().trim();
|
||||||
|
if (searchText?.length > 0) {
|
||||||
|
list = list.filter((i) => i.label.toLowerCase().includes(searchText));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.sort((a) => (value?.includes(a.value) ? -1 : 1));
|
||||||
|
}, [items, search, value]);
|
||||||
|
|
||||||
|
const onCreateNew = () => {
|
||||||
|
const newItem = search.trim();
|
||||||
|
onCreate?.(newItem);
|
||||||
|
onItemPress(newItem);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onItemPress = (key: string) => {
|
||||||
|
const curValues = [...(value || [])];
|
||||||
|
const idx = curValues.indexOf(key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
curValues.splice(idx, 1);
|
||||||
|
onChange?.(curValues);
|
||||||
|
} else {
|
||||||
|
curValues.push(key);
|
||||||
|
onChange?.(curValues);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
if (itemList.length > 0) {
|
||||||
|
onItemPress(itemList[0].value);
|
||||||
|
setSearch("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem = search.trim();
|
||||||
|
if (isCreatable && newItem.length > 0) {
|
||||||
|
return onCreateNew();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Trigger asChild {...restProps}>
|
||||||
|
<ListItem
|
||||||
|
ref={ref}
|
||||||
|
backgrounded
|
||||||
|
hoverTheme
|
||||||
|
pressTheme
|
||||||
|
borderRadius="$4"
|
||||||
|
tabIndex={0}
|
||||||
|
focusVisibleStyle={{
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: "$outlineColor",
|
||||||
|
}}
|
||||||
|
borderWidth={1}
|
||||||
|
title={value?.join(", ") || placeholder}
|
||||||
|
/>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
|
||||||
|
<Adapt when="sm" platform="touch">
|
||||||
|
<Sheet animation="quick" zIndex={999} modal dismissOnSnapToBottom>
|
||||||
|
<Sheet.Overlay
|
||||||
|
opacity={0.1}
|
||||||
|
animation="quickest"
|
||||||
|
enterStyle={{ opacity: 0 }}
|
||||||
|
exitStyle={{ opacity: 0 }}
|
||||||
|
zIndex={0}
|
||||||
|
/>
|
||||||
|
<Sheet.Frame>
|
||||||
|
<Adapt.Contents />
|
||||||
|
</Sheet.Frame>
|
||||||
|
</Sheet>
|
||||||
|
</Adapt>
|
||||||
|
|
||||||
|
<Dialog.Portal zIndex={999}>
|
||||||
|
<Dialog.Overlay
|
||||||
|
key="overlay"
|
||||||
|
animation="quickest"
|
||||||
|
opacity={0.2}
|
||||||
|
enterStyle={{ opacity: 0 }}
|
||||||
|
exitStyle={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog.Content
|
||||||
|
bordered
|
||||||
|
key="content"
|
||||||
|
elevate
|
||||||
|
animateOnly={["transform", "opacity"]}
|
||||||
|
animation={["quickest", { opacity: { overshootClamping: true } }]}
|
||||||
|
enterStyle={{ x: 0, opacity: 0, scale: 0.95 }}
|
||||||
|
exitStyle={{ x: 0, opacity: 0, scale: 0.98 }}
|
||||||
|
p="$1"
|
||||||
|
w="90%"
|
||||||
|
maxWidth={400}
|
||||||
|
height="80%"
|
||||||
|
maxHeight={400}
|
||||||
|
>
|
||||||
|
<View p="$3">
|
||||||
|
<Dialog.Title fontWeight="normal" fontSize="$7">
|
||||||
|
{placeholder}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Input
|
||||||
|
mt="$1"
|
||||||
|
placeholder="Search..."
|
||||||
|
autoFocus
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
onSubmitEditing={onEnter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<Button
|
||||||
|
position="absolute"
|
||||||
|
top="$2"
|
||||||
|
right="$2"
|
||||||
|
size="$3"
|
||||||
|
bg="$colorTransparent"
|
||||||
|
circular
|
||||||
|
icon={<Icons name="close" size={16} />}
|
||||||
|
/>
|
||||||
|
</Dialog.Close>
|
||||||
|
|
||||||
|
<ScrollView>
|
||||||
|
{!itemList?.length && !isCreatable ? (
|
||||||
|
<Text textAlign="center" my="$2">
|
||||||
|
No results
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!itemList?.length && isCreatable && search.trim().length > 0 ? (
|
||||||
|
<ListItem
|
||||||
|
hoverTheme
|
||||||
|
pressTheme
|
||||||
|
onPress={onCreateNew}
|
||||||
|
title={`Create "${search}"`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{itemList?.map((item) => (
|
||||||
|
<ListItem
|
||||||
|
key={item.value}
|
||||||
|
bg="$colorTransparent"
|
||||||
|
hoverTheme
|
||||||
|
pressTheme
|
||||||
|
onPress={() => onItemPress(item.value)}
|
||||||
|
iconAfter={
|
||||||
|
value?.includes(item.value) ? (
|
||||||
|
<Icons name="check" size={16} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
title={item.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type SelectMultipleFieldProps<T extends FieldValues> = FormFieldBaseProps<T> &
|
||||||
|
SelectMultipleProps;
|
||||||
|
|
||||||
|
export const SelectMultipleField = <T extends FieldValues>({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
...props
|
||||||
|
}: SelectMultipleFieldProps<T>) => (
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<SelectMultiple w="auto" f={1} {...field} {...props} />
|
||||||
|
<ErrorMessage error={fieldState.error} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useSelectCreatableItems = (initialItems?: SelectItem[] | null) => {
|
||||||
|
const [items, setItems] = useState<SelectItem[]>([]);
|
||||||
|
const itemsCombined = useMemo(() => {
|
||||||
|
return [...items, ...(initialItems || [])];
|
||||||
|
}, [items, initialItems]);
|
||||||
|
|
||||||
|
const addItem = (value: string) => {
|
||||||
|
const idx = itemsCombined.findIndex((i) => i.value === value);
|
||||||
|
if (idx >= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems([...items, { label: value, value }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [itemsCombined, addItem, setItems] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectMultiple;
|
@ -46,7 +46,7 @@ const Select = forwardRef<SelectRef, SelectProps>(
|
|||||||
<Sheet native modal dismissOnSnapToBottom snapPoints={[40, 60, 80]}>
|
<Sheet native modal dismissOnSnapToBottom snapPoints={[40, 60, 80]}>
|
||||||
<Sheet.Overlay
|
<Sheet.Overlay
|
||||||
opacity={0.1}
|
opacity={0.1}
|
||||||
animation="quick"
|
animation="quickest"
|
||||||
enterStyle={{ opacity: 0 }}
|
enterStyle={{ opacity: 0 }}
|
||||||
exitStyle={{ opacity: 0 }}
|
exitStyle={{ opacity: 0 }}
|
||||||
zIndex={0}
|
zIndex={0}
|
||||||
|
@ -8,12 +8,16 @@ import { ScrollView, XStack } from "tamagui";
|
|||||||
import { FormSchema, formSchema, typeOptions } from "../schema/form";
|
import { FormSchema, formSchema, typeOptions } from "../schema/form";
|
||||||
import { InputField } from "@/components/ui/input";
|
import { InputField } from "@/components/ui/input";
|
||||||
import FormField from "@/components/ui/form";
|
import FormField from "@/components/ui/form";
|
||||||
import { useSaveHost } from "../hooks/query";
|
import { useSaveHost, useTags } from "../hooks/query";
|
||||||
import { ErrorAlert } from "@/components/ui/alert";
|
import { ErrorAlert } from "@/components/ui/alert";
|
||||||
import Button from "@/components/ui/button";
|
import Button from "@/components/ui/button";
|
||||||
import { PVEFormFields } from "./pve";
|
import { PVEFormFields } from "./pve";
|
||||||
import { IncusFormFields } from "./incus";
|
import { IncusFormFields } from "./incus";
|
||||||
import { SSHFormFields } from "./ssh";
|
import { SSHFormFields } from "./ssh";
|
||||||
|
import {
|
||||||
|
SelectMultipleField,
|
||||||
|
useSelectCreatableItems,
|
||||||
|
} from "@/components/ui/select-multiple";
|
||||||
|
|
||||||
export const hostFormModal = createDisclosure<FormSchema>();
|
export const hostFormModal = createDisclosure<FormSchema>();
|
||||||
|
|
||||||
@ -22,6 +26,8 @@ const HostForm = () => {
|
|||||||
const form = useZForm(formSchema, data);
|
const form = useZForm(formSchema, data);
|
||||||
const isEditing = data?.id != null;
|
const isEditing = data?.id != null;
|
||||||
const type = form.watch("type");
|
const type = form.watch("type");
|
||||||
|
const hostTags = useTags();
|
||||||
|
const [tags, addTag] = useSelectCreatableItems(hostTags.data);
|
||||||
|
|
||||||
const saveMutation = useSaveHost();
|
const saveMutation = useSaveHost();
|
||||||
|
|
||||||
@ -53,6 +59,17 @@ const HostForm = () => {
|
|||||||
|
|
||||||
{type !== "group" && (
|
{type !== "group" && (
|
||||||
<>
|
<>
|
||||||
|
<FormField label="Tags">
|
||||||
|
<SelectMultipleField
|
||||||
|
form={form}
|
||||||
|
name="tags"
|
||||||
|
placeholder="Select Tags"
|
||||||
|
items={tags}
|
||||||
|
isCreatable
|
||||||
|
onCreate={addTag}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Hostname">
|
<FormField label="Hostname">
|
||||||
<InputField
|
<InputField
|
||||||
form={form}
|
form={form}
|
||||||
@ -98,4 +115,4 @@ const HostForm = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HostForm;
|
export default React.memo(HostForm);
|
||||||
|
@ -3,6 +3,7 @@ import React from "react";
|
|||||||
import { MultiTapPressable } from "@/components/ui/pressable";
|
import { MultiTapPressable } from "@/components/ui/pressable";
|
||||||
import Icons from "@/components/ui/icons";
|
import Icons from "@/components/ui/icons";
|
||||||
import OSIcons from "@/components/ui/os-icons";
|
import OSIcons from "@/components/ui/os-icons";
|
||||||
|
import Badge from "@/components/ui/badge";
|
||||||
|
|
||||||
type HostItemProps = {
|
type HostItemProps = {
|
||||||
host: any;
|
host: any;
|
||||||
@ -26,12 +27,14 @@ const HostItem = ({
|
|||||||
numberOfTaps={2}
|
numberOfTaps={2}
|
||||||
onMultiTap={onMultiTap}
|
onMultiTap={onMultiTap}
|
||||||
onTap={onTap}
|
onTap={onTap}
|
||||||
|
h="100%"
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
bordered
|
bordered
|
||||||
p="$4"
|
p="$4"
|
||||||
borderColor={selected ? "$blue8" : "$borderColor"}
|
borderColor={selected ? "$blue8" : "$borderColor"}
|
||||||
bg={selected ? "$blue3" : undefined}
|
bg={selected ? "$blue3" : undefined}
|
||||||
|
h="100%"
|
||||||
>
|
>
|
||||||
<XStack>
|
<XStack>
|
||||||
{host.type === "group" ? (
|
{host.type === "group" ? (
|
||||||
@ -48,6 +51,15 @@ const HostItem = ({
|
|||||||
|
|
||||||
<View flex={1}>
|
<View flex={1}>
|
||||||
<Text>{host.label}</Text>
|
<Text>{host.label}</Text>
|
||||||
|
|
||||||
|
{host.tags?.length > 0 && (
|
||||||
|
<XStack mt="$1" gap="$1" flexWrap="wrap">
|
||||||
|
{host.tags.map((i: any) => (
|
||||||
|
<Badge key={i.name}>{i.name}</Badge>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text fontSize="$3" mt="$2">
|
<Text fontSize="$3" mt="$2">
|
||||||
{host.host}
|
{host.host}
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -14,6 +14,7 @@ type HostsListProps = {
|
|||||||
onParentIdChange?: (id: string | null) => void;
|
onParentIdChange?: (id: string | null) => void;
|
||||||
selected?: string[];
|
selected?: string[];
|
||||||
onSelectedChange?: (ids: string[]) => void;
|
onSelectedChange?: (ids: string[]) => void;
|
||||||
|
hideGroups?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HostList = ({
|
const HostList = ({
|
||||||
@ -22,12 +23,15 @@ const HostList = ({
|
|||||||
onParentIdChange,
|
onParentIdChange,
|
||||||
selected = [],
|
selected = [],
|
||||||
onSelectedChange,
|
onSelectedChange,
|
||||||
|
hideGroups = false,
|
||||||
}: HostsListProps) => {
|
}: 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 { data, isLoading } = useHosts({ parentId });
|
const { data, isLoading } = useHosts({
|
||||||
|
parentId: !search.length ? parentId : "none",
|
||||||
|
});
|
||||||
|
|
||||||
const hostsList = useMemo(() => {
|
const hostsList = useMemo(() => {
|
||||||
let items = data || [];
|
let items = data || [];
|
||||||
@ -37,7 +41,8 @@ const HostList = ({
|
|||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
return (
|
return (
|
||||||
item.label.toLowerCase().includes(q) ||
|
item.label.toLowerCase().includes(q) ||
|
||||||
item.host.toLowerCase().includes(q)
|
item.host.toLowerCase().includes(q) ||
|
||||||
|
item.tags.find((i: any) => i.name.toLowerCase().includes(q)) != null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -65,7 +70,8 @@ const HostList = ({
|
|||||||
|
|
||||||
const onEdit = (host: any) => {
|
const onEdit = (host: any) => {
|
||||||
if (!allowEdit) return;
|
if (!allowEdit) return;
|
||||||
hostFormModal.onOpen(host);
|
const data = { ...host, tags: host.tags?.map((i: any) => i.name) };
|
||||||
|
hostFormModal.onOpen(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOpenTerminal = (host: any) => {
|
const onOpenTerminal = (host: any) => {
|
||||||
@ -103,7 +109,7 @@ const HostList = ({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
{groups.length > 0 && (
|
{groups.length > 0 && !hideGroups && (
|
||||||
<>
|
<>
|
||||||
<Text mx="$4">Groups</Text>
|
<Text mx="$4">Groups</Text>
|
||||||
<ItemList
|
<ItemList
|
||||||
|
@ -66,3 +66,17 @@ export const useMoveHost = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useTags = () => {
|
||||||
|
const teamId = useTeamId();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["hosts/tags", teamId],
|
||||||
|
queryFn: () => api("/hosts/tags", { params: { teamId } }),
|
||||||
|
select: (data) => {
|
||||||
|
return data?.rows?.map((row: any) => ({
|
||||||
|
label: row.name,
|
||||||
|
value: row.name,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
@ -14,6 +14,7 @@ const groupSchema = baseFormSchema.merge(
|
|||||||
|
|
||||||
const hostSchema = baseFormSchema.merge(
|
const hostSchema = baseFormSchema.merge(
|
||||||
z.object({
|
z.object({
|
||||||
|
tags: z.string().array(),
|
||||||
host: hostnameShape(),
|
host: hostnameShape(),
|
||||||
port: z.coerce
|
port: z.coerce
|
||||||
.number({ message: "Invalid port" })
|
.number({ message: "Invalid port" })
|
||||||
@ -65,6 +66,7 @@ export type FormSchema = z.infer<typeof formSchema>;
|
|||||||
export const initialValues: FormSchema = {
|
export const initialValues: FormSchema = {
|
||||||
type: "ssh",
|
type: "ssh",
|
||||||
host: "",
|
host: "",
|
||||||
|
tags: [],
|
||||||
port: 22,
|
port: 22,
|
||||||
label: "",
|
label: "",
|
||||||
keyId: "",
|
keyId: "",
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
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;
|
|
@ -8,7 +8,7 @@ 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";
|
import HostList from "../hosts/components/host-list";
|
||||||
|
|
||||||
const TerminalPage = () => {
|
const TerminalPage = () => {
|
||||||
const pagerViewRef = useRef<PagerViewRef>(null!);
|
const pagerViewRef = useRef<PagerViewRef>(null!);
|
||||||
@ -58,7 +58,11 @@ const TerminalPage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{sessions.length > 0 && media.gtSm ? <SessionTabs /> : null}
|
{sessions.length > 0 && media.gtSm ? <SessionTabs /> : null}
|
||||||
{!sessions.length ? <NewSessionPage /> : pagerView}
|
{!sessions.length ? (
|
||||||
|
<HostList allowEdit={false} parentId="none" hideGroups />
|
||||||
|
) : (
|
||||||
|
pagerView
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -21,7 +21,9 @@ 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.
|
||||||
|
Preload("Tags").
|
||||||
|
Order("id DESC")
|
||||||
|
|
||||||
if len(opt.ID) > 0 {
|
if len(opt.ID) > 0 {
|
||||||
query = query.Where("hosts.id IN (?)", opt.ID)
|
query = query.Where("hosts.id IN (?)", opt.ID)
|
||||||
@ -33,7 +35,7 @@ func (r *Hosts) GetAll(opt GetAllOpt) ([]*models.Host, error) {
|
|||||||
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 != nil && *opt.ParentID != "none" {
|
||||||
if *opt.ParentID != "" {
|
if *opt.ParentID != "" {
|
||||||
query = query.Where("hosts.parent_id = ?", *opt.ParentID)
|
query = query.Where("hosts.parent_id = ?", *opt.ParentID)
|
||||||
} else {
|
} else {
|
||||||
@ -89,3 +91,18 @@ func (r *Hosts) Delete(id string) error {
|
|||||||
func (r *Hosts) SetParentId(id *string, hostIds []string) 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
|
return r.db.Model(&models.Host{}).Where("id IN (?)", hostIds).Update("parent_id", id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Hosts) GetAvailableTags(teamId string) (*[]models.HostTag, error) {
|
||||||
|
query := r.db.Model(&models.HostTag{}).
|
||||||
|
Joins("JOIN hosts ON hosts.id = host_tags.host_id").
|
||||||
|
Distinct("host_tags.name")
|
||||||
|
|
||||||
|
if teamId != "" {
|
||||||
|
query = query.Where("hosts.team_id = ?", teamId)
|
||||||
|
} else {
|
||||||
|
query = query.Where("hosts.owner_id = ? AND hosts.team_id IS NULL", r.User.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []models.HostTag
|
||||||
|
return &result, query.Find(&result).Error
|
||||||
|
}
|
||||||
|
@ -20,6 +20,7 @@ func Router(app fiber.Router) {
|
|||||||
router.Put("/:id", update)
|
router.Put("/:id", update)
|
||||||
router.Delete("/:id", delete)
|
router.Delete("/:id", delete)
|
||||||
router.Post("/move", move)
|
router.Post("/move", move)
|
||||||
|
router.Get("/tags", getTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAll(c *fiber.Ctx) error {
|
func getAll(c *fiber.Ctx) error {
|
||||||
@ -69,6 +70,11 @@ func create(c *fiber.Ctx) error {
|
|||||||
AltKeyID: body.AltKeyID,
|
AltKeyID: body.AltKeyID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item.Tags = []*models.HostTag{}
|
||||||
|
for _, tag := range body.Tags {
|
||||||
|
item.Tags = append(item.Tags, &models.HostTag{Name: tag})
|
||||||
|
}
|
||||||
|
|
||||||
osName, err := tryConnect(c, item)
|
osName, err := tryConnect(c, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500)
|
return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500)
|
||||||
@ -113,16 +119,28 @@ func update(c *fiber.Ctx) error {
|
|||||||
AltKeyID: body.AltKeyID,
|
AltKeyID: body.AltKeyID,
|
||||||
}
|
}
|
||||||
|
|
||||||
osName, err := tryConnect(c, item)
|
// osName, err := tryConnect(c, item)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500)
|
// return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500)
|
||||||
}
|
// }
|
||||||
item.OS = osName
|
// item.OS = osName
|
||||||
|
|
||||||
if err := repo.Update(id, item); err != nil {
|
if err := repo.Update(id, item); err != nil {
|
||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tags := []models.HostTag{}
|
||||||
|
for _, tag := range body.Tags {
|
||||||
|
tags = append(tags, models.HostTag{
|
||||||
|
Name: tag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.db.Unscoped().Model(&item).
|
||||||
|
Association("Tags").Unscoped().Replace(&tags); err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(item)
|
return c.JSON(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,3 +218,18 @@ func move(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(true)
|
return c.JSON(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTags(c *fiber.Ctx) error {
|
||||||
|
teamId := c.Query("teamId")
|
||||||
|
user := lib.GetUser(c)
|
||||||
|
repo := NewRepository(&Hosts{User: user})
|
||||||
|
|
||||||
|
rows, err := repo.GetAvailableTags(teamId)
|
||||||
|
if err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ type CreateHostSchema struct {
|
|||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Metadata datatypes.JSONMap `json:"metadata"`
|
Metadata datatypes.JSONMap `json:"metadata"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
|
||||||
TeamID *string `json:"teamId"`
|
TeamID *string `json:"teamId"`
|
||||||
ParentID *string `json:"parentId"`
|
ParentID *string `json:"parentId"`
|
||||||
|
@ -10,6 +10,7 @@ var Models = []interface{}{
|
|||||||
&models.UserAccount{},
|
&models.UserAccount{},
|
||||||
&models.Keychain{},
|
&models.Keychain{},
|
||||||
&models.Host{},
|
&models.Host{},
|
||||||
|
&models.HostTag{},
|
||||||
&models.Team{},
|
&models.Team{},
|
||||||
&models.TeamMembers{},
|
&models.TeamMembers{},
|
||||||
&models.TermSession{},
|
&models.TermSession{},
|
||||||
|
@ -28,6 +28,7 @@ type Host struct {
|
|||||||
Port int `json:"port" gorm:"type:smallint"`
|
Port int `json:"port" gorm:"type:smallint"`
|
||||||
OS string `json:"os" gorm:"type:varchar(32)"`
|
OS string `json:"os" gorm:"type:varchar(32)"`
|
||||||
Metadata datatypes.JSONMap `json:"metadata"`
|
Metadata datatypes.JSONMap `json:"metadata"`
|
||||||
|
Tags []*HostTag `json:"tags" gorm:"foreignKey:HostID"`
|
||||||
|
|
||||||
ParentID *string `json:"parentId" gorm:"type:varchar(26)"`
|
ParentID *string `json:"parentId" gorm:"type:varchar(26)"`
|
||||||
Parent *Host `json:"parent" gorm:"foreignKey:ParentID"`
|
Parent *Host `json:"parent" gorm:"foreignKey:ParentID"`
|
||||||
@ -40,6 +41,12 @@ type Host struct {
|
|||||||
SoftDeletes
|
SoftDeletes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HostTag struct {
|
||||||
|
HostID string `gorm:"primarykey;type:varchar(26)" json:"-"`
|
||||||
|
Host *Host `gorm:"foreignKey:HostID;references:ID" json:"-"`
|
||||||
|
Name string `gorm:"primarykey;type:varchar(64)" json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type HostDecrypted struct {
|
type HostDecrypted struct {
|
||||||
Host
|
Host
|
||||||
Key map[string]interface{}
|
Key map[string]interface{}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user