fix: preserve remote environment checks across settings pages
The Hosts settings page discarded the last successful Agent and Runtime version check whenever users switched categories or restarted GoodBuddy, forcing them to refresh the same Host again. Validated results now persist locally and remain visible until users refresh, edit or delete the Host, or start a remote environment update. Release note: 远程主机的 Agent 和 Runtime 版本检查结果现在会在设置页面切换和应用重启后保留,无需重复点击刷新版本。
This commit is contained in:
@@ -749,6 +749,23 @@ describe('SshHostsSettingsSection', () => {
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the last successful version check when the settings page remounts', async () => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
const first = render(<SshHostsSettingsSection />)
|
||||
|
||||
await refreshHostEnvironment()
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledOnce()
|
||||
first.unmount()
|
||||
|
||||
render(<SshHostsSettingsSection />)
|
||||
const host = await screen.findByRole('region', {
|
||||
name: 'Build host'
|
||||
})
|
||||
|
||||
expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not contact any verified Host when the section opens', async () => {
|
||||
getSnapshot.mockResolvedValue({
|
||||
hosts: [
|
||||
|
||||
@@ -31,6 +31,11 @@ import type { AppNotificationInput } from './notifications'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
import { SshHostDialog } from './SshHostDialog'
|
||||
import {
|
||||
getCachedSshHostRemoteEnvironments,
|
||||
removeCachedSshHostRemoteEnvironment,
|
||||
setCachedSshHostRemoteEnvironment
|
||||
} from './ssh-host-remote-environment-cache'
|
||||
import {
|
||||
DestructiveConfirmActions,
|
||||
EmptyState,
|
||||
@@ -112,7 +117,16 @@ export function SshHostsSettingsSection({
|
||||
>({})
|
||||
const [remoteEnvironments, setRemoteEnvironments] = useState<
|
||||
Record<string, RemoteEnvironmentLoadState>
|
||||
>({})
|
||||
>(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(getCachedSshHostRemoteEnvironments()).map(
|
||||
([hostId, value]) => [
|
||||
hostId,
|
||||
{ loading: false, value }
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
const [
|
||||
remoteEnvironmentPreparationMethods,
|
||||
setRemoteEnvironmentPreparationMethods
|
||||
@@ -252,6 +266,7 @@ export function SshHostsSettingsSection({
|
||||
...current,
|
||||
[hostId]: { loading: false, value }
|
||||
}))
|
||||
setCachedSshHostRemoteEnvironment(value)
|
||||
} catch (reason) {
|
||||
if (
|
||||
!mounted.current ||
|
||||
@@ -285,6 +300,7 @@ export function SshHostsSettingsSection({
|
||||
) {
|
||||
return
|
||||
}
|
||||
removeCachedSshHostRemoteEnvironment(host.id)
|
||||
const requestId = remoteEnvironmentUpdateRequestId.current + 1
|
||||
remoteEnvironmentUpdateRequestId.current = requestId
|
||||
const activeUpdate: ActiveRemoteEnvironmentUpdate = {
|
||||
@@ -474,6 +490,7 @@ export function SshHostsSettingsSection({
|
||||
|
||||
const handleSaved = (result: SshHostValidationResult): void => {
|
||||
const edited = hosts.some((host) => host.id === result.host.id)
|
||||
removeCachedSshHostRemoteEnvironment(result.host.id)
|
||||
setHosts((current) => upsertHost(current, result.host))
|
||||
setTestResults((current) => ({
|
||||
...current,
|
||||
@@ -500,6 +517,7 @@ export function SshHostsSettingsSection({
|
||||
result.deletedProjects.map((project) => project.id)
|
||||
)
|
||||
setConfirmingDeleteId(undefined)
|
||||
removeCachedSshHostRemoteEnvironment(host.id)
|
||||
setHosts((current) =>
|
||||
current.filter((candidate) => candidate.id !== host.id)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { SshHostRemoteEnvironment } from '../../shared/ssh-host-contracts'
|
||||
import { SshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache'
|
||||
|
||||
const hostId = '00000000-0000-4000-8000-000000000104'
|
||||
const version = {
|
||||
version: '1.2.3',
|
||||
architecture: 'x64' as const
|
||||
}
|
||||
const remoteEnvironment: SshHostRemoteEnvironment = {
|
||||
hostId,
|
||||
checkedAt: '2026-08-31T00:00:00.000Z',
|
||||
architecture: 'x64',
|
||||
agent: {
|
||||
state: 'current',
|
||||
expected: version,
|
||||
installed: version
|
||||
},
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
state: 'current',
|
||||
expected: version,
|
||||
installed: version
|
||||
}
|
||||
],
|
||||
remoteDownload: {
|
||||
available: true,
|
||||
source: 'mirror',
|
||||
packageSize: 1024
|
||||
}
|
||||
}
|
||||
|
||||
describe('SshHostRemoteEnvironmentCache', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('restores successful checks in a new cache instance', () => {
|
||||
const first = new SshHostRemoteEnvironmentCache(localStorage)
|
||||
first.set(remoteEnvironment)
|
||||
|
||||
const restarted = new SshHostRemoteEnvironmentCache(localStorage)
|
||||
expect(restarted.getAll()).toEqual({
|
||||
[hostId]: remoteEnvironment
|
||||
})
|
||||
})
|
||||
|
||||
it('persists Host invalidation across cache instances', () => {
|
||||
const first = new SshHostRemoteEnvironmentCache(localStorage)
|
||||
first.set(remoteEnvironment)
|
||||
first.remove(hostId)
|
||||
|
||||
const restarted = new SshHostRemoteEnvironmentCache(localStorage)
|
||||
expect(restarted.getAll()).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores malformed persisted values', () => {
|
||||
localStorage.setItem(
|
||||
'goodbuddy.ssh-host-remote-environments.v1',
|
||||
JSON.stringify([
|
||||
remoteEnvironment,
|
||||
{ ...remoteEnvironment, checkedAt: 'not-a-date' }
|
||||
])
|
||||
)
|
||||
|
||||
const restarted = new SshHostRemoteEnvironmentCache(localStorage)
|
||||
expect(restarted.getAll()).toEqual({
|
||||
[hostId]: remoteEnvironment
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
SSH_HOST_LIMITS,
|
||||
sshHostRemoteEnvironmentSchema,
|
||||
type SshHostRemoteEnvironment
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
|
||||
type CacheStorage = Pick<
|
||||
Storage,
|
||||
'getItem' | 'removeItem' | 'setItem'
|
||||
>
|
||||
|
||||
const storageKey = 'goodbuddy.ssh-host-remote-environments.v1'
|
||||
const maximumPersistedPayloadLength = 1_000_000
|
||||
|
||||
function getLocalStorage(): CacheStorage | undefined {
|
||||
try {
|
||||
return typeof window === 'undefined' ? undefined : window.localStorage
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class SshHostRemoteEnvironmentCache {
|
||||
private readonly remoteEnvironments = new Map<
|
||||
string,
|
||||
SshHostRemoteEnvironment
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly storage: CacheStorage | undefined = getLocalStorage()
|
||||
) {
|
||||
this.load()
|
||||
}
|
||||
|
||||
getAll(): Readonly<Record<string, SshHostRemoteEnvironment>> {
|
||||
return Object.fromEntries(this.remoteEnvironments)
|
||||
}
|
||||
|
||||
set(environment: SshHostRemoteEnvironment): void {
|
||||
this.remoteEnvironments.set(environment.hostId, environment)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
remove(hostId: string): void {
|
||||
if (this.remoteEnvironments.delete(hostId)) {
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.remoteEnvironments.clear()
|
||||
try {
|
||||
this.storage?.removeItem(storageKey)
|
||||
} catch {
|
||||
// The in-memory cache remains usable when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
try {
|
||||
const raw = this.storage?.getItem(storageKey)
|
||||
if (!raw || raw.length > maximumPersistedPayloadLength) {
|
||||
return
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return
|
||||
}
|
||||
for (const candidate of parsed.slice(
|
||||
0,
|
||||
SSH_HOST_LIMITS.maximumHosts
|
||||
)) {
|
||||
const environment =
|
||||
sshHostRemoteEnvironmentSchema.safeParse(candidate)
|
||||
if (environment.success) {
|
||||
this.remoteEnvironments.set(
|
||||
environment.data.hostId,
|
||||
environment.data
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or inaccessible storage behaves like an empty cache.
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
this.storage?.setItem(
|
||||
storageKey,
|
||||
JSON.stringify([...this.remoteEnvironments.values()])
|
||||
)
|
||||
} catch {
|
||||
// Version checks still remain cached for the current process.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new SshHostRemoteEnvironmentCache()
|
||||
|
||||
export function getCachedSshHostRemoteEnvironments(): Readonly<
|
||||
Record<string, SshHostRemoteEnvironment>
|
||||
> {
|
||||
return cache.getAll()
|
||||
}
|
||||
|
||||
export function setCachedSshHostRemoteEnvironment(
|
||||
environment: SshHostRemoteEnvironment
|
||||
): void {
|
||||
cache.set(environment)
|
||||
}
|
||||
|
||||
export function removeCachedSshHostRemoteEnvironment(hostId: string): void {
|
||||
cache.remove(hostId)
|
||||
}
|
||||
|
||||
export function clearSshHostRemoteEnvironmentCache(): void {
|
||||
cache.clear()
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { beforeEach, vi } from 'vitest'
|
||||
import i18n from './i18n'
|
||||
import { clearSshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache'
|
||||
|
||||
if (typeof Element !== 'undefined') {
|
||||
Element.prototype.scrollTo = vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
clearSshHostRemoteEnvironmentCache()
|
||||
if (typeof localStorage === 'undefined' || typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user