fix: resolve random css issues with mobile layouts

This commit is contained in:
Aarnav Tale
2026-01-11 15:54:08 -05:00
parent 8105b8f94d
commit d189d75d5d
14 changed files with 261 additions and 252 deletions
+1
View File
@@ -22,6 +22,7 @@
- Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)). - Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)).
- Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)). - Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)).
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)). - Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
--- ---
# 0.6.1 (October 12, 2025) # 0.6.1 (October 12, 2025)
+5 -5
View File
@@ -23,15 +23,15 @@ function Tabs({ label, className, ...props }: TabsProps) {
<div className={cn('flex flex-col', className)}> <div className={cn('flex flex-col', className)}>
<div <div
{...tabListProps} {...tabListProps}
ref={ref}
className={cn( className={cn(
'flex items-center rounded-t-xl w-fit', 'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
'border-headplane-100 dark:border-headplane-800', 'border-headplane-100 dark:border-headplane-800',
'border-t border-x', 'border-t border-x',
)} )}
ref={ref}
> >
{[...state.collection].map((item) => ( {[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} /> <Tab item={item} key={item.key} state={state} />
))} ))}
</div> </div>
<TabsPanel key={state.selectedItem?.key} state={state} /> <TabsPanel key={state.selectedItem?.key} state={state} />
@@ -52,7 +52,6 @@ function Tab({ item, state }: TabsTabProps) {
return ( return (
<div <div
{...tabProps} {...tabProps}
ref={ref}
className={cn( className={cn(
'pl-2 pr-3 py-2.5', 'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950', 'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
@@ -60,6 +59,7 @@ function Tab({ item, state }: TabsTabProps) {
'border-r border-headplane-100 dark:border-headplane-800', 'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0', 'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)} )}
ref={ref}
> >
{rendered} {rendered}
</div> </div>
@@ -76,11 +76,11 @@ function TabsPanel({ state, ...props }: TabsPanelProps) {
return ( return (
<div <div
{...tabPanelProps} {...tabPanelProps}
ref={ref}
className={cn( className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl', 'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800', 'border border-headplane-100 dark:border-headplane-800',
)} )}
ref={ref}
> >
{state.selectedItem?.props.children} {state.selectedItem?.props.children}
</div> </div>
+1 -1
View File
@@ -45,7 +45,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Links /> <Links />
<link href="favicon.ico" rel="icon" /> <link href="favicon.ico" rel="icon" />
</head> </head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50"> <body className="overscroll-none overflow-x-hidden dark:bg-headplane-900 dark:text-headplane-50">
{children} {children}
<ToastProvider queue={toastQueue} /> <ToastProvider queue={toastQueue} />
<ScrollRestoration /> <ScrollRestoration />
+23 -23
View File
@@ -1,18 +1,18 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core'; import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import { import {
restrictToParentElement, restrictToParentElement,
restrictToVerticalAxis, restrictToVerticalAxis,
} from '@dnd-kit/modifiers'; } from '@dnd-kit/modifiers';
import { import {
SortableContext,
arrayMove, arrayMove,
SortableContext,
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react'; import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router'; import { Form } from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Input from '~/components/Input'; import Input from '~/components/Input';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
@@ -37,18 +37,15 @@ export default function ManageDomains({
}, [searchDomains]); }, [searchDomains]);
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1> <h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4"> <p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS, Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain. your tailnet domain is used as the first search domain.
</p> </p>
<DndContext <DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners} collisionDetection={closestCorners}
onDragStart={(event) => { modifiers={[restrictToVerticalAxis, restrictToParentElement]}
setActiveId(event.active.id);
}}
onDragEnd={(event) => { onDragEnd={(event) => {
setActiveId(null); setActiveId(null);
const { active, over } = event; const { active, over } = event;
@@ -70,6 +67,9 @@ export default function ManageDomains({
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex)); setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
} }
}} }}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
> >
<TableList> <TableList>
{magic ? ( {magic ? (
@@ -91,19 +91,19 @@ export default function ManageDomains({
> >
{localDomains.map((sd, index) => ( {localDomains.map((sd, index) => (
<Domain <Domain
key={sd}
domain={sd} domain={sd}
id={index + 1} id={index + 1}
isDisabled={isDisabled} isDisabled={isDisabled}
key={sd}
/> />
))} ))}
<DragOverlay adjustScale> <DragOverlay adjustScale>
{activeId ? ( {activeId ? (
<Domain <Domain
isDragging
domain={localDomains[(activeId as number) - 1]} domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1} id={(activeId as number) - 1}
isDisabled={isDisabled} isDisabled={isDisabled}
isDragging
/> />
) : undefined} ) : undefined}
</DragOverlay> </DragOverlay>
@@ -111,28 +111,28 @@ export default function ManageDomains({
{isDisabled ? undefined : ( {isDisabled ? undefined : (
<TableList.Item key="add-sd"> <TableList.Item key="add-sd">
<Form <Form
method="POST"
className="flex items-center justify-between w-full" className="flex items-center justify-between w-full"
method="POST"
> >
<input type="hidden" name="action_id" value="add_domain" /> <input name="action_id" type="hidden" value="add_domain" />
<Input <Input
type="text"
className={cn( className={cn(
'border-none font-mono p-0 text-sm', 'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1', 'rounded-none focus:ring-0 w-full ml-1',
)} )}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired isRequired
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
/> />
<Button <Button
type="submit"
className={cn( className={cn(
'px-2 py-1 rounded-md', 'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
type="submit"
> >
Add Add
</Button> </Button>
@@ -164,11 +164,11 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
return ( return (
<TableList.Item <TableList.Item
ref={setNodeRef}
className={cn( className={cn(
isSortableDragging ? 'opacity-50' : '', isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '', isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)} )}
ref={setNodeRef}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@@ -186,15 +186,15 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
</p> </p>
{isDragging ? undefined : ( {isDragging ? undefined : (
<Form method="POST"> <Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" /> <input name="action_id" type="hidden" value="remove_domain" />
<input type="hidden" name="domain" value={domain} /> <input name="domain" type="hidden" value={domain} />
<Button <Button
type="submit"
isDisabled={isDisabled}
className={cn( className={cn(
'px-2 py-1 rounded-md', 'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400', 'text-red-500 dark:text-red-400',
)} )}
isDisabled={isDisabled}
type="submit"
> >
Remove Remove
</Button> </Button>
+18 -17
View File
@@ -20,14 +20,14 @@ export default function ManageNS({
overrideLocalDns, overrideLocalDns,
}: Props) { }: Props) {
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1> <h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p> <p>
Set the nameservers used by devices on the Tailnet to resolve DNS Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '} queries.{' '}
<Link <Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation" name="Tailscale DNS Documentation"
to="https://tailscale.com/kb/1054/dns"
> >
Learn more Learn more
</Link> </Link>
@@ -35,12 +35,12 @@ export default function ManageNS({
<div className="mt-4"> <div className="mt-4">
{Object.keys(nameservers).map((key) => ( {Object.keys(nameservers).map((key) => (
<NameserverList <NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled} isDisabled={isDisabled}
isGlobal={key === 'global'}
key={key}
name={key}
nameservers={nameservers} nameservers={nameservers}
overrideLocalDns={overrideLocalDns} overrideLocalDns={overrideLocalDns}
name={key}
/> />
))} ))}
@@ -66,16 +66,17 @@ function NameserverList({
name, name,
}: ListProps) { }: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name]; const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
if (list.length === 0) { if (list.length === 0) {
return null; return null;
} }
const submit = useSubmit();
return ( return (
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
{isGlobal ? ( {isGlobal ? (
<div className="flex items-center justify-between w-full"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
<h2 className="text-md font-medium opacity-80"> <h2 className="text-md font-medium opacity-80">
Global Nameservers Global Nameservers
</h2> </h2>
@@ -87,8 +88,8 @@ function NameserverList({
names outside the tailnet. When disabled (default), devices names outside the tailnet. When disabled (default), devices
will prefer their local DNS configuration. will prefer their local DNS configuration.
<Link <Link
to="https://tailscale.com/kb/1054/dns#global-nameservers"
name="Tailscale Global Nameservers Documentation" name="Tailscale Global Nameservers Documentation"
to="https://tailscale.com/kb/1054/dns#global-nameservers"
> >
Learn More Learn More
</Link> </Link>
@@ -96,11 +97,10 @@ function NameserverList({
</Tooltip> </Tooltip>
<p>Override DNS servers</p> <p>Override DNS servers</p>
<Switch <Switch
label="Override local DNS settings" className="h-[15px] w-[23px] p-0.5"
className="h-[15px] w-[23px] p-[2px]"
switchClassName="h-[9px] w-[9px]"
name="override_dns"
defaultSelected={overrideLocalDns} defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => { onChange={(v) => {
submit( submit(
{ {
@@ -112,6 +112,7 @@ function NameserverList({
}, },
); );
}} }}
switchClassName="h-[9px] w-[9px]"
/> />
</div> </div>
</div> </div>
@@ -125,20 +126,20 @@ function NameserverList({
<TableList.Item key={ns}> <TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p> <p className="font-mono text-sm">{ns}</p>
<Form method="POST"> <Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" /> <input name="action_id" type="hidden" value="remove_ns" />
<input type="hidden" name="ns" value={ns} /> <input name="ns" type="hidden" value={ns} />
<input <input
type="hidden"
name="split_name" name="split_name"
type="hidden"
value={isGlobal ? 'global' : name} value={isGlobal ? 'global' : name}
/> />
<Button <Button
isDisabled={isDisabled}
type="submit"
className={cn( className={cn(
'px-2 py-1 rounded-md', 'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400', 'text-red-500 dark:text-red-400',
)} )}
isDisabled={isDisabled}
type="submit"
> >
Remove Remove
</Button> </Button>
+13 -11
View File
@@ -13,14 +13,14 @@ interface Props {
export default function ManageRecords({ records, isDisabled }: Props) { export default function ManageRecords({ records, isDisabled }: Props) {
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1> <h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p> <p>
Headscale supports adding custom DNS records to your Tailnet. As of now, Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '} only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
<Link <Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation" name="Headscale DNS Records documentation"
to="https://headscale.net/stable/ref/dns"
> >
Learn More Learn More
</Link> </Link>
@@ -32,7 +32,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
<p className="opacity-50 mx-auto">No DNS records found</p> <p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item> </TableList.Item>
) : ( ) : (
records.map((record, index) => ( records.map((record) => (
<TableList.Item key={`${record.name}-${record.value}`}> <TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-2 items-center w-full"> <div className="flex gap-2 items-center w-full">
<p <p
@@ -43,22 +43,24 @@ export default function ManageRecords({ records, isDisabled }: Props) {
> >
{record.type} {record.type}
</p> </p>
<div className="grid grid-cols-2 gap-2 w-full"> <div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
<p className="font-mono text-sm">{record.name}</p> <p className="font-mono text-sm truncate">{record.name}</p>
<p className="font-mono text-sm">{record.value}</p> <p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
{record.value}
</p>
</div> </div>
</div> </div>
<Form method="POST"> <Form method="POST">
<input type="hidden" name="action_id" value="remove_record" /> <input name="action_id" type="hidden" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} /> <input name="record_name" type="hidden" value={record.name} />
<input type="hidden" name="record_type" value={record.type} /> <input name="record_type" type="hidden" value={record.type} />
<Button <Button
type="submit"
isDisabled={isDisabled}
className={cn( className={cn(
'px-2 py-1 rounded-md', 'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400', 'text-red-500 dark:text-red-400',
)} )}
isDisabled={isDisabled}
type="submit"
> >
Remove Remove
</Button> </Button>
+7 -7
View File
@@ -9,21 +9,21 @@ interface Props {
export default function RenameTailnet({ name, isDisabled }: Props) { export default function RenameTailnet({ name, isDisabled }: Props) {
return ( return (
<div className="flex flex-col w-2/3 gap-y-4"> <div className="flex flex-col w-full sm:w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1> <h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p> <p>
This is the base domain name of your Tailnet. Devices are accessible at{' '} This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled. <Code>[device].{name}</Code> when Magic DNS is enabled.
</p> </p>
<Input <Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm" className="w-3/5 font-medium text-sm"
isReadOnly
label="Tailnet name" label="Tailnet name"
value={name} labelHidden
onFocus={(event) => { onFocus={(event) => {
event.target.select(); event.target.select();
}} }}
value={name}
/> />
<Dialog> <Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button> <Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
@@ -33,13 +33,13 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
Keep in mind that changing this can lead to all sorts of unexpected Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet. behavior and may break existing devices in your tailnet.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" /> <input name="action_id" type="hidden" value="rename_tailnet" />
<Input <Input
defaultValue={name}
isRequired isRequired
label="Tailnet name" label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name" name="new_name"
placeholder="ts.net"
/> />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
+6 -6
View File
@@ -84,20 +84,20 @@ export default function Page() {
tailnet. tailnet.
</Notice> </Notice>
)} )}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} /> <RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
<ManageNS <ManageNS
nameservers={allNs}
isDisabled={isDisabled} isDisabled={isDisabled}
nameservers={allNs}
overrideLocalDns={data.overrideDns} overrideLocalDns={data.overrideDns}
/> />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} /> <ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageDomains <ManageDomains
searchDomains={data.searchDomains}
isDisabled={isDisabled} isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined} magic={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
/> />
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1> <h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="mb-4"> <p className="mb-4">
Automatically register domain names for each device on the tailnet. Automatically register domain names for each device on the tailnet.
@@ -108,7 +108,7 @@ export default function Page() {
</Code>{' '} </Code>{' '}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} /> <ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
</div> </div>
</div> </div>
); );
+152 -150
View File
@@ -157,8 +157,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
return ( return (
<> <>
<div className="flex justify-between items-center mb-6"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div className="flex flex-col w-2/3"> <div className="flex flex-col">
<h1 className="text-2xl font-medium mb-2">Machines</h1> <h1 className="text-2xl font-medium mb-2">Machines</h1>
<p> <p>
Manage the devices connected to your Tailnet.{' '} Manage the devices connected to your Tailnet.{' '}
@@ -210,85 +210,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
: `${loaderData.populatedNodes.length} machines`} : `${loaderData.populatedNodes.length} machines`}
</span> </span>
</div> </div>
<table className="table-auto w-full rounded-lg"> <div className="overflow-x-auto">
<thead className="text-headplane-600 dark:text-headplane-300"> <table className="table-auto w-full rounded-lg min-w-[640px]">
<tr className="text-left px-0.5"> <thead className="text-headplane-600 dark:text-headplane-300">
<th <tr className="text-left px-0.5">
aria-sort={
sortField === 'name'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by name"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('name')}
type="button"
>
Name
{sortField === 'name' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === 'ip'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="pb-2 w-1/4"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('ip')}
type="button"
>
Addresses
{sortField === 'ip' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th <th
aria-sort={ aria-sort={
sortField === 'version' sortField === 'name'
? sortDirection === 'asc' ? sortDirection === 'asc'
? 'ascending' ? 'ascending'
: 'descending' : 'descending'
@@ -297,16 +225,16 @@ export default function Page({ loaderData }: Route.ComponentProps) {
className="uppercase text-xs font-bold pb-2" className="uppercase text-xs font-bold pb-2"
> >
<button <button
aria-label="Sort by version" aria-label="Sort by name"
className={cn( className={cn(
'flex items-center gap-x-1 cursor-pointer', 'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100', 'hover:text-headplane-900 dark:hover:text-headplane-100',
)} )}
onClick={() => handleSort('version')} onClick={() => handleSort('name')}
type="button" type="button"
> >
Version Name
{sortField === 'version' && {sortField === 'name' &&
(sortDirection === 'asc' ? ( (sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" /> <ChevronUp className="w-3 h-3" />
) : ( ) : (
@@ -314,76 +242,150 @@ export default function Page({ loaderData }: Route.ComponentProps) {
))} ))}
</button> </button>
</th> </th>
) : undefined} <th
<th aria-sort={
aria-sort={ sortField === 'ip'
sortField === 'lastSeen' ? sortDirection === 'asc'
? sortDirection === 'asc' ? 'ascending'
? 'ascending' : 'descending'
: 'descending' : 'none'
: 'none' }
} className="pb-2 w-1/4"
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by last seen"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('lastSeen')}
type="button"
> >
Last Seen <div className="flex items-center gap-x-1">
{sortField === 'lastSeen' && <button
(sortDirection === 'asc' ? ( aria-label="Sort by IP address"
<ChevronUp className="w-3 h-3" /> className={cn(
) : ( 'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
<ChevronDown className="w-3 h-3" /> 'hover:text-headplane-900 dark:hover:text-headplane-100',
))} )}
</button> onClick={() => handleSort('ip')}
</th> type="button"
</tr> >
</thead> Addresses
<tbody {sortField === 'ip' &&
className={cn( (sortDirection === 'asc' ? (
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top', <ChevronUp className="w-3 h-3" />
'border-t border-headplane-100 dark:border-headplane-800', ) : (
)} <ChevronDown className="w-3 h-3" />
> ))}
{filteredAndSortedNodes.length === 0 ? ( </button>
<tr> {loaderData.magic ? (
<td <Tooltip>
className="py-8 text-center text-headplane-500" <Info className="w-4 h-4" />
colSpan={loaderData.agent !== undefined ? 5 : 4} <Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based
on their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === 'version'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by version"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('version')}
type="button"
>
Version
{sortField === 'version' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === 'lastSeen'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
> >
No machines found matching "{searchQuery}" <button
</td> aria-label="Sort by last seen"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('lastSeen')}
type="button"
>
Last Seen
{sortField === 'lastSeen' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
</tr> </tr>
) : ( </thead>
filteredAndSortedNodes.map((node) => ( <tbody
<MachineRow className={cn(
existingTags={sortNodeTags(loaderData.nodes)} 'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
isAgent={ 'border-t border-headplane-100 dark:border-headplane-800',
loaderData.agent )}
? loaderData.agent === node.nodeKey >
: undefined {filteredAndSortedNodes.length === 0 ? (
} <tr>
isDisabled={ <td
loaderData.writable className="py-8 text-center text-headplane-500"
? false // If the user has write permissions, they can edit all machines colSpan={loaderData.agent !== undefined ? 5 : 4}
: node.user.providerId?.split('/').pop() !== >
loaderData.subject No machines found matching "{searchQuery}"
} </td>
key={node.id} </tr>
magic={loaderData.magic} ) : (
node={node} filteredAndSortedNodes.map((node) => (
users={loaderData.users} <MachineRow
/> existingTags={sortNodeTags(loaderData.nodes)}
)) isAgent={
)} loaderData.agent
</tbody> ? loaderData.agent === node.nodeKey
</table> : undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
/>
))
)}
</tbody>
</table>
</div>
</> </>
); );
} }
+3 -3
View File
@@ -15,7 +15,7 @@ export default function Page({
}: Route.ComponentProps) { }: Route.ComponentProps) {
return ( return (
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)"> <div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1> <h1 className="text-2xl font-medium mb-4">Settings</h1>
<p> <p>
The settings page is still under construction. As I'm able to add more The settings page is still under construction. As I'm able to add more
@@ -23,7 +23,7 @@ export default function Page({
free to open an issue on the GitHub repository. free to open an issue on the GitHub repository.
</p> </p>
</div> </div>
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1> <h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p> <p>
Headscale fully supports pre-authentication keys in order to easily Headscale fully supports pre-authentication keys in order to easily
@@ -45,7 +45,7 @@ export default function Page({
</RemixLink> </RemixLink>
{config && isOidcEnabled ? ( {config && isOidcEnabled ? (
<> <>
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4"> <h1 className="text-2xl font-medium mb-4">
Authentication Restrictions Authentication Restrictions
</h1> </h1>
@@ -43,7 +43,7 @@ export default function Page({
return ( return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)"> <div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full sm:w-2/3">
<p className="mb-4 text-md"> <p className="mb-4 text-md">
<RemixLink className="font-medium" to="/settings"> <RemixLink className="font-medium" to="/settings">
Settings Settings
+2 -2
View File
@@ -19,7 +19,7 @@ export default function RestrictionTable({
isDisabled, isDisabled,
}: RestrictionProps) { }: RestrictionProps) {
return ( return (
<div className="w-2/3"> <div className="w-full sm:w-2/3">
<h2 className="text-2xl font-medium mt-8"> <h2 className="text-2xl font-medium mt-8">
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
</h2> </h2>
@@ -61,7 +61,7 @@ export default function RestrictionTable({
) : ( ) : (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70"> <TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
{iconForType(type)} {iconForType(type)}
<p className="font-semibold"> <p className="font-semibold text-center">
All {type}s are permitted to authenticate. All {type}s are permitted to authenticate.
</p> </p>
</TableList.Item> </TableList.Item>
+28 -26
View File
@@ -114,32 +114,34 @@ export default function Page({ loaderData }: Route.ComponentProps) {
Manage the users in your network and their permissions. Manage the users in your network and their permissions.
</p> </p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} /> <ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<table className="table-auto w-full rounded-lg"> <div className="overflow-x-auto">
<thead className="text-headplane-600 dark:text-headplane-300"> <table className="table-auto w-full rounded-lg min-w-[640px]">
<tr className="text-left px-0.5"> <thead className="text-headplane-600 dark:text-headplane-300">
<th className="uppercase text-xs font-bold pb-2">User</th> <tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">Role</th> <th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th> <th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th> <th className="uppercase text-xs font-bold pb-2">Created At</th>
</tr> <th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</thead> </tr>
<tbody </thead>
className={cn( <tbody
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top', className={cn(
'border-t border-headplane-100 dark:border-headplane-800', 'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
)} 'border-t border-headplane-100 dark:border-headplane-800',
> )}
{users >
.sort((a, b) => a.name.localeCompare(b.name)) {users
.map((user) => ( .sort((a, b) => a.name.localeCompare(b.name))
<UserRow .map((user) => (
key={user.id} <UserRow
role={loaderData.roles[users.indexOf(user)]} key={user.id}
user={user} role={loaderData.roles[users.indexOf(user)]}
/> user={user}
))} />
</tbody> ))}
</table> </tbody>
</table>
</div>
</> </>
); );
} }
+1
View File
@@ -65,6 +65,7 @@
@utility container { @utility container {
margin-inline: auto; margin-inline: auto;
padding-inline: 1rem; padding-inline: 1rem;
max-width: 100%;
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */ /* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
@media (width >= theme(--breakpoint-sm)) { @media (width >= theme(--breakpoint-sm)) {