mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): show Hub SQLite storage usage in Settings (#1225)
This commit is contained in:
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -683,3 +683,11 @@ export type SlashCommandsResponse = {
|
||||
commands?: SlashCommand[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SqliteStorageUsageResponse = {
|
||||
path: string
|
||||
databaseBytes: number
|
||||
walBytes: number
|
||||
shmBytes: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '语言',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user