mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Add v5 maintenance center (#535)
This commit is contained in:
parent
398ac2f606
commit
62f258052c
17 changed files with 1954 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
41
docs/MAINTENANCE-CENTER.md
Normal file
41
docs/MAINTENANCE-CENTER.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
104
server/src/__tests__/maintenance-service.test.ts
Normal file
104
server/src/__tests__/maintenance-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
server/src/routes/maintenance.ts
Normal file
79
server/src/routes/maintenance.ts
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
584
server/src/services/maintenance-service.ts
Normal file
584
server/src/services/maintenance-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
126
shared/src/types/maintenance.types.ts
Normal file
126
shared/src/types/maintenance.types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
255
web/src/__tests__/settings-maintenance-mantine.test.tsx
Normal file
255
web/src/__tests__/settings-maintenance-mantine.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
586
web/src/components/settings/tabs/MaintenanceTab.tsx
Normal file
586
web/src/components/settings/tabs/MaintenanceTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
74
web/src/lib/api/maintenance.ts
Normal file
74
web/src/lib/api/maintenance.ts
Normal 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),
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue