From c3e07e4e101859585ec440782c66d77ea57b6a95 Mon Sep 17 00:00:00 2001 From: Meow Date: Thu, 6 Nov 2025 19:49:55 -0500 Subject: [PATCH] feat: change node tag dialog to use Select --- app/components/Select.tsx | 28 +++--- .../machines/components/machine-row.tsx | 5 +- app/routes/machines/components/menu.tsx | 5 +- app/routes/machines/dialogs/tags.tsx | 93 +++++++++++-------- app/routes/machines/machine.tsx | 14 ++- app/routes/machines/overview.tsx | 1 + 6 files changed, 86 insertions(+), 60 deletions(-) diff --git a/app/components/Select.tsx b/app/components/Select.tsx index 096edea..5df6e21 100644 --- a/app/components/Select.tsx +++ b/app/components/Select.tsx @@ -50,11 +50,11 @@ function Select(props: SelectProps) {
@@ -63,18 +63,18 @@ function Select(props: SelectProps) { 'flex rounded-xl focus:outline-hidden focus-within:ring-3', 'bg-white dark:bg-headplane-900', 'border border-headplane-100 dark:border-headplane-800', + props.isInvalid && 'ring-red-400', )} > @@ -99,12 +100,12 @@ function Select(props: SelectProps) { )} {state.isOpen && ( @@ -114,23 +115,22 @@ function Select(props: SelectProps) { } interface ListBoxProps extends AriaListBoxOptions { - listBoxRef?: React.RefObject; + listBoxRef: React.RefObject; state: ListState; } function ListBox(props: ListBoxProps) { const { listBoxRef, state } = props; - const ref = listBoxRef ?? useRef(null); - const { listBoxProps } = useListBox(props, state, ref); + const { listBoxProps } = useListBox(props, state, listBoxRef); return (
    {[...state.collection].map((item) => ( -
); @@ -154,7 +154,6 @@ function Option({ item, state }: OptionProps) { return (
  • {item.rendered} {isSelected && } diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index cb44738..2c7f662 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -9,7 +9,7 @@ import { ExpiryTag } from '~/components/tags/Expiry'; import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent'; import { SubnetTag } from '~/components/tags/Subnet'; import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH'; -import type { User } from '~/types'; +import type { Machine, User } from '~/types'; import cn from '~/utils/cn'; import * as hinfo from '~/utils/host-info'; import { PopulatedNode } from '~/utils/node-info'; @@ -22,6 +22,7 @@ interface Props { isAgent?: boolean; magic?: string; isDisabled?: boolean; + nodeList?: Machine[]; } export default function MachineRow({ @@ -30,6 +31,7 @@ export default function MachineRow({ isAgent, magic, isDisabled, + nodeList, }: Props) { const uiTags = useMemo(() => { const tags = uiTagsForNode(node, isAgent); @@ -149,6 +151,7 @@ export default function MachineRow({ isDisabled={isDisabled} magic={magic} node={node} + nodeList={nodeList} users={users} /> diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index 26434fb..2da92ae 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -2,7 +2,7 @@ import { Cog, Ellipsis, SquareTerminal } from 'lucide-react'; import { useState } from 'react'; import Button from '~/components/Button'; import Menu from '~/components/Menu'; -import type { User } from '~/types'; +import type { Machine, User } from '~/types'; import cn from '~/utils/cn'; import { PopulatedNode } from '~/utils/node-info'; import Delete from '../dialogs/delete'; @@ -18,6 +18,7 @@ interface MenuProps { magic?: string; isFullButton?: boolean; isDisabled?: boolean; + nodeList?: Machine[]; } type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; @@ -28,6 +29,7 @@ export default function MachineMenu({ users, isFullButton, isDisabled, + nodeList, }: MenuProps) { const [modal, setModal] = useState(null); const supportsTailscaleSSH = @@ -77,6 +79,7 @@ export default function MachineMenu({ { if (!isOpen) setModal(null); }} diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index b00d081..504d5bc 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -1,9 +1,9 @@ import { Plus, TagsIcon, X } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Button from '~/components/Button'; import Dialog from '~/components/Dialog'; -import Input from '~/components/Input'; import Link from '~/components/Link'; +import Select from '~/components/Select'; import TableList from '~/components/TableList'; import type { Machine } from '~/types'; import cn from '~/utils/cn'; @@ -12,11 +12,30 @@ interface TagsProps { machine: Machine; isOpen: boolean; 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 [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 ( @@ -57,44 +76,36 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) { )) )} - 0 && - (!tag.startsWith('tag:') || tags.includes(tag)) && - 'ring-3 ring-red-500 ring-opacity-50', - )} - > - - - + +
    + + +
    ); diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index c054ed5..86b7cf0 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -35,9 +35,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { } 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.getUsers(), + api.getNodes(), ]); const lookup = await context.agents?.lookup([node.nodeKey]); @@ -53,13 +54,14 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { magic, agent: context.agents?.agentID(), stats: lookup?.[enhancedNode.nodeKey], + nodeList: allNodes, }; } export const action = machineAction; export default function Page({ - loaderData: { node, tags, users, magic, agent, stats }, + loaderData: { node, tags, users, magic, agent, stats, nodeList }, }: Route.ComponentProps) { const [showRouting, setShowRouting] = useState(false); @@ -87,7 +89,13 @@ export default function Page({

    {node.givenName}

    - +
    diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index cf99c1c..d3c12d0 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -138,6 +138,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { key={node.id} magic={loaderData.magic} node={node} + nodeList={loaderData.nodes} users={loaderData.users} /> ))}