feat(web): show Hub SQLite storage usage in Settings (#1225)

This commit is contained in:
SSU-WEI HUANG
2026-07-29 20:13:04 +08:00
committed by GitHub
parent f5673e89bd
commit 46ab828daa
13 changed files with 229 additions and 2 deletions
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Hono } from 'hono'
import type { WebAppEnv } from '../middleware/auth'
import { createStorageRoutes } from './storage'
const directories: string[] = []
afterEach(async () => {
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
})
describe('GET /api/storage/sqlite', () => {
function createApp(dbPath: string, namespace = 'default') {
const app = new Hono<WebAppEnv>()
app.use('*', async (c, next) => {
c.set('namespace', namespace)
await next()
})
app.route('/api', createStorageRoutes(dbPath))
return app
}
it('returns the database and existing sidecar sizes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'hapi-storage-'))
directories.push(directory)
const dbPath = join(directory, 'hapi.db')
await Promise.all([
writeFile(dbPath, Buffer.alloc(10)),
writeFile(`${dbPath}-wal`, Buffer.alloc(20)),
])
const app = createApp(dbPath)
const response = await app.request('/api/storage/sqlite')
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(await response.json()).toEqual({
path: dbPath,
databaseBytes: 10,
walBytes: 20,
shmBytes: 0,
totalBytes: 30,
})
})
it('rejects non-default namespaces', async () => {
const response = await createApp('/unused/hapi.db', 'tenant').request('/api/storage/sqlite')
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'Storage usage is only available to the hub owner' })
})
})
+45
View File
@@ -0,0 +1,45 @@
import { stat } from 'node:fs/promises'
import type { SqliteStorageUsageResponse } from '@hapi/protocol/apiTypes'
import { Hono } from 'hono'
import type { WebAppEnv } from '../middleware/auth'
async function fileSize(path: string, required = false): Promise<number> {
try {
return (await stat(path)).size
} catch (error) {
if (!required && error instanceof Error && 'code' in error && error.code === 'ENOENT') return 0
throw error
}
}
export function createStorageRoutes(dbPath: string): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
app.get('/storage/sqlite', async (c) => {
if (c.get('namespace') !== 'default') {
return c.json({ error: 'Storage usage is only available to the hub owner' }, 403)
}
c.header('Cache-Control', 'no-store')
try {
const [databaseBytes, walBytes, shmBytes] = await Promise.all([
fileSize(dbPath, true),
fileSize(`${dbPath}-wal`),
fileSize(`${dbPath}-shm`),
])
const response: SqliteStorageUsageResponse = {
path: dbPath,
databaseBytes,
walBytes,
shmBytes,
totalBytes: databaseBytes + walBytes + shmBytes,
}
return c.json(response)
} catch (error) {
return c.json({
error: error instanceof Error ? error.message : 'Failed to read SQLite storage usage'
}, 500)
}
})
return app
}
+2
View File
@@ -18,6 +18,7 @@ import { createSessionsRoutes } from './routes/sessions'
import { createMessagesRoutes } from './routes/messages'
import { createPermissionsRoutes } from './routes/permissions'
import { createMachinesRoutes } from './routes/machines'
import { createStorageRoutes } from './routes/storage'
import { createGitRoutes } from './routes/git'
import { createCliRoutes } from './routes/cli'
import { createCodexDesktopRoutes } from './routes/codexDesktop'
@@ -248,6 +249,7 @@ function createWebApp(options: {
app.route('/api', createMessagesRoutes(options.getSyncEngine))
app.route('/api', createPermissionsRoutes(options.getSyncEngine))
app.route('/api', createMachinesRoutes(options.getSyncEngine))
app.route('/api', createStorageRoutes(configuration.dbPath))
app.route('/api', createGitRoutes(options.getSyncEngine))
// 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。
app.route('/api', createCodexDesktopRoutes({
+8
View File
@@ -683,3 +683,11 @@ export type SlashCommandsResponse = {
commands?: SlashCommand[]
error?: string
}
export type SqliteStorageUsageResponse = {
path: string
databaseBytes: number
walBytes: number
shmBytes: number
totalBytes: number
}
+5
View File
@@ -42,6 +42,7 @@ import type {
OpencodeReasoningEffortResponse,
QueuedStateResponse,
ReopenSessionResponse,
SqliteStorageUsageResponse,
UploadFileResponse
} from '@hapi/protocol/apiTypes'
import type { AgentFlavor } from '@hapi/protocol'
@@ -621,6 +622,10 @@ export class ApiClient {
})
}
async getSqliteStorageUsage(): Promise<SqliteStorageUsageResponse> {
return await this.request<SqliteStorageUsageResponse>('/api/storage/sqlite')
}
async listMachineDirectory(
machineId: string,
path: string
+17 -1
View File
@@ -3,11 +3,25 @@ import { useTranslation } from '@/lib/use-translation'
import { useAppearance } from '@/hooks/useTheme'
import { useFontScale } from '@/hooks/useFontScale'
import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { useAppContext } from '@/lib/app-context'
import { settingsCategories } from '@/routes/settings/categories'
import { ChevronRightIcon } from './SettingsPrimitives'
function getNamespace(token: string): string | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=')
const decoded = JSON.parse(atob(base64)) as { ns?: unknown }
return typeof decoded.ns === 'string' ? decoded.ns : null
} catch {
return null
}
}
export function SettingsNav(props: { activeId?: string; mobile?: boolean }) {
const navigate = useNavigate()
const { token } = useAppContext()
const { t, locale } = useTranslation()
const { appearance } = useAppearance()
const { fontScale } = useFontScale()
@@ -19,12 +33,14 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) {
chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`),
voice: t('settings.hub.voice.summary'),
machines: t('settings.hub.machines.summary'),
storage: t('settings.storage.summary'),
about: `v${__APP_VERSION__}`,
}
const visibleCategories = settingsCategories.filter((category) => category.id !== 'storage' || getNamespace(token) === 'default')
return (
<nav aria-label={t('settings.title')} className={props.mobile ? 'divide-y divide-[var(--app-divider)]' : 'space-y-1 p-3'}>
{settingsCategories.map((category) => {
{visibleCategories.map((category) => {
const active = props.activeId === category.id
return (
<button
+12
View File
@@ -621,6 +621,18 @@ export default {
'settings.hub.description': 'Choose a category to adjust HAPI to your workflow.',
'settings.hub.voice.summary': 'Voice, language, and behavior',
'settings.general.title': 'General',
'settings.storage.title': 'Storage',
'settings.storage.summary': 'Hub database usage',
'settings.storage.description': 'Current on-disk size of the Hub SQLite database.',
'settings.storage.loading': 'Loading storage usage…',
'settings.storage.error': 'Unable to load storage usage',
'settings.storage.total': 'Total',
'settings.storage.database': 'Database',
'settings.storage.wal': 'Write-ahead log',
'settings.storage.shm': 'Shared memory',
'settings.storage.path': 'Path',
'settings.storage.refresh': 'Refresh',
'settings.storage.refreshing': 'Refreshing…',
'settings.general.description': 'Language, companion pairing, and general application preferences.',
'settings.language.title': 'Language',
'settings.language.label': 'Language',
+12
View File
@@ -625,6 +625,18 @@ export default {
'settings.hub.description': '选择一个分类,按你的工作方式调整 HAPI。',
'settings.hub.voice.summary': '声音、语言和行为',
'settings.general.title': '通用',
'settings.storage.title': '存储空间',
'settings.storage.summary': 'Hub 数据库用量',
'settings.storage.description': 'Hub SQLite 数据库当前占用的磁盘空间。',
'settings.storage.loading': '正在加载存储用量…',
'settings.storage.error': '无法加载存储用量',
'settings.storage.total': '总计',
'settings.storage.database': '数据库',
'settings.storage.wal': '预写日志',
'settings.storage.shm': '共享内存',
'settings.storage.path': '路径',
'settings.storage.refresh': '刷新',
'settings.storage.refreshing': '正在刷新…',
'settings.general.description': '语言、伴侣应用配对和通用应用偏好。',
'settings.language.title': '语言',
'settings.language.label': '语言',
+1
View File
@@ -3,6 +3,7 @@ export const queryKeys = {
session: (sessionId: string) => ['session', sessionId] as const,
messages: (sessionId: string) => ['messages', sessionId] as const,
machines: ['machines'] as const,
sqliteStorage: ['sqlite-storage'] as const,
machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const,
gitStatus: (sessionId: string) => ['git-status', sessionId] as const,
sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const,
+8
View File
@@ -62,6 +62,7 @@ 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 SettingsStoragePage from '@/routes/settings/storage'
import SharePage from '@/routes/share'
import { setSharePendingTransfer } from '@/lib/sharePendingState'
import { deleteShareTransfer } from '@/lib/shareTransfer'
@@ -1450,6 +1451,12 @@ const settingsAboutRoute = createRoute({
component: SettingsAboutPage,
})
const settingsStorageRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'storage',
component: SettingsStoragePage,
})
// Web Share Target landing route. Service worker (`web/src/sw.ts`)
// intercepts the manifest's `POST /share` and 303-redirects here with an
// IDB transfer id. `error=ingest` is set when the SW failed to write IDB.
@@ -1490,6 +1497,7 @@ export const routeTree = rootRoute.addChildren([
settingsVoiceVoicesRoute,
settingsVoiceAdvancedRoute,
settingsMachinesRoute,
settingsStorageRoute,
settingsAboutRoute,
]),
shareRoute,
+1
View File
@@ -4,6 +4,7 @@ export const settingsCategories = [
{ 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: 'storage', path: '/settings/storage', titleKey: 'settings.storage.title' },
{ id: 'about', path: '/settings/about', titleKey: 'settings.about.title' },
] as const
+10 -1
View File
@@ -10,7 +10,8 @@ import SettingsVoicePage from './voice'
import SettingsVoiceVoicesPage from './voice-voices'
import SettingsVoiceAdvancedPage from './voice-advanced'
const { navigate, setAppearance, setColorTheme, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({
const { context, navigate, setAppearance, setColorTheme, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({
context: { token: '' },
navigate: vi.fn(),
setAppearance: vi.fn(),
setColorTheme: vi.fn(),
@@ -130,6 +131,7 @@ vi.mock('@/lib/app-context', () => ({
useAppContext: () => ({
api: {},
baseUrl: 'http://127.0.0.1:3006',
token: context.token,
}),
}))
@@ -170,6 +172,7 @@ describe('responsive settings pages', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
context.token = `x.${btoa(JSON.stringify({ ns: 'default' }))}.x`
})
it('renders the mobile hub categories with current summaries', () => {
@@ -186,6 +189,12 @@ describe('responsive settings pages', () => {
expect(navigate).toHaveBeenCalledWith({ to: '/settings/general' })
})
it('hides Hub storage from tenant namespaces', () => {
context.token = `x.${btoa(JSON.stringify({ ns: 'tenant' }))}.x`
renderPage(<SettingsHubPage />)
expect(screen.queryByText('Hub database usage')).not.toBeInTheDocument()
})
it('changes the application language inline', () => {
renderPage(<SettingsGeneralPage />)
expect(screen.getByText('Companion')).toBeInTheDocument()
+53
View File
@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query'
import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives'
import { useAppContext } from '@/lib/app-context'
import { formatFileSize } from '@/lib/file-metadata'
import { queryKeys } from '@/lib/query-keys'
import { useTranslation } from '@/lib/use-translation'
export default function SettingsStoragePage() {
const { api } = useAppContext()
const { t } = useTranslation()
const query = useQuery({
queryKey: queryKeys.sqliteStorage,
queryFn: async () => {
if (!api) throw new Error('API unavailable')
return await api.getSqliteStorageUsage()
},
enabled: Boolean(api),
staleTime: 0,
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
return (
<SettingsPageContent description={t('settings.storage.description')}>
<SettingsSection>
{query.isLoading ? <SettingsRow label={t('settings.storage.loading')} /> : null}
{query.error ? <SettingsRow label={t('settings.storage.error')} description={query.error instanceof Error ? query.error.message : undefined} /> : null}
{query.data ? (
<>
<SettingsRow label={t('settings.storage.total')} trailing={<span className="font-medium text-[var(--app-fg)]">{formatFileSize(query.data.totalBytes)}</span>} />
<SettingsRow label={t('settings.storage.database')} trailing={<span className="text-[var(--app-hint)]">{formatFileSize(query.data.databaseBytes)}</span>} />
<SettingsRow label={t('settings.storage.wal')} trailing={<span className="text-[var(--app-hint)]">{formatFileSize(query.data.walBytes)}</span>} />
<SettingsRow label={t('settings.storage.shm')} trailing={<span className="text-[var(--app-hint)]">{formatFileSize(query.data.shmBytes)}</span>} />
<SettingsRow label={t('settings.storage.path')} trailing={
<code className="block max-w-[min(20rem,55vw)] truncate text-xs text-[var(--app-hint)]" title={query.data.path}>
{query.data.path}
</code>
} />
</>
) : null}
</SettingsSection>
<button
type="button"
onClick={() => void query.refetch()}
disabled={query.isFetching}
className="rounded-lg bg-[var(--app-link)] px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{query.isFetching ? t('settings.storage.refreshing') : t('settings.storage.refresh')}
</button>
</SettingsPageContent>
)
}