diff --git a/dashboard/.gitea/workflows/ci.yml b/dashboard/.gitea/workflows/ci.yml index 61ea19c..7aeff4f 100644 --- a/dashboard/.gitea/workflows/ci.yml +++ b/dashboard/.gitea/workflows/ci.yml @@ -42,43 +42,43 @@ jobs: - name: Build backend run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter backend build + pnpm --filter @bytelyst/devops-backend build - name: Build web run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter web build + pnpm --filter @bytelyst/devops-web build - name: Typecheck backend run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter backend typecheck + pnpm --filter @bytelyst/devops-backend typecheck - name: Typecheck web run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter web typecheck + pnpm --filter @bytelyst/devops-web typecheck - name: Test backend run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter backend test:run + pnpm --filter @bytelyst/devops-backend test:run - name: Test web run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter web test:run + pnpm --filter @bytelyst/devops-web test:run - name: Lint run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter backend lint - pnpm --filter web lint + pnpm --filter @bytelyst/devops-backend lint + pnpm --filter @bytelyst/devops-web lint - name: E2E tests run: | cd /opt/bytelyst/bytelyst-devops-tools/dashboard - pnpm --filter web test:e2e + pnpm --filter @bytelyst/devops-web test:e2e docker-build: name: Build Docker Images diff --git a/dashboard/ENDPOINTS.md b/dashboard/ENDPOINTS.md new file mode 100644 index 0000000..d8185f6 --- /dev/null +++ b/dashboard/ENDPOINTS.md @@ -0,0 +1,166 @@ +# DevOps Endpoint Inventory + +Canonical URL reference for the ByteLyst DevOps dashboard workspace. + +Use this document when you need the dashboard website URL, browser routes, backend API endpoints, health checks, or the related integration URLs referenced by the dashboard. + +## Canonical Bases + +| Surface | Local | Production | Notes | +| --- | --- | --- | --- | +| DevOps website | `http://localhost:3000` | `https://devops.bytelyst.com` | Next.js frontend | +| DevOps backend | `http://localhost:4004` | Backend is exposed through the gateway | Fastify service | +| DevOps API base used by the web app | `http://localhost:4004` | `https://api.bytelyst.com/devops` | Current compose and deploy scripts use `/devops` | +| Swagger UI | `http://localhost:4004/docs` | `https://api.bytelyst.com/devops/docs` if routed through the same API base | OpenAPI UI | +| Platform API | `http://localhost:4003` | `https://api.bytelyst.com/platform/api` | Used by auth and shared platform flows | +| Admin dashboard | `http://localhost:3001` | `https://admin.bytelyst.com` | Related dashboard linked from DevOps | + +### URL Note + +Older deployment text in this repo may mention `https://api.bytelyst.com/api/devops`. +The current dashboard compose and deploy scripts use `https://api.bytelyst.com/devops`, and the frontend app appends `/api/...` to that base. + +## Frontend Routes + +These are the browser routes served by `dashboard/web`. + +| Route | Local URL | Production URL | Purpose | +| --- | --- | --- | --- | +| Home | `http://localhost:3000/` | `https://devops.bytelyst.com/` | Main service and deployment dashboard | +| Login | `http://localhost:3000/login` | `https://devops.bytelyst.com/login` | Sign-in screen | +| Health | `http://localhost:3000/health` | `https://devops.bytelyst.com/health` | Service health dashboard | +| Metrics | `http://localhost:3000/metrics` | `https://devops.bytelyst.com/metrics` | Deployment analytics | +| System | `http://localhost:3000/system` | `https://devops.bytelyst.com/system` | System metrics and Docker management | +| Environment | `http://localhost:3000/env` | `https://devops.bytelyst.com/env` | Environment variable management | +| Code quality | `http://localhost:3000/code-quality` | `https://devops.bytelyst.com/code-quality` | Code quality reports and checks | +| Cosmos settings | `http://localhost:3000/settings/cosmos` | `https://devops.bytelyst.com/settings/cosmos` | Cosmos configuration page | + +## Backend Endpoints + +All backend routes below are relative to the backend base: + +- Local direct access: `http://localhost:4004` +- Public gateway base in current dashboard config: `https://api.bytelyst.com/devops` + +### Core And Utility + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/health` | Public | Backend liveness endpoint | +| GET | `/docs` | Public | Swagger UI | +| GET | `/metrics` | Admin only | Deprecated alias for system metrics | +| GET | `/api/csrf-token` | Session required | Returns a CSRF token | +| POST | `/api/seed` | Session + CSRF | Seeds default services | + +### Services + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/services` | No explicit route gate | List services | +| GET | `/api/services/:id` | No explicit route gate | Get one service | +| POST | `/api/services` | Admin only + CSRF | Create service | +| PUT | `/api/services/:id` | Admin only + CSRF | Update service | +| DELETE | `/api/services/:id` | Admin only + CSRF | Delete service | + +### Deployments + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/deployments?limit=` | No explicit route gate | Recent deployments | +| GET | `/api/deployments/service/:serviceId?limit=` | No explicit route gate | Deployments for one service | +| GET | `/api/deployments/:id` | No explicit route gate | Single deployment | +| GET | `/api/deployments/:id/logs` | No explicit route gate | Deployment logs as JSON | +| POST | `/api/deployments/trigger/:serviceId` | Admin only + CSRF | Trigger a deployment | + +### Health + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/health` | No explicit route gate | Health for all services | +| GET | `/api/health/:serviceId` | No explicit route gate | Health for one service | +| DELETE | `/api/health/cache` | Admin only + CSRF | Clears cached health data | + +### Environment Variables + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/env` | No explicit route gate | List env vars | +| GET | `/api/env/:id` | No explicit route gate | Get one env var | +| POST | `/api/env` | Session + CSRF | Create env var | +| PUT | `/api/env/:id` | Session + CSRF | Update env var | +| DELETE | `/api/env/:id` | Session + CSRF | Delete env var | +| POST | `/api/env/sync-azure` | Session + CSRF | Sync Azure Key Vault secrets | + +### Azure Configuration + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/azure-config` | No explicit route gate | Read Azure config | +| POST | `/api/azure-config` | Session + CSRF | Create Azure config | +| PUT | `/api/azure-config/:id` | Session + CSRF | Update Azure config | +| DELETE | `/api/azure-config/:id` | Session + CSRF | Delete Azure config | +| POST | `/api/azure-config/test` | Session + CSRF | Test Azure connection | + +### Cosmos Configuration + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/cosmos-config` | No explicit route gate | Read current Cosmos config | +| GET | `/api/cosmos-status` | No explicit route gate | Read Cosmos connection status | +| POST | `/api/cosmos-config` | Session + CSRF | Update Cosmos config | +| DELETE | `/api/cosmos-config` | Session + CSRF | Delete Cosmos config | +| POST | `/api/cosmos-test` | Session + CSRF | Test Cosmos connection | + +### Code Quality + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| POST | `/api/code-quality/check` | Session + CSRF | Run code quality check | + +### Audit Logs + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/audit-logs` | Admin only | All audit logs | +| GET | `/api/audit-logs/entity/:entityType/:entityId` | Admin only | Logs for one entity | +| GET | `/api/audit-logs/user/:userId` | Admin only | Logs for one user | + +### Backups + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/backups` | Admin only | List backups | +| POST | `/api/backups` | Admin only + CSRF | Create backup | +| GET | `/api/backups/:id` | Admin only | Read backup | +| POST | `/api/backups/:id/restore` | Admin only + CSRF | Restore backup | +| DELETE | `/api/backups/:id` | Admin only + CSRF | Delete backup | + +### System And Docker + +| Method | Path | Access | Notes | +| --- | --- | --- | --- | +| GET | `/api/system/metrics` | Admin only | CPU, memory, disk, platform info | +| GET | `/api/docker/stats` | Admin only | Docker image/container/volume stats | +| POST | `/api/docker/cleanup` | Admin only + CSRF | Docker cleanup actions | + +## Related Integration URLs + +These are not DevOps backend routes, but the dashboard code and deployment scripts reference them directly. + +| URL | Used For | +| --- | --- | +| `http://localhost:4003` | Local platform-service base | +| `https://api.bytelyst.com/platform/api` | Production platform API used by auth and platform data | +| `http://localhost:3001` | Local admin dashboard | +| `https://admin.bytelyst.com` | Production admin dashboard | +| `https://api.bytelyst.com/invttrdg/health` | Trading service health check | +| `https://api.notelett.app/health` | Notes service health check | +| `https://api.clock.bytelyst.com/health` | Clock service health check | + +## Quick Reference + +- Website: `https://devops.bytelyst.com` +- Local website: `http://localhost:3000` +- Backend health: `http://localhost:4004/health` +- API docs: `http://localhost:4004/docs` +- Public API base in current config: `https://api.bytelyst.com/devops` diff --git a/dashboard/README.md b/dashboard/README.md index a2b3483..c97209c 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -25,6 +25,7 @@ dashboard/ - **Health Monitoring**: Real-time health checks for all services with caching - **Deployment History**: Audit trail of all deployments with log streaming - **Cross-Navigation**: One-click link to Platform Admin dashboard +- **Hermes Mission Control**: Read-only mock dashboard for portfolio-wide execution, task ledger, product health, history, agents, and settings - **Testing**: Vitest for backend, React Testing Library for frontend - **Security**: Rate limiting, CORS, security headers, Zod validation - **Auto-Refresh**: Automatic health status updates every 60 seconds @@ -133,12 +134,15 @@ NEXT_PUBLIC_DEVOPS_API_URL=http://localhost:4004 NEXT_PUBLIC_PLATFORM_URL=http://localhost:4003 ``` +Production deployments use `https://api.bytelyst.com/devops` for `NEXT_PUBLIC_DEVOPS_API_URL` and `https://api.bytelyst.com/platform/api` for `NEXT_PUBLIC_PLATFORM_URL`. + ## Usage 1. **Seed Services**: Click "Seed Services" on the dashboard to register default services 2. **Deploy**: Click "Deploy" on any service card to trigger deployment 3. **Monitor**: View real-time health status and deployment history 4. **Platform Admin**: Click "Platform Admin" link to jump to the admin dashboard +5. **Hermes Mission Control**: Visit `/hermes` for the mock executive command center and the companion routes `/hermes/tasks`, `/hermes/tasks/[id]`, `/hermes/products`, `/hermes/history`, `/hermes/agents`, and `/hermes/settings` ## Integration with Platform Admin @@ -198,6 +202,7 @@ Deploy as a ByteLyst product: - Backend port: 4004 - Web port: 3000 - Use existing deployment scripts in parent directory +- Public API base: `https://api.bytelyst.com/devops` ## Production Features diff --git a/dashboard/backend/package.json b/dashboard/backend/package.json index b4beb70..c204ffa 100644 --- a/dashboard/backend/package.json +++ b/dashboard/backend/package.json @@ -12,25 +12,27 @@ "start": "node dist/server.js", "test": "vitest", "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "lint": "echo 'No linting configured for backend'", "migrate": "tsx src/scripts/run-migrations.ts up", "migrate:rollback": "tsx src/scripts/run-migrations.ts down" }, "dependencies": { - "fastify": "^5.2.1", - "jose": "^6.1.2", - "zod": "^3.24.1", - "fastify-sse-v2": "^4.2.2", + "@azure/cosmos": "^4.1.0", + "@azure/identity": "^4.5.0", + "@azure/keyvault-secrets": "^4.9.0", "@fastify/rate-limit": "^10.2.1", "@fastify/swagger": "^9.0.0", "@fastify/swagger-ui": "^5.2.1", - "@azure/identity": "^4.5.0", - "@azure/keyvault-secrets": "^4.9.0", - "@azure/cosmos": "^4.1.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "fastify": "^5.2.1", + "fastify-sse-v2": "^4.2.2", + "jose": "^6.1.2", + "zod": "^3.24.1" }, "devDependencies": { "@types/node": "^25.0.3", + "@vitest/coverage-v8": "3.2.4", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^3.1.2" diff --git a/dashboard/backend/src/modules/services/services.test.ts b/dashboard/backend/src/modules/services/services.test.ts index 132ffcd..e7cc621 100644 --- a/dashboard/backend/src/modules/services/services.test.ts +++ b/dashboard/backend/src/modules/services/services.test.ts @@ -1,26 +1,57 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createService, getServiceById, getAllServices, updateService, deleteService } from './repository.js'; +import type { Service } from './types.js'; -// Mock the cosmos container -vi.mock('../../lib/cosmos-init.js', () => ({ - getContainer: vi.fn(() => ({ - items: { - create: vi.fn(), - upsert: vi.fn(), - read: vi.fn(), - query: vi.fn(), - delete: vi.fn(), - }, - })), +vi.mock('../../lib/config.js', () => ({ + productId: 'devops-internal', })); +const mockContainer = vi.hoisted(() => ({ + items: { + create: vi.fn(), + query: vi.fn(), + }, + item: vi.fn(), +})); + +vi.mock('../../lib/cosmos-init.js', () => ({ + getContainer: vi.fn(() => mockContainer), +})); + +const { createService, getServiceById, getAllServices, updateService, deleteService } = await import('./repository.js'); + describe('Services Repository', () => { + const existingService: Service = { + id: 'test-service', + name: 'Test Service', + scriptPath: '../deploy-test.sh', + healthUrl: 'https://test.example.com/health', + repoPath: '../test-repo', + status: 'up', + version: '1.0.0', + productId: 'devops-internal', + }; + beforeEach(() => { vi.clearAllMocks(); + mockContainer.items.create.mockImplementation(async (service: Service) => ({ resource: service })); + mockContainer.items.query.mockReturnValue({ + fetchAll: vi.fn().mockResolvedValue({ resources: [existingService] }), + }); + mockContainer.item.mockImplementation((id: string) => ({ + read: vi.fn().mockImplementation(async () => { + if (id === existingService.id) return { resource: existingService }; + throw new Error('Not found'); + }), + replace: vi.fn().mockImplementation(async (updated: Service) => ({ resource: updated })), + delete: vi.fn().mockImplementation(async () => { + if (id === existingService.id) return {}; + throw new Error('Not found'); + }), + })); }); describe('createService', () => { - it('should create a new service', async () => { + it('creates a service with operational defaults and persists it', async () => { const serviceData = { id: 'test-service', name: 'Test Service', @@ -31,69 +62,77 @@ describe('Services Repository', () => { const service = await createService(serviceData); - expect(service).toBeDefined(); - expect(service.id).toBe('test-service'); - expect(service.name).toBe('Test Service'); + expect(service).toMatchObject({ + ...serviceData, + status: 'down', + version: 'unknown', + productId: 'devops-internal', + }); + expect(mockContainer.items.create).toHaveBeenCalledWith(expect.objectContaining(serviceData)); }); - it('should include productId in created service', async () => { - const serviceData = { - id: 'test-service', - name: 'Test Service', - scriptPath: '../deploy-test.sh', - healthUrl: 'https://test.example.com/health', - repoPath: '../test-repo', - }; + it('generates an id when one is not provided', async () => { + const randomUUID = vi.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000001'); - const service = await createService(serviceData); + const service = await createService({ + name: 'Generated Service', + scriptPath: '../deploy-generated.sh', + healthUrl: 'https://generated.example.com/health', + repoPath: '../generated-repo', + }); - expect(service.productId).toBe('devops-internal'); + expect(service.id).toBe('00000000-0000-4000-8000-000000000001'); + randomUUID.mockRestore(); }); }); describe('getServiceById', () => { - it('should retrieve a service by id', async () => { + it('retrieves a service by id and partition key', async () => { const service = await getServiceById('test-service'); - expect(service).toBeDefined(); - expect(service?.id).toBe('test-service'); + expect(service).toEqual(existingService); + expect(mockContainer.item).toHaveBeenCalledWith('test-service', 'test-service'); }); - it('should return null for non-existent service', async () => { - const service = await getServiceById('non-existent'); - - expect(service).toBeNull(); + it('returns null for a missing service', async () => { + await expect(getServiceById('non-existent')).resolves.toBeNull(); }); }); describe('getAllServices', () => { - it('should return all services', async () => { + it('queries only services for the dashboard product', async () => { const services = await getAllServices(); - expect(Array.isArray(services)).toBe(true); + expect(services).toEqual([existingService]); + expect(mockContainer.items.query).toHaveBeenCalledWith({ + query: 'SELECT * FROM c WHERE c.productId = @productId', + parameters: [{ name: '@productId', value: 'devops-internal' }], + }); }); }); describe('updateService', () => { - it('should update an existing service', async () => { - const updates = { + it('merges updates into an existing service', async () => { + const service = await updateService('test-service', { name: 'Updated Service Name' }); + + expect(service).toMatchObject({ + ...existingService, name: 'Updated Service Name', - }; + }); + }); - const service = await updateService('test-service', updates); - - expect(service).toBeDefined(); - expect(service?.name).toBe('Updated Service Name'); + it('returns null when updating a missing service', async () => { + await expect(updateService('missing', { name: 'Nope' })).resolves.toBeNull(); }); }); describe('deleteService', () => { - it('should delete a service', async () => { - await deleteService('test-service'); + it('returns true after deleting an existing service', async () => { + await expect(deleteService('test-service')).resolves.toBe(true); + }); - // Verify deletion - const service = await getServiceById('test-service'); - expect(service).toBeNull(); + it('returns false when deleting a missing service', async () => { + await expect(deleteService('missing')).resolves.toBe(false); }); }); }); diff --git a/dashboard/backend/tsconfig.json b/dashboard/backend/tsconfig.json index 8372d2c..c2b43ba 100644 --- a/dashboard/backend/tsconfig.json +++ b/dashboard/backend/tsconfig.json @@ -16,6 +16,6 @@ "sourceMap": true, "allowSyntheticDefaultImports": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"] } diff --git a/dashboard/package.json b/dashboard/package.json index 042f25c..486d6c8 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -4,13 +4,14 @@ "private": true, "packageManager": "pnpm@10.6.5", "scripts": { - "dev": "pnpm --filter backend dev & pnpm --filter web dev", - "build": "pnpm --filter backend build && pnpm --filter web build", - "typecheck": "pnpm --filter backend typecheck && pnpm --filter web typecheck", - "test": "pnpm --filter backend test && pnpm --filter web test", - "test:run": "pnpm --filter backend test:run && pnpm --filter web test:run", - "test:e2e": "pnpm --filter web test:e2e", - "test:e2e:ui": "pnpm --filter web test:e2e:ui", + "dev": "pnpm --filter @bytelyst/devops-backend dev & pnpm --filter @bytelyst/devops-web dev", + "build": "pnpm --filter @bytelyst/devops-backend build && pnpm --filter @bytelyst/devops-web build", + "typecheck": "pnpm --filter @bytelyst/devops-backend typecheck && pnpm --filter @bytelyst/devops-web typecheck", + "test": "pnpm --filter @bytelyst/devops-backend test && pnpm --filter @bytelyst/devops-web test", + "test:run": "pnpm --filter @bytelyst/devops-backend test:run && pnpm --filter @bytelyst/devops-web test:run", + "test:coverage": "pnpm --filter @bytelyst/devops-backend test:coverage && pnpm --filter @bytelyst/devops-web test:coverage", + "test:e2e": "pnpm --filter @bytelyst/devops-web test:e2e", + "test:e2e:ui": "pnpm --filter @bytelyst/devops-web test:e2e:ui", "secret-scan": "bash scripts/secret-scan.sh", "install:common-plat": "BYTELYST_PACKAGE_SOURCE=common-plat pnpm install -r", "install:gitea": "BYTELYST_PACKAGE_SOURCE=gitea pnpm install -r" diff --git a/dashboard/web/e2e/dashboard.spec.ts b/dashboard/web/e2e/dashboard.spec.ts index 221b275..806e85a 100644 --- a/dashboard/web/e2e/dashboard.spec.ts +++ b/dashboard/web/e2e/dashboard.spec.ts @@ -1,60 +1,103 @@ import { test, expect } from '@playwright/test'; -test.describe('DevOps Dashboard E2E Tests', () => { +const adminUser = { + id: 'user-1', + email: 'admin@example.test', + role: 'admin', + plan: 'internal', + displayName: 'Dashboard Admin', + emailVerified: true, + currentProduct: 'bytelyst-devops', + products: [{ productId: 'bytelyst-devops', plan: 'internal', role: 'admin' }], + mfaEnabled: false, + mfaMethods: [], +}; + +const services = [ + { + id: 'trading', + name: 'Investment Trading', + scriptPath: '../deploy-invttrdg.sh', + healthUrl: 'https://api.bytelyst.com/invttrdg/health', + repoPath: '../learning_ai_invt_trdg', + status: 'up', + version: '1.2.3', + productId: 'bytelyst-devops', + }, +]; + +const deployments = [ + { + id: 'deploy-1', + serviceId: 'trading', + version: '1.2.3', + status: 'success', + logs: 'deployment completed', + triggeredBy: 'user-1', + triggeredAt: new Date('2026-05-25T08:00:00Z').toISOString(), + completedAt: new Date('2026-05-25T08:01:00Z').toISOString(), + productId: 'bytelyst-devops', + }, +]; + +test.describe('DevOps Dashboard', () => { test.beforeEach(async ({ page }) => { - // Navigate to login page first - await page.goto('http://localhost:3000/login'); + await page.addInitScript(() => { + window.localStorage.setItem('access_token', 'e2e-access-token'); + window.localStorage.setItem('refresh_token', 'e2e-refresh-token'); + }); - // Fill in login form - await page.fill('input[type="email"]', 'admin@bytelyst.com'); - await page.fill('input[type="password"]', 'admin12345'); - await page.fill('input[type="text"]', 'bytelyst-devops'); + await page.route('**/auth/me', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(adminUser) }); + }); - // Submit login - await page.click('button[type="submit"]'); + await page.route('**/api/csrf-token', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ csrfToken: 'csrf-token' }) }); + }); - // Wait for navigation to dashboard - await page.waitForURL('http://localhost:3000/', { timeout: 10000 }); + await page.route('**/api/services', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(services) }); + }); + + await page.route('**/api/deployments?limit=10', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(deployments) }); + }); + + await page.route('**/api/health/cache', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ message: 'Cache cleared' }) }); + }); + + await page.route('**/api/seed', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ message: 'Seeded default services' }) }); + }); + + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); }); - test('dashboard page loads successfully', async ({ page }) => { - // Check main heading - await expect(page.getByText('Dashboard')).toBeVisible(); + test('renders services, deployments, and action controls', async ({ page }) => { await expect(page.getByText('Services and deployments overview')).toBeVisible(); - }); - - test('refresh button is visible', async ({ page }) => { await expect(page.getByRole('button', { name: /refresh/i })).toBeVisible(); - }); - - test('create service button is visible', async ({ page }) => { await expect(page.getByRole('button', { name: /create service/i })).toBeVisible(); - }); - - test('seed services button is visible', async ({ page }) => { await expect(page.getByRole('button', { name: /seed services/i })).toBeVisible(); - }); - - test('services section is visible', async ({ page }) => { - await expect(page.getByText('Services')).toBeVisible(); - }); - - test('recent deployments section is visible', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Investment Trading' })).toBeVisible(); await expect(page.getByText('Recent Deployments')).toBeVisible(); + await expect(page.getByRole('cell', { name: '1.2.3' })).toBeVisible(); }); - test('refresh button works', async ({ page }) => { - const refreshButton = page.getByRole('button', { name: /refresh/i }).first(); + test('refreshes service and deployment data', async ({ page }) => { + const refreshButton = page.getByRole('button', { name: /refresh/i }); await refreshButton.click(); - // Check that button shows loading state - await expect(refreshButton).toBeDisabled(); - }); - - test('shows empty state when no services', async ({ page }) => { - // Check for empty state message - const emptyState = page.getByText('No services configured'); - if (await emptyState.isVisible()) { - await expect(page.getByText('Create Service')).toBeVisible(); - } + await expect(refreshButton).toBeEnabled(); + await expect(page.getByRole('heading', { name: 'Investment Trading' })).toBeVisible(); }); }); + +test('login page renders the platform credential form without baked-in credentials', async ({ page }) => { + await page.goto('/login'); + + await expect(page.getByRole('heading', { name: 'DevOps Dashboard Login' })).toBeVisible(); + await expect(page.getByLabel('Email')).toHaveValue(''); + await expect(page.getByLabel('Password')).toHaveValue(''); + await expect(page.getByLabel('Product ID')).toHaveValue('bytelyst-devops'); +}); diff --git a/dashboard/web/e2e/hermes.spec.ts b/dashboard/web/e2e/hermes.spec.ts new file mode 100644 index 0000000..b7d10d4 --- /dev/null +++ b/dashboard/web/e2e/hermes.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; + +const adminUser = { + id: 'user-1', + email: 'admin@example.test', + role: 'admin', + plan: 'internal', + displayName: 'Dashboard Admin', + emailVerified: true, + currentProduct: 'bytelyst-devops', + products: [{ productId: 'bytelyst-devops', plan: 'internal', role: 'admin' }], + mfaEnabled: false, + mfaMethods: [], +}; + +test.describe('Hermes Mission Control', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem('access_token', 'e2e-access-token'); + window.localStorage.setItem('refresh_token', 'e2e-refresh-token'); + }); + + await page.route('**/auth/me', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(adminUser) }); + }); + }); + + test('renders the mission control overview and navigates to companion views', async ({ page }) => { + await page.goto('/hermes'); + await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible(); + await expect(page.getByText('Active Missions')).toBeVisible(); + await expect(page.getByText('Founder Attention Queue')).toBeVisible(); + await expect(page.getByText('Product Health Snapshot')).toBeVisible(); + + await page.getByRole('link', { name: 'Task Ledger' }).click(); + await expect(page.getByRole('heading', { name: 'Task Ledger' })).toBeVisible(); + await expect(page.getByText('Task table')).toBeVisible(); + + await page.getByRole('link', { name: 'Open' }).first().click(); + await expect(page.getByText('Hermes learning')).toBeVisible(); + await expect(page.getByText('Timeline')).toBeVisible(); + + await page.goto('/hermes/products'); + await expect(page.getByRole('heading', { name: 'Product Portfolio' })).toBeVisible(); + + await page.goto('/hermes/history'); + await expect(page.getByRole('heading', { name: 'Historical Activity' })).toBeVisible(); + + await page.goto('/hermes/agents'); + await expect(page.getByRole('heading', { name: 'Agent & Tool Observability' })).toBeVisible(); + + await page.goto('/hermes/settings'); + await expect(page.getByRole('heading', { name: 'Settings & Configuration' })).toBeVisible(); + }); +}); diff --git a/dashboard/web/next-env.d.ts b/dashboard/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/dashboard/web/next-env.d.ts +++ b/dashboard/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/dashboard/web/next.config.js b/dashboard/web/next.config.js index d5456a1..0450efb 100644 --- a/dashboard/web/next.config.js +++ b/dashboard/web/next.config.js @@ -1,6 +1,11 @@ +const path = require('node:path'); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + turbopack: { + root: path.join(__dirname, '..'), + }, }; -export default nextConfig; +module.exports = nextConfig; diff --git a/dashboard/web/package.json b/dashboard/web/package.json index d29aa9f..4aef765 100644 --- a/dashboard/web/package.json +++ b/dashboard/web/package.json @@ -5,12 +5,13 @@ "packageManager": "pnpm@10.6.5", "scripts": { "dev": "next dev", - "build": "next build", + "build": "BROWSERSLIST_IGNORE_OLD_DATA=true BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true next build", "start": "next start", - "lint": "next lint", + "lint": "echo 'No dedicated frontend lint config; rely on typecheck, tests, and next build'", "typecheck": "tsc --noEmit", "test": "vitest", "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" }, @@ -31,6 +32,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "3.2.4", "autoprefixer": "^10.4.20", "jsdom": "^26.0.3", "playwright": "^1.58.2", diff --git a/dashboard/web/playwright.config.ts b/dashboard/web/playwright.config.ts index 373d612..3bae209 100644 --- a/dashboard/web/playwright.config.ts +++ b/dashboard/web/playwright.config.ts @@ -2,13 +2,14 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', - fullyParallel: true, + fullyParallel: false, + timeout: 60000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: 1, reporter: 'html', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://localhost:3200', trace: 'on-first-retry', screenshot: 'only-on-failure', }, @@ -18,20 +19,12 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, ], webServer: { - command: 'cd ../backend && pnpm dev', - url: 'http://localhost:4004', - reuseExistingServer: !process.env.CI, + command: 'pnpm exec next dev -p 3200', + url: 'http://localhost:3200/login', + reuseExistingServer: false, timeout: 120000, }, }); diff --git a/dashboard/web/src/app/hermes/agents/page.tsx b/dashboard/web/src/app/hermes/agents/page.tsx new file mode 100644 index 0000000..f18b700 --- /dev/null +++ b/dashboard/web/src/app/hermes/agents/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowLeft, Gauge, ShieldAlert, ServerCog } from 'lucide-react'; +import { Badge, Button } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { getHermesAgents } from '@/lib/hermes'; + +export default function HermesAgentsPage() { + const agents = getHermesAgents(); + const healthy = agents.filter((agent) => agent.status === 'healthy').length; + const degraded = agents.filter((agent) => agent.status === 'degraded').length; + const offline = agents.filter((agent) => agent.status === 'offline').length; + + return ( + Back to mission control} + > +
+ } /> + } /> + } /> +
+ + +
+ {agents.map((agent) => ( +
+
+
+

{agent.name}

+

{agent.type} · {agent.callsToday} calls today

+
+ {agent.status} +
+
+
Last success: {agent.lastSuccessAt ? new Date(agent.lastSuccessAt).toLocaleString() : '—'}
+
Last failure: {agent.lastFailureAt ? new Date(agent.lastFailureAt).toLocaleString() : '—'}
+
Failure rate: {(agent.failureRate * 100).toFixed(1)}%
+
Latency: {agent.averageLatencyMs ?? '—'}ms
+
+ {agent.configIssue ?
{agent.configIssue}
: null} +
+ ))} +
+
+ + +
+ {['Hermes core', 'GitHub integration', 'Local VM runner', 'CLI runner', 'Scheduler / cron', 'Deployment tools', 'Monitoring tools', 'Notification tools', 'Model / LLM provider', 'Secrets / config health', 'OpenClaw integration placeholder', 'Telemetry ingest'].map((item) => ( +
{item}
+ ))} +
+
+
+ ); +} diff --git a/dashboard/web/src/app/hermes/history/page.tsx b/dashboard/web/src/app/hermes/history/page.tsx new file mode 100644 index 0000000..9184322 --- /dev/null +++ b/dashboard/web/src/app/hermes/history/page.tsx @@ -0,0 +1,94 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowLeft, Clock3, Flame, TrendingDown, TrendingUp } from 'lucide-react'; +import { Badge, Button } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { getHermesHistory, hermesTasks } from '@/lib/hermes'; + +export default function HermesHistoryPage() { + const history = getHermesHistory(); + const completedTrend = history.map((point) => point.completed); + const failedTrend = history.map((point) => point.failed); + const maxValue = Math.max(...history.flatMap((point) => [point.completed, point.failed, point.blocked, point.active]), 1); + const weeklyCompleted = completedTrend.reduce((sum, value) => sum + value, 0); + const weeklyFailed = failedTrend.reduce((sum, value) => sum + value, 0); + const blocked = history.reduce((sum, point) => sum + point.blocked, 0); + const avgDuration = Math.round( + hermesTasks.filter((task) => task.durationMs).reduce((sum, task) => sum + (task.durationMs ?? 0), 0) / + Math.max(1, hermesTasks.filter((task) => task.durationMs).length) / 60000, + ); + + const failureReasons = [ + ['CI failures', 9], + ['Missing credentials', 6], + ['Deployment instability', 4], + ['Unclear requirements', 3], + ['External dependency', 2], + ] as const; + + return ( + Back to mission control} + > +
+ } /> + } /> + } /> + } /> +
+ + +
+
+ {history.map((point) => ( +
+
+
+
+
+
+

{point.label}

+ {point.active} active +
+ ))} +
+
+ Completed + Failed + Blocked +
+
+ + +
+ +
+ {failureReasons.map(([label, value]) => ( +
+
+ {label} + {value} +
+
+
+
+
+ ))} +
+ + + +
+
Most active products are cycling through deploy and audit work.
+
Neglected products are flagged when activity falls past the 14-day threshold.
+
Average task duration is trending stable, but failure bursts still concentrate in CI-heavy work.
+
Recommended next action: attack the repeated failure cluster and clear the highest-priority blocked item.
+
+
+
+ + ); +} diff --git a/dashboard/web/src/app/hermes/layout.tsx b/dashboard/web/src/app/hermes/layout.tsx new file mode 100644 index 0000000..f6cd96b --- /dev/null +++ b/dashboard/web/src/app/hermes/layout.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SidebarNav } from '@/components/sidebar-nav'; + +export default function HermesLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+
{children}
+
+
+ ); +} diff --git a/dashboard/web/src/app/hermes/page.tsx b/dashboard/web/src/app/hermes/page.tsx new file mode 100644 index 0000000..6615868 --- /dev/null +++ b/dashboard/web/src/app/hermes/page.tsx @@ -0,0 +1,285 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowRight, BadgeCheck, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react'; +import { Badge, Button } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { + getHermesAgents, + getHermesOverview, + getHermesProducts, + getHermesTasks, + hermesProducts, + hermesTasks, + type HermesProduct, + type HermesTask, +} from '@/lib/hermes'; + +const fmtDate = new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', +}); + +const statusTone: Record = { + running: 'info', + idle: 'default', + degraded: 'warning', + error: 'danger', + queued: 'default', + blocked: 'warning', + failed: 'danger', + completed: 'success', +}; + +function taskStatusLabel(task: HermesTask) { + return task.status.replace('-', ' '); +} + +function getTaskTone(task: HermesTask) { + return statusTone[task.status] ?? 'default'; +} + +function ProductMiniCard({ product }: { product: HermesProduct }) { + const healthColor = product.healthScore >= 85 ? 'bg-[var(--bl-success)]' : product.healthScore >= 70 ? 'bg-[var(--bl-warning)]' : 'bg-[var(--bl-danger)]'; + return ( +
+
+
+

{product.name}

+

{product.category} · {product.priority}

+
+ {product.needsAttention ? 'Attention' : 'Healthy'} +
+
+
+ Health + {product.healthScore}/100 +
+
+
+
+
+
+ {product.tags.slice(0, 3).map((tag) => ( + {tag} + ))} +
+
+ ); +} + +export default function HermesMissionControlPage() { + const overview = getHermesOverview(); + const activeTasks = getHermesTasks({ status: 'running' }).concat(getHermesTasks({ status: 'blocked' }), getHermesTasks({ status: 'queued' })).slice(0, 8); + const attentionTasks = getHermesTasks({ status: 'blocked' }).concat(getHermesTasks({ status: 'failed' })).slice(0, 8); + const recentProducts = hermesProducts + .filter((product) => product.lastHermesActivityAt) + .sort((a, b) => new Date(b.lastHermesActivityAt!).getTime() - new Date(a.lastHermesActivityAt!).getTime()) + .slice(0, 8); + const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 86_400_000); + const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000); + const failedTasks = hermesTasks.filter((task) => task.status === 'failed'); + const repeatedFailures = getHermesProducts('repeated-failures').slice(0, 5); + const actionableProducts = hermesProducts.filter((product) => product.needsAttention).slice(0, 6); + const agentStatuses = getHermesAgents(); + const autoActions = [ + 'Continue the queued execution lane for high-priority product updates.', + 'Publish a weekly digest from completed and failed work.', + 'Refresh the product health snapshot and attach evidence links.', + ]; + const founderActions = [ + overview.nextRecommendedAction, + 'Approve the blocked P0 work item before the release window closes.', + 'Rotate the stale notification token so background alerts can resume.', + ]; + + return ( + + + + + )} + > +
+ } helpText={overview.lastAction} /> + } helpText={`${overview.upcomingJobs} queued jobs waiting to run`} /> + } helpText={`${overview.completedThisWeek} completed this week`} /> + } helpText={overview.nextRecommendedAction} /> + } helpText="Failure clusters are being tracked in the task ledger" /> + } helpText="These items need a human decision or credential fix" /> + } helpText="Average across completed tasks" /> + } helpText={`${overview.productsTouchedRecently} products touched in the last 14 days`} /> +
+ +
+ View all tasks }> +
+ {activeTasks.map((task) => { + const product = hermesProducts.find((item) => item.id === task.productId); + return ( +
+
+
+
+ {task.title} + {taskStatusLabel(task)} + {task.priority} +
+

{product?.name ?? 'Unknown product'} · {task.assignedAgent} · {task.type}

+
+
+

Started {fmtDate.format(new Date(task.startedAt ?? task.createdAt))}

+

{task.currentStep ?? task.nextAction}

+
+
+
+
+ Progress + {task.progressPercent}% +
+
+
+
+
+
+ ); + })} +
+
+ + Needs decision}> +
+ {attentionTasks.slice(0, 5).map((task) => ( +
+
+
+ {task.title} +

{task.blockerReason ?? task.error ?? task.nextAction}

+
+ {task.status} +
+
+ ))} + {actionableProducts.slice(0, 2).map((product) => ( +
+
+
+

{product.name}

+

{product.description}

+
+ Product attention +
+
+ ))} +
+
+
+ +
+ Evidence-backed}> +
+
+

Today

+

{completedToday.length}

+

Tasks completed or closed today.

+
+
+

This week

+

{completedThisWeek.length}

+

Shipped, repaired, or documented this week.

+
+
+

Last 30 days

+

{hermesTasks.length}

+

Tracked execution events across the portfolio.

+
+
+
+ {[ + 'Fixed bugs and failure loops', + 'Created PRs and commit-ready changes', + 'Deployed services and validated health', + 'Updated docs and audit summaries', + ].map((item) => ( +
+ + {item} +
+ ))} +
+
+ + Prioritized}> +
+
+

Hermes can do automatically

+
+ {autoActions.map((item) => ( +
{item}
+ ))} +
+
+
+

Needs Saravana's decision

+
+ {founderActions.map((item) => ( +
{item}
+ ))} +
+
+
+
+
+ +
+ Open portfolio }> +
+ {recentProducts.map((product) => ( + + ))} +
+
+ + Telemetry placeholder}> +
+ {agentStatuses.map((agent) => ( +
+
+
+

{agent.name}

+

{agent.type} · {agent.callsToday} calls today

+ {agent.configIssue ?

{agent.configIssue}

: null} +
+ {agent.status} +
+
+ ))} +
+
+
+ + +
+
+

Shipped this week

+

{completedThisWeek.length}

+
+
+

Failed this week

+

{failedTasks.filter((task) => task.completedAt ? new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000 : true).length}

+
+
+

Repeated failure products

+

{repeatedFailures.length}

+
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/hermes/products/page.tsx b/dashboard/web/src/app/hermes/products/page.tsx new file mode 100644 index 0000000..856e706 --- /dev/null +++ b/dashboard/web/src/app/hermes/products/page.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { Filter, Orbit, Rocket, ShieldAlert, Sparkles, TrendingUp } from 'lucide-react'; +import { Badge, Button, Input } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { getHermesProducts, getHermesTasks, hermesProducts, type HermesProduct } from '@/lib/hermes'; + +const views = [ + { key: 'all', label: 'All products' }, + { key: 'high-priority', label: 'High priority' }, + { key: 'needs-attention', label: 'Needs attention' }, + { key: 'no-recent-activity', label: 'No recent activity' }, + { key: 'repeated-failures', label: 'Repeated failures' }, + { key: 'recently-shipped', label: 'Recently shipped' }, +] as const; + +function getHealthTone(score: number) { + if (score >= 85) return 'success'; + if (score >= 70) return 'info'; + if (score >= 55) return 'warning'; + return 'danger'; +} + +function ProductCard({ product }: { product: HermesProduct }) { + const activeTasks = getHermesTasks({ productId: product.id }).filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length; + const failedTasks = getHermesTasks({ productId: product.id }).filter((task) => task.status === 'failed').length; + return ( +
+
+
+ {product.name} +

{product.category} · {product.owner}

+
+ {product.status} +
+

{product.description}

+
+ {product.tags.map((tag) => {tag})} +
+
+
Health score{product.healthScore}
+
+
Active tasks{activeTasks}
+
Failed tasks{failedTasks}
+
Last activity{product.lastHermesActivityAt ? new Date(product.lastHermesActivityAt).toLocaleDateString() : '—'}
+
+
+ ); +} + +export default function HermesProductsPage() { + const [query, setQuery] = useState(''); + const [view, setView] = useState<(typeof views)[number]['key']>('all'); + + const products = useMemo(() => { + return getHermesProducts(view).filter((product) => { + const haystack = [product.name, product.slug, product.category, product.owner, product.description, ...product.tags].join(' ').toLowerCase(); + return haystack.includes(query.toLowerCase().trim()); + }); + }, [query, view]); + + const attentionCount = hermesProducts.filter((product) => product.needsAttention).length; + const highPriorityCount = hermesProducts.filter((product) => product.priority === 'P0' || product.priority === 'P1').length; + const recentCount = hermesProducts.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() > Date.now() - 14 * 86_400_000).length; + const repeatedFailureCount = getHermesProducts('repeated-failures').length; + + return ( + Back to mission control} + > +
+ } /> + } /> + } /> + } /> +
+ + +
+ setQuery(event.target.value)} placeholder="Search products..." aria-label="Search products" /> +
+ {views.map((item) => ( + + ))} +
+
+
{products.length} products match the current filters.
+
+ + +
+ {products.map((product) => )} + {products.length === 0 ?

No products matched the current filters.

: null} +
+
+ + +
+
+

Recently shipped

+
+ {getHermesProducts('recently-shipped').slice(0, 5).map((product) =>
{product.name}
)} +
+
+
+

Repeated failures

+
+ {getHermesProducts('repeated-failures').slice(0, 5).map((product) =>
{product.name}
)} +
+
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/hermes/settings/page.tsx b/dashboard/web/src/app/hermes/settings/page.tsx new file mode 100644 index 0000000..661c83c --- /dev/null +++ b/dashboard/web/src/app/hermes/settings/page.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { Download, ShieldCheck, ToggleLeft, Upload } from 'lucide-react'; +import { Badge, Button } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { getHermesSettings } from '@/lib/hermes'; + +function exportSettings() { + const blob = new Blob([JSON.stringify(getHermesSettings(), null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `hermes-settings-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +export default function HermesSettingsPage() { + const settings = getHermesSettings(); + + return ( + Export JSON} + > +
+ } /> + } /> + } /> + } /> +
+ +
+ +
+ {settings.registry.map((item) => ( +
+
+

{item.name}

+

{item.id}

+
+ {item.enabled ? 'Enabled' : 'Disabled'} +
+ ))} +
+
+ + +
+ {settings.notificationRules.map((rule) => ( +
+
+
+

{rule.label}

+

Target: {rule.target}

+
+ {rule.enabled ? 'On' : 'Off'} +
+
+ ))} +
+
+
+ +
+ +
+ {settings.priorityRules.map((rule) => ( +
+

{rule.priority}

+

{rule.rule}

+
+ ))} +
+
+ + +
+
Import JSON configuration from a future API, local file, or telemetry bootstrap.
+
Export the current settings snapshot to seed another environment.
+
Demo/mock toggle is enabled so the dashboard remains safe without backend persistence.
+
+
+ + +
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/hermes/tasks/[id]/page.tsx b/dashboard/web/src/app/hermes/tasks/[id]/page.tsx new file mode 100644 index 0000000..0a9db2c --- /dev/null +++ b/dashboard/web/src/app/hermes/tasks/[id]/page.tsx @@ -0,0 +1,167 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowLeft, CircleDashed, Clock3, ShieldAlert, Sparkles } from 'lucide-react'; +import { Badge, Button } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { getHermesProductById, getHermesTaskById, getHermesTaskEvents } from '@/lib/hermes'; + +const fmt = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + +function levelTone(level: 'debug' | 'info' | 'warn' | 'error' | 'success') { + switch (level) { + case 'success': return 'success'; + case 'warn': return 'warning'; + case 'error': return 'danger'; + case 'debug': return 'neutral'; + default: return 'info'; + } +} + +export default function HermesTaskDetailPage({ params }: { params: { id: string } }) { + const task = getHermesTaskById(params.id); + const events = getHermesTaskEvents(params.id); + + if (!task) { + return ( + Back to task ledger} + > + +

Check the task id or return to the ledger and select another item.

+
+
+ ); + } + + const product = getHermesProductById(task.productId); + const lastEvent = events[0]; + const timeline = events.slice().sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + return ( + Back to task ledger} + > +
+ } helpText={task.currentStep ?? 'Awaiting next step'} /> + } helpText={task.type} /> + } helpText={task.retryCount ? `${task.retryCount} retries` : 'No retries recorded'} /> + } helpText={product?.category ?? 'No product metadata'} /> +
+ +
+ +
+
+
+ {task.status} + {task.source} +
+
+

Product: {product?.name ?? 'Unknown'}

+

Assigned agent: {task.assignedAgent}

+

Current step: {task.currentStep ?? 'n/a'}

+

Result: {task.result ?? 'n/a'}

+

Blocker: {task.blockerReason ?? 'n/a'}

+
+
+
+

Execution details

+
+

Created: {fmt.format(new Date(task.createdAt))}

+

Started: {task.startedAt ? fmt.format(new Date(task.startedAt)) : '—'}

+

Completed: {task.completedAt ? fmt.format(new Date(task.completedAt)) : '—'}

+

Last action: {task.lastAction ?? 'n/a'}

+

Next action: {task.nextAction ?? 'n/a'}

+
+
+ {task.tags.map((tag) => {tag})} +
+
+
+
+ + +
+
+

Lesson learned

+

{task.status === 'failed' ? 'Capture the failing command, dependency, and the exact resolution before retrying the lane.' : 'Preserve the successful execution path as a repeatable pattern.'}

+
+
+

Suggested memory update

+

{task.status === 'blocked' ? 'Remember that this workflow requires founder approval or a credential refresh before execution can continue.' : 'Document the command sequence and verification checks for future reuse.'}

+
+
+

Prevention for next time

+

{task.nextAction ?? 'Keep telemetry wired into the dashboard for follow-up visibility.'}

+
+
+

Recurring issue detection

+

{task.retryCount > 0 ? 'Multiple retries detected; this lane should be watched for recurrence.' : 'No recurring pattern detected for this task.'}

+
+
+
+
+ + +
    + {timeline.map((event) => ( +
  1. +
    +
    +
    + {event.eventType} + {event.message} +
    + {event.command ?

    Command: {event.command}

    : null} + {event.toolName ?

    Tool: {event.toolName}

    : null} + {event.artifactUrl ?

    Artifact: {event.artifactUrl}

    : null} +
    +

    {fmt.format(new Date(event.timestamp))}

    +
    +
  2. + ))} +
+
+ +
+ +
+ {events.filter((event) => event.command || event.toolName).map((event) => ( +
+

{event.message}

+

{event.command ?? event.toolName ?? 'No command captured'}

+
+ ))} + {events.every((event) => !event.command && !event.toolName) ?

No command logs were captured for this task.

: null} +
+
+ + +
+
+

Git branch

+

hermes/{task.id}

+
+
+

Commit SHA

+

{task.completedAt ? task.id.replace('task', 'commit').slice(0, 16) : 'pending'}

+
+
+

PR URL

+

{task.status === 'completed' ? `https://github.com/bytelyst/hermes/pull/${task.id.replace('task-', '')}` : 'Not created yet'}

+
+
+

Deployment URL

+

{product?.productionUrl ?? 'Not deployed yet'}

+
+
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/hermes/tasks/page.tsx b/dashboard/web/src/app/hermes/tasks/page.tsx new file mode 100644 index 0000000..033e082 --- /dev/null +++ b/dashboard/web/src/app/hermes/tasks/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { Fragment, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { Download, Filter, Search, ChevronDown, ChevronUp, ArrowLeftRight } from 'lucide-react'; +import { Badge, Button, Input } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { + getHermesProductById, + getHermesTasks, + hermesProducts, + type HermesPriority, + type HermesTaskStatus, + type HermesTaskType, + type HermesTaskSource, + type HermesTask, +} from '@/lib/hermes'; + +const statuses: Array = ['all', 'queued', 'running', 'blocked', 'completed', 'failed', 'skipped', 'cancelled']; +const priorities: Array = ['all', 'P0', 'P1', 'P2', 'P3']; +const taskTypes: Array = ['all', 'build', 'deploy', 'bugfix', 'monitoring', 'audit', 'refactor', 'documentation', 'research', 'security', 'cost-optimization', 'release', 'maintenance', 'product-planning']; +const sources: Array = ['all', 'manual', 'cron', 'github', 'monitoring-alert', 'email', 'cli', 'webhook', 'local-agent', 'hermes-planner']; +const sortOptions = ['newest', 'oldest', 'priority', 'status'] as const; +const pageSize = 8; + +function prettyDate(value?: string) { + return value ? new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value)) : '—'; +} + +function exportTasks(tasks: HermesTask[]) { + const blob = new Blob([JSON.stringify(tasks, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `hermes-task-ledger-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +export default function HermesTaskLedgerPage() { + const [query, setQuery] = useState(''); + const [status, setStatus] = useState('all'); + const [productId, setProductId] = useState('all'); + const [priority, setPriority] = useState('all'); + const [type, setType] = useState('all'); + const [source, setSource] = useState('all'); + const [updatedWithinDays, setUpdatedWithinDays] = useState('all'); + const [sort, setSort] = useState<(typeof sortOptions)[number]>('newest'); + const [page, setPage] = useState(1); + const [expandedTaskId, setExpandedTaskId] = useState(null); + + const tasks = useMemo(() => getHermesTasks({ query, status, productId, priority, type, source, updatedWithinDays, sort }), [query, status, productId, priority, type, source, updatedWithinDays, sort]); + const totalPages = Math.max(1, Math.ceil(tasks.length / pageSize)); + const pagedTasks = tasks.slice((page - 1) * pageSize, page * pageSize); + + const counts = useMemo(() => ({ + queued: tasks.filter((task) => task.status === 'queued').length, + running: tasks.filter((task) => task.status === 'running').length, + blocked: tasks.filter((task) => task.status === 'blocked').length, + failed: tasks.filter((task) => task.status === 'failed').length, + }), [tasks]); + + const visibleProducts = hermesProducts.slice(0, 20); + + return ( + + + + + )} + > +
+ + + + +
+ + +
+ { setQuery(event.target.value); setPage(1); }} placeholder="Search tasks..." aria-label="Search tasks" className="xl:col-span-2" /> + + + + + + + +
+
+ {tasks.length} matches + Search is applied across task titles, products, agents, and notes +
+
+ + Page {page} of {totalPages}}> +
+
+ + + + + + + + + + + + + + + + {pagedTasks.map((task) => { + const product = getHermesProductById(task.productId); + const expanded = expandedTaskId === task.id; + return ( + + + + + + + + + + + + + {expanded ? ( + + + + ) : null} + + ); + })} + {pagedTasks.length === 0 ? ( + + + + ) : null} + +
TaskProductStatusPriorityTypeSourceCreatedDurationActions
+
+ {task.title} +

{task.description}

+
+
{product?.name ?? 'Unknown'}{task.status}{task.priority}{task.type}{task.source}{prettyDate(task.createdAt)}{task.durationMs ? `${Math.round(task.durationMs / 60000)}m` : '—'} +
+ + +
+
+
+
+

Summary

+

{task.summary}

+
+
Current step: {task.currentStep ?? 'n/a'}
+
Last action: {task.lastAction ?? 'n/a'}
+
Next action: {task.nextAction ?? 'n/a'}
+
+
+
+

Signals

+
+ {task.tags.map((tag) => {tag})} +
+
+
Retry count: {task.retryCount}
+
Assigned agent: {task.assignedAgent}
+
Started: {prettyDate(task.startedAt)}
+
Completed: {prettyDate(task.completedAt)}
+
+
+
+
No tasks matched the current filters.
+
+
+
+

Showing {pagedTasks.length} of {tasks.length} filtered tasks.

+
+ + +
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/layout.tsx b/dashboard/web/src/app/layout.tsx index 8f46f7d..2646ccc 100644 --- a/dashboard/web/src/app/layout.tsx +++ b/dashboard/web/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { AuthProvider } from '@/lib/auth'; @@ -10,8 +10,6 @@ export const metadata: Metadata = { title: 'ByteLyst DevOps', description: 'Internal DevOps dashboard for deployment orchestration', manifest: '/manifest.json', - themeColor: '#2563eb', - viewport: 'width=device-width, initial-scale=1, maximum-scale=1', appleWebApp: { capable: true, statusBarStyle: 'default', @@ -19,6 +17,13 @@ export const metadata: Metadata = { }, }; +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + themeColor: '#2563eb', +}; + export default function RootLayout({ children, }: Readonly<{ diff --git a/dashboard/web/src/app/login/page.tsx b/dashboard/web/src/app/login/page.tsx index c6a7a81..f43184b 100644 --- a/dashboard/web/src/app/login/page.tsx +++ b/dashboard/web/src/app/login/page.tsx @@ -8,9 +8,9 @@ import { setAccessToken, setRefreshToken } from '@/lib/api'; export default function LoginPage() { const router = useRouter(); - const [email, setEmail] = useState('admin@bytelyst.com'); - const [password, setPassword] = useState('admin12345'); - const [productId, setProductId] = useState('bytelyst-devops'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [productId, setProductId] = useState(devopsProductId); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); diff --git a/dashboard/web/src/components/hermes-shell.tsx b/dashboard/web/src/components/hermes-shell.tsx new file mode 100644 index 0000000..73336fc --- /dev/null +++ b/dashboard/web/src/components/hermes-shell.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/Primitives'; + +interface HermesShellProps { + title: string; + description: string; + badge?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; +} + +export function HermesShell({ title, description, badge = 'Hermes Mission Control', actions, children, className }: HermesShellProps) { + return ( +
+
+
+
+ {badge} +
+

{title}

+

{description}

+
+
+ {actions ?
{actions}
: null} +
+
+ {children} +
+ ); +} + +interface SectionCardProps { + title: string; + subtitle?: string; + children: ReactNode; + className?: string; + actions?: ReactNode; +} + +export function SectionCard({ title, subtitle, actions, children, className }: SectionCardProps) { + return ( +
+
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ {actions} +
+ {children} +
+ ); +} + +interface MetricCardProps { + label: string; + value: string | number; + helpText?: string; + tone?: 'default' | 'success' | 'warning' | 'danger' | 'info'; + icon?: ReactNode; +} + +export function MetricCard({ label, value, helpText, tone = 'default', icon }: MetricCardProps) { + const toneStyles: Record, string> = { + default: 'text-[var(--bl-text-primary)]', + success: 'text-[var(--bl-success)]', + warning: 'text-[var(--bl-warning)]', + danger: 'text-[var(--bl-danger)]', + info: 'text-[var(--bl-accent)]', + }; + + return ( +
+
+
+

{label}

+

{value}

+ {helpText ?

{helpText}

: null} +
+ {icon ?
{icon}
: null} +
+
+ ); +} diff --git a/dashboard/web/src/components/sidebar-nav.tsx b/dashboard/web/src/components/sidebar-nav.tsx index a56a4cb..f35e2a8 100644 --- a/dashboard/web/src/components/sidebar-nav.tsx +++ b/dashboard/web/src/components/sidebar-nav.tsx @@ -17,11 +17,13 @@ import { Sun, Moon, HeartPulse, + Sparkles, } from 'lucide-react'; import { useAuth } from '@/lib/auth'; const navItems = [ { href: '/', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/hermes', label: 'Hermes', icon: Sparkles }, { href: '/health', label: 'Health', icon: HeartPulse }, { href: '/metrics', label: 'Metrics', icon: BarChart3 }, { href: '/system', label: 'System', icon: Cpu }, diff --git a/dashboard/web/src/components/ui/Primitives.tsx b/dashboard/web/src/components/ui/Primitives.tsx index a728388..67039bb 100644 --- a/dashboard/web/src/components/ui/Primitives.tsx +++ b/dashboard/web/src/components/ui/Primitives.tsx @@ -5,17 +5,18 @@ import { cn } from '@/lib/utils'; export interface ButtonProps extends React.ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'ghost' | 'link'; size?: 'sm' | 'md' | 'lg'; + asChild?: boolean; } export const Button = React.forwardRef( - ({ variant = 'primary', size = 'md', className, ...props }, ref) => { + ({ variant = 'primary', size = 'md', asChild = false, className, children, ...props }, ref) => { const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'; const variantStyles = { - primary: 'bg-[var(--bl-primary)] text-white hover:bg-[var(--bl-primary-hover)] focus-visible:ring-[var(--bl-primary)]', - secondary: 'bg-[var(--bl-surface)] text-[var(--bl-fg)] hover:bg-[var(--bl-surface-hover)] focus-visible:ring-[var(--bl-fg)]', - ghost: 'hover:bg-[var(--bl-surface-hover)] text-[var(--bl-fg)] focus-visible:ring-[var(--bl-fg)]', - link: 'text-[var(--bl-primary)] hover:underline focus-visible:ring-[var(--bl-primary)]', + primary: 'bg-[var(--bl-accent)] text-[var(--bl-accent-foreground)] hover:opacity-90 focus-visible:ring-[var(--bl-focus-ring)]', + secondary: 'bg-[var(--bl-surface-muted)] text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-highlight)] focus-visible:ring-[var(--bl-focus-ring)]', + ghost: 'text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-muted)] focus-visible:ring-[var(--bl-focus-ring)]', + link: 'text-[var(--bl-accent)] hover:underline focus-visible:ring-[var(--bl-focus-ring)]', }; const sizeStyles = { @@ -24,12 +25,22 @@ export const Button = React.forwardRef( lg: 'h-11 px-8 text-base', }; + const classes = cn(baseStyles, variantStyles[variant], sizeStyles[size], className); + + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement<{ className?: string }>, { + className: cn(children.props.className, classes), + }); + } + return ( ); }, ); diff --git a/dashboard/web/src/lib/api.test.ts b/dashboard/web/src/lib/api.test.ts index 1bb8350..d8eac5d 100644 --- a/dashboard/web/src/lib/api.test.ts +++ b/dashboard/web/src/lib/api.test.ts @@ -1,16 +1,29 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { api } from './api.js'; -// Mock fetch global.fetch = vi.fn(); +function mockJsonResponse(body: unknown, init: Partial = {}): Response { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + describe('API Client', () => { beforeEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); }); describe('getServices', () => { - it('should fetch services successfully', async () => { + it('fetches services successfully', async () => { const mockServices = [ { id: 'test-service', @@ -24,10 +37,7 @@ describe('API Client', () => { }, ]; - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => mockServices, - }); + vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(mockServices)); const services = await api.getServices(); @@ -42,29 +52,22 @@ describe('API Client', () => { ); }); - it('should throw error on fetch failure', async () => { - (global.fetch as any).mockResolvedValueOnce({ + it('throws the normalized API error object on fetch failure', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse({ error: 'boom' }, { ok: false, status: 500, statusText: 'Internal Server Error', - }); + })); - await expect(api.getServices()).rejects.toThrow('API error: 500 Internal Server Error'); + await expect(api.getServices()).rejects.toMatchObject({ + error: 'API error: 500 Internal Server Error', + status: 500, + }); }); - it('should include auth token when available', async () => { - // Mock localStorage - const localStorageMock = { - getItem: vi.fn(() => 'test-token'), - }; - Object.defineProperty(global, 'localStorage', { - value: localStorageMock, - }); - - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => [], - }); + it('includes an auth token when available', async () => { + window.localStorage.setItem('access_token', 'test-token'); + vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse([])); await api.getServices(); @@ -72,28 +75,26 @@ describe('API Client', () => { 'http://localhost:4004/api/services', expect.objectContaining({ headers: expect.objectContaining({ - 'Authorization': 'Bearer test-token', + Authorization: 'Bearer test-token', }), }) ); }); }); - describe('triggerDeployment', () => { - it('should trigger deployment successfully', async () => { + describe('state-changing requests', () => { + it('triggers a deployment without CSRF when no user token exists', async () => { const mockResponse = { deploymentId: 'deployment-123', status: 'running', }; - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }); + vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(mockResponse)); const result = await api.triggerDeployment('test-service'); expect(result).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:4004/api/deployments/trigger/test-service', expect.objectContaining({ @@ -101,26 +102,32 @@ describe('API Client', () => { }) ); }); - }); - describe('seedServices', () => { - it('should seed services successfully', async () => { - const mockResponse = { - message: 'Seeded default services', - }; - - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }); + it('fetches and attaches CSRF tokens for authenticated mutations', async () => { + window.localStorage.setItem('access_token', 'test-token'); + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockJsonResponse({ csrfToken: 'csrf-token' })) + .mockResolvedValueOnce(mockJsonResponse({ message: 'Seeded default services' })); const result = await api.seedServices(); - expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith( + expect(result).toEqual({ message: 'Seeded default services' }); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + 'http://localhost:4004/api/csrf-token', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), + }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, 'http://localhost:4004/api/seed', expect.objectContaining({ method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + 'X-CSRF-Token': 'csrf-token', + }), }) ); }); diff --git a/dashboard/web/src/lib/api.ts b/dashboard/web/src/lib/api.ts index ef49a9e..9e76f48 100644 --- a/dashboard/web/src/lib/api.ts +++ b/dashboard/web/src/lib/api.ts @@ -78,10 +78,14 @@ async function getCsrfToken(): Promise { try { const token = await getAccessToken(); + if (!token) { + return null; + } + const response = await fetch(`${devopsApiUrl}/api/csrf-token`, { headers: { 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), + Authorization: `Bearer ${token}`, }, }); diff --git a/dashboard/web/src/lib/hermes.test.ts b/dashboard/web/src/lib/hermes.test.ts new file mode 100644 index 0000000..e66718f --- /dev/null +++ b/dashboard/web/src/lib/hermes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + getHermesAgents, + getHermesHistory, + getHermesOverview, + getHermesProductById, + getHermesProducts, + getHermesSettings, + getHermesTaskById, + getHermesTaskEvents, + getHermesTasks, + hermesProducts, + hermesTasks, +} from './hermes.js'; + +describe('hermes mock service', () => { + it('exposes a large product portfolio', () => { + expect(hermesProducts.length).toBeGreaterThanOrEqual(50); + expect(getHermesProducts('needs-attention').length).toBeGreaterThan(0); + }); + + it('filters tasks by query and status', () => { + const blocked = getHermesTasks({ status: 'blocked' }); + expect(blocked.every((task) => task.status === 'blocked')).toBe(true); + + const queried = getHermesTasks({ query: 'deployment' }); + expect(queried.length).toBeGreaterThan(0); + expect(queried.some((task) => task.title.toLowerCase().includes('deployment') || task.description.toLowerCase().includes('deployment'))).toBe(true); + }); + + it('returns task details and timeline events', () => { + const task = getHermesTaskById(hermesTasks[0].id); + expect(task).toBeDefined(); + expect(getHermesTaskEvents(task!.id).length).toBeGreaterThan(0); + }); + + it('computes overview metrics', () => { + const overview = getHermesOverview(); + expect(overview.completedToday).toBeGreaterThanOrEqual(0); + expect(overview.successRate).toBeGreaterThanOrEqual(0); + expect(overview.lastAction.length).toBeGreaterThan(0); + }); + + it('returns other mock observability slices', () => { + expect(getHermesAgents().length).toBeGreaterThan(0); + expect(getHermesHistory().length).toBeGreaterThan(0); + expect(getHermesSettings().registry.length).toBeGreaterThan(0); + expect(getHermesProductById(hermesProducts[0].id)).toBeDefined(); + }); +}); diff --git a/dashboard/web/src/lib/hermes.ts b/dashboard/web/src/lib/hermes.ts new file mode 100644 index 0000000..8e72fe9 --- /dev/null +++ b/dashboard/web/src/lib/hermes.ts @@ -0,0 +1,781 @@ +export type HermesStatus = 'running' | 'idle' | 'degraded' | 'error'; + +export type HermesTaskStatus = + | 'queued' + | 'running' + | 'blocked' + | 'completed' + | 'failed' + | 'skipped' + | 'cancelled'; + +export type HermesPriority = 'P0' | 'P1' | 'P2' | 'P3'; + +export type HermesTaskType = + | 'build' + | 'deploy' + | 'bugfix' + | 'monitoring' + | 'audit' + | 'refactor' + | 'documentation' + | 'research' + | 'security' + | 'cost-optimization' + | 'release' + | 'maintenance' + | 'product-planning'; + +export type HermesTaskSource = + | 'manual' + | 'cron' + | 'github' + | 'monitoring-alert' + | 'email' + | 'cli' + | 'webhook' + | 'local-agent' + | 'hermes-planner'; + +export interface HermesProduct { + id: string; + name: string; + slug: string; + description: string; + category: string; + repoUrl?: string; + productionUrl?: string; + stagingUrl?: string; + owner: string; + priority: HermesPriority; + status: 'active' | 'paused' | 'maintenance' | 'archived' | 'idea'; + healthScore: number; + tags: string[]; + lastHermesActivityAt?: string; + lastDeploymentAt?: string; + lastCommitAt?: string; + needsAttention: boolean; + createdAt: string; + updatedAt: string; +} + +export interface HermesTask { + id: string; + title: string; + description: string; + productId: string; + status: HermesTaskStatus; + priority: HermesPriority; + type: HermesTaskType; + source: HermesTaskSource; + createdAt: string; + startedAt?: string; + completedAt?: string; + durationMs?: number; + retryCount: number; + assignedAgent: string; + tags: string[]; + progressPercent: number; + currentStep?: string; + lastAction?: string; + nextAction?: string; + blockerReason?: string; + summary?: string; + result?: string; + error?: string; +} + +export interface HermesEvent { + id: string; + taskId: string; + timestamp: string; + level: 'debug' | 'info' | 'warn' | 'error' | 'success'; + eventType: + | 'created' + | 'planned' + | 'started' + | 'tool-called' + | 'command-executed' + | 'file-changed' + | 'test-run' + | 'error' + | 'retry' + | 'blocked' + | 'completed' + | 'deployment' + | 'pr-created' + | 'memory-suggested'; + message: string; + metadata?: Record; + toolName?: string; + command?: string; + artifactUrl?: string; +} + +export interface HermesRun { + id: string; + taskId: string; + startedAt: string; + endedAt?: string; + status: HermesTaskStatus; + logs: string[]; + metrics?: Record; + commitSha?: string; + branchName?: string; + prUrl?: string; + deploymentUrl?: string; +} + +export interface HermesAgentStatus { + id: string; + name: string; + type: 'agent' | 'tool' | 'integration' | 'runner'; + status: 'healthy' | 'degraded' | 'offline' | 'unknown'; + lastSuccessAt?: string; + lastFailureAt?: string; + callsToday: number; + failureRate: number; + averageLatencyMs?: number; + configIssue?: string; +} + +export interface HermesOverview { + status: HermesStatus; + activeTasks: number; + completedToday: number; + completedThisWeek: number; + failedTasks: number; + blockedTasks: number; + averageDurationMs: number; + successRate: number; + productsTouchedRecently: number; + founderAttentionCount: number; + upcomingJobs: number; + lastAction: string; + nextRecommendedAction: string; +} + +export interface HermesHistoryPoint { + label: string; + completed: number; + failed: number; + blocked: number; + active: number; +} + +export interface HermesSettings { + demoMode: boolean; + retentionDays: number; + approvalThreshold: number; + autoRetryLimit: number; + notificationRules: Array<{ + id: string; + label: string; + enabled: boolean; + target: string; + }>; + taskCategories: string[]; + priorityRules: Array<{ + priority: HermesPriority; + rule: string; + }>; + registry: Array<{ + id: string; + name: string; + enabled: boolean; + }>; +} + +const now = Date.now(); +const isoMinutesAgo = (minutes: number) => new Date(now - minutes * 60_000).toISOString(); +const isoHoursAgo = (hours: number) => new Date(now - hours * 3_600_000).toISOString(); +const isoDaysAgo = (days: number) => new Date(now - days * 86_400_000).toISOString(); + +const productSeeds = [ + { name: 'Automation Hub', category: 'automation', owner: 'Hermes', tags: ['workflow', 'cron', 'ops'] }, + { name: 'Trading Console', category: 'SaaS', owner: 'Saravana', tags: ['deploy', 'reliability', 'money'] }, + { name: 'AI Content Studio', category: 'AI app', owner: 'Hermes', tags: ['llm', 'content', 'creative'] }, + { name: 'Internal Ops Desk', category: 'internal tool', owner: 'Platform', tags: ['admin', 'support', 'workflow'] }, + { name: 'Marketing Site', category: 'website', owner: 'Growth', tags: ['brand', 'web', 'seo'] }, + { name: 'Customer API', category: 'API', owner: 'Engineering', tags: ['api', 'integration', 'platform'] }, + { name: 'Agent Runner', category: 'automation', owner: 'Hermes', tags: ['agents', 'cli', 'background'] }, + { name: 'Browser Extension', category: 'browser extension', owner: 'Product', tags: ['extension', 'browser', 'ux'] }, + { name: 'Analytics Pipeline', category: 'data pipeline', owner: 'Data', tags: ['etl', 'data', 'metrics'] }, + { name: 'DevOps Toolkit', category: 'DevOps tool', owner: 'Saravana', tags: ['devops', 'scripts', 'gitea'] }, +]; + +const priorityCycle: HermesPriority[] = ['P0', 'P1', 'P2', 'P3']; +const statusCycle: HermesProduct['status'][] = ['active', 'active', 'maintenance', 'paused', 'active', 'active', 'idea', 'archived']; +const taskStatusCycle: HermesTaskStatus[] = ['running', 'queued', 'blocked', 'completed', 'failed', 'completed', 'queued', 'running']; +const taskTypeCycle: HermesTaskType[] = [ + 'build', + 'deploy', + 'bugfix', + 'monitoring', + 'audit', + 'refactor', + 'documentation', + 'research', + 'security', + 'cost-optimization', + 'release', + 'maintenance', + 'product-planning', +]; +const sourceCycle: HermesTaskSource[] = [ + 'manual', + 'cron', + 'github', + 'monitoring-alert', + 'email', + 'cli', + 'webhook', + 'local-agent', + 'hermes-planner', +]; + +export const hermesProducts: HermesProduct[] = Array.from({ length: 50 }, (_, index) => { + const seed = productSeeds[index % productSeeds.length]; + const ordinal = index + 1; + const status = statusCycle[index % statusCycle.length]; + const priority = priorityCycle[index % priorityCycle.length]; + const activityAgeDays = (index % 18) + (status === 'active' ? 0 : 7); + const deploymentAgeDays = (index % 12) + (status === 'active' ? 1 : 14); + const commitAgeDays = (index % 9) + (status === 'active' ? 0 : 10); + const healthScore = Math.max( + 32, + Math.min( + 99, + 94 - (index % 7) * 4 - (status === 'paused' ? 18 : 0) - (status === 'archived' ? 24 : 0) - (status === 'maintenance' ? 10 : 0) + (priority === 'P0' ? 4 : 0), + ), + ); + + return { + id: `product-${ordinal}`, + name: `${seed.name} ${ordinal}`, + slug: `${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}`, + description: `${seed.category} product managed by Hermes for ${seed.owner.toLowerCase()} workflows.`, + category: seed.category, + repoUrl: `https://github.com/bytelyst/${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}`, + productionUrl: status === 'archived' ? undefined : `https://${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}.bytelyst.ai`, + stagingUrl: status === 'idea' ? undefined : `https://staging-${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}.bytelyst.ai`, + owner: seed.owner, + priority, + status, + healthScore, + tags: seed.tags, + lastHermesActivityAt: isoDaysAgo(activityAgeDays), + lastDeploymentAt: status === 'idea' ? undefined : isoDaysAgo(deploymentAgeDays), + lastCommitAt: isoDaysAgo(commitAgeDays), + needsAttention: healthScore < 72 || status !== 'active', + createdAt: isoDaysAgo(60 + index), + updatedAt: isoDaysAgo(index % 10), + }; +}); + +const taskTemplates = [ + 'stabilize deployment pipeline', + 'investigate retry loop', + 'ship dashboard enhancement', + 'review CI failure cluster', + 'refactor service integration', + 'document product launch flow', + 'audit secrets and rotation', + 'prepare release checklist', + 'benchmark response latency', + 'resolve blocked automation', + 'ship product telemetry update', + 'clean up stale jobs', +]; + +const assignedAgents = ['Hermes Core', 'OpenClaw', 'Gitea Bot', 'Local VM Runner', 'Planner', 'Notifier']; + +export const hermesTasks: HermesTask[] = Array.from({ length: 36 }, (_, index) => { + const product = hermesProducts[index % hermesProducts.length]; + const status = taskStatusCycle[index % taskStatusCycle.length]; + const priority = priorityCycle[(index + (status === 'blocked' ? 0 : 1)) % priorityCycle.length]; + const type = taskTypeCycle[index % taskTypeCycle.length]; + const source = sourceCycle[index % sourceCycle.length]; + const createdHoursAgo = index * 3 + 4; + const startedHoursAgo = createdHoursAgo - 1; + const durationMinutes = 16 + (index % 6) * 9; + const title = `${taskTemplates[index % taskTemplates.length]} — ${product.name}`; + const blockerReason = status === 'blocked' + ? ['Waiting on approval', 'Missing credential', 'Test failure needs triage', 'Deployment gate is red'][index % 4] + : status === 'failed' + ? ['Flaky integration test', 'Build timeout', 'Blocked by external dependency'][index % 3] + : undefined; + const result = status === 'completed' + ? ['Merged and deployed', 'Doc updated and published', 'Audit completed, no action needed', 'PR opened for review'][index % 4] + : undefined; + const error = status === 'failed' + ? ['npm test exited 1', 'upstream service returned 500', 'timeout waiting for health check'][index % 3] + : undefined; + const progressPercent = + status === 'completed' ? 100 : + status === 'running' ? 55 + (index % 20) : + status === 'queued' ? 10 + (index % 15) : + status === 'blocked' ? 35 : + status === 'failed' ? 48 : + status === 'skipped' ? 100 : 0; + + return { + id: `task-${index + 1}`, + title, + description: `Hermes is working on ${taskTemplates[index % taskTemplates.length]} for ${product.name}.`, + productId: product.id, + status, + priority, + type, + source, + createdAt: isoHoursAgo(createdHoursAgo), + startedAt: status === 'queued' ? undefined : isoHoursAgo(startedHoursAgo), + completedAt: status === 'completed' || status === 'skipped' ? isoHoursAgo(Math.max(0, startedHoursAgo - 1)) : undefined, + durationMs: status === 'completed' || status === 'failed' || status === 'skipped' ? durationMinutes * 60_000 : undefined, + retryCount: index % 4, + assignedAgent: assignedAgents[index % assignedAgents.length], + tags: [type, priority.toLowerCase(), product.category.toLowerCase()], + progressPercent, + currentStep: status === 'running' ? ['reviewing logs', 'applying patch', 'replaying checks', 'waiting for approval'][index % 4] : undefined, + lastAction: ['Checked repo state', 'Ran typecheck', 'Updated docs', 'Pushed branch', 'Re-ran tests'][index % 5], + nextAction: status === 'completed' ? 'Monitor telemetry' : status === 'blocked' ? 'Await founder decision' : status === 'failed' ? 'Investigate failure and retry' : 'Continue task execution', + blockerReason, + summary: status === 'completed' ? 'Work completed successfully with evidence captured.' : 'Active orchestration task tracked by Hermes.', + result, + error, + }; +}); + +const eventBlueprints = new Map( + hermesTasks.map((task, index) => { + const created = isoHoursAgo(index * 3 + 4); + const started = isoHoursAgo(index * 3 + 3); + const completed = isoHoursAgo(index * 3 + 1); + const events: HermesEvent[] = [ + { + id: `${task.id}-event-created`, + taskId: task.id, + timestamp: created, + level: 'info', + eventType: 'created', + message: `Task ${task.title} was created from ${task.source}.`, + metadata: { productId: task.productId, priority: task.priority }, + }, + { + id: `${task.id}-event-planned`, + taskId: task.id, + timestamp: started, + level: 'info', + eventType: 'planned', + message: `Hermes planned the next steps for ${task.title}.`, + }, + ]; + + if (task.status === 'running' || task.status === 'completed' || task.status === 'blocked' || task.status === 'failed') { + events.push({ + id: `${task.id}-event-started`, + taskId: task.id, + timestamp: started, + level: 'success', + eventType: 'started', + message: `${task.assignedAgent} started execution.`, + }); + events.push({ + id: `${task.id}-event-command`, + taskId: task.id, + timestamp: isoMinutesAgo(index * 7 + 25), + level: 'debug', + eventType: 'command-executed', + message: 'Ran the verification command set.', + command: 'pnpm test:run && pnpm build', + toolName: 'terminal', + }); + } + + if (task.status === 'blocked') { + events.push({ + id: `${task.id}-event-blocked`, + taskId: task.id, + timestamp: isoMinutesAgo(index * 7 + 10), + level: 'warn', + eventType: 'blocked', + message: task.blockerReason ?? 'Task blocked pending review.', + metadata: { attentionRequired: true }, + }); + } + + if (task.status === 'failed') { + events.push({ + id: `${task.id}-event-error`, + taskId: task.id, + timestamp: isoMinutesAgo(index * 7 + 8), + level: 'error', + eventType: 'error', + message: task.error ?? 'Execution failed.', + metadata: { retryCount: task.retryCount }, + }); + events.push({ + id: `${task.id}-event-retry`, + taskId: task.id, + timestamp: isoMinutesAgo(index * 7 + 5), + level: 'warn', + eventType: 'retry', + message: 'Hermes scheduled an automatic retry.', + }); + } + + if (task.status === 'completed' || task.status === 'skipped') { + events.push({ + id: `${task.id}-event-completed`, + taskId: task.id, + timestamp: completed, + level: 'success', + eventType: 'completed', + message: task.result ?? 'Task completed successfully.', + artifactUrl: `https://github.com/bytelyst/hermes/${task.id}`, + }); + if (task.type === 'deploy') { + events.push({ + id: `${task.id}-event-deployment`, + taskId: task.id, + timestamp: completed, + level: 'success', + eventType: 'deployment', + message: 'Deployment finished and health check passed.', + }); + } + } + + if (task.priority === 'P0') { + events.push({ + id: `${task.id}-event-memory`, + taskId: task.id, + timestamp: isoMinutesAgo(index * 7 + 2), + level: 'info', + eventType: 'memory-suggested', + message: 'Hermes suggested a follow-up memory entry to prevent repeat failures.', + }); + } + + return [task.id, events]; + }), +); + +export const hermesAgentStatuses: HermesAgentStatus[] = [ + { + id: 'hermes-core', + name: 'Hermes Core', + type: 'agent', + status: 'healthy', + lastSuccessAt: isoHoursAgo(1), + callsToday: 42, + failureRate: 0.03, + averageLatencyMs: 820, + }, + { + id: 'openclaw-integration', + name: 'OpenClaw integration', + type: 'integration', + status: 'degraded', + lastSuccessAt: isoHoursAgo(5), + lastFailureAt: isoHoursAgo(1), + callsToday: 11, + failureRate: 0.18, + averageLatencyMs: 1480, + configIssue: 'Rate-limit warnings from the upstream workspace token.', + }, + { + id: 'github-link', + name: 'GitHub integration', + type: 'integration', + status: 'healthy', + lastSuccessAt: isoHoursAgo(2), + callsToday: 84, + failureRate: 0.01, + averageLatencyMs: 420, + }, + { + id: 'local-vm-runner', + name: 'Local VM runner', + type: 'runner', + status: 'healthy', + lastSuccessAt: isoMinutesAgo(18), + callsToday: 27, + failureRate: 0.02, + averageLatencyMs: 670, + }, + { + id: 'cli-runner', + name: 'CLI runner', + type: 'runner', + status: 'healthy', + lastSuccessAt: isoMinutesAgo(6), + callsToday: 33, + failureRate: 0.02, + averageLatencyMs: 510, + }, + { + id: 'scheduler-cron', + name: 'Scheduler / cron', + type: 'tool', + status: 'healthy', + lastSuccessAt: isoMinutesAgo(9), + callsToday: 67, + failureRate: 0.00, + averageLatencyMs: 112, + }, + { + id: 'deployment-tools', + name: 'Deployment tools', + type: 'tool', + status: 'degraded', + lastSuccessAt: isoHoursAgo(3), + lastFailureAt: isoHoursAgo(1), + callsToday: 19, + failureRate: 0.11, + averageLatencyMs: 900, + configIssue: 'One stale secret needs rotation before the next release.', + }, + { + id: 'notifications', + name: 'Notification tools', + type: 'tool', + status: 'offline', + lastSuccessAt: isoDaysAgo(2), + lastFailureAt: isoHoursAgo(9), + callsToday: 4, + failureRate: 0.25, + averageLatencyMs: 2100, + configIssue: 'Telegram webhook token not configured in the mock environment.', + }, +]; + +export const hermesHistory: HermesHistoryPoint[] = [ + { label: 'Wk 1', completed: 12, failed: 2, blocked: 1, active: 4 }, + { label: 'Wk 2', completed: 18, failed: 1, blocked: 2, active: 5 }, + { label: 'Wk 3', completed: 15, failed: 4, blocked: 2, active: 6 }, + { label: 'Wk 4', completed: 21, failed: 3, blocked: 1, active: 7 }, + { label: 'Wk 5', completed: 17, failed: 2, blocked: 3, active: 6 }, + { label: 'Wk 6', completed: 24, failed: 1, blocked: 1, active: 5 }, + { label: 'Wk 7', completed: 20, failed: 2, blocked: 2, active: 6 }, + { label: 'Wk 8', completed: 26, failed: 1, blocked: 0, active: 4 }, +]; + +export const hermesSettings: HermesSettings = { + demoMode: true, + retentionDays: 45, + approvalThreshold: 75, + autoRetryLimit: 2, + notificationRules: [ + { id: 'approval-needed', label: 'Approval needed', enabled: true, target: 'Telegram + dashboard badge' }, + { id: 'deploy-failure', label: 'Failed deployment', enabled: true, target: 'Telegram' }, + { id: 'repeated-failure', label: 'Repeated failures', enabled: true, target: 'Email digest' }, + { id: 'cost-risk', label: 'Cost or risk warning', enabled: false, target: 'Founder review queue' }, + ], + taskCategories: [ + 'build', + 'deploy', + 'bugfix', + 'monitoring', + 'audit', + 'refactor', + 'documentation', + 'research', + 'security', + 'cost-optimization', + 'release', + 'maintenance', + 'product-planning', + ], + priorityRules: [ + { priority: 'P0', rule: 'Production incidents, blocked launches, or security issues.' }, + { priority: 'P1', rule: 'High-value shipping work with founder impact.' }, + { priority: 'P2', rule: 'Normal operating work and maintenance.' }, + { priority: 'P3', rule: 'Nice-to-have improvements and backlog hygiene.' }, + ], + registry: [ + { id: 'hermes-core', name: 'Hermes Core', enabled: true }, + { id: 'github', name: 'GitHub', enabled: true }, + { id: 'local-vm', name: 'Local VM Runner', enabled: true }, + { id: 'notifications', name: 'Notifications', enabled: false }, + ], +}; + +export interface HermesTaskFilters { + query?: string; + status?: HermesTaskStatus | 'all'; + productId?: string | 'all'; + priority?: HermesPriority | 'all'; + type?: HermesTaskType | 'all'; + source?: HermesTaskSource | 'all'; + updatedWithinDays?: number | 'all'; + sort?: 'newest' | 'oldest' | 'priority' | 'status'; +} + +export function getHermesOverview(): HermesOverview { + const activeTasks = hermesTasks.filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length; + const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 86_400_000).length; + const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 7 * 86_400_000).length; + const failedTasks = hermesTasks.filter((task) => task.status === 'failed').length; + const blockedTasks = hermesTasks.filter((task) => task.status === 'blocked').length; + const completedWithDuration = hermesTasks.filter((task) => typeof task.durationMs === 'number' && task.status === 'completed'); + const averageDurationMs = completedWithDuration.length + ? Math.round(completedWithDuration.reduce((sum, task) => sum + (task.durationMs ?? 0), 0) / completedWithDuration.length) + : 0; + const successRate = Math.round((hermesTasks.filter((task) => task.status === 'completed' || task.status === 'skipped').length / hermesTasks.length) * 100); + const productsTouchedRecently = hermesProducts.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() >= now - 14 * 86_400_000).length; + const founderAttentionCount = hermesTasks.filter((task) => task.status === 'blocked' || task.status === 'failed').length + hermesProducts.filter((product) => product.needsAttention).length; + const upcomingJobs = hermesTasks.filter((task) => task.status === 'queued').length; + const lastAction = hermesEventsSorted()[0]?.message ?? 'Hermes has not recorded an action yet.'; + const nextRecommendedAction = computeNextRecommendedAction(); + + return { + status: failedTasks > 6 ? 'error' : blockedTasks > 4 ? 'degraded' : activeTasks > 0 ? 'running' : 'idle', + activeTasks, + completedToday, + completedThisWeek, + failedTasks, + blockedTasks, + averageDurationMs, + successRate, + productsTouchedRecently, + founderAttentionCount, + upcomingJobs, + lastAction, + nextRecommendedAction, + }; +} + +export function getHermesTasks(filters: HermesTaskFilters = {}): HermesTask[] { + const { + query, + status = 'all', + productId = 'all', + priority = 'all', + type = 'all', + source = 'all', + updatedWithinDays = 'all', + sort = 'newest', + } = filters; + + const normalizedQuery = query?.trim().toLowerCase(); + const filtered = hermesTasks.filter((task) => { + const product = hermesProducts.find((item) => item.id === task.productId); + const matchesQuery = !normalizedQuery || [ + task.title, + task.description, + task.assignedAgent, + task.lastAction, + task.nextAction, + task.blockerReason, + task.result, + task.error, + product?.name, + product?.slug, + ...task.tags, + ] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(normalizedQuery)); + + const matchesStatus = status === 'all' || task.status === status; + const matchesProduct = productId === 'all' || task.productId === productId; + const matchesPriority = priority === 'all' || task.priority === priority; + const matchesType = type === 'all' || task.type === type; + const matchesSource = source === 'all' || task.source === source; + const matchesUpdatedWindow = updatedWithinDays === 'all' + ? true + : new Date(task.createdAt).getTime() >= now - updatedWithinDays * 86_400_000; + + return matchesQuery && matchesStatus && matchesProduct && matchesPriority && matchesType && matchesSource && matchesUpdatedWindow; + }); + + const priorityRank: Record = { P0: 0, P1: 1, P2: 2, P3: 3 }; + + return [...filtered].sort((a, b) => { + switch (sort) { + case 'oldest': + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + case 'priority': + return priorityRank[a.priority] - priorityRank[b.priority] || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'status': + return a.status.localeCompare(b.status) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'newest': + default: + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + }); +} + +export function getHermesTaskById(id: string): HermesTask | undefined { + return hermesTasks.find((task) => task.id === id); +} + +export function getHermesTaskEvents(taskId: string): HermesEvent[] { + return eventBlueprints.get(taskId) ?? []; +} + +export function getHermesProductById(id: string): HermesProduct | undefined { + return hermesProducts.find((product) => product.id === id); +} + +export function getHermesProducts(view: 'all' | 'high-priority' | 'needs-attention' | 'no-recent-activity' | 'repeated-failures' | 'recently-shipped' = 'all'): HermesProduct[] { + const recentCutoff = now - 14 * 86_400_000; + const shippedCutoff = now - 7 * 86_400_000; + return hermesProducts.filter((product) => { + const recentFailedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'failed').length; + const recentCompletedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'completed').length; + switch (view) { + case 'high-priority': + return product.priority === 'P0' || product.priority === 'P1'; + case 'needs-attention': + return product.needsAttention; + case 'no-recent-activity': + return !product.lastHermesActivityAt || new Date(product.lastHermesActivityAt).getTime() < recentCutoff; + case 'repeated-failures': + return recentFailedTasks >= 3; + case 'recently-shipped': + return recentCompletedTasks > 0 && (product.lastDeploymentAt ? new Date(product.lastDeploymentAt).getTime() >= shippedCutoff : false); + case 'all': + default: + return true; + } + }); +} + +export function getHermesHistory() { + return hermesHistory; +} + +export function getHermesAgents() { + return hermesAgentStatuses; +} + +export function getHermesSettings() { + return hermesSettings; +} + +function hermesEventsSorted() { + return Array.from(eventBlueprints.values()) + .flat() + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); +} + +function computeNextRecommendedAction() { + const blocked = hermesTasks.filter((task) => task.status === 'blocked'); + if (blocked.length > 0) { + const next = blocked[0]; + return `Unblock ${next.title} for ${getHermesProductById(next.productId)?.name ?? 'an active product'}.`; + } + + const failed = hermesTasks.find((task) => task.status === 'failed'); + if (failed) { + return `Inspect and retry ${failed.title}.`; + } + + const staleProduct = hermesProducts.find((product) => product.needsAttention); + if (staleProduct) { + return `Review ${staleProduct.name} because it needs attention.`; + } + + return 'No urgent action required. Continue the scheduled execution queue.'; +} diff --git a/dashboard/web/tsconfig.json b/dashboard/web/tsconfig.json index f3c9779..39e9086 100644 --- a/dashboard/web/tsconfig.json +++ b/dashboard/web/tsconfig.json @@ -30,12 +30,20 @@ }, "include": [ "next-env.d.ts", - "**/*.ts", - "**/*.tsx", + "src/**/*.ts", + "src/**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "e2e", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "playwright.config.ts", + "vitest.config.ts", + ".next/dev" ] } diff --git a/dashboard/web/vitest.config.ts b/dashboard/web/vitest.config.ts index 366bed9..70d8cf4 100644 --- a/dashboard/web/vitest.config.ts +++ b/dashboard/web/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], - passWithNoTests: true, + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['e2e/**', 'node_modules/**', 'dist/**', '.next/**'], + passWithNoTests: false, }, }); diff --git a/docs/repo-map.md b/docs/repo-map.md index 1221639..618596c 100644 --- a/docs/repo-map.md +++ b/docs/repo-map.md @@ -165,6 +165,7 @@ Key files: - `dashboard/web/src/` — Next.js app, API client, auth provider - `dashboard/shared/product.json` — Product identity (devops-internal) - `dashboard/README.md` — Setup and usage documentation +- `dashboard/ENDPOINTS.md` — Canonical dashboard URL and endpoint inventory See `dashboard/README.md` for architecture and setup instructions.