mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(hub,web): stop machines from showing raw id prefixes as names
Hub: getOrCreateMachine now merges incoming machine-owned metadata over the stored row (first-write-wins previously kept rows registered without a host name nameless forever; hub-only fields like displayName survive). Web: session-list machine labels are cached in localStorage so machines whose row is gone or whose query has not loaded yet keep their last known name instead of flickering to the 8-char id prefix.
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { Store } from './index'
|
||||||
|
import { mergeMachineMetadata } from './machines'
|
||||||
|
|
||||||
|
describe('machine metadata backfill', () => {
|
||||||
|
it('merges incoming metadata over stored fields on re-registration', () => {
|
||||||
|
const store = new Store(':memory:')
|
||||||
|
const created = store.machines.getOrCreateMachine('machine-1', null, null, 'ns')
|
||||||
|
expect(created.metadata).toBeNull()
|
||||||
|
|
||||||
|
const refreshed = store.machines.getOrCreateMachine(
|
||||||
|
'machine-1',
|
||||||
|
{ host: 'MacBook Pro', platform: 'darwin' },
|
||||||
|
null,
|
||||||
|
'ns'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(refreshed.metadata).toEqual({ host: 'MacBook Pro', platform: 'darwin' })
|
||||||
|
expect(refreshed.metadataVersion).toBe(created.metadataVersion + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves hub-side fields the CLI never sends', () => {
|
||||||
|
const store = new Store(':memory:')
|
||||||
|
store.machines.getOrCreateMachine('machine-1', { displayName: 'Workstation', host: 'old-host' }, null, 'ns')
|
||||||
|
|
||||||
|
const refreshed = store.machines.getOrCreateMachine('machine-1', { host: 'new-host' }, null, 'ns')
|
||||||
|
|
||||||
|
expect(refreshed.metadata).toEqual({ displayName: 'Workstation', host: 'new-host' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not write when the merge changes nothing', () => {
|
||||||
|
const store = new Store(':memory:')
|
||||||
|
const created = store.machines.getOrCreateMachine('machine-1', { host: 'alpha' }, null, 'ns')
|
||||||
|
|
||||||
|
const again = store.machines.getOrCreateMachine('machine-1', { host: 'alpha' }, null, 'ns')
|
||||||
|
|
||||||
|
expect(again.metadataVersion).toBe(created.metadataVersion)
|
||||||
|
expect(again.updatedAt).toBe(created.updatedAt)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeMachineMetadata', () => {
|
||||||
|
it('returns undefined for non-object incoming metadata', () => {
|
||||||
|
expect(mergeMachineMetadata({ host: 'a' }, null)).toBeUndefined()
|
||||||
|
expect(mergeMachineMetadata({ host: 'a' }, 'host')).toBeUndefined()
|
||||||
|
expect(mergeMachineMetadata({ host: 'a' }, ['host'])).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined when the merge is a no-op', () => {
|
||||||
|
expect(mergeMachineMetadata({ host: 'a' }, { host: 'a' })).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,6 +34,23 @@ function toStoredMachine(row: DbMachineRow): StoredMachine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows created before the CLI reported full metadata (or by older versions)
|
||||||
|
// would keep missing fields like `host` forever — get-or-create returns the
|
||||||
|
// existing row untouched and every client hits it on startup. Merge incoming
|
||||||
|
// machine-owned fields over the stored ones so registration doubles as a
|
||||||
|
// refresh; hub-side fields the CLI never sends (e.g. displayName) survive.
|
||||||
|
// Returns undefined when the merge would not change anything.
|
||||||
|
export function mergeMachineMetadata(stored: unknown, incoming: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!isPlainObject(incoming)) return undefined
|
||||||
|
const base = isPlainObject(stored) ? stored : {}
|
||||||
|
const merged = { ...base, ...incoming }
|
||||||
|
return JSON.stringify(merged) === JSON.stringify(base) ? undefined : merged
|
||||||
|
}
|
||||||
|
|
||||||
export function getOrCreateMachine(
|
export function getOrCreateMachine(
|
||||||
db: Database,
|
db: Database,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -47,6 +64,26 @@ export function getOrCreateMachine(
|
|||||||
if (stored.namespace !== namespace) {
|
if (stored.namespace !== namespace) {
|
||||||
throw new Error('Machine namespace mismatch')
|
throw new Error('Machine namespace mismatch')
|
||||||
}
|
}
|
||||||
|
const merged = mergeMachineMetadata(stored.metadata, metadata)
|
||||||
|
if (merged !== undefined) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE machines
|
||||||
|
SET metadata = @metadata,
|
||||||
|
metadata_version = metadata_version + 1,
|
||||||
|
updated_at = @updated_at,
|
||||||
|
seq = seq + 1
|
||||||
|
WHERE id = @id
|
||||||
|
`).run({
|
||||||
|
metadata: JSON.stringify(merged),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
id
|
||||||
|
})
|
||||||
|
const row = getMachine(db, id)
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Failed to refresh machine metadata')
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
return stored
|
return stored
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import type { Machine } from '@/types/api'
|
||||||
|
import { getMachineTitle, useMachineLabels } from './useMachineLabels'
|
||||||
|
|
||||||
|
function makeMachine(id: string, metadata: Machine['metadata']): Machine {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
namespace: 'default',
|
||||||
|
seq: 1,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
active: true,
|
||||||
|
activeAt: 0,
|
||||||
|
metadata,
|
||||||
|
metadataVersion: 1,
|
||||||
|
runnerState: null,
|
||||||
|
runnerStateVersion: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getMachineTitle', () => {
|
||||||
|
it('prefers displayName, then host, then the id prefix', () => {
|
||||||
|
expect(getMachineTitle(makeMachine('abcdef123456', { displayName: 'Work', host: 'mac' } as Machine['metadata']))).toBe('Work')
|
||||||
|
expect(getMachineTitle(makeMachine('abcdef123456', { host: 'mac' } as Machine['metadata']))).toBe('mac')
|
||||||
|
expect(getMachineTitle(makeMachine('abcdef123456', null))).toBe('abcdef12')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useMachineLabels', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns live titles and caches them', () => {
|
||||||
|
const machines = [makeMachine('machine-1', { host: 'MacBook Pro' } as Machine['metadata'])]
|
||||||
|
const { result } = renderHook(() => useMachineLabels(machines))
|
||||||
|
|
||||||
|
expect(result.current['machine-1']).toBe('MacBook Pro')
|
||||||
|
expect(JSON.parse(window.localStorage.getItem('hapi-machine-labels')!)).toEqual({ 'machine-1': 'MacBook Pro' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the cached label when the machine is absent from the list', () => {
|
||||||
|
window.localStorage.setItem('hapi-machine-labels', JSON.stringify({ 'gone-machine': 'MacBook Pro' }))
|
||||||
|
const { result } = renderHook(() => useMachineLabels([]))
|
||||||
|
|
||||||
|
expect(result.current['gone-machine']).toBe('MacBook Pro')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the live title over a stale cached one', () => {
|
||||||
|
window.localStorage.setItem('hapi-machine-labels', JSON.stringify({ 'machine-1': 'old-name' }))
|
||||||
|
const machines = [makeMachine('machine-1', { host: 'new-name' } as Machine['metadata'])]
|
||||||
|
const { result } = renderHook(() => useMachineLabels(machines))
|
||||||
|
|
||||||
|
expect(result.current['machine-1']).toBe('new-name')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import type { Machine } from '@/types/api'
|
||||||
|
|
||||||
|
export function getMachineTitle(machine: Machine): string {
|
||||||
|
if (machine.metadata?.displayName) return machine.metadata.displayName
|
||||||
|
if (machine.metadata?.host) return machine.metadata.host
|
||||||
|
return machine.id.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hapi-machine-labels'
|
||||||
|
|
||||||
|
function isBrowser(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedLabels(): Record<string, string> {
|
||||||
|
if (!isBrowser()) return {}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
labels[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCachedLabels(labels: Record<string, string>): void {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(labels))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine id → display label for the session list. Live data from the
|
||||||
|
* machines query wins, but labels are also cached in localStorage so a
|
||||||
|
* machine whose row is gone (reinstalled CLI, stale sessions) or whose
|
||||||
|
* query has not loaded yet keeps its last known name instead of falling
|
||||||
|
* back to a raw id prefix.
|
||||||
|
*/
|
||||||
|
export function useMachineLabels(machines: Machine[]): Record<string, string> {
|
||||||
|
const labels = useMemo(() => {
|
||||||
|
const merged = readCachedLabels()
|
||||||
|
for (const machine of machines) {
|
||||||
|
merged[machine.id] = getMachineTitle(machine)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}, [machines])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
writeCachedLabels(labels)
|
||||||
|
}, [labels])
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}
|
||||||
+3
-14
@@ -27,6 +27,7 @@ import { isTelegramApp } from '@/hooks/useTelegram'
|
|||||||
import { useSidebarResize } from '@/hooks/useSidebarResize'
|
import { useSidebarResize } from '@/hooks/useSidebarResize'
|
||||||
import { useMessages } from '@/hooks/queries/useMessages'
|
import { useMessages } from '@/hooks/queries/useMessages'
|
||||||
import { useMachines } from '@/hooks/queries/useMachines'
|
import { useMachines } from '@/hooks/queries/useMachines'
|
||||||
|
import { useMachineLabels } from '@/hooks/useMachineLabels'
|
||||||
import { useSession } from '@/hooks/queries/useSession'
|
import { useSession } from '@/hooks/queries/useSession'
|
||||||
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
|
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
|
||||||
import { useSessions } from '@/hooks/queries/useSessions'
|
import { useSessions } from '@/hooks/queries/useSessions'
|
||||||
@@ -44,7 +45,7 @@ import { inactiveSessionCanResume } from '@/lib/sessionResume'
|
|||||||
import { markSessionSeen } from '@/lib/sessionLastSeen'
|
import { markSessionSeen } from '@/lib/sessionLastSeen'
|
||||||
import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle'
|
import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle'
|
||||||
import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions'
|
import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions'
|
||||||
import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api'
|
import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api'
|
||||||
import FilesPage from '@/routes/sessions/files'
|
import FilesPage from '@/routes/sessions/files'
|
||||||
import FilePage from '@/routes/sessions/file'
|
import FilePage from '@/routes/sessions/file'
|
||||||
import TerminalPage from '@/routes/sessions/terminal'
|
import TerminalPage from '@/routes/sessions/terminal'
|
||||||
@@ -182,12 +183,6 @@ function SettingsIcon(props: { className?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMachineTitle(machine: Machine): string {
|
|
||||||
if (machine.metadata?.displayName) return machine.metadata.displayName
|
|
||||||
if (machine.metadata?.host) return machine.metadata.host
|
|
||||||
return machine.id.slice(0, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionsPage() {
|
function SessionsPage() {
|
||||||
const { api } = useAppContext()
|
const { api } = useAppContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -232,13 +227,7 @@ function SessionsPage() {
|
|||||||
})()
|
})()
|
||||||
}, [addToast, refetch, t])
|
}, [addToast, refetch, t])
|
||||||
|
|
||||||
const machineLabelsById = useMemo(() => {
|
const machineLabelsById = useMachineLabels(machines)
|
||||||
const labels: Record<string, string> = {}
|
|
||||||
for (const machine of machines) {
|
|
||||||
labels[machine.id] = getMachineTitle(machine)
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}, [machines])
|
|
||||||
const machinesById = useMemo(() => {
|
const machinesById = useMemo(() => {
|
||||||
const byId: Record<string, typeof machines[number]> = {}
|
const byId: Record<string, typeof machines[number]> = {}
|
||||||
for (const machine of machines) {
|
for (const machine of machines) {
|
||||||
|
|||||||
Reference in New Issue
Block a user