diff --git a/hub/src/sync/machineCache.test.ts b/hub/src/sync/machineCache.test.ts new file mode 100644 index 00000000..1e8b0d5f --- /dev/null +++ b/hub/src/sync/machineCache.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'bun:test' +import type { SyncEvent } from '@hapi/protocol/types' +import { Store } from '../store' +import type { SSEManager } from '../sse/sseManager' +import { EventPublisher } from './eventPublisher' +import { MachineCache } from './machineCache' + +function createCache() { + const store = new Store(':memory:') + const broadcast: SyncEvent[] = [] + const sseManager = { broadcast: (event: SyncEvent) => { broadcast.push(event) } } as unknown as SSEManager + const publisher = new EventPublisher(sseManager, () => 'ns') + const cache = new MachineCache(store, publisher) + return { store, publisher, cache, broadcast } +} + +function seedMachine(store: Store, metadata: unknown) { + store.machines.getOrCreateMachine('machine-1', metadata, null, 'ns') +} + +const BASE_METADATA = { + host: 'workstation.local', + platform: 'linux', + happyCliVersion: '1.0.0', + workspaceRoots: ['/home/user'] +} + +describe('MachineCache.renameMachine', () => { + it('sets displayName without touching CLI-reported fields', async () => { + const { store, cache } = createCache() + seedMachine(store, BASE_METADATA) + cache.reloadAll() + + await cache.renameMachine('machine-1', 'Workstation') + + expect(store.machines.getMachine('machine-1')?.metadata).toEqual({ + ...BASE_METADATA, + displayName: 'Workstation' + }) + }) + + it('replaces an existing displayName', async () => { + const { store, cache } = createCache() + seedMachine(store, { ...BASE_METADATA, displayName: 'Old' }) + cache.reloadAll() + + await cache.renameMachine('machine-1', 'New') + + expect((store.machines.getMachine('machine-1')?.metadata as { displayName?: string })?.displayName).toBe('New') + }) + + it('removes the key when given an empty name rather than storing an empty string', async () => { + const { store, cache } = createCache() + seedMachine(store, { ...BASE_METADATA, displayName: 'Workstation' }) + cache.reloadAll() + + await cache.renameMachine('machine-1', '') + + const metadata = store.machines.getMachine('machine-1')?.metadata as Record + expect(metadata).toEqual(BASE_METADATA) + expect('displayName' in metadata).toBe(false) + }) + + it('survives a CLI re-registration afterwards', async () => { + const { store, cache } = createCache() + seedMachine(store, BASE_METADATA) + cache.reloadAll() + + await cache.renameMachine('machine-1', 'Workstation') + store.machines.getOrCreateMachine('machine-1', { ...BASE_METADATA, host: 'renamed-host' }, null, 'ns') + + const metadata = store.machines.getMachine('machine-1')?.metadata as Record + expect(metadata.displayName).toBe('Workstation') + expect(metadata.host).toBe('renamed-host') + }) + + it('publishes machine-updated so clients refetch', async () => { + const { store, publisher, cache } = createCache() + seedMachine(store, BASE_METADATA) + cache.reloadAll() + + const seen: string[] = [] + publisher.subscribe((event) => { + if (event.type === 'machine-updated') { + seen.push(event.machineId) + } + }) + + await cache.renameMachine('machine-1', 'Workstation') + + expect(seen).toContain('machine-1') + }) + + it('throws when the machine is unknown', async () => { + const { cache } = createCache() + + await expect(cache.renameMachine('missing', 'Nope')).rejects.toThrow('Machine not found') + }) + + it('preserves fields the metadata schema does not recognise', async () => { + const { store, cache } = createCache() + seedMachine(store, { ...BASE_METADATA, futureField: 'keep me' }) + cache.reloadAll() + + await cache.renameMachine('machine-1', 'Workstation') + + expect(store.machines.getMachine('machine-1')?.metadata).toEqual({ + ...BASE_METADATA, + futureField: 'keep me', + displayName: 'Workstation' + }) + }) + + it('does not wipe stored metadata that fails schema validation', async () => { + const { store, cache } = createCache() + // A CLI can write metadata the hub never validates (machineHandlers takes + // z.unknown()), so a row missing required fields is reachable in practice. + seedMachine(store, { host: 'workstation.local' }) + cache.reloadAll() + + await cache.renameMachine('machine-1', 'Workstation') + + expect(store.machines.getMachine('machine-1')?.metadata).toEqual({ + host: 'workstation.local', + displayName: 'Workstation' + }) + }) + + it('renames a machine that has no metadata at all', async () => { + const { store, cache } = createCache() + seedMachine(store, null) + cache.reloadAll() + + await cache.renameMachine('machine-1', 'Workstation') + + expect(store.machines.getMachine('machine-1')?.metadata).toEqual({ displayName: 'Workstation' }) + }) + + it('writes against the stored version even when the cache is stale', async () => { + const { store, cache } = createCache() + seedMachine(store, BASE_METADATA) + cache.reloadAll() + + // Another writer bumps the version behind the cache's back. + store.machines.getOrCreateMachine('machine-1', { ...BASE_METADATA, host: 'other-writer' }, null, 'ns') + + await cache.renameMachine('machine-1', 'Workstation') + + expect((store.machines.getMachine('machine-1')?.metadata as { displayName?: string })?.displayName).toBe('Workstation') + }) +}) diff --git a/hub/src/sync/machineCache.ts b/hub/src/sync/machineCache.ts index 1579fd75..f7018282 100644 --- a/hub/src/sync/machineCache.ts +++ b/hub/src/sync/machineCache.ts @@ -10,6 +10,12 @@ type MachineAlivePayload = { health?: unknown } +const METADATA_RETRY_ATTEMPTS = 5 + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function parseMachineHealth(value: unknown): Machine['health'] { const parsed = MachineHealthSchema.safeParse(value) return parsed.success ? parsed.data : null @@ -76,6 +82,57 @@ export class MachineCache { return this.refreshMachine(stored.id) ?? (() => { throw new Error('Failed to load machine') })() } + /** + * Set or clear a machine's user-facing name. + * + * An empty `displayName` removes the key so the label falls back to the + * hostname; the empty string is never stored. Only that one key is touched, + * leaving everything the CLI reported intact. + * + * Reads the raw stored metadata rather than `this.machines`, because the + * cached view is narrowed by `MachineMetadataSchema`: it strips unknown keys + * and collapses to `null` when a row fails validation (which is reachable — + * the CLI's `machine-update-metadata` handler accepts `z.unknown()`). Merging + * against that view would write those fields out of existence. + * + * Retries on version-mismatch. Reading the version straight from the store + * makes contention with this process impossible (both calls are synchronous + * SQLite), so the retry only matters when another process writes the same + * database between the read and the write. `refreshMachine` publishes the + * `machine-updated` event that makes web clients refetch. + */ + async renameMachine(machineId: string, displayName: string): Promise { + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const stored = this.store.machines.getMachine(machineId) + if (!stored) { + throw new Error('Machine not found') + } + + const current = isPlainObject(stored.metadata) ? stored.metadata : {} + const { displayName: _previous, ...rest } = current + const newMetadata = displayName.length > 0 ? { ...rest, displayName } : rest + + const result = this.store.machines.updateMachineMetadata( + machineId, + newMetadata, + stored.metadataVersion, + stored.namespace + ) + + if (result.result === 'error') { + throw new Error('Failed to update machine metadata') + } + + this.refreshMachine(machineId) + + if (result.result === 'success') { + return + } + } + + throw new Error('Machine was modified concurrently. Please try again.') + } + refreshMachine(machineId: string): Machine | null { const stored = this.store.machines.getMachine(machineId) if (!stored) { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 3654b9f2..035f3264 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -321,6 +321,10 @@ export class SyncEngine { return this.machineCache.getOnlineMachinesByNamespace(namespace) } + async renameMachine(machineId: string, displayName: string): Promise { + return this.machineCache.renameMachine(machineId, displayName) + } + getMessagesPage( sessionId: string, options: { diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 6c0308cc..449622a0 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -314,4 +314,140 @@ describe('machines routes', () => { currentModelId: 'composer-2.5[fast=true]' }) }) + + describe('PATCH /machines/:id', () => { + function createApp(engine: Partial) { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + return app + } + + function patch(app: Hono, body: unknown, machineId = 'machine-1') { + return app.request(`/api/machines/${machineId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + } + + it('renames a machine', async () => { + const machine = createMachine() + let captured: { id: string; displayName: string } | undefined + const app = createApp({ + getMachine: () => machine, + renameMachine: async (id: string, displayName: string) => { + captured = { id, displayName } + } + } as Partial) + + const response = await patch(app, { displayName: 'Workstation' }) + + expect(response.status).toBe(200) + expect(captured).toEqual({ id: 'machine-1', displayName: 'Workstation' }) + }) + + it('trims the name before storing it', async () => { + const machine = createMachine() + let captured: string | undefined + const app = createApp({ + getMachine: () => machine, + renameMachine: async (_id: string, displayName: string) => { + captured = displayName + } + } as Partial) + + await patch(app, { displayName: ' Workstation ' }) + + expect(captured).toBe('Workstation') + }) + + it('clears the name when given an empty string', async () => { + const machine = createMachine() + let captured: string | undefined + const app = createApp({ + getMachine: () => machine, + renameMachine: async (_id: string, displayName: string) => { + captured = displayName + } + } as Partial) + + const response = await patch(app, { displayName: ' ' }) + + expect(response.status).toBe(200) + expect(captured).toBe('') + }) + + it('rejects a name longer than 64 characters', async () => { + const machine = createMachine() + let called = false + const app = createApp({ + getMachine: () => machine, + renameMachine: async () => { + called = true + } + } as Partial) + + const response = await patch(app, { displayName: 'x'.repeat(65) }) + + expect(response.status).toBe(400) + expect(called).toBe(false) + }) + + it('rejects a body without displayName', async () => { + const machine = createMachine() + const app = createApp({ + getMachine: () => machine, + renameMachine: async () => {} + } as Partial) + + expect((await patch(app, {})).status).toBe(400) + }) + + it('returns 404 for an unknown machine', async () => { + const app = createApp({ + getMachine: () => undefined, + renameMachine: async () => {} + } as Partial) + + expect((await patch(app, { displayName: 'Nope' }, 'missing')).status).toBe(404) + }) + + it('returns 403 for a machine in another namespace', async () => { + const machine = createMachine({ namespace: 'other' }) + const app = createApp({ + getMachine: () => machine, + renameMachine: async () => {} + } as Partial) + + expect((await patch(app, { displayName: 'Nope' })).status).toBe(403) + }) + + it('maps a concurrency failure to 409', async () => { + const machine = createMachine() + const app = createApp({ + getMachine: () => machine, + renameMachine: async () => { + throw new Error('Machine was modified concurrently. Please try again.') + } + } as Partial) + + expect((await patch(app, { displayName: 'Workstation' })).status).toBe(409) + }) + + it('maps an unexpected failure to 500', async () => { + const machine = createMachine() + const app = createApp({ + getMachine: () => machine, + renameMachine: async () => { + throw new Error('disk on fire') + } + } as Partial) + + expect((await patch(app, { displayName: 'Workstation' })).status).toBe(500) + }) + }) }) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index a277902c..876b481b 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -1,6 +1,8 @@ import { + MACHINE_DISPLAY_NAME_MAX_LENGTH, MachineListDirectoryRequestSchema, MachinePathsExistsRequestSchema, + RenameMachineRequestSchema, SpawnSessionRequestSchema } from '@hapi/protocol' import { Hono } from 'hono' @@ -22,6 +24,44 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ machines }) }) + app.patch('/machines/:id', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const body = await c.req.json().catch(() => null) + const parsed = RenameMachineRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body: displayName is required' }, 400) + } + + // Trim first: a name is stored trimmed, so the ceiling applies to what + // actually gets stored. An empty result clears the custom name. + const displayName = parsed.data.displayName.trim() + if (displayName.length > MACHINE_DISPLAY_NAME_MAX_LENGTH) { + return c.json({ error: `displayName must be at most ${MACHINE_DISPLAY_NAME_MAX_LENGTH} characters` }, 400) + } + + try { + await engine.renameMachine(machineId, displayName) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to rename machine' + // Match the session rename contract: contention maps to 409. + if (message.includes('concurrently') || message.includes('version')) { + return c.json({ error: message }, 409) + } + return c.json({ error: message }, 500) + } + }) + app.post('/machines/:id/spawn', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index d5a910ea..d678e105 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -231,6 +231,20 @@ export const RenameSessionRequestSchema = z.object({ export type RenameSessionRequest = z.infer +/** + * An empty string clears the custom name, so unlike session rename there is no + * `min(1)`: the machine falls back to its hostname. The length ceiling is + * enforced after trimming, so it is not expressed here. + */ +export const RenameMachineRequestSchema = z.object({ + displayName: z.string() +}) + +export type RenameMachineRequest = z.infer + +export const MACHINE_DISPLAY_NAME_MAX_LENGTH = 64 + + /** * Scratchlist v2 (tiann/hapi#893) per-entry caps. * diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0ef8a6ed..56459aa0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -613,6 +613,14 @@ export class ApiClient { return await this.request('/api/machines') } + /** Pass an empty string to clear the custom name and fall back to the hostname. */ + async renameMachine(machineId: string, displayName: string): Promise { + await this.request(`/api/machines/${encodeURIComponent(machineId)}`, { + method: 'PATCH', + body: JSON.stringify({ displayName }) + }) + } + async listMachineDirectory( machineId: string, path: string diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index 3abb26b3..c8bf495d 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -18,6 +18,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`, chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`), voice: t('settings.hub.voice.summary'), + machines: t('settings.hub.machines.summary'), about: `v${__APP_VERSION__}`, } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 98846109..f1863209 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -817,6 +817,14 @@ export default { 'settings.companion.copyLink': 'Copy link', 'settings.companion.copied': 'Copied!', 'settings.companion.hide': 'Hide', + 'settings.machines.title': 'Machines', + 'settings.machines.description': 'Give your machines names of your own. Only machines that are currently online are listed.', + 'settings.machines.section': 'Your machines', + 'settings.machines.rename': 'Rename {name}', + 'settings.machines.namePlaceholder': 'Use hostname', + 'settings.machines.empty': 'No machines online.', + 'settings.machines.error': 'Could not rename this machine.', + 'settings.hub.machines.summary': 'Custom names for your machines', 'settings.about.title': 'About', 'settings.about.description': 'HAPI links and version information.', 'settings.about.website': 'Website', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index dccd82b4..7875871b 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -821,6 +821,14 @@ export default { 'settings.companion.copyLink': '复制链接', 'settings.companion.copied': '已复制!', 'settings.companion.hide': '隐藏', + 'settings.machines.title': '设备', + 'settings.machines.description': '给设备起自己的名字。这里只列出当前在线的设备。', + 'settings.machines.section': '我的设备', + 'settings.machines.rename': '重命名 {name}', + 'settings.machines.namePlaceholder': '使用主机名', + 'settings.machines.empty': '没有在线设备。', + 'settings.machines.error': '重命名失败。', + 'settings.hub.machines.summary': '给设备起自定义名字', 'settings.about.title': '关于', 'settings.about.description': 'HAPI 链接和版本信息。', 'settings.about.website': '官方网站', diff --git a/web/src/router.tsx b/web/src/router.tsx index 2768388a..5598f931 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -60,6 +60,7 @@ import SettingsChatPage from '@/routes/settings/chat' import SettingsVoicePage from '@/routes/settings/voice' import SettingsVoiceVoicesPage from '@/routes/settings/voice-voices' import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced' +import SettingsMachinesPage from '@/routes/settings/machines' import SettingsAboutPage from '@/routes/settings/about' import SharePage from '@/routes/share' import { setSharePendingTransfer } from '@/lib/sharePendingState' @@ -1437,6 +1438,12 @@ const settingsVoiceAdvancedRoute = createRoute({ component: SettingsVoiceAdvancedPage, }) +const settingsMachinesRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'machines', + component: SettingsMachinesPage, +}) + const settingsAboutRoute = createRoute({ getParentRoute: () => settingsRoute, path: 'about', @@ -1482,6 +1489,7 @@ export const routeTree = rootRoute.addChildren([ settingsVoiceRoute, settingsVoiceVoicesRoute, settingsVoiceAdvancedRoute, + settingsMachinesRoute, settingsAboutRoute, ]), shareRoute, diff --git a/web/src/routes/settings/categories.ts b/web/src/routes/settings/categories.ts index 1eb1aec6..7c7fc024 100644 --- a/web/src/routes/settings/categories.ts +++ b/web/src/routes/settings/categories.ts @@ -3,6 +3,7 @@ export const settingsCategories = [ { id: 'display', path: '/settings/display', titleKey: 'settings.display.title' }, { id: 'chat', path: '/settings/chat', titleKey: 'settings.chat.title' }, { id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' }, + { id: 'machines', path: '/settings/machines', titleKey: 'settings.machines.title' }, { id: 'about', path: '/settings/about', titleKey: 'settings.about.title' }, ] as const diff --git a/web/src/routes/settings/machines.test.tsx b/web/src/routes/settings/machines.test.tsx new file mode 100644 index 00000000..a2ba934b --- /dev/null +++ b/web/src/routes/settings/machines.test.tsx @@ -0,0 +1,183 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import type { Machine } from '@/types/api' +import SettingsMachinesPage from '@/routes/settings/machines' + +const renameMachineMock = vi.fn() +const machinesMock = vi.fn() + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ api: { renameMachine: renameMachineMock } }), +})) + +vi.mock('@/hooks/queries/useMachines', () => ({ + useMachines: () => ({ machines: machinesMock(), isLoading: false, error: null, refetch: vi.fn() }), +})) + +function makeMachine(overrides?: Partial): Machine { + return { + id: 'machine-1', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: { + host: 'workstation.local', + platform: 'linux', + happyCliVersion: '1.0.0', + }, + ...overrides, + } as Machine +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +function startEditing(name: string) { + fireEvent.click(screen.getByRole('button', { name: `Rename ${name}` })) + return screen.getByRole('textbox') +} + +describe('SettingsMachinesPage', () => { + beforeEach(() => { + vi.clearAllMocks() + renameMachineMock.mockResolvedValue(undefined) + machinesMock.mockReturnValue([makeMachine()]) + }) + + afterEach(() => { + cleanup() + }) + + it('falls back to the hostname and always shows host and platform', () => { + renderPage() + + expect(screen.getByRole('button', { name: 'Rename workstation.local' })).toBeTruthy() + expect(screen.getByText('workstation.local · linux')).toBeTruthy() + }) + + it('shows the custom name while keeping the hostname visible', () => { + machinesMock.mockReturnValue([makeMachine({ + metadata: { host: 'workstation.local', platform: 'linux', happyCliVersion: '1.0.0', displayName: 'Workstation' }, + } as Partial)]) + + renderPage() + + expect(screen.getByRole('button', { name: 'Rename Workstation' })).toBeTruthy() + expect(screen.getByText('workstation.local · linux')).toBeTruthy() + }) + + it('seeds the input with the current custom name, not the hostname', () => { + machinesMock.mockReturnValue([makeMachine({ + metadata: { host: 'workstation.local', platform: 'linux', happyCliVersion: '1.0.0', displayName: 'Workstation' }, + } as Partial)]) + renderPage() + + expect((startEditing('Workstation') as HTMLInputElement).value).toBe('Workstation') + }) + + it('leaves the input empty when no custom name is set', () => { + renderPage() + + expect((startEditing('workstation.local') as HTMLInputElement).value).toBe('') + }) + + it('saves a trimmed name on Enter', async () => { + renderPage() + const input = startEditing('workstation.local') + + fireEvent.change(input, { target: { value: ' Workstation ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => expect(renameMachineMock).toHaveBeenCalledWith('machine-1', 'Workstation')) + }) + + it('saves on blur', async () => { + renderPage() + const input = startEditing('workstation.local') + + fireEvent.change(input, { target: { value: 'Workstation' } }) + fireEvent.blur(input) + + await waitFor(() => expect(renameMachineMock).toHaveBeenCalledWith('machine-1', 'Workstation')) + }) + + it('submits once when Enter is followed by a blur', async () => { + // Disabling a focused control forces it to blur, so a real browser fires + // blur right after Enter starts the save. jsdom does not reproduce that, + // so the sequence is driven explicitly here. + renderPage() + const input = startEditing('workstation.local') + + fireEvent.change(input, { target: { value: 'Workstation' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + + await waitFor(() => expect(renameMachineMock).toHaveBeenCalled()) + expect(renameMachineMock).toHaveBeenCalledTimes(1) + }) + + it('clears the name with an empty string', async () => { + machinesMock.mockReturnValue([makeMachine({ + metadata: { host: 'workstation.local', platform: 'linux', happyCliVersion: '1.0.0', displayName: 'Workstation' }, + } as Partial)]) + renderPage() + const input = startEditing('Workstation') + + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => expect(renameMachineMock).toHaveBeenCalledWith('machine-1', '')) + }) + + it('does not call the API when the name is unchanged', async () => { + renderPage() + const input = startEditing('workstation.local') + + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull()) + expect(renameMachineMock).not.toHaveBeenCalled() + }) + + it('cancels on Escape without calling the API', async () => { + renderPage() + const input = startEditing('workstation.local') + + fireEvent.change(input, { target: { value: 'Workstation' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull()) + expect(renameMachineMock).not.toHaveBeenCalled() + }) + + it('surfaces an error and keeps the editor open when the save fails', async () => { + renameMachineMock.mockRejectedValue(new Error('HTTP 409')) + renderPage() + const input = startEditing('workstation.local') + + fireEvent.change(input, { target: { value: 'Workstation' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => expect(screen.getByText('Could not rename this machine.')).toBeTruthy()) + expect(screen.getByRole('textbox')).toBeTruthy() + }) + + it('renders an empty state when no machines are online', () => { + machinesMock.mockReturnValue([]) + renderPage() + + expect(screen.getByText('No machines online.')).toBeTruthy() + }) +}) diff --git a/web/src/routes/settings/machines.tsx b/web/src/routes/settings/machines.tsx new file mode 100644 index 00000000..240d0e6e --- /dev/null +++ b/web/src/routes/settings/machines.tsx @@ -0,0 +1,131 @@ +import { useRef, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { MACHINE_DISPLAY_NAME_MAX_LENGTH } from '@hapi/protocol' +import type { ApiClient } from '@/api/client' +import type { Machine } from '@/types/api' +import { useAppContext } from '@/lib/app-context' +import { useTranslation } from '@/lib/use-translation' +import { useMachines } from '@/hooks/queries/useMachines' +import { getMachineTitle } from '@/hooks/useMachineLabels' +import { queryKeys } from '@/lib/query-keys' +import { SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' + +function MachineRow(props: { api: ApiClient | null; machine: Machine }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [error, setError] = useState(null) + const savingRef = useRef(false) + + const label = getMachineTitle(props.machine) + const host = props.machine.metadata?.host + const platform = props.machine.metadata?.platform + const subtitle = [host, platform].filter(Boolean).join(' · ') + + const renameMutation = useMutation({ + mutationFn: async (displayName: string) => { + if (!props.api) { + throw new Error('API unavailable') + } + await props.api.renameMachine(props.machine.id, displayName) + }, + onSuccess: () => { + setEditing(false) + setError(null) + void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) + }, + onError: () => setError(t('settings.machines.error')), + }) + + function startEditing() { + setDraft(props.machine.metadata?.displayName ?? '') + setError(null) + setEditing(true) + } + + function save() { + // Disabling the focused input on submit forces a blur, so `save` is + // reached twice for a single Enter. A ref (not `isPending`, which is a + // render-timing-dependent closure value) keeps that to one request. + if (savingRef.current) { + return + } + const next = draft.trim() + if (next === (props.machine.metadata?.displayName ?? '')) { + setEditing(false) + return + } + savingRef.current = true + renameMutation.mutate(next, { + onSettled: () => { + savingRef.current = false + }, + }) + } + + return ( +
+
+
+ {editing ? ( + setDraft(event.target.value)} + onBlur={save} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + save() + } else if (event.key === 'Escape') { + event.preventDefault() + setEditing(false) + setError(null) + } + }} + className="w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1 text-sm text-[var(--app-fg)] outline-none focus:border-[var(--app-link)] disabled:opacity-60" + /> + ) : ( + + )} + {subtitle ? ( +
{subtitle}
+ ) : null} +
+
+ {error ?
{error}
: null} +
+ ) +} + +export default function SettingsMachinesPage() { + const { t } = useTranslation() + const { api } = useAppContext() + const { machines } = useMachines(api, true) + + return ( + + + {machines.length === 0 ? ( +
{t('settings.machines.empty')}
+ ) : ( + machines.map((machine) => ( + + )) + )} +
+
+ ) +}