mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 23:47:31 +00:00
feat: change node tag dialog to use Select
This commit is contained in:
+14
-14
@@ -50,11 +50,11 @@ function Select(props: SelectProps) {
|
|||||||
<div className={cn('flex flex-col', props.className)}>
|
<div className={cn('flex flex-col', props.className)}>
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
htmlFor={id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
'text-xs font-medium px-3 mb-0.5',
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
'text-headplane-700 dark:text-headplane-100',
|
||||||
)}
|
)}
|
||||||
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
{props.label}
|
{props.label}
|
||||||
</label>
|
</label>
|
||||||
@@ -63,18 +63,18 @@ function Select(props: SelectProps) {
|
|||||||
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
|
props.isInvalid && 'ring-red-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
ref={inputRef}
|
|
||||||
id={id}
|
|
||||||
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||||
data-1p-ignore
|
data-1p-ignore
|
||||||
|
id={id}
|
||||||
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
ref={buttonRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||||
@@ -82,6 +82,7 @@ function Select(props: SelectProps) {
|
|||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||||
)}
|
)}
|
||||||
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
<ChevronDown className="p-0.5" />
|
<ChevronDown className="p-0.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -99,12 +100,12 @@ function Select(props: SelectProps) {
|
|||||||
)}
|
)}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
<Popover
|
<Popover
|
||||||
popoverRef={popoverRef}
|
className="w-full max-w-xs"
|
||||||
triggerRef={inputRef}
|
|
||||||
state={state}
|
|
||||||
isNonModal
|
isNonModal
|
||||||
placement="bottom start"
|
placement="bottom start"
|
||||||
className="w-full max-w-xs"
|
popoverRef={popoverRef}
|
||||||
|
state={state}
|
||||||
|
triggerRef={inputRef}
|
||||||
>
|
>
|
||||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -114,23 +115,22 @@ function Select(props: SelectProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||||
listBoxRef?: React.RefObject<HTMLUListElement | null>;
|
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
||||||
state: ListState<object>;
|
state: ListState<object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListBox(props: ListBoxProps) {
|
function ListBox(props: ListBoxProps) {
|
||||||
const { listBoxRef, state } = props;
|
const { listBoxRef, state } = props;
|
||||||
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
|
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||||
const { listBoxProps } = useListBox(props, state, ref);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
{...listBoxProps}
|
{...listBoxProps}
|
||||||
ref={listBoxRef}
|
|
||||||
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
||||||
|
ref={listBoxRef}
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<Option key={item.key} item={item} state={state} />
|
<Option item={item} key={item.key} state={state} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
@@ -154,7 +154,6 @@ function Option({ item, state }: OptionProps) {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
{...optionProps}
|
{...optionProps}
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between',
|
'flex items-center justify-between',
|
||||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||||
@@ -164,6 +163,7 @@ function Option({ item, state }: OptionProps) {
|
|||||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||||
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{item.rendered}
|
{item.rendered}
|
||||||
{isSelected && <Check className="p-0.5" />}
|
{isSelected && <Check className="p-0.5" />}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ExpiryTag } from '~/components/tags/Expiry';
|
|||||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
||||||
import { SubnetTag } from '~/components/tags/Subnet';
|
import { SubnetTag } from '~/components/tags/Subnet';
|
||||||
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
|
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
|
||||||
import type { User } from '~/types';
|
import type { Machine, User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import * as hinfo from '~/utils/host-info';
|
import * as hinfo from '~/utils/host-info';
|
||||||
import { PopulatedNode } from '~/utils/node-info';
|
import { PopulatedNode } from '~/utils/node-info';
|
||||||
@@ -22,6 +22,7 @@ interface Props {
|
|||||||
isAgent?: boolean;
|
isAgent?: boolean;
|
||||||
magic?: string;
|
magic?: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
nodeList?: Machine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MachineRow({
|
export default function MachineRow({
|
||||||
@@ -30,6 +31,7 @@ export default function MachineRow({
|
|||||||
isAgent,
|
isAgent,
|
||||||
magic,
|
magic,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
nodeList,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const uiTags = useMemo(() => {
|
const uiTags = useMemo(() => {
|
||||||
const tags = uiTagsForNode(node, isAgent);
|
const tags = uiTagsForNode(node, isAgent);
|
||||||
@@ -149,6 +151,7 @@ export default function MachineRow({
|
|||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
magic={magic}
|
magic={magic}
|
||||||
node={node}
|
node={node}
|
||||||
|
nodeList={nodeList}
|
||||||
users={users}
|
users={users}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Menu from '~/components/Menu';
|
import Menu from '~/components/Menu';
|
||||||
import type { User } from '~/types';
|
import type { Machine, User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import { PopulatedNode } from '~/utils/node-info';
|
import { PopulatedNode } from '~/utils/node-info';
|
||||||
import Delete from '../dialogs/delete';
|
import Delete from '../dialogs/delete';
|
||||||
@@ -18,6 +18,7 @@ interface MenuProps {
|
|||||||
magic?: string;
|
magic?: string;
|
||||||
isFullButton?: boolean;
|
isFullButton?: boolean;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
nodeList?: Machine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||||
@@ -28,6 +29,7 @@ export default function MachineMenu({
|
|||||||
users,
|
users,
|
||||||
isFullButton,
|
isFullButton,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
nodeList,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
const supportsTailscaleSSH =
|
const supportsTailscaleSSH =
|
||||||
@@ -77,6 +79,7 @@ export default function MachineMenu({
|
|||||||
<Tags
|
<Tags
|
||||||
isOpen={modal === 'tags'}
|
isOpen={modal === 'tags'}
|
||||||
machine={node}
|
machine={node}
|
||||||
|
nodeList={nodeList}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Plus, TagsIcon, X } from 'lucide-react';
|
import { Plus, TagsIcon, X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Input from '~/components/Input';
|
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
|
import Select from '~/components/Select';
|
||||||
import TableList from '~/components/TableList';
|
import TableList from '~/components/TableList';
|
||||||
import type { Machine } from '~/types';
|
import type { Machine } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
@@ -12,11 +12,30 @@ interface TagsProps {
|
|||||||
machine: Machine;
|
machine: Machine;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
nodeList?: Machine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
export default function Tags({
|
||||||
|
machine,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
nodeList,
|
||||||
|
}: TagsProps) {
|
||||||
const [tags, setTags] = useState(machine.forcedTags);
|
const [tags, setTags] = useState(machine.forcedTags);
|
||||||
const [tag, setTag] = useState('');
|
const [tag, setTag] = useState('tag:');
|
||||||
|
const tagIsInvalid = useMemo(() => {
|
||||||
|
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
|
||||||
|
}, [tag, tags]);
|
||||||
|
|
||||||
|
const validNodeTags = useMemo(() => {
|
||||||
|
if (!nodeList?.length) return [];
|
||||||
|
const allNodeTags = Array.from(
|
||||||
|
new Set(
|
||||||
|
nodeList.flatMap((node) => [...node.validTags, ...node.forcedTags]),
|
||||||
|
),
|
||||||
|
).sort();
|
||||||
|
return allNodeTags.filter((nodeTag) => !tags.includes(nodeTag));
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
@@ -57,44 +76,36 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
|||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<TableList.Item
|
|
||||||
className={cn(
|
|
||||||
'rounded-b-xl focus-within:ring-3',
|
|
||||||
tag.length > 0 &&
|
|
||||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
|
||||||
'ring-3 ring-red-500 ring-opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
className={cn(
|
|
||||||
'border-none font-mono p-0',
|
|
||||||
'rounded-none focus:ring-0 w-full',
|
|
||||||
)}
|
|
||||||
label="Add a tag"
|
|
||||||
labelHidden
|
|
||||||
onChange={setTag}
|
|
||||||
placeholder="tag:example"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'rounded-md p-0.5',
|
|
||||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
|
||||||
'opacity-50 cursor-not-allowed',
|
|
||||||
)}
|
|
||||||
isDisabled={
|
|
||||||
tag.length === 0 ||
|
|
||||||
!tag.startsWith('tag:') ||
|
|
||||||
tags.includes(tag)
|
|
||||||
}
|
|
||||||
onPress={() => {
|
|
||||||
setTags([...tags, tag]);
|
|
||||||
setTag('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="p-1" />
|
|
||||||
</Button>
|
|
||||||
</TableList.Item>
|
|
||||||
</TableList>
|
</TableList>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Select
|
||||||
|
allowsCustomValue
|
||||||
|
aria-label="Add a tag"
|
||||||
|
className="w-full"
|
||||||
|
inputValue={tag}
|
||||||
|
isInvalid={tag.length > 0 && tagIsInvalid}
|
||||||
|
onInputChange={setTag}
|
||||||
|
placeholder="tag:example"
|
||||||
|
>
|
||||||
|
{validNodeTags.map((nodeTag) => {
|
||||||
|
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1',
|
||||||
|
tagIsInvalid && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
isDisabled={tagIsInvalid}
|
||||||
|
onPress={() => {
|
||||||
|
setTags([...tags, tag]);
|
||||||
|
setTag('tag:');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="p-1" size={30} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||||
const [node, users] = await Promise.all([
|
const [node, users, allNodes] = await Promise.all([
|
||||||
api.getNode(params.id),
|
api.getNode(params.id),
|
||||||
api.getUsers(),
|
api.getUsers(),
|
||||||
|
api.getNodes(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||||
@@ -53,13 +54,14 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
magic,
|
magic,
|
||||||
agent: context.agents?.agentID(),
|
agent: context.agents?.agentID(),
|
||||||
stats: lookup?.[enhancedNode.nodeKey],
|
stats: lookup?.[enhancedNode.nodeKey],
|
||||||
|
nodeList: allNodes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const action = machineAction;
|
export const action = machineAction;
|
||||||
|
|
||||||
export default function Page({
|
export default function Page({
|
||||||
loaderData: { node, tags, users, magic, agent, stats },
|
loaderData: { node, tags, users, magic, agent, stats, nodeList },
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const [showRouting, setShowRouting] = useState(false);
|
const [showRouting, setShowRouting] = useState(false);
|
||||||
|
|
||||||
@@ -87,7 +89,13 @@ export default function Page({
|
|||||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||||
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
||||||
</span>
|
</span>
|
||||||
<MenuOptions isFullButton magic={magic} node={node} users={users} />
|
<MenuOptions
|
||||||
|
isFullButton
|
||||||
|
magic={magic}
|
||||||
|
node={node}
|
||||||
|
nodeList={nodeList}
|
||||||
|
users={users}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mb-4">
|
<div className="flex gap-1 mb-4">
|
||||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
key={node.id}
|
key={node.id}
|
||||||
magic={loaderData.magic}
|
magic={loaderData.magic}
|
||||||
node={node}
|
node={node}
|
||||||
|
nodeList={loaderData.nodes}
|
||||||
users={loaderData.users}
|
users={loaderData.users}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user