Add v5 maintenance center (#535)

This commit is contained in:
Brad Groux 2026-06-03 05:02:57 -07:00 committed by GitHub
parent 398ac2f606
commit 62f258052c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1954 additions and 13 deletions

View file

@ -43,10 +43,11 @@
30. [Tool Policies](#tool-policies)
31. [Traces](#traces)
32. [Audit](#audit)
33. [Common Workflows](#common-workflows)
34. [Versioning & Deprecation](#versioning--deprecation)
35. [Rate Limits](#rate-limits)
36. [Additional Endpoint Groups](#additional-endpoint-groups)
33. [Maintenance Center](#maintenance-center-apiv1maintenance)
34. [Common Workflows](#common-workflows)
35. [Versioning & Deprecation](#versioning--deprecation)
36. [Rate Limits](#rate-limits)
37. [Additional Endpoint Groups](#additional-endpoint-groups)
---
@ -2759,6 +2760,53 @@ Restores the bundle into SQLite and rebuilds derived search indexes.
---
## Maintenance Center (`/api/v1/maintenance`)
Admin/backup endpoints that power Settings -> Maintenance. The full contract is
documented in [v5.0 Maintenance Center](MAINTENANCE-CENTER.md).
#### Summary
```
GET /api/v1/maintenance/summary
```
Returns health checks, storage categories, lifecycle policy metadata, work
product maintenance preview data, safe cleanup preview items, and allowlisted
log sources with redacted local paths. Cleanup is preview-only; the endpoint
does not delete data.
#### Redacted Log Tail
```
GET /api/v1/maintenance/logs?source=server&tail=200
```
Returns redacted lines and redacted source metadata from an allowlisted source.
`tail` is capped at 500.
#### Debug Bundle
```
POST /api/v1/maintenance/debug-bundle
```
Creates a redacted debug bundle under the runtime debug-bundles directory and
returns the output path plus a manifest of included categories, excluded
sensitive categories, redaction rules, and redacted file metadata.
#### SQLite Export and Import
```
POST /api/v1/maintenance/sqlite/export
POST /api/v1/maintenance/sqlite/import
```
Wrappers around the SQLite portability export/import handlers. They return the
same portability report used by `/api/v1/sqlite`.
---
### System Health (`/api/v1/system/health`)
Get a real-time snapshot of system health across resources, agents, and operations.

View file

@ -0,0 +1,41 @@
# v5.0 Maintenance Center
The Maintenance Center is the operator surface for local-first upkeep. It lives
in Settings -> Maintenance and is backed by `/api/v1/maintenance`.
## What It Shows
- Health checks for storage, disk space, logs, work products, agent runner
registry state, recent run success, and lifecycle policy loading.
- Storage usage for task files, attachments, telemetry/traces, workflow runs,
worktrees, logs, debug bundles, and work products.
- Cleanup previews that separate active work, archived/restorable work, and
safe-to-review generated data.
- Redacted log tails from allowlisted sources only.
- Redacted debug bundles with a manifest of included and excluded categories.
- SQLite export/import actions that report bundle path, database path, table
counts, warnings, and failure messages.
## Safety Rules
- Cleanup is preview-only until a dedicated delete handler exists for the
affected data class.
- Destructive cleanup must require explicit confirmation and must never delete
active task worktrees or current run state silently.
- Debug bundles include redacted log tails, health metadata, storage summaries,
lifecycle policy metadata, and work-product preview metadata.
- Maintenance summaries and log-tail responses redact local log paths before
returning data to the UI.
- Debug bundles exclude raw tokens, token hashes, cookies, private keys, raw
prompts, raw chat content, and generated sensitive text.
- Local home, project, storage, runtime, and log paths are redacted in bundle
files by default.
## Verification
Focused regression coverage:
```bash
pnpm --filter @veritas-kanban/server test -- maintenance-service.test.ts
pnpm --filter @veritas-kanban/web test -- settings-maintenance-mantine.test.tsx
```

View file

@ -15,6 +15,9 @@ operator checklist for final release verification.
- [ ] Data lifecycle controls define retention, export, deletion, privacy, and
support-bundle redaction policy for durable v5 data classes. Track the
contract in [v5.0 data lifecycle controls](DATA-LIFECYCLE.md).
- [ ] Maintenance Center verifies health, storage usage, redacted log tails,
debug bundles, backup/import reporting, and cleanup previews. Track the
contract in [v5.0 Maintenance Center](MAINTENANCE-CENTER.md).
- [ ] Multi-user mode verifies workspace switching, memberships, invitations,
scoped API tokens, actor attribution, and RBAC denial paths.
- [ ] Remote mode verifies the

View file

@ -21,7 +21,7 @@ test.describe('Settings', () => {
// Settings dialog should open — verify by the dialog title heading
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(dialog.locator('.mantine-Modal-title')).toHaveText('Settings');
});
test('settings dialog shows tab navigation', async ({ page }) => {
@ -39,6 +39,26 @@ test.describe('Settings', () => {
await expect(dialog.getByRole('tab', { name: 'Board' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Tasks' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Agents' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Maintenance' })).toBeVisible();
});
test('maintenance tab renders live maintenance state', async ({ page }) => {
await page.goto('/');
const settingsBtn = page.locator('header button:has(svg.lucide-settings)');
await settingsBtn.click();
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole('tab', { name: 'Maintenance' }).click();
await expect(dialog.getByText('Maintenance Center')).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText('Health')).toBeVisible();
await expect(dialog.getByText('Storage Usage')).toBeVisible();
await expect(dialog.getByText('Cleanup Preview').first()).toBeVisible();
await expect(dialog.getByText('Backup and Restore')).toBeVisible();
await expect(dialog.getByLabel('Redacted log tail')).toBeVisible();
});
test('switch to Board tab and toggle a setting', async ({ page }) => {
@ -64,14 +84,18 @@ test.describe('Settings', () => {
const toggle = dialog.getByRole('switch', { name: 'Show Dashboard' });
// Get the current state
const initialState = await toggle.getAttribute('data-state');
const expectedNewState = initialState === 'checked' ? 'unchecked' : 'checked';
const initialState = await toggle.isChecked();
// Click to toggle
await toggle.click();
// Toggle by keyboard because Mantine's visual track can intercept pointer events.
await toggle.focus();
await page.keyboard.press('Space');
// Wait for the state to change (may be debounced)
await expect(toggle).toHaveAttribute('data-state', expectedNewState, { timeout: 3_000 });
if (initialState) {
await expect(toggle).not.toBeChecked({ timeout: 3_000 });
} else {
await expect(toggle).toBeChecked({ timeout: 3_000 });
}
});
test('settings dialog closes on escape', async ({ page }) => {
@ -103,9 +127,7 @@ test.describe('Settings', () => {
// The Card Density uses a Radix Select — find the trigger within the setting row
// The row structure: div > div(label) + div(select trigger)
const cardDensityTrigger = dialog
.locator('button[role="combobox"]')
.filter({ hasText: /Normal|Compact/ });
const cardDensityTrigger = dialog.getByRole('combobox', { name: 'Card Density' });
await cardDensityTrigger.click();
// Verify the dropdown options appear (Radix portals them to body)

View file

@ -0,0 +1,104 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MaintenanceService } from '../services/maintenance-service.js';
import { resetWorkProductServiceForTests } from '../services/work-product-service.js';
describe('MaintenanceService', () => {
let root: string;
let originalDataDir: string | undefined;
beforeEach(async () => {
originalDataDir = process.env.DATA_DIR;
root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-maintenance-'));
process.env.DATA_DIR = root;
resetWorkProductServiceForTests();
await fs.mkdir(path.join(root, '.veritas-kanban', 'logs'), { recursive: true });
await fs.writeFile(
path.join(root, '.veritas-kanban', 'logs', 'server.log'),
[
'startup ok',
'Bearer abcdefghijklmnop token=sk_supersecret1234567890',
'/Users/brad/private/project/file.txt',
].join('\n'),
'utf-8'
);
});
afterEach(async () => {
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
resetWorkProductServiceForTests();
await fs.rm(root, { recursive: true, force: true });
});
it('builds a read-only cleanup preview with lifecycle and storage data', async () => {
const service = new MaintenanceService();
const summary = await service.buildSummary();
expect(summary.cleanupPreview.destructiveActionsEnabled).toBe(false);
expect(summary.cleanupPreview.confirmationRequired).toBe(true);
expect(summary.storage.categories.map((category) => category.id)).toEqual(
expect.arrayContaining(['logs', 'work-products', 'debug-bundles'])
);
expect(summary.logs.find((source) => source.id === 'server')?.path).toContain(
'[redacted-logs]'
);
expect(summary.logs.find((source) => source.id === 'server')?.path).not.toContain(root);
expect(summary.lifecycle.map((entry) => entry.id)).toContain('workProducts');
expect(summary.health.map((check) => check.id)).toEqual(
expect.arrayContaining([
'storage',
'disk',
'logs',
'agent-runner',
'recent-runs',
'lifecycle-policy',
])
);
});
it('tails allowlisted logs with secrets and local paths redacted', async () => {
const service = new MaintenanceService();
const tail = await service.tailLog('server', 10);
const text = tail.lines.join('\n');
expect(tail.redacted).toBe(true);
expect(tail.source.path).toContain('[redacted-logs]');
expect(tail.source.path).not.toContain(root);
expect(text).toContain('Bearer [REDACTED]');
expect(text).toContain('[REDACTED_API_KEY]');
expect(text).toContain('[redacted-local-path]');
expect(text).not.toContain('abcdefghijklmnop');
expect(text).not.toContain('sk_supersecret1234567890');
expect(text).not.toContain('/Users/brad/private');
});
it('creates a redacted debug bundle manifest and redacted log tails', async () => {
const service = new MaintenanceService();
const bundle = await service.createDebugBundle();
const manifest = JSON.parse(
await fs.readFile(path.join(bundle.outputPath, 'manifest.json'), 'utf-8')
) as typeof bundle.manifest;
const serverLog = await fs.readFile(
path.join(bundle.outputPath, 'logs', 'server.log'),
'utf-8'
);
const summary = await fs.readFile(path.join(bundle.outputPath, 'summary.json'), 'utf-8');
expect(bundle.redacted).toBe(true);
expect(manifest.includedCategories).toEqual(
expect.arrayContaining(['health', 'storage', 'redacted-log-tails'])
);
expect(manifest.files.find((file) => file.id === 'server')?.path).toContain('[redacted-logs]');
expect(serverLog).not.toContain('sk_supersecret1234567890');
expect(summary).not.toContain(root);
});
});

View file

@ -0,0 +1,79 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { asyncHandler } from '../middleware/async-handler.js';
import { ValidationError } from '../middleware/error-handler.js';
import { getMaintenanceService } from '../services/maintenance-service.js';
import { getSqlitePortabilityService } from '../services/sqlite-portability-service.js';
const router: RouterType = Router();
const pathSchema = z.string().min(1).max(4096);
const logQuerySchema = z.object({
source: z.string().min(1).max(80),
tail: z.coerce.number().int().min(1).max(500).optional(),
});
const sqliteExportSchema = z.object({
sqlitePath: pathSchema,
outputDir: pathSchema,
workspaceId: z.string().min(1).max(200).optional(),
});
const sqliteImportSchema = z.object({
sqlitePath: pathSchema,
bundleDir: pathSchema,
replaceExisting: z.boolean().optional(),
});
function parse<T>(schema: z.ZodSchema<T>, input: unknown): T {
const result = schema.safeParse(input);
if (result.success) return result.data;
throw new ValidationError(
'Validation failed',
result.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
}))
);
}
router.get(
'/summary',
asyncHandler(async (_req, res) => {
res.json(await getMaintenanceService().buildSummary());
})
);
router.get(
'/logs',
asyncHandler(async (req, res) => {
const query = parse(logQuerySchema, req.query);
res.json(await getMaintenanceService().tailLog(query.source, query.tail));
})
);
router.post(
'/debug-bundle',
asyncHandler(async (_req, res) => {
res.status(201).json(await getMaintenanceService().createDebugBundle());
})
);
router.post(
'/sqlite/export',
asyncHandler(async (req, res) => {
const input = parse(sqliteExportSchema, req.body);
res.status(201).json(await getSqlitePortabilityService().exportSqliteBackup(input));
})
);
router.post(
'/sqlite/import',
asyncHandler(async (req, res) => {
const input = parse(sqliteImportSchema, req.body);
res.json(await getSqlitePortabilityService().importSqliteBackup(input));
})
);
export { router as maintenanceRoutes };

View file

@ -117,6 +117,7 @@ import { scoringRoutes } from '../scoring.js';
import { feedbackRoutes } from '../feedback.js';
import promptRegistryRoutes from '../prompt-registry.js';
import { sqlitePortabilityRoutes } from '../sqlite-portability.js';
import { maintenanceRoutes } from '../maintenance.js';
import { identityRoutes } from '../identity.js';
const v1Router: IRouter = Router();
@ -219,6 +220,7 @@ v1Router.use('/decisions', taskAccess, decisionRoutes);
v1Router.use('/feedback', feedbackAccess, feedbackRoutes);
v1Router.use('/prompt-registry', promptRegistryAccess, promptRegistryRoutes);
v1Router.use('/sqlite', backupAccess, sqlitePortabilityRoutes);
v1Router.use('/maintenance', backupAccess, maintenanceRoutes);
v1Router.use('/identity', workspaceAccess, identityRoutes);
export { v1Router };

View file

@ -0,0 +1,584 @@
import fs from 'fs/promises';
import path from 'path';
import {
type MaintenanceCleanupPreviewItem,
type MaintenanceDebugBundle,
type MaintenanceHealthCheck,
type MaintenanceLogSource,
type MaintenanceLogTail,
type MaintenanceStorageCategory,
type MaintenanceSummary,
} from '@veritas-kanban/shared';
import { getWorkProductService } from './work-product-service.js';
import { getSystemHealthService } from './system-health-service.js';
import { buildDataLifecycleManifest } from './data-lifecycle-policy.js';
import {
getLogsDir,
getRuntimeDir,
getStorageRoot,
getTasksActiveDir,
getTasksArchiveDir,
getTasksAttachmentsDir,
getTasksBacklogDir,
getTelemetryDir,
getWorkflowRunsDir,
getWorktreesDir,
} from '../utils/paths.js';
import { redactString } from '../lib/redact.js';
interface DirectoryStats {
bytes: number;
itemCount: number;
updatedAt?: string;
}
interface LogSourceDefinition {
id: string;
label: string;
path: string;
}
const MAX_TAIL_LINES = 500;
export class MaintenanceService {
async buildSummary(): Promise<MaintenanceSummary> {
const generatedAt = new Date().toISOString();
const workProducts = await getWorkProductService().maintenancePreview();
const [
storageRoot,
runtimeDir,
activeTasks,
archivedTasks,
backlogTasks,
attachments,
telemetry,
workflowRuns,
worktrees,
logs,
debugBundles,
] = await Promise.all([
this.collectDirectoryStats(getStorageRoot()),
this.collectDirectoryStats(getRuntimeDir()),
this.collectDirectoryStats(getTasksActiveDir()),
this.collectDirectoryStats(getTasksArchiveDir()),
this.collectDirectoryStats(getTasksBacklogDir()),
this.collectDirectoryStats(getTasksAttachmentsDir()),
this.collectDirectoryStats(getTelemetryDir()),
this.collectDirectoryStats(getWorkflowRunsDir()),
this.collectDirectoryStats(getWorktreesDir()),
this.collectDirectoryStats(getLogsDir()),
this.collectDirectoryStats(this.debugBundlesDir()),
]);
const rawLogSources = await this.listLogSources();
const storageCategories: MaintenanceStorageCategory[] = [
this.storageCategory('storage-root', 'Storage root', storageRoot, 0, 'Canonical data root.'),
this.storageCategory(
'runtime-state',
'Runtime state',
runtimeDir,
0,
'Settings, logs, traces, workflows, and local runtime data.'
),
this.storageCategory(
'active-tasks',
'Active task files',
activeTasks,
0,
'Active work is retained.'
),
this.storageCategory(
'archived-tasks',
'Archived task files',
archivedTasks,
archivedTasks.itemCount,
'Archived work requires explicit cleanup confirmation.'
),
this.storageCategory(
'backlog-tasks',
'Backlog task files',
backlogTasks,
0,
'Backlog work is retained until promoted, archived, or deleted.'
),
this.storageCategory(
'attachments',
'Attachment files',
attachments,
0,
'Attachment cleanup requires parent task and orphan previews.'
),
this.storageCategory(
'telemetry',
'Telemetry and traces',
telemetry,
telemetry.itemCount,
'Telemetry retention follows Data settings and requires range preview.'
),
this.storageCategory(
'workflow-runs',
'Workflow run state',
workflowRuns,
0,
'Current run state is retained.'
),
this.storageCategory(
'worktrees',
'Agent worktrees',
worktrees,
0,
'Active worktrees are never deleted silently.'
),
this.storageCategory(
'logs',
'Logs',
logs,
logs.itemCount,
'Logs are redacted before support bundle inclusion.'
),
this.storageCategory(
'debug-bundles',
'Debug bundles',
debugBundles,
debugBundles.itemCount,
'Generated support bundles are removed only by explicit filesystem cleanup.'
),
{
id: 'work-products',
label: 'Work products and versions',
bytes: workProducts.totals.estimatedBytes,
itemCount: workProducts.totals.products,
cleanupEligibleCount: workProducts.totals.cleanupCandidates,
retainedReason:
'Archived generated outputs are cleanup candidates; active products are retained.',
lastUsedAt: this.latestDate(
[...workProducts.cleanupCandidates, ...workProducts.retained].map(
(item) => item.updatedAt
)
),
},
];
return {
generatedAt,
mode: process.env.VERITAS_REMOTE_MODE === 'true' ? 'remote' : 'local',
storageMode: process.env.VERITAS_STORAGE ?? 'file',
health: await this.buildHealthChecks(generatedAt),
storage: {
totalBytes: storageRoot.bytes,
categories: storageCategories,
},
logs: rawLogSources.map((source) => this.redactLogSource(source)),
lifecycle: buildDataLifecycleManifest({
tableCounts: {
work_products: workProducts.totals.products,
work_product_versions: workProducts.totals.versions,
},
}),
cleanupPreview: {
items: this.buildCleanupPreview(storageCategories, workProducts),
destructiveActionsEnabled: false,
confirmationRequired: true,
notes: [
'Preview only. This endpoint never deletes active task worktrees or current run state.',
'Cleanup handlers must require explicit confirmation before deleting retained data.',
'Support bundles redact secrets, private paths, prompts, logs, and generated sensitive text by default.',
],
},
workProducts,
};
}
async tailLog(sourceId: string, tail = 200): Promise<MaintenanceLogTail> {
const sources = await this.listLogSources();
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) {
throw new Error(`Unknown maintenance log source: ${sourceId}`);
}
if (!source.exists) {
return { source: this.redactLogSource(source), lines: [], truncated: false, redacted: true };
}
const maxLines = Math.min(Math.max(Math.floor(tail), 1), MAX_TAIL_LINES);
const content = await fs.readFile(source.path, 'utf-8').catch(() => '');
const lines = content.split(/\r?\n/);
const selected = lines.slice(-maxLines).map((line) => this.redactMaintenanceText(line));
return {
source: this.redactLogSource(source),
lines: selected,
truncated: lines.length > selected.length,
redacted: true,
};
}
async createDebugBundle(): Promise<MaintenanceDebugBundle> {
const createdAt = new Date().toISOString();
const id = `debug-bundle-${createdAt.replace(/[:.]/g, '-')}`;
const bundleDir = path.join(this.debugBundlesDir(), id);
await fs.mkdir(bundleDir, { recursive: true });
await fs.mkdir(path.join(bundleDir, 'logs'), { recursive: true });
const summary = await this.buildSummary();
const logTails: MaintenanceLogTail[] = [];
for (const source of summary.logs.filter((entry) => entry.exists)) {
const tail = await this.tailLog(source.id, 200);
logTails.push(tail);
await fs.writeFile(
path.join(bundleDir, 'logs', `${source.id}.log`),
tail.lines.join('\n'),
'utf-8'
);
}
const manifest: MaintenanceDebugBundle['manifest'] = {
includedCategories: ['health', 'storage', 'lifecycle', 'work-products', 'redacted-log-tails'],
excludedCategories: [
'raw tokens',
'token hashes',
'cookies',
'private keys',
'raw prompts',
'raw chat content',
'generated sensitive text',
],
redactionRules: [
'Bearer tokens, API keys, JWTs, opaque tokens, and long hashes are replaced.',
'Local home, project, storage, runtime, and log paths are replaced with redacted path labels.',
'Log files are included as redacted tails only, capped at 200 lines per source.',
],
files: summary.logs.map((source) => this.redactLogSource(source)),
};
await fs.writeFile(
path.join(bundleDir, 'summary.json'),
JSON.stringify(this.redactMaintenanceValue(summary), null, 2),
'utf-8'
);
await fs.writeFile(
path.join(bundleDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
'utf-8'
);
return {
id,
createdAt,
outputPath: bundleDir,
redacted: true,
manifest,
};
}
async listLogSources(): Promise<MaintenanceLogSource[]> {
const definitions = await this.logSourceDefinitions();
return Promise.all(
definitions.map(async (definition) => {
const stat = await fs.stat(definition.path).catch(() => null);
return {
id: definition.id,
label: definition.label,
path: definition.path,
exists: Boolean(stat?.isFile()),
sizeBytes: stat?.isFile() ? stat.size : 0,
updatedAt: stat?.isFile() ? stat.mtime.toISOString() : undefined,
redacted: true,
};
})
);
}
private async buildHealthChecks(checkedAt: string): Promise<MaintenanceHealthCheck[]> {
const [storageWritable, diskState, logsState, workProductsState] = await Promise.all([
this.checkStorageWritable(),
this.checkDisk(),
this.checkPathExists(getLogsDir()),
getWorkProductService()
.maintenancePreview()
.then(() => true)
.catch(() => false),
]);
const systemHealth = await getSystemHealthService()
.getStatus()
.catch(() => null);
const agentState = this.signalState(systemHealth?.signals.agents.status);
const operationsState = this.signalState(systemHealth?.signals.operations.status);
return [
{
id: 'storage',
label: 'Storage',
state: storageWritable ? 'ok' : 'fail',
detail: storageWritable
? 'Runtime storage is readable and writable.'
: 'Storage write check failed.',
checkedAt,
},
{
id: 'disk',
label: 'Disk',
state: diskState,
detail:
diskState === 'ok'
? 'Free disk space is above the maintenance threshold.'
: 'Free disk space is below the maintenance threshold or unavailable.',
checkedAt,
},
{
id: 'logs',
label: 'Logs',
state: logsState ? 'ok' : 'warn',
detail: logsState ? 'Log directory is available.' : 'No log directory found yet.',
checkedAt,
},
{
id: 'work-products',
label: 'Work products',
state: workProductsState ? 'ok' : 'warn',
detail: workProductsState
? 'Work product maintenance preview is available.'
: 'Work product maintenance preview could not be generated.',
checkedAt,
},
{
id: 'agent-runner',
label: 'Agent runner',
state: agentState,
detail: systemHealth
? `${systemHealth.signals.agents.online}/${systemHealth.signals.agents.total} registered agents online.`
: 'Agent runner status is unavailable.',
checkedAt,
},
{
id: 'recent-runs',
label: 'Recent runs',
state: operationsState,
detail: systemHealth
? `${systemHealth.signals.operations.successRate}% success across ${systemHealth.signals.operations.recentRuns} recent runs.`
: 'Recent run status is unavailable.',
checkedAt,
},
{
id: 'lifecycle-policy',
label: 'Lifecycle policy',
state: 'ok',
detail: 'Data lifecycle policy is loaded for cleanup previews.',
checkedAt,
},
];
}
private signalState(status: string | undefined): MaintenanceHealthCheck['state'] {
if (!status) return 'unknown';
if (status === 'ok') return 'ok';
if (status === 'warn') return 'warn';
return 'fail';
}
private async checkStorageWritable(): Promise<boolean> {
const runtimeDir = getRuntimeDir();
try {
await fs.mkdir(runtimeDir, { recursive: true });
const probe = path.join(runtimeDir, `.maintenance-${Date.now()}.tmp`);
await fs.writeFile(probe, 'ok', 'utf-8');
await fs.unlink(probe);
return true;
} catch {
return false;
}
}
private async checkDisk(): Promise<MaintenanceHealthCheck['state']> {
try {
const stats = await fs.statfs(getStorageRoot());
const freeBytes = stats.bfree * stats.bsize;
return freeBytes > 100 * 1024 * 1024 ? 'ok' : 'warn';
} catch {
return 'unknown';
}
}
private async checkPathExists(targetPath: string): Promise<boolean> {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
private buildCleanupPreview(
categories: MaintenanceStorageCategory[],
workProducts: MaintenanceSummary['workProducts']
): MaintenanceCleanupPreviewItem[] {
const categoryItems = categories
.filter((category) => category.cleanupEligibleCount > 0)
.map((category) => ({
id: category.id,
label: category.label,
category: 'storage',
cleanupEligible: category.id !== 'worktrees' && category.id !== 'active-tasks',
affectedCount: category.cleanupEligibleCount,
estimatedBytes: category.bytes,
retainedReason: category.retainedReason,
lastUsedAt: category.lastUsedAt,
}));
const productItems = workProducts.cleanupCandidates.slice(0, 20).map((item) => ({
id: `work-product:${item.id}`,
label: item.title,
category: 'work-products',
cleanupEligible: item.cleanupEligible,
affectedCount: item.versionCount,
estimatedBytes: item.estimatedBytes,
retainedReason: item.retainedReason,
sourceHref: item.taskId ? `/tasks/${encodeURIComponent(item.taskId)}` : undefined,
lastUsedAt: item.updatedAt,
}));
return [...categoryItems, ...productItems].sort(
(a, b) => b.estimatedBytes - a.estimatedBytes || a.label.localeCompare(b.label)
);
}
private storageCategory(
id: string,
label: string,
stats: DirectoryStats,
cleanupEligibleCount: number,
retainedReason: string
): MaintenanceStorageCategory {
return {
id,
label,
bytes: stats.bytes,
itemCount: stats.itemCount,
cleanupEligibleCount,
retainedReason,
lastUsedAt: stats.updatedAt,
};
}
private async collectDirectoryStats(dirPath: string): Promise<DirectoryStats> {
let bytes = 0;
let itemCount = 0;
let latestMs = 0;
const walk = async (current: string): Promise<void> => {
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const entryPath = path.join(current, entry.name);
const stat = await fs.stat(entryPath).catch(() => null);
if (!stat) continue;
latestMs = Math.max(latestMs, stat.mtimeMs);
if (entry.isFile()) {
itemCount += 1;
bytes += stat.size;
} else if (entry.isDirectory()) {
await walk(entryPath);
}
}
};
await walk(dirPath);
return {
bytes,
itemCount,
updatedAt: latestMs > 0 ? new Date(latestMs).toISOString() : undefined,
};
}
private async logSourceDefinitions(): Promise<LogSourceDefinition[]> {
const logsDir = getLogsDir();
const latestAgentLog = await this.latestLogFile(logsDir, '.md');
return [
{ id: 'server', label: 'Server log', path: path.join(logsDir, 'server.log') },
{ id: 'web', label: 'Web log', path: path.join(logsDir, 'web.log') },
{
id: 'agent-run',
label: 'Latest agent run log',
path: latestAgentLog ?? path.join(logsDir, 'agent-run.log'),
},
];
}
private async latestLogFile(dirPath: string, extension: string): Promise<string | null> {
const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => []);
const files = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(extension))
.map(async (entry) => {
const filePath = path.join(dirPath, entry.name);
const stat = await fs.stat(filePath).catch(() => null);
return stat ? { filePath, mtimeMs: stat.mtimeMs } : null;
})
);
return (
files
.filter((entry): entry is { filePath: string; mtimeMs: number } => Boolean(entry))
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.filePath ?? null
);
}
private debugBundlesDir(): string {
return path.join(getRuntimeDir(), 'debug-bundles');
}
private latestDate(values: Array<string | undefined>): string | undefined {
const latest = values
.filter((value): value is string => Boolean(value))
.map((value) => Date.parse(value))
.filter((value) => Number.isFinite(value))
.sort((a, b) => b - a)[0];
return latest ? new Date(latest).toISOString() : undefined;
}
private redactLogSource(source: MaintenanceLogSource): MaintenanceLogSource {
return {
...source,
path: this.redactMaintenanceText(source.path),
redacted: true,
};
}
private redactMaintenanceValue(value: unknown): unknown {
if (typeof value === 'string') return this.redactMaintenanceText(value);
if (Array.isArray(value)) return value.map((entry) => this.redactMaintenanceValue(entry));
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
key,
this.redactMaintenanceValue(entry),
])
);
}
return value;
}
private redactMaintenanceText(value: string): string {
let redacted = redactString(value);
const replacements = [
[getLogsDir(), '[redacted-logs]'],
[getRuntimeDir(), '[redacted-runtime]'],
[getStorageRoot(), '[redacted-storage]'],
[process.env.HOME, '[redacted-home]'],
] as const;
for (const [prefix, label] of replacements) {
if (prefix) {
redacted = redacted.split(prefix).join(label);
}
}
return redacted
.replace(/\/Users\/[^/\s]+\/[^\s)]+/g, '[redacted-local-path]')
.replace(/[A-Z]:\\Users\\[^\\\s]+\\[^\s)]+/g, '[redacted-local-path]');
}
}
let singleton: MaintenanceService | null = null;
export function getMaintenanceService(): MaintenanceService {
singleton ??= new MaintenanceService();
return singleton;
}

View file

@ -23,3 +23,4 @@ export * from './system-health.types.js';
export * from './feedback.types.js';
export * from './workflow.js';
export * from './work-product.types.js';
export * from './maintenance.types.js';

View file

@ -0,0 +1,126 @@
import type { WorkProductMaintenancePreview } from './work-product.types.js';
export type DataLifecycleClassId =
| 'workspaceIdentity'
| 'tasks'
| 'comments'
| 'uploadsAttachments'
| 'workProducts'
| 'telemetry'
| 'workflowRuns'
| 'notifications'
| 'chat'
| 'audit'
| 'deviceAccess'
| 'configuration'
| 'backupsExports'
| 'debugBundles';
export interface DataLifecycleManifestEntry {
id: DataLifecycleClassId;
label: string;
tables: string[];
rowCount: number;
defaultRetention: string;
exportBehavior: string;
deleteBehavior: string;
redaction: string;
containsSecrets: boolean;
containsPrivatePaths: boolean;
containsGeneratedContent: boolean;
workspaceScoped: boolean;
}
export type MaintenanceHealthState = 'ok' | 'warn' | 'fail' | 'unknown';
export interface MaintenanceHealthCheck {
id: string;
label: string;
state: MaintenanceHealthState;
detail: string;
checkedAt: string;
}
export interface MaintenanceStorageCategory {
id: string;
label: string;
bytes: number;
itemCount: number;
cleanupEligibleCount: number;
retainedReason: string;
lastUsedAt?: string;
}
export interface MaintenanceLogSource {
id: string;
label: string;
path: string;
exists: boolean;
sizeBytes: number;
updatedAt?: string;
redacted: boolean;
}
export interface MaintenanceCleanupPreviewItem {
id: string;
label: string;
category: string;
cleanupEligible: boolean;
affectedCount: number;
estimatedBytes: number;
retainedReason: string;
sourceHref?: string;
lastUsedAt?: string;
}
export interface MaintenanceSummary {
generatedAt: string;
mode: 'local' | 'remote';
storageMode: string;
health: MaintenanceHealthCheck[];
storage: {
totalBytes: number;
categories: MaintenanceStorageCategory[];
};
logs: MaintenanceLogSource[];
lifecycle: DataLifecycleManifestEntry[];
cleanupPreview: {
items: MaintenanceCleanupPreviewItem[];
destructiveActionsEnabled: boolean;
confirmationRequired: true;
notes: string[];
};
workProducts: WorkProductMaintenancePreview;
}
export interface MaintenanceLogTail {
source: MaintenanceLogSource;
lines: string[];
truncated: boolean;
redacted: true;
}
export interface MaintenanceDebugBundle {
id: string;
createdAt: string;
outputPath: string;
redacted: true;
manifest: {
includedCategories: string[];
excludedCategories: string[];
redactionRules: string[];
files: MaintenanceLogSource[];
};
}
export interface MaintenanceSqliteExportInput {
sqlitePath: string;
outputDir: string;
workspaceId?: string;
}
export interface MaintenanceSqliteImportInput {
sqlitePath: string;
bundleDir: string;
replaceExisting?: boolean;
}

View file

@ -281,6 +281,7 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
],
},
{ prefix: '/api/sqlite', read: 'backup:read', write: 'backup:write' },
{ prefix: '/api/maintenance', read: 'backup:read', write: 'backup:write' },
{ prefix: '/api/identity', read: 'workspace:read', write: 'admin:manage' },
];

View file

@ -0,0 +1,255 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MaintenanceTab } from '@/components/settings/tabs/MaintenanceTab';
import { renderWithProviders } from './test-utils';
import type { MaintenanceSummary } from '@veritas-kanban/shared';
const mocks = vi.hoisted(() => ({
summary: vi.fn(),
tailLog: vi.fn(),
createDebugBundle: vi.fn(),
exportSqlite: vi.fn(),
importSqlite: vi.fn(),
toast: vi.fn(),
}));
const summary: MaintenanceSummary = {
generatedAt: '2026-06-03T12:00:00.000Z',
mode: 'local',
storageMode: 'sqlite',
health: [
{
id: 'storage',
label: 'Storage',
state: 'ok',
detail: 'Runtime storage is readable and writable.',
checkedAt: '2026-06-03T12:00:00.000Z',
},
{
id: 'logs',
label: 'Logs',
state: 'ok',
detail: 'Log directory is available.',
checkedAt: '2026-06-03T12:00:00.000Z',
},
],
storage: {
totalBytes: 4096,
categories: [
{
id: 'logs',
label: 'Logs',
bytes: 2048,
itemCount: 2,
cleanupEligibleCount: 2,
retainedReason: 'Logs are redacted before support bundle inclusion.',
lastUsedAt: '2026-06-03T11:00:00.000Z',
},
{
id: 'work-products',
label: 'Work products and versions',
bytes: 2048,
itemCount: 1,
cleanupEligibleCount: 1,
retainedReason: 'Archived generated outputs are cleanup candidates.',
lastUsedAt: '2026-06-03T10:00:00.000Z',
},
],
},
logs: [
{
id: 'server',
label: 'Server log',
path: '/Users/redacted/logs/server.log',
exists: true,
sizeBytes: 120,
updatedAt: '2026-06-03T11:00:00.000Z',
redacted: true,
},
],
lifecycle: [
{
id: 'workProducts',
label: 'Work products and versions',
tables: ['work_products', 'work_product_versions'],
rowCount: 2,
defaultRetention: 'Retained until cleanup.',
exportBehavior: 'Included in exports.',
deleteBehavior: 'Preview before cleanup.',
redaction: 'Redact generated sensitive text.',
containsSecrets: false,
containsPrivatePaths: true,
containsGeneratedContent: true,
workspaceScoped: true,
},
],
cleanupPreview: {
destructiveActionsEnabled: false,
confirmationRequired: true,
notes: ['Preview only.'],
items: [
{
id: 'work-product:wp_1',
label: 'Archived report',
category: 'work-products',
cleanupEligible: true,
affectedCount: 3,
estimatedBytes: 2048,
retainedReason: 'Archived generated output eligible for explicit cleanup.',
sourceHref: '/tasks/task-1',
lastUsedAt: '2026-06-03T10:00:00.000Z',
},
],
},
workProducts: {
generatedAt: '2026-06-03T12:00:00.000Z',
workspaceId: 'local',
totals: {
products: 1,
active: 0,
archived: 1,
versions: 3,
cleanupCandidates: 1,
estimatedBytes: 2048,
},
byKind: [{ kind: 'report', products: 1, versions: 3, estimatedBytes: 2048 }],
cleanupCandidates: [],
retained: [],
notes: ['Preview only.'],
},
};
vi.mock('@/lib/api', () => ({
api: {
maintenance: {
summary: mocks.summary,
tailLog: mocks.tailLog,
createDebugBundle: mocks.createDebugBundle,
exportSqlite: mocks.exportSqlite,
importSqlite: mocks.importSqlite,
},
},
}));
vi.mock('@/hooks/useToast', () => ({
useToast: () => ({ toast: mocks.toast }),
}));
describe('Maintenance settings tab', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.summary.mockResolvedValue(summary);
mocks.tailLog.mockResolvedValue({
source: summary.logs[0],
lines: ['startup ok', 'Bearer [REDACTED]'],
truncated: false,
redacted: true,
});
mocks.createDebugBundle.mockResolvedValue({
id: 'debug-bundle-1',
createdAt: '2026-06-03T12:00:00.000Z',
outputPath: '/tmp/debug-bundle-1',
redacted: true,
manifest: {
includedCategories: ['health', 'storage'],
excludedCategories: ['raw tokens'],
redactionRules: ['tokens redacted'],
files: [summary.logs[0]],
},
});
mocks.exportSqlite.mockResolvedValue({
operation: 'sqlite-export',
dryRun: false,
startedAt: '2026-06-03T12:00:00.000Z',
completedAt: '2026-06-03T12:00:01.000Z',
sqlitePath: '/tmp/veritas.db',
bundlePath: '/tmp/export',
counts: [{ entity: 'table.tasks', scanned: 1, written: 1, skipped: 0 }],
warnings: [],
});
mocks.importSqlite.mockResolvedValue({
operation: 'sqlite-import',
dryRun: false,
startedAt: '2026-06-03T12:00:00.000Z',
completedAt: '2026-06-03T12:00:01.000Z',
sqlitePath: '/tmp/veritas.db',
bundlePath: '/tmp/export',
counts: [{ entity: 'table.tasks', scanned: 1, written: 1, skipped: 0 }],
warnings: [],
});
});
afterEach(() => {
cleanup();
});
it('renders health, storage, cleanup preview, logs, lifecycle, and backup controls', async () => {
renderWithProviders(<MaintenanceTab />);
expect(await screen.findByText('Maintenance Center')).toBeDefined();
expect(screen.getByText('Runtime storage is readable and writable.')).toBeDefined();
expect(screen.getByText('Archived report')).toBeDefined();
expect(
(screen.getByRole('textbox', { name: 'Redacted log tail' }) as HTMLTextAreaElement).value
).toBe('startup ok\nBearer [REDACTED]');
expect(screen.getAllByText('Work products and versions').length).toBeGreaterThanOrEqual(1);
expect(screen.getByRole('button', { name: 'Export Backup' }).hasAttribute('disabled')).toBe(
true
);
});
it('creates a redacted debug bundle and reports the output path', async () => {
const user = userEvent.setup();
renderWithProviders(<MaintenanceTab />);
await user.click(await screen.findByRole('button', { name: 'Debug Bundle' }));
await waitFor(() => expect(mocks.createDebugBundle).toHaveBeenCalled());
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Debug bundle created',
description: '/tmp/debug-bundle-1',
})
);
});
it('requires cleanup confirmation while destructive actions remain disabled', async () => {
const user = userEvent.setup();
renderWithProviders(<MaintenanceTab />);
await user.click(await screen.findByRole('button', { name: 'Review Cleanup' }));
const dialog = await screen.findByRole('dialog', { name: 'Review cleanup' });
await user.type(within(dialog).getByRole('textbox', { name: 'Confirmation' }), 'DELETE');
expect(
within(dialog)
.getByRole('button', { name: 'Delete Previewed Items' })
.hasAttribute('disabled')
).toBe(true);
});
it('submits backup export with optional workspace scope', async () => {
renderWithProviders(<MaintenanceTab />);
await screen.findByText('Maintenance Center');
fireEvent.change(screen.getByRole('textbox', { name: 'SQLite path' }), {
target: { value: '/tmp/veritas.db' },
});
fireEvent.change(screen.getByRole('textbox', { name: 'Output directory' }), {
target: { value: '/tmp/export' },
});
fireEvent.change(screen.getByRole('textbox', { name: 'Workspace scope' }), {
target: { value: 'workspace-a' },
});
fireEvent.click(screen.getByRole('button', { name: 'Export Backup' }));
await waitFor(() => expect(mocks.exportSqlite).toHaveBeenCalled());
expect(mocks.exportSqlite.mock.calls[0][0]).toEqual({
sqlitePath: '/tmp/veritas.db',
outputDir: '/tmp/export',
workspaceId: 'workspace-a',
});
expect(await screen.findByText('Exported 1 tables to /tmp/export')).toBeDefined();
});
});

View file

@ -21,6 +21,7 @@ import {
Boxes,
BookOpen,
UserCog,
Wrench,
} from 'lucide-react';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import type { ClientAuthPermission } from '@veritas-kanban/shared';
@ -64,6 +65,9 @@ const LazyDocFreshnessTab = lazy(() =>
const LazyMultiUserTab = lazy(() =>
import('./tabs/MultiUserTab').then((m) => ({ default: m.MultiUserTab }))
);
const LazyMaintenanceTab = lazy(() =>
import('./tabs/MaintenanceTab').then((m) => ({ default: m.MaintenanceTab }))
);
// ============ Tab Skeleton ============
@ -96,6 +100,7 @@ type TabId =
| 'shared-resources'
| 'doc-freshness'
| 'multi-user'
| 'maintenance'
| 'manage';
interface TabDef {
@ -114,6 +119,7 @@ const TABS: TabDef[] = [
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'security', label: 'Security', icon: Shield, requiredPermission: 'settings:read' },
{ id: 'multi-user', label: 'Multi-user', icon: UserCog, requiredPermission: 'workspace:read' },
{ id: 'maintenance', label: 'Maintenance', icon: Wrench, requiredPermission: 'backup:read' },
{ id: 'delegation', label: 'Delegation', icon: Plane, requiredPermission: 'agent:read' },
{ id: 'tool-policies', label: 'Tool Policies', icon: Lock, requiredPermission: 'policy:read' },
{
@ -422,6 +428,11 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
<LazyManageTab />
</SettingsErrorBoundary>
)}
{activeTab === 'maintenance' && (
<SettingsErrorBoundary tabName="Maintenance">
<LazyMaintenanceTab />
</SettingsErrorBoundary>
)}
</Suspense>
);
};

View file

@ -0,0 +1,586 @@
import { useEffect, useMemo, useState } from 'react';
import type { ElementType } from 'react';
import {
Alert,
Badge,
Button,
Checkbox,
Code,
Group,
Loader,
Modal,
NumberInput,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Textarea,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useToast } from '@/hooks/useToast';
import {
Archive,
Database,
FileArchive,
FileClock,
HardDrive,
RefreshCcw,
ShieldCheck,
Trash2,
Wrench,
} from 'lucide-react';
import type {
MaintenanceCleanupPreviewItem,
MaintenanceHealthCheck,
MaintenanceStorageCategory,
} from '@veritas-kanban/shared';
const HEALTH_COLORS: Record<MaintenanceHealthCheck['state'], string> = {
ok: 'green',
warn: 'yellow',
fail: 'red',
unknown: 'gray',
};
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const scaled = value / 1024 ** index;
return `${scaled >= 10 ? scaled.toFixed(0) : scaled.toFixed(1)} ${units[index]}`;
}
function formatDate(value?: string): string {
if (!value) return 'No activity';
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function SummaryMetric({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: ElementType;
}) {
return (
<Paper withBorder radius="md" p="sm">
<Group gap="sm" wrap="nowrap">
<Icon className="h-4 w-4 text-muted-foreground" />
<div>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</div>
</Group>
</Paper>
);
}
function HealthCheckList({ checks }: { checks: MaintenanceHealthCheck[] }) {
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
{checks.map((check) => (
<Paper key={check.id} withBorder radius="md" p="sm">
<Group justify="space-between" gap="sm">
<Text size="sm" fw={600}>
{check.label}
</Text>
<Badge color={HEALTH_COLORS[check.state]} variant="light">
{check.state}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{check.detail}
</Text>
</Paper>
))}
</SimpleGrid>
);
}
function StorageTable({ categories }: { categories: MaintenanceStorageCategory[] }) {
return (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Artifact</Table.Th>
<Table.Th>Items</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Cleanup</Table.Th>
<Table.Th>Last used</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{categories.map((category) => (
<Table.Tr key={category.id}>
<Table.Td>
<Text size="sm" fw={600}>
{category.label}
</Text>
<Text size="xs" c="dimmed">
{category.retainedReason}
</Text>
</Table.Td>
<Table.Td>{category.itemCount}</Table.Td>
<Table.Td>{formatBytes(category.bytes)}</Table.Td>
<Table.Td>
<Badge color={category.cleanupEligibleCount > 0 ? 'yellow' : 'gray'} variant="light">
{category.cleanupEligibleCount}
</Badge>
</Table.Td>
<Table.Td>{formatDate(category.lastUsedAt)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
function CleanupPreviewList({ items }: { items: MaintenanceCleanupPreviewItem[] }) {
if (items.length === 0) {
return (
<Text size="sm" c="dimmed">
No cleanup candidates found.
</Text>
);
}
return (
<Stack gap="xs">
{items.slice(0, 8).map((item) => (
<Paper key={item.id} withBorder radius="md" p="sm">
<Group justify="space-between" align="flex-start" gap="sm">
<div>
<Group gap="xs">
<Text size="sm" fw={600}>
{item.label}
</Text>
<Badge color={item.cleanupEligible ? 'yellow' : 'gray'} variant="light">
{item.category}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{item.retainedReason}
</Text>
</div>
<Text size="sm" fw={600}>
{formatBytes(item.estimatedBytes)}
</Text>
</Group>
</Paper>
))}
</Stack>
);
}
export function MaintenanceTab() {
const { toast } = useToast();
const [selectedLog, setSelectedLog] = useState<string>('server');
const [tailLines, setTailLines] = useState(200);
const [cleanupOpen, setCleanupOpen] = useState(false);
const [cleanupConfirm, setCleanupConfirm] = useState('');
const [sqlitePath, setSqlitePath] = useState('');
const [outputDir, setOutputDir] = useState('');
const [workspaceId, setWorkspaceId] = useState('');
const [bundleDir, setBundleDir] = useState('');
const [replaceExisting, setReplaceExisting] = useState(false);
const [lastBackupResult, setLastBackupResult] = useState<string | null>(null);
const summaryQuery = useQuery({
queryKey: ['maintenance', 'summary'],
queryFn: api.maintenance.summary,
});
const summary = summaryQuery.data;
useEffect(() => {
if (!summary?.logs.length) return;
if (!summary.logs.some((source) => source.id === selectedLog)) {
setSelectedLog(summary.logs[0].id);
}
}, [selectedLog, summary?.logs]);
const logQuery = useQuery({
queryKey: ['maintenance', 'logs', selectedLog, tailLines],
queryFn: () => api.maintenance.tailLog(selectedLog, tailLines),
enabled: Boolean(selectedLog),
});
const debugBundle = useMutation({
mutationFn: api.maintenance.createDebugBundle,
onSuccess: (bundle) => {
toast({
title: 'Debug bundle created',
description: bundle.outputPath,
});
summaryQuery.refetch();
},
onError: (error) => {
toast({
title: 'Debug bundle failed',
description: error instanceof Error ? error.message : 'Unknown error',
duration: Infinity,
});
},
});
const exportSqlite = useMutation({
mutationFn: api.maintenance.exportSqlite,
onSuccess: (report) => {
const result = `Exported ${report.counts.length} tables to ${report.bundlePath ?? outputDir}`;
setLastBackupResult(result);
toast({ title: 'SQLite export complete', description: result });
summaryQuery.refetch();
},
onError: (error) => {
const message = error instanceof Error ? error.message : 'Unknown error';
setLastBackupResult(message);
toast({ title: 'SQLite export failed', description: message, duration: Infinity });
},
});
const importSqlite = useMutation({
mutationFn: api.maintenance.importSqlite,
onSuccess: (report) => {
const result = `Imported ${report.counts.length} tables into ${report.sqlitePath ?? sqlitePath}`;
setLastBackupResult(result);
toast({ title: 'SQLite import complete', description: result });
summaryQuery.refetch();
},
onError: (error) => {
const message = error instanceof Error ? error.message : 'Unknown error';
setLastBackupResult(message);
toast({ title: 'SQLite import failed', description: message, duration: Infinity });
},
});
const logOptions = useMemo(
() =>
(summary?.logs ?? []).map((source) => ({
value: source.id,
label: `${source.label}${source.exists ? '' : ' (missing)'}`,
})),
[summary?.logs]
);
if (summaryQuery.isLoading) {
return (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading maintenance state
</Text>
</Group>
);
}
if (summaryQuery.isError || !summary) {
return (
<Alert color="red" title="Maintenance unavailable">
{summaryQuery.error instanceof Error ? summaryQuery.error.message : 'Failed to load state'}
</Alert>
);
}
const cleanupEnabled =
summary.cleanupPreview.destructiveActionsEnabled && cleanupConfirm === 'DELETE';
const cleanupBytes = summary.cleanupPreview.items.reduce(
(total, item) => total + item.estimatedBytes,
0
);
const workProductRatio =
summary.workProducts.totals.products > 0
? (summary.workProducts.totals.cleanupCandidates / summary.workProducts.totals.products) * 100
: 0;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text size="sm" fw={700}>
Maintenance Center
</Text>
<Text size="xs" c="dimmed">
{summary.storageMode} storage, {summary.mode} mode, refreshed{' '}
{formatDate(summary.generatedAt)}
</Text>
</div>
<Group gap="xs">
<Tooltip label="Refresh maintenance state">
<Button
type="button"
size="xs"
variant="light"
leftSection={<RefreshCcw className="h-4 w-4" />}
onClick={() => summaryQuery.refetch()}
>
Refresh
</Button>
</Tooltip>
<Button
type="button"
size="xs"
leftSection={<FileArchive className="h-4 w-4" />}
loading={debugBundle.isPending}
onClick={() => debugBundle.mutate()}
>
Debug Bundle
</Button>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 4 }} spacing="xs">
<SummaryMetric
label="Storage"
value={formatBytes(summary.storage.totalBytes)}
icon={HardDrive}
/>
<SummaryMetric
label="Cleanup Preview"
value={`${summary.cleanupPreview.items.length} items`}
icon={Trash2}
/>
<SummaryMetric
label="Work Products"
value={`${summary.workProducts.totals.products} products`}
icon={Archive}
/>
<SummaryMetric
label="Lifecycle Classes"
value={`${summary.lifecycle.length} classes`}
icon={ShieldCheck}
/>
</SimpleGrid>
<section className="space-y-3">
<Text size="sm" fw={700}>
Health
</Text>
<HealthCheckList checks={summary.health} />
</section>
<section className="space-y-3">
<Group justify="space-between">
<Text size="sm" fw={700}>
Storage Usage
</Text>
<Badge variant="light">{formatBytes(summary.storage.totalBytes)}</Badge>
</Group>
<StorageTable categories={summary.storage.categories} />
</section>
<section className="space-y-3">
<Group justify="space-between">
<div>
<Text size="sm" fw={700}>
Cleanup Preview
</Text>
<Text size="xs" c="dimmed">
{formatBytes(cleanupBytes)} across {summary.cleanupPreview.items.length} previewed
items
</Text>
</div>
<Button
type="button"
size="xs"
color="red"
variant="outline"
leftSection={<Trash2 className="h-4 w-4" />}
onClick={() => setCleanupOpen(true)}
>
Review Cleanup
</Button>
</Group>
<CleanupPreviewList items={summary.cleanupPreview.items} />
<Progress
value={workProductRatio}
size="sm"
color="yellow"
aria-label="Work product cleanup ratio"
/>
</section>
<section className="space-y-3">
<Text size="sm" fw={700}>
Logs
</Text>
<Group align="flex-end" gap="sm">
<Select
label="Source"
value={selectedLog}
onChange={(value) => value && setSelectedLog(value)}
data={logOptions}
leftSection={<FileClock className="h-4 w-4" />}
className="flex-1"
/>
<NumberInput
label="Tail"
value={tailLines}
onChange={(value) => setTailLines(typeof value === 'number' ? value : 200)}
min={1}
max={500}
w={120}
/>
<Button
type="button"
variant="light"
leftSection={<RefreshCcw className="h-4 w-4" />}
onClick={() => logQuery.refetch()}
>
Tail
</Button>
</Group>
<Textarea
aria-label="Redacted log tail"
value={logQuery.data?.lines.join('\n') ?? ''}
minRows={8}
readOnly
styles={{ input: { fontFamily: 'var(--mantine-font-family-monospace)' } }}
/>
</section>
<section className="space-y-3">
<Text size="sm" fw={700}>
Backup and Restore
</Text>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<Stack gap="xs">
<TextInput
label="SQLite path"
value={sqlitePath}
onChange={(event) => setSqlitePath(event.currentTarget.value)}
placeholder="/path/to/veritas.db"
/>
<TextInput
label="Output directory"
value={outputDir}
onChange={(event) => setOutputDir(event.currentTarget.value)}
placeholder="/path/to/export"
/>
<TextInput
label="Workspace scope"
value={workspaceId}
onChange={(event) => setWorkspaceId(event.currentTarget.value)}
placeholder="Optional workspace ID"
/>
<Button
type="button"
leftSection={<Database className="h-4 w-4" />}
loading={exportSqlite.isPending}
disabled={!sqlitePath || !outputDir}
onClick={() =>
exportSqlite.mutate({
sqlitePath,
outputDir,
workspaceId: workspaceId || undefined,
})
}
>
Export Backup
</Button>
</Stack>
<Stack gap="xs">
<TextInput
label="Bundle directory"
value={bundleDir}
onChange={(event) => setBundleDir(event.currentTarget.value)}
placeholder="/path/to/backup-bundle"
/>
<Checkbox
label="Replace existing SQLite rows"
checked={replaceExisting}
onChange={(event) => setReplaceExisting(event.currentTarget.checked)}
/>
<Button
type="button"
variant="outline"
leftSection={<Wrench className="h-4 w-4" />}
loading={importSqlite.isPending}
disabled={!sqlitePath || !bundleDir}
onClick={() =>
importSqlite.mutate({
sqlitePath,
bundleDir,
replaceExisting,
})
}
>
Import Backup
</Button>
{lastBackupResult && <Code block>{lastBackupResult}</Code>}
</Stack>
</SimpleGrid>
</section>
<section className="space-y-3">
<Text size="sm" fw={700}>
Lifecycle Policy
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
{summary.lifecycle.map((entry) => (
<Paper key={entry.id} withBorder radius="md" p="sm">
<Group justify="space-between" align="flex-start">
<div>
<Text size="sm" fw={600}>
{entry.label}
</Text>
<Text size="xs" c="dimmed">
{entry.rowCount} rows
</Text>
</div>
<Group gap={4}>
{entry.containsSecrets && <Badge color="red">Secrets</Badge>}
{entry.containsPrivatePaths && <Badge color="yellow">Paths</Badge>}
{entry.containsGeneratedContent && <Badge color="blue">Generated</Badge>}
</Group>
</Group>
</Paper>
))}
</SimpleGrid>
</section>
<Modal
opened={cleanupOpen}
onClose={() => setCleanupOpen(false)}
title="Review cleanup"
centered
>
<Stack gap="md">
<CleanupPreviewList items={summary.cleanupPreview.items} />
<Text size="xs" c="dimmed">
{summary.cleanupPreview.notes.join(' ')}
</Text>
<TextInput
label="Confirmation"
value={cleanupConfirm}
onChange={(event) => setCleanupConfirm(event.currentTarget.value)}
placeholder="Type DELETE"
/>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={() => setCleanupOpen(false)}>
Close
</Button>
<Button color="red" disabled={!cleanupEnabled}>
Delete Previewed Items
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View file

@ -6,6 +6,7 @@ export { DataTab } from './DataTab';
export { NotificationsTab } from './NotificationsTab';
export { MultiUserTab } from './MultiUserTab';
export { ManageTab } from './ManageTab';
export { MaintenanceTab } from './MaintenanceTab';
export { EnforcementTab } from './EnforcementTab';
export { SharedResourcesTab } from './SharedResourcesTab';
export { DocFreshnessTab } from './DocFreshnessTab';

View file

@ -18,6 +18,7 @@ import { searchApi } from './search';
import { identityApi } from './identity';
import { workProductsApi } from './work-products';
import { tracesApi } from './traces';
import { maintenanceApi } from './maintenance';
// Assemble the full API object (matches original structure exactly)
export const api = {
@ -46,6 +47,7 @@ export const api = {
identity: identityApi,
workProducts: workProductsApi,
traces: tracesApi,
maintenance: maintenanceApi,
};
export type {
@ -60,6 +62,7 @@ export type {
export type { WorkProductExportFormat, WorkProductExportOptions } from './work-products';
export type { TraceStatus } from './traces';
export type { SqlitePortabilityReport } from './maintenance';
// Re-export managed list helper
export { managedList } from './managed-list';

View file

@ -0,0 +1,74 @@
import type {
MaintenanceDebugBundle,
MaintenanceLogTail,
MaintenanceSqliteExportInput,
MaintenanceSqliteImportInput,
MaintenanceSummary,
} from '@veritas-kanban/shared';
import { API_BASE, handleResponse } from './helpers';
export interface SqlitePortabilityReport {
operation: 'file-to-sqlite' | 'sqlite-export' | 'sqlite-import';
dryRun: boolean;
startedAt: string;
completedAt: string;
sqlitePath?: string;
sourceRoot?: string;
backupPath?: string;
bundlePath?: string;
counts: Array<{
entity: string;
scanned: number;
written: number;
skipped: number;
}>;
warnings: Array<{
entity: string;
source?: string;
message: string;
}>;
}
function buildQuery(params: Record<string, string | number | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) query.set(key, String(value));
}
const serialized = query.toString();
return serialized ? `?${serialized}` : '';
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return handleResponse<T>(response);
}
export const maintenanceApi = {
summary: async (): Promise<MaintenanceSummary> => {
const response = await fetch(`${API_BASE}/maintenance/summary`, {
credentials: 'include',
});
return handleResponse<MaintenanceSummary>(response);
},
tailLog: async (source: string, tail = 200): Promise<MaintenanceLogTail> => {
const response = await fetch(`${API_BASE}/maintenance/logs${buildQuery({ source, tail })}`, {
credentials: 'include',
});
return handleResponse<MaintenanceLogTail>(response);
},
createDebugBundle: async (): Promise<MaintenanceDebugBundle> =>
postJson<MaintenanceDebugBundle>('/maintenance/debug-bundle', {}),
exportSqlite: async (input: MaintenanceSqliteExportInput): Promise<SqlitePortabilityReport> =>
postJson<SqlitePortabilityReport>('/maintenance/sqlite/export', input),
importSqlite: async (input: MaintenanceSqliteImportInput): Promise<SqlitePortabilityReport> =>
postJson<SqlitePortabilityReport>('/maintenance/sqlite/import', input),
};