Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions web/src/components/machine-mark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { CloudIcon, ComputerDesktopIcon } from '@heroicons/react/16/solid'

import { cn } from '@/lib/utils'

/**
* The two words a machine mark can say, spelled once because three screens
* say them: the sessions list on its headings, the same list on every row's
* machine badge, and the terminal's own corner chip. A pair of glyphs that
* meant one thing on one screen and another elsewhere would teach nobody
* anything.
*/
export const THIS_MACHINE_LABEL = 'This machine'
export const RELAY_MACHINE_LABEL = 'Machine reached over the relay'

/**
* Which kind of machine this is: the one the browser is running on, or one
* reached the long way round.
*
* The question it answers is not "which machine" — the name beside it already
* does that — but "is that name the box in front of me". Machines are named
* after their hostnames by default (the join line takes `os.Hostname`), and a
* hostname is exactly the kind of string nobody has memorised.
*
* The glyphs are the two the app already uses for these facts: a desktop for
* the local machine, and the cloud that stands for Cloudflare everywhere else
* in this UI — remote access is a Worker on the reader's own account, so a
* session on another machine really is reached through it. The cloud keeps
* Cloudflare's orange, as it does on the Remote access screen. The desktop
* takes whatever colour it lands in, because the ordinary case should not
* shout and because this mark rides two very different grounds: a muted badge
* on the sessions list, and a translucent chip over a terminal.
*
* `home` is the caller's judgement, never derived here. Only the route knows
* whether this page came off the daemon's own origin — a relay tab reaches
* every machine on the fleet, including the one it rides, over the relay.
*/
export function MachineMark({ home, className }: { home: boolean; className?: string }) {
const Icon = home ? ComputerDesktopIcon : CloudIcon
const label = home ? THIS_MACHINE_LABEL : RELAY_MACHINE_LABEL
return (
// A labelled image rather than a decorative one: the glyph carries a fact
// no text beside it repeats, so dropping it from the accessibility tree
// would drop the fact. Same treatment the pinned star gets.
<span role="img" aria-label={label} title={label} className={cn('flex shrink-0', className)}>
<Icon aria-hidden="true" className={cn('size-3.5', !home && 'text-[#f6821f]')} />
</span>
)
}
98 changes: 98 additions & 0 deletions web/src/components/session-table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,52 @@ describe('SessionTable', () => {
expect(text.indexOf('MacBook Pro')).toBeLessThan(text.indexOf('devbox'))
})

it('marks a machine heading as this machine or as a relay one', async () => {
await renderTable({
groups: [
group('machine:m1', 'MacBook Pro', [fs({ id: 'a1' })]),
group('machine:m2', 'devbox', [fs({ id: 'b2', machineId: 'm2', machineName: 'devbox' })]),
],
columns: ['name'],
machineMarks: { home: 'm1' },
})
expect(screen.getAllByLabelText('This machine').length).toBe(1)
expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(1)
})

it('marks a machine subheading too, wherever the second cut puts it', async () => {
const api = fs({ id: 'a1', tags: ['api'] })
await renderTable({
groups: [
{
...group('tag:api', 'api', [api]),
children: [group(`tag:api${SUBKEY_SEP}machine:m1`, 'MacBook Pro', [api])],
},
],
columns: ['name'],
machineMarks: { home: 'm1' },
})
expect(screen.getAllByLabelText('This machine').length).toBe(1)
})

it('leaves a heading about anything but a machine unmarked', async () => {
await renderTable({
groups: [group('tag:api', 'api', [fs({ id: 'a1', tags: ['api'] })])],
columns: ['name'],
machineMarks: { home: 'm1' },
})
expect(screen.queryByLabelText('This machine')).toBeNull()
expect(screen.queryByLabelText('Machine reached over the relay')).toBeNull()
})

it('keeps the fold control named after its group alone', async () => {
// The mark stands beside the toggle, never inside it: the button's
// accessible name is the heading's, and a glyph swallowed into it
// would make every machine heading announce two facts as one name.
await renderTable({ machineMarks: { home: 'm1' } })
expect(screen.getByRole('button', { name: 'MacBook Pro' })).toBeTruthy()
})

it('folds a collapsed group down to its heading', async () => {
await renderTable({ collapsed: new Set(['machine:m1']) })

Expand Down Expand Up @@ -314,6 +360,58 @@ describe('SessionTable', () => {
expect(screen.getByText('MacBook Pro')).toBeTruthy()
})

it('marks a row on the machine this browser is running on', async () => {
// The house half of the pair: the name alone answers "which machine",
// and this answers "is that the one in front of me" — the question a
// hostname nobody has memorised cannot.
await renderTable({
groups: [group('tag:api', 'api', [fs({ id: 'a1', tags: ['api'] })])],
machineMarks: { home: 'm1' },
})
expect(screen.getAllByLabelText('This machine').length).toBe(1)
expect(screen.queryByLabelText('Machine reached over the relay')).toBeNull()
})

it('marks a row on any other machine as reached over the relay', async () => {
await renderTable({
groups: [
group('tag:api', 'api', [
fs({ id: 'a1', tags: ['api'] }),
fs({ id: 'b2', tags: ['api'], machineId: 'm2', machineName: 'devbox' }),
]),
],
machineMarks: { home: 'm1' },
})
expect(screen.getAllByLabelText('This machine').length).toBe(1)
expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(1)
})

it('calls every machine remote when no machine is the one in front of the reader', async () => {
// A phone on a relay origin: it reaches all of them the long way round,
// and a house on any of them would name a box in another room.
await renderTable({
groups: [
group('tag:api', 'api', [
fs({ id: 'a1', tags: ['api'] }),
fs({ id: 'b2', tags: ['api'], machineId: 'm2', machineName: 'devbox' }),
]),
],
machineMarks: { home: null },
})
expect(screen.queryByLabelText('This machine')).toBeNull()
expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(2)
})

it('draws no marks at all when the caller offers none', async () => {
// One machine on the fleet: there is nothing to tell apart, and a badge
// wearing a glyph about it would be chrome answering a question nobody
// asked.
await renderTable({ groups: [group('tag:api', 'api', [fs({ id: 'a1', tags: ['api'] })])] })
expect(screen.queryByLabelText('This machine')).toBeNull()
expect(screen.queryByLabelText('Machine reached over the relay')).toBeNull()
expect(screen.getByText('MacBook Pro')).toBeTruthy()
})

it('shows the tags as badges', async () => {
await renderTable({
groups: [group('machine:m1', 'MacBook Pro', [fs({ id: 'a1', tags: ['api', 'prod'] })])],
Expand Down
60 changes: 58 additions & 2 deletions web/src/components/session-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '@heroicons/react/16/solid'
import { GripVerticalIcon } from 'lucide-react'

import { MachineMark } from '@/components/machine-mark'
import { SessionPreview } from '@/components/session-preview'
import { TagBadges } from '@/components/tag-badges'
import { Badge } from '@/components/ui/badge'
Expand All @@ -36,11 +37,36 @@ import {
import { keyOf, type FleetSession } from '@/fleet/types'
import { ago } from '@/lib/time'
import { cn } from '@/lib/utils'
import { COLUMN_KEYS, displayName, type ColumnKey, type Group } from '@/sessions/view'
import {
COLUMN_KEYS,
displayName,
machineOfGroup,
type ColumnKey,
type Group,
} from '@/sessions/view'

/** What a row's ⋯ menu can ask of a session. */
export type RowAction = 'rename' | 'tags' | 'pin' | 'unpin' | 'close'

/**
* Whether machines wear a mark on this screen, and which of them is the one
* the browser is running on.
*
* Undefined turns the marks off entirely, which is the one-machine fleet: a
* screen where every row and every heading names the same machine has nothing
* to tell apart, and a glyph repeated down it would be chrome answering a
* question nobody asked.
*
* `home` is null when no machine is the one in front of the reader — a phone
* on a relay origin reaches every machine the long way round, its own ride
* included — and the marks then read as all-remote, which is the truth there.
* The caller decides both, because only the route knows how this page was
* served (fleet/provider.tsx, `useLoopbackTab`).
*/
export interface MachineMarks {
home: string | null
}

/**
* How a dragged row is let go, when the caller offers dragging at all.
*
Expand Down Expand Up @@ -206,6 +232,7 @@ function SessionRow({
onToggleSelect,
onAction,
peek,
machineMarks,
}: {
s: FleetSession
/** The heading this row renders under — what a drag is picked up from. */
Expand All @@ -221,6 +248,7 @@ function SessionRow({
onToggleSelect: (key: string) => void
onAction: (action: RowAction, s: FleetSession) => void
peek?: PeekFn
machineMarks?: MachineMarks
}) {
const key = keyOf(s)
const name = displayName(s)
Expand Down Expand Up @@ -367,7 +395,15 @@ function SessionRow({
</span>
)}
{shown.includes('machine') && (
<Badge variant="outline" className="text-zinc-500 dark:text-zinc-400">
<Badge variant="outline" className="gap-x-1.5 text-zinc-500 dark:text-zinc-400">
{/*
The mark leads the name, because it is the coarser fact: which
kind of machine, then which one. Absent on a one-machine fleet,
where the badge is the plain name it always was.
*/}
{machineMarks !== undefined && (
<MachineMark home={s.machineId === machineMarks.home} />
)}
{s.machineName}
</Badge>
)}
Expand Down Expand Up @@ -495,6 +531,7 @@ export function SessionTable({
spawnLabel,
drag,
peek,
machineMarks,
}: {
groups: Group[]
/**
Expand Down Expand Up @@ -522,6 +559,8 @@ export function SessionTable({
drag?: DragToGroup
/** How a row asks what it is doing, for the hover preview. See PeekFn. */
peek?: PeekFn
/** Whether machines are marked local or remote here. See MachineMarks. */
machineMarks?: MachineMarks
}) {
/**
* The ghost's subject, held here because only the drag layer's own events
Expand Down Expand Up @@ -644,6 +683,7 @@ export function SessionTable({
onAction={onAction}
onSpawnIn={onSpawnIn}
peek={peek}
machineMarks={machineMarks}
/>
))}
</div>
Expand Down Expand Up @@ -693,6 +733,7 @@ function GroupSection({
onAction,
onSpawnIn,
peek,
machineMarks,
}: {
g: Group
/** How many headings sit above this one: 0 for a group, 1 for its children. */
Expand All @@ -710,6 +751,7 @@ function GroupSection({
onAction(action: RowAction, s: FleetSession): void
onSpawnIn?(group: Group): void
peek?: PeekFn
machineMarks?: MachineMarks
}) {
/*
* Registered even for a heading that refuses drops, and deliberately: the
Expand All @@ -736,6 +778,10 @@ function GroupSection({
* announce it as.
*/
const spawn = onSpawnIn === undefined ? undefined : spawnLabel?.(g)
// Which machine this heading names, when it names one at all: a tag or a
// directory heading gathers rows from every machine there is, and a mark on
// it would be a claim about all of them at once.
const headingMachine = machineMarks === undefined ? null : machineOfGroup(g.key)
return (
<section ref={setNodeRef} className="flex flex-col">
{/*
Expand Down Expand Up @@ -783,6 +829,14 @@ function GroupSection({
{g.label}
</span>
</button>
{/*
Beside the toggle, never inside it: the fold control's accessible
name is the group's own, and a mark swallowed into it would make
every machine heading announce two facts as one name.
*/}
{headingMachine !== null && machineMarks !== undefined && (
<MachineMark home={headingMachine === machineMarks.home} />
)}
<span className="shrink-0 text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
{tally(g.sessions)}
</span>
Expand Down Expand Up @@ -834,6 +888,7 @@ function GroupSection({
onAction={onAction}
onSpawnIn={onSpawnIn}
peek={peek}
machineMarks={machineMarks}
/>
))}
</div>
Expand All @@ -857,6 +912,7 @@ function GroupSection({
onToggleSelect={onToggleSelect}
onAction={onAction}
peek={peek}
machineMarks={machineMarks}
/>
</Fragment>
))}
Expand Down
Loading
Loading