diff --git a/hub/src/web/routes/storage.test.ts b/hub/src/web/routes/storage.test.ts new file mode 100644 index 00000000..4706f863 --- /dev/null +++ b/hub/src/web/routes/storage.test.ts @@ -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() + 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' }) + }) +}) diff --git a/hub/src/web/routes/storage.ts b/hub/src/web/routes/storage.ts new file mode 100644 index 00000000..c95dfb4d --- /dev/null +++ b/hub/src/web/routes/storage.ts @@ -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 { + 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 { + const app = new Hono() + + 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 +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index aab64425..b9668b27 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -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({ diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 5c58a1b3..bf4d3355 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -683,3 +683,11 @@ export type SlashCommandsResponse = { commands?: SlashCommand[] error?: string } + +export type SqliteStorageUsageResponse = { + path: string + databaseBytes: number + walBytes: number + shmBytes: number + totalBytes: number +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bef02a80..17c1b9a3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + return await this.request('/api/storage/sqlite') + } + async listMachineDirectory( machineId: string, path: string diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index c8bf495d..7fb3bf07 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -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 (