Compare commits

...

10 Commits

Author SHA1 Message Date
root
5a928b1925 docs: add Hermes setup upgrade roadmap 2026-05-26 22:12:05 +00:00
root
dea1546d9f feat: add Hermes mission control dashboard 2026-05-26 08:27:59 +00:00
root
62cf0c8c29 docs: correct Hermes dashboard roadmap repo path 2026-05-26 04:59:47 +00:00
root
1ee9c54a54 docs: clarify Hermes dashboard roadmap 2026-05-26 02:52:27 +00:00
sarvana7
62089a11cc
Create hermes_dashboard_roadmap.md 2026-05-25 03:32:35 -07:00
root
47c96d5db4 docs(devops): add repo context note 2026-05-18 09:01:09 +00:00
root
1deb832b1a chore(devops): tighten deployment scripts 2026-05-18 09:01:03 +00:00
root
85f21ae9f6 feat(devops): restore dashboard build and log polling 2026-05-18 09:00:56 +00:00
root
4ae55fd3c8 chore(deploy): remove debug logging from deployment script
Removed debug logging that was added to troubleshoot the commit hash issue.
The issue has been fixed by changing docker-compose.yml to use pre-built images.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-13 02:48:54 +00:00
root
d70accecb8 debug(deploy): add debug output to track git metadata collection
Added debug logging to see what commit SHA is being collected and passed to Docker build.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-13 02:35:02 +00:00
52 changed files with 4282 additions and 542 deletions

355
REPO_CONTEXT.md Normal file
View File

@ -0,0 +1,355 @@
# ByteLyst DevOps Tools - Repository Context
> **Purpose**: This file provides quick context for AI coding agents about what this repository contains and how to use it effectively.
## What This Repository Provides
This is the **operational tools repository** for the ByteLyst ecosystem. It contains:
- **Deployment scripts** for all products (deploy-{project}.sh pattern)
- **GitHub administration tools** and multi-repo safety helpers
- **Operational automation** scripts
- **Internal DevOps dashboard** (dashboard/ - full product)
- **Cross-repo utilities** and maintenance scripts
## Key Locations
### Deployment Scripts (MOST IMPORTANT FOR PRODUCT DEPLOYMENTS)
- **Location**: Root level `deploy-*.sh` files
- **Pattern**: `deploy-{project}.sh`
- **Available Scripts**:
- `deploy-invttrdg.sh` - Investment Trading deployment
- `deploy-notes.sh` - Notes deployment
- `deploy-clock.sh` - Clock deployment
- `deploy-all.sh` - Deploy all products
- **Always check here first** before attempting custom deployment solutions
### Deployment Script Pattern
All deployment scripts follow this standard pattern:
1. Dirty checks (uncommitted changes, unpushed commits)
2. Pull and rebase from origin/main
3. Run smoke tests (if available)
4. Build Docker images with `--network host` (critical for Gitea registry access)
5. Deploy containers via docker-compose
6. Health checks and endpoint verification
### Critical Deployment Configuration
- **Docker Build**: Uses `--network host` flag for Gitea registry access
- **Gitea Token**: Required for @bytelyst/* package installation
- **Token Location**:
- Environment variable: `GITEA_NPM_TOKEN`
- Fallback 1: `/opt/bytelyst/.gitea_token`
- Fallback 2: `$HOME/.gitea_npm_token`
- **Build Metadata**: Automatically includes commit SHA, branch, author, build time
### GitHub Administration Tools
- **bytelyst-cli.sh**: Main CLI for common GitHub operations
- **remove_user_interactive.sh**: Interactive collaborator removal
- **remove_user_guided.sh**: Guided removal workflow
- **remove_user_from_repos.sh**: Scripted removal flow
### Multi-Repo Git Safety Tools
- **Location**: `git-work-safety-tools/`
- **Purpose**: Safe bulk git workflows across multiple repositories
- **Key Scripts**:
- `git_repos_status.sh` - Scan many repos for dirty state
- `git_repos_rebase_commit_push.sh` - Safe rebase + commit + push
- `multi_repo_safe_push.sh` - Safe multi-repo pushing
### Scripts Directory
- **Location**: `scripts/`
- **Purpose**: Self-contained operational scripts
- **Pattern**: More focused than root-level helpers
### Documentation
- **Location**: `docs/`
- **Key Files**:
- `getting-started.md` - Onboarding guide
- `repo-map.md` - Repository structure map
- `tooling-status.md` - Tool availability status
- `operations.md` - Operational patterns
## Common Usage Patterns
### Deployment (Most Common Use Case)
```bash
# Deploy specific product
./deploy-invttrdg.sh
# Force deployment (skip dirty checks)
./deploy-invttrdg.sh --force
# Skip health checks
./deploy-invttrdg.sh --skip-health-check
# Deploy all products
./deploy-all.sh
```
### GitHub Operations
```bash
# List public repos for a user
./bytelyst-cli.sh list-public-repos --user <username>
# List private repos for an org
./bytelyst-cli.sh list-private-repos --org <orgname>
# Interactive user removal
./remove_user_interactive.sh
```
### Multi-Repo Safety
```bash
# Check status of multiple repos
./git-work-safety-tools/git_repos_status.sh
# Safe rebase + commit + push across repos
./git-work-safety-tools/git_repos_rebase_commit_push.sh
```
## Deployment Script Details
### Standard Deployment Flow
1. **Prerequisites Check**: Verify repo directory exists
2. **Dirty Checks**:
- Uncommitted changes (fail unless --force)
- Untracked files (fail unless --force)
- Unpushed commits (fail unless --force)
3. **Git Sync**: Pull and rebase origin/main
4. **Smoke Tests**: Run `scripts/smoke-release.sh` if available
5. **Docker Build**:
- Build backend and web images
- Use `--network host` for Gitea registry access
- Pass GITEA_NPM_TOKEN via BuildKit secrets
- Include build metadata (commit SHA, branch, etc.)
6. **Deployment**: `docker compose up -d --no-build`
7. **Health Checks**: Verify local and production endpoints
### Docker Build Configuration
```bash
docker build --network host \
--secret id=gitea_npm_token,env=GITEA_NPM_TOKEN \
--build-arg "BYTELYST_COMMIT_SHA=${COMMIT_SHA}" \
--build-arg "BYTELYST_BRANCH=${BRANCH}" \
-f Dockerfile -t image:tag .
```
### Health Check Endpoints
- **Backend**: `http://localhost:4025/health/live`
- **Web**: `http://localhost:3085`
- **Production API**: `https://api.bytelyst.com/invttrdg`
- **Production Web**: `https://invttrdg.bytelyst.com`
## Important Notes
### Before Deployment
1. **Always check deployment scripts here first** - don't build custom deployment solutions
2. Ensure GITEA_NPM_TOKEN is set correctly
3. Run smoke tests if available
4. Check git status for uncommitted changes
### Common Deployment Issues
- **"Unauthorized - 401" from Gitea**: Wrong token, check GITEA_NPM_TOKEN
- **"Cannot find module @bytelyst/..."**: Package not built, check learning_ai_common_plat
- **Docker build network errors**: Missing --network host flag
- **Port conflicts**: Check if containers are already running
### Gitea Registry Access
- **Registry URL**: `http://localhost:3300/api/packages/bytelyst/npm/`
- **Access Method**: --network host flag in Docker build
- **Why this works**: Allows build container to access host's localhost:3300
- **Alternative approaches** (network bridges, container names) typically fail
### Build Metadata
Deployment scripts automatically inject:
- `BYTELYST_COMMIT_SHA`: Short commit hash
- `BYTELYST_COMMIT_SHA_FULL`: Full commit hash
- `BYTELYST_BRANCH`: Git branch name
- `BYTELYST_BUILT_AT`: ISO timestamp
- `BYTELYST_COMMIT_AUTHOR`: Commit author
- `BYTELYST_COMMIT_MESSAGE`: Commit message (truncated)
- `BYTELYST_DOCKER_IMAGE`: Docker image tag
## Internal DevOps Dashboard
### Dashboard Product
- **Location**: `dashboard/`
- **Purpose**: Internal deployment orchestration and service monitoring
- **Architecture**: Full ByteLyst product (backend + web)
- **Backend**: Fastify 5 (port 4004)
- **Web**: Next.js 16 (port 3000)
- **Integration**: Uses @bytelyst/* packages, links to admin-web
### Dashboard Setup
See `dashboard/README.md` for setup and usage instructions.
## Safety and Operational Guidelines
### Sensitive Files
- `accounts.json` - GitHub account credentials
- `.env` files - Environment configuration
- Generated contributor/output data - Operational snapshots
- **Never echo these files** unless task requires it
### Destructive Operations
- Many scripts are operational and potentially destructive
- Preserve prompts, dry-run behavior, and confirmations
- Review scripts before automation use
- Some encode assumptions about ByteLyst org structure
### Pre-commit Validation
```bash
pre-commit run --all-files
```
## Quick Reference for Common Tasks
| Task | Command |
|------|---------|
| Deploy invttrdg | `./deploy-invttrdg.sh` |
| Force deployment | `./deploy-invttrdg.sh --force` |
| Deploy all products | `./deploy-all.sh` |
| Check repo status | `./git-work-safety-tools/git_repos_status.sh` |
| List user repos | `./bytelyst-cli.sh list-public-repos --user <user>` |
| Remove user interactively | `./remove_user_interactive.sh` |
| Run pre-commit checks | `pre-commit run --all-files` |
## Related Repositories
- **learning_ai_common_plat**: Shared platform packages consumed by products
- **Product repos**: learning_ai_invt_trdg, learning_ai_notes, etc. (deployed via scripts here)
## First Steps for Any Deployment Task
1. **Check deployment scripts first** - look for `deploy-{project}.sh`
2. **Read the script** - understand its flow and requirements
3. **Check GITEA_NPM_TOKEN** - ensure it's set correctly
4. **Verify companion repos** - ensure learning_ai_common_plat is accessible
5. **Run the deployment script** - use appropriate flags (--force, --skip-health-check)
6. **Never build custom deployment** unless script doesn't exist for your use case
## New Deployment Pattern for @bytelyst/* Packages (Learned 2026-05)
### Background
Products that consume @bytelyst/* packages from learning_ai_common_plat require a special build preparation step because:
- Gitea registry is not accessible from Docker build context
- Direct npm install would fail during Docker build
- Dependencies need to be pre-packaged as tarballs
### Required Pre-Build Step
**Products using @bytelyst/* packages MUST have a `scripts/docker-prep.sh` script:**
```bash
# Before building Docker images
bash scripts/docker-prep.sh
# After building Docker images
bash scripts/docker-prep.sh --restore
```
### Dockerfile Changes Required
**Dockerfiles MUST be updated to remove Gitea secret mounting:**
**Before (incorrect):**
```dockerfile
RUN --mount=type=secret,id=gitea_npm_token \
TOKEN=$(cat /run/secrets/gitea_npm_token) && \
echo "//localhost:3300/api/packages/bytelyst/npm/:_authToken=$TOKEN" >> .npmrc && \
pnpm install --ignore-scripts --lockfile=false
```
**After (correct):**
```dockerfile
COPY .docker-deps/ ../.docker-deps/
RUN pnpm install --ignore-scripts --lockfile=false
```
### Environment Variable Configuration
**NEXT_PUBLIC_ variables MUST use public API URLs:**
**Incorrect:**
```yaml
NEXT_PUBLIC_BACKEND_URL: http://backend:4011
```
**Correct:**
```yaml
NEXT_PUBLIC_BACKEND_URL: https://api.bytelyst.com/{product}
```
### Docker Compose Format
**Use YAML mapping syntax, not array syntax:**
**Incorrect:**
```yaml
environment:
- NODE_ENV=production
- PORT:4011
```
**Correct:**
```yaml
environment:
NODE_ENV: production
PORT: 4011
```
### Container Naming
**Container names MUST match Caddyfile configuration:**
```yaml
services:
backend:
container_name: {product}-backend
web:
container_name: {product}-web
```
### DNS Setup
**Add DNS records via GoDaddy API for new subdomains:**
```bash
curl -X PUT "https://api.godaddy.com/v1/domains/bytelyst.com/records/A/{subdomain}" \
-H "Authorization: sso-key $GODADDY_API_KEY:$GODADDY_API_SECRET" \
-H "Content-Type: application/json" \
-d '[{"data": "187.124.159.82", "name": "{subdomain}", "ttl": 600, "type": "A"}]'
```
### Caddy Configuration
**Add subdomain routing to /opt/bytelyst/Caddyfile:**
```caddy
{subdomain}.bytelyst.com {
encode gzip
reverse_proxy {product}-web:{port}
}
```
### Products Requiring This Pattern
- learning_ai_notes (NoteLett)
- learning_ai_clock (ChronoMind)
- learning_ai_invttrdg (Investment Trading)
- Any product consuming @bytelyst/* packages
### Deployment Script Updates
**Deployment scripts should be updated to:**
1. Check for docker-prep.sh existence
2. Run docker-prep.sh before Docker build
3. Run docker-prep.sh --restore after successful deployment
4. Provide option to skip docker-prep if already run
### Health Check Requirements
**Backend Dockerfiles MUST include wget for health checks:**
```dockerfile
RUN apk add --no-cache wget
```
Health check in docker-compose.yml:
```yaml
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:{port}/health"]
interval: 30s
timeout: 5s
retries: 3
```

View File

@ -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

166
dashboard/ENDPOINTS.md Normal file
View File

@ -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`

View File

@ -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

View File

@ -1,40 +1,48 @@
# Stage 1: Build
FROM node:22-alpine AS builder
# Build context: bytelyst-devops-tools/dashboard/ (monorepo root)
# --- Stage 1: Build ---
FROM node:20-alpine AS builder
WORKDIR /app
WORKDIR /app/backend
# Install dependencies
COPY package.json pnpm-lock.yaml* ./
RUN npm install -g pnpm@10.6.5
RUN pnpm install
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --ignore-scripts
# Copy source
COPY package.json tsconfig.json ./
COPY src ./src
COPY backend/tsconfig.json ./
COPY backend/src/ ./src/
# Skip TypeScript build for now
# RUN pnpm add -D typescript && pnpm build
# Build-time env vars (baked into the bundle)
ARG BYTELYST_COMMIT_SHA=unknown
ARG BYTELYST_COMMIT_SHA_FULL=unknown
ARG BYTELYST_BRANCH=unknown
ARG BYTELYST_BUILT_AT=unknown
ARG BYTELYST_COMMIT_AUTHOR=unknown
ARG BYTELYST_COMMIT_MESSAGE=unknown
ARG BYTELYST_DOCKER_IMAGE=devops-backend:latest
# Stage 2: Run
FROM node:22-alpine AS runner
ENV BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA} \
BYTELYST_COMMIT_SHA_FULL=${BYTELYST_COMMIT_SHA_FULL} \
BYTELYST_BRANCH=${BYTELYST_BRANCH} \
BYTELYST_BUILT_AT=${BYTELYST_BUILT_AT} \
BYTELYST_COMMIT_AUTHOR=${BYTELYST_COMMIT_AUTHOR} \
BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \
BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE}
WORKDIR /app
RUN npm run build
# Install dependencies
COPY package.json pnpm-lock.yaml* ./
RUN npm install -g pnpm@10.6.5
RUN pnpm install --prod --ignore-scripts
RUN npm install -g tsx
# --- Stage 2: Run ---
FROM node:20-alpine AS runner
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
RUN apk add --no-cache curl
# Copy source
COPY package.json tsconfig.json ./
COPY src ./src
COPY --from=builder /app/backend/dist ./dist
# Set environment
ENV NODE_ENV=production
ENV PORT=4004
EXPOSE 4004
CMD ["tsx", "src/server.js"]
CMD ["node", "dist/server.js"]

View File

@ -9,29 +9,30 @@
"dev": "node --import tsx src/server.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/backend/src/server.js",
"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",
"@bytelyst/devops": "workspace:*"
"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"

View File

@ -20,7 +20,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const { clientSecret, ...safeConfig } = config;
return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret });
} catch (error) {
fastify.log.error('Failed to get Azure config:', error as any);
fastify.log.error(error, 'Failed to get Azure config');
return reply.code(500).send({ error: 'Failed to get Azure configuration' });
}
});
@ -33,7 +33,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const { clientSecret, ...safeConfig } = config;
return reply.code(201).send({ ...safeConfig, hasClientSecret: true });
} catch (error) {
fastify.log.error('Failed to create Azure config:', error as any);
fastify.log.error(error, 'Failed to create Azure config');
return reply.code(500).send({ error: 'Failed to create Azure configuration' });
}
});
@ -50,7 +50,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const { clientSecret, ...safeConfig } = config;
return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret });
} catch (error) {
fastify.log.error('Failed to update Azure config:', error as any);
fastify.log.error(error, 'Failed to update Azure config');
return reply.code(500).send({ error: 'Failed to update Azure configuration' });
}
});
@ -65,7 +65,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
}
return reply.code(204).send();
} catch (error) {
fastify.log.error('Failed to delete Azure config:', error as any);
fastify.log.error(error, 'Failed to delete Azure config');
return reply.code(500).send({ error: 'Failed to delete Azure configuration' });
}
});
@ -76,7 +76,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const result = await testAzureConnection();
return reply.send(result);
} catch (error) {
fastify.log.error('Failed to test Azure connection:', error as any);
fastify.log.error(error, 'Failed to test Azure connection');
return reply.code(500).send({ success: false, error: 'Failed to test Azure connection' });
}
});

View File

@ -13,7 +13,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
const backup = await createBackup(params);
return reply.code(201).send(backup);
} catch (error) {
fastify.log.error('Backup creation failed:', error);
fastify.log.error(error, 'Backup creation failed');
return reply.code(500).send({ error: 'Failed to create backup' });
}
});
@ -26,7 +26,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
const backups = await getBackups();
return reply.send(backups);
} catch (error) {
fastify.log.error('Failed to get backups:', error);
fastify.log.error(error, 'Failed to get backups');
return reply.code(500).send({ error: 'Failed to get backups' });
}
});
@ -43,7 +43,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
}
return reply.send(backup);
} catch (error) {
fastify.log.error('Failed to get backup:', error);
fastify.log.error(error, 'Failed to get backup');
return reply.code(500).send({ error: 'Failed to get backup' });
}
});
@ -57,7 +57,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
await restoreBackup(id);
return reply.send({ message: 'Backup restored successfully' });
} catch (error: any) {
fastify.log.error('Restore failed:', error);
fastify.log.error(error, 'Restore failed');
return reply.code(500).send({ error: error.message || 'Failed to restore backup' });
}
});
@ -71,7 +71,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
await deleteBackup(id);
return reply.code(204).send();
} catch (error) {
fastify.log.error('Failed to delete backup:', error);
fastify.log.error(error, 'Failed to delete backup');
return reply.code(500).send({ error: 'Failed to delete backup' });
}
});

View File

@ -10,7 +10,7 @@ export async function codeQualityRoutes(fastify: FastifyInstance) {
const report = await runCodeQualityCheck(params);
return reply.send(report);
} catch (error) {
fastify.log.error('Failed to run code quality check:', error as any);
fastify.log.error(error, 'Failed to run code quality check');
return reply.code(500).send({ error: 'Failed to run code quality check' });
}
});

View File

@ -3,6 +3,7 @@ import { z } from 'zod';
import { getCosmosConfig, updateCosmosConfig, deleteCosmosConfig } from './repository.js';
import { reinitializeContainers, getCosmosStatus } from '../../lib/cosmos-init.js';
import { BadRequestError } from '../../lib/auth.js';
import type { UpdateCosmosConfig } from './types.js';
const updateConfigSchema = z.object({
endpoint: z.string().url(),
@ -40,7 +41,7 @@ export async function cosmosConfigRoutes(fastify: FastifyInstance) {
// Update Cosmos configuration
fastify.post('/cosmos-config', async (request: FastifyRequest, reply: FastifyReply) => {
try {
const body = updateConfigSchema.parse(request.body);
const body = updateConfigSchema.parse(request.body) as UpdateCosmosConfig;
// Update the configuration
await updateCosmosConfig(body);

View File

@ -62,7 +62,7 @@ async function runDeploymentScript(service: Service, deploymentId: string) {
}
} catch (error: any) {
const logs = error instanceof Error
? `ERROR: ${error.message}\n\n${error.stdout ? `STDOUT:\n${error.stdout}\n\n` : ''}${error.stderr ? `STDERR:\n${error.stderr}` : ''}`
? `ERROR: ${error.message}\n\n${(error as any).stdout ? `STDOUT:\n${(error as any).stdout}\n\n` : ''}${(error as any).stderr ? `STDERR:\n${(error as any).stderr}` : ''}`
: String(error);
// Update deployment as failed

View File

@ -38,7 +38,8 @@ export async function deploymentRoutes(fastify: FastifyInstance) {
return reply.send(deployment);
});
// Stream deployment logs via SSE
// Get deployment logs (SSE disabled due to Fastify 5 compatibility)
// TODO: Re-enable SSE when fastify-sse-v2 supports Fastify 5
fastify.get('/deployments/:id/logs', async (req, reply) => {
const params = DeploymentParamsSchema.parse(req.params);
const deployment = await getDeploymentById(params.id);
@ -47,52 +48,11 @@ export async function deploymentRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ error: 'Deployment not found' });
}
// Set SSE headers
reply.header('Content-Type', 'text/event-stream');
reply.header('Cache-Control', 'no-cache');
reply.header('Connection', 'keep-alive');
reply.header('X-Accel-Buffering', 'no');
// Send initial logs
reply.sse({ event: 'logs', data: deployment.logs });
// Poll for updates if deployment is still running
if (deployment.status === 'running') {
const pollInterval = setInterval(async () => {
try {
const updatedDeployment = await getDeploymentById(params.id);
if (!updatedDeployment) {
clearInterval(pollInterval);
reply.sse({ event: 'error', data: 'Deployment not found' });
reply.raw.end();
return;
}
// Send updated logs
reply.sse({ event: 'logs', data: updatedDeployment.logs });
// Check if deployment completed
if (updatedDeployment.status !== 'running') {
clearInterval(pollInterval);
reply.sse({ event: 'complete', data: updatedDeployment.status });
reply.raw.end();
}
} catch (error) {
clearInterval(pollInterval);
reply.sse({ event: 'error', data: 'Failed to fetch deployment updates' });
reply.raw.end();
}
}, 1000); // Poll every second
// Clean up on connection close
req.raw.on('close', () => {
clearInterval(pollInterval);
});
} else {
// Deployment already completed
reply.sse({ event: 'complete', data: deployment.status });
reply.raw.end();
}
// Return logs as JSON
return reply.send({
logs: deployment.logs,
status: deployment.status,
});
});
// Trigger deployment (admin only)

View File

@ -1,26 +1,57 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createService, getService, 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('getService', () => {
it('should retrieve a service by id', async () => {
const service = await getService('test-service');
describe('getServiceById', () => {
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 getService('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 getService('test-service');
expect(service).toBeNull();
it('returns false when deleting a missing service', async () => {
await expect(deleteService('missing')).resolves.toBe(false);
});
});
});

View File

@ -12,7 +12,7 @@ export async function systemRoutes(fastify: FastifyInstance) {
const metrics = await getSystemMetrics();
return reply.send(metrics);
} catch (error) {
fastify.log.error('Failed to get system metrics:', error);
fastify.log.error(error, 'Failed to get system metrics');
return reply.code(500).send({ error: 'Failed to get system metrics' });
}
});
@ -25,7 +25,7 @@ export async function systemRoutes(fastify: FastifyInstance) {
const stats = await getDockerStats();
return reply.send(stats);
} catch (error) {
fastify.log.error('Failed to get Docker stats:', error);
fastify.log.error(error, 'Failed to get Docker stats');
return reply.code(500).send({ error: 'Failed to get Docker stats' });
}
});
@ -39,7 +39,7 @@ export async function systemRoutes(fastify: FastifyInstance) {
const result = await dockerCleanup(params.type, params.force);
return reply.send(result);
} catch (error: any) {
fastify.log.error('Docker cleanup failed:', error);
fastify.log.error(error, 'Docker cleanup failed');
return reply.code(500).send({ error: error.message || 'Docker cleanup failed' });
}
});

View File

@ -1,9 +1,8 @@
import Fastify from 'fastify';
import { config } from './lib/config.js';
import { initializeContainers } from './lib/cosmos-init.js';
import { extractAuth, AuthError } from './lib/auth.js';
import { extractAuth, AuthError, requireAdmin } from './lib/auth.js';
import { generateCsrfToken, validateCsrfToken, getSessionId } from './lib/csrf.js';
import { collectDevopsInfo, getBuildInfo, httpDependencyCheck, readServiceVersion } from '@bytelyst/devops/server';
import { serviceRoutes } from './modules/services/routes.js';
import { deploymentRoutes } from './modules/deployments/routes.js';
import { healthRoutes } from './modules/health/routes.js';
@ -14,7 +13,7 @@ import { envRoutes } from './modules/env/routes.js';
import { azureConfigRoutes } from './modules/azure-config/routes.js';
import { codeQualityRoutes } from './modules/code-quality/routes.js';
import { cosmosConfigRoutes } from './modules/cosmos-config/routes.js';
import sse from 'fastify-sse-v2';
// import sse from 'fastify-sse-v2';
import rateLimit from '@fastify/rate-limit';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
@ -24,7 +23,8 @@ const fastify = Fastify({
});
// Register SSE plugin
await fastify.register(sse);
// TODO: fastify-sse-v2 has compatibility issues with Fastify 5
// await fastify.register(sse);
// Register rate limiting
await fastify.register(rateLimit, {
@ -191,14 +191,6 @@ fastify.options('*', async (request, reply) => {
// Health check
fastify.get('/health', async () => ({ status: 'ok', service: 'devops-backend' }));
// Admin check helper
async function requireAdmin(request: any) {
const role = request.authRole;
if (role !== 'admin') {
throw new Error('Admin access required');
}
}
// Register standalone routes with /api prefix
await fastify.register(async function (fastify) {
// Performance metrics endpoint (admin only) - DEPRECATED: Use /api/system/metrics instead
@ -210,7 +202,7 @@ await fastify.register(async function (fastify) {
const metrics = await getSystemMetrics();
return reply.send(metrics);
} catch (error) {
fastify.log.error('Failed to get metrics:', error);
fastify.log.error(error, 'Failed to get metrics');
return reply.code(500).send({ error: 'Failed to get metrics' });
}
});
@ -264,34 +256,6 @@ await fastify.register(async function (fastify) {
return reply.send({ message: 'Seeded default services' });
});
// DevOps version endpoint (public - no auth required)
fastify.get('/devops/version', async (request, reply) => {
return reply.send(getBuildInfo());
});
// DevOps info endpoint (admin only)
fastify.get('/devops/info', {
preHandler: async (req) => requireAdmin(req),
}, async (request, reply) => {
try {
const info = await collectDevopsInfo({
productId: config.PRODUCT_ID || 'devops',
serviceName: 'devops-backend',
serviceVersion: readServiceVersion(import.meta.url),
dependencyChecks: [
() => httpDependencyCheck('platform-service', `${config.PLATFORM_URL}/health`),
],
extra: {
devopsApiUrl: config.DEVOPS_API_URL,
},
});
return reply.send(info);
} catch (error: any) {
fastify.log.error('Failed to collect devops info:', error);
return reply.code(500).send({ error: error.message });
}
});
}, { prefix: '/api' });
// Register modular routes with /api prefix
@ -314,7 +278,7 @@ async function start() {
await initializeContainers();
fastify.log.info('Cosmos containers initialized successfully');
} catch (err) {
fastify.log.warn('Failed to initialize Cosmos containers (server will start anyway):', err);
fastify.log.warn(err, 'Failed to initialize Cosmos containers (server will start anyway)');
}
await fastify.listen({ port: parseInt(config.PORT), host: '0.0.0.0' });

View File

@ -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"]
}

View File

@ -29,7 +29,7 @@ services:
- platform_net
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:4004/health']
test: ['CMD', 'curl', '-f', 'http://localhost:4004/health']
interval: 30s
timeout: 5s
retries: 3

View File

@ -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"

View File

@ -2,22 +2,16 @@
# --- Stage 1: Build ---
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
WORKDIR /app/web
# Gitea npm registry for @bytelyst/* packages
COPY web/package.json ./package.json
COPY web/package.json web/package-lock.json ./
RUN npm ci --ignore-scripts
RUN --mount=type=secret,id=gitea_npm_token \
TOKEN=$(cat /run/secrets/gitea_npm_token) && \
printf '@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/\n//localhost:3300/api/packages/bytelyst/npm/:_authToken=%s\n' "$TOKEN" > .npmrc && \
npm install --ignore-scripts --legacy-peer-deps
COPY web/tsconfig*.json ./
COPY web/tsconfig.json ./
COPY web/next-env.d.ts ./
COPY web/next.config.js ./
COPY web/tailwind.config.ts ./tailwind.config.ts
COPY web/postcss.config.js ./postcss.config.js
COPY web/tailwind.config.ts ./
COPY web/postcss.config.js ./
COPY web/src/ ./src/
COPY web/public/ ./public/
@ -46,17 +40,15 @@ ENV NEXT_PUBLIC_PRODUCT_ID=${NEXT_PUBLIC_PRODUCT_ID} \
NEXT_PUBLIC_BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \
NEXT_PUBLIC_BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE}
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
RUN npm run build
# --- Stage 2: Serve ---
FROM node:20-alpine AS runner
WORKDIR /app/web
COPY --from=builder /app/web/package.json ./package.json
COPY --from=builder /app/web/pnpm-lock.yaml* ./pnpm-lock.yaml*
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
RUN pnpm install --prod --ignore-scripts
COPY web/package.json web/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --from=builder /app/web/.next ./.next
COPY --from=builder /app/web/public ./public
@ -66,4 +58,4 @@ ENV PORT=3000
EXPOSE 3000
CMD ["pnpm", "start"]
CMD ["npm", "run", "start"]

View File

@ -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');
});

View File

@ -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();
});
});

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View File

@ -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;

View File

@ -5,17 +5,17 @@
"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"
},
"dependencies": {
"@bytelyst/devops": "^0.1.3",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next": "16.0.0",
@ -32,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",

View File

@ -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,
},
});

View File

@ -1,85 +0,0 @@
'use client';
import { DevopsPanel, type DevopsInfo } from '@bytelyst/devops/ui';
import { devopsApiUrl } from '@/lib/product-config';
import { getAccessToken } from '@/lib/api';
const bundleStartTime = Date.now();
async function fetchBackendInfo(): Promise<DevopsInfo> {
const token = await getAccessToken();
const res = await fetch(`${devopsApiUrl}/api/devops/info`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body?.error ?? `Backend devops info failed (${res.status})`);
}
return (await res.json()) as DevopsInfo;
}
async function fetchWebInfo(): Promise<DevopsInfo> {
const env = process.env as Record<string, string | undefined>;
const builtAt = env.NEXT_PUBLIC_BYTELYST_BUILT_AT || null;
const startedAtMs = bundleStartTime;
const uptimeSec = Math.floor((Date.now() - startedAtMs) / 1000);
return {
build: {
commitSha: env.NEXT_PUBLIC_BYTELYST_COMMIT_SHA || null,
commitShaFull: env.NEXT_PUBLIC_BYTELYST_COMMIT_SHA_FULL || null,
branch: env.NEXT_PUBLIC_BYTELYST_BRANCH || null,
builtAt,
commitAuthor: env.NEXT_PUBLIC_BYTELYST_COMMIT_AUTHOR || null,
commitMessage: env.NEXT_PUBLIC_BYTELYST_COMMIT_MESSAGE || null,
dockerImage: env.NEXT_PUBLIC_BYTELYST_DOCKER_IMAGE || null,
},
runtime: {
uptimeSeconds: uptimeSec,
uptimeHuman: humanizeUptime(uptimeSec),
nodeVersion: 'browser',
platform: typeof window !== 'undefined' ? navigator.platform || 'unknown' : 'unknown',
arch: typeof window !== 'undefined' && navigator.userAgent.includes('arm') ? 'arm' : 'x86',
pid: 0,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'unknown',
memoryMb: Math.round(((performance as any)?.memory?.usedJSHeapSize ?? 0) / 1024 / 1024),
heapMb: Math.round(((performance as any)?.memory?.usedJSHeapSize ?? 0) / 1024 / 1024),
startedAt: new Date(startedAtMs).toISOString(),
},
config: {
productId: env.NEXT_PUBLIC_PRODUCT_ID || 'devops',
serviceName: 'devops-web',
serviceVersion: '1.0.0',
nodeEnv: env.NODE_ENV || 'production',
envKeys: Object.keys(env)
.filter((k) => /^NEXT_PUBLIC_/.test(k) && !/SECRET|KEY|TOKEN|PASSWORD/i.test(k))
.sort(),
},
extra: {
devopsApiUrl,
userAgent: typeof window !== 'undefined' ? navigator.userAgent : 'unknown',
},
};
}
function humanizeUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
if (mins < 60) return `${mins}m ${seconds % 60}s`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
const days = Math.floor(hrs / 24);
return `${days}d ${hrs % 24}h ${mins % 60}m`;
}
export default function DevOpsPage() {
return (
<div className="p-8 max-md:p-4">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">DevOps</h1>
<p className="text-sm text-gray-600">System information and deployment details</p>
</div>
<DevopsPanel fetchInfo={fetchBackendInfo} fetchWebInfo={fetchWebInfo} />
</div>
);
}

View File

@ -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 (
<HermesShell
title="Agent & Tool Observability"
description="Status board for Hermes core, integrations, runners, scheduler, and notification tooling."
actions={<Button asChild><Link href="/hermes"><ArrowLeft className="mr-2 h-4 w-4" />Back to mission control</Link></Button>}
>
<section className="grid gap-4 md:grid-cols-3">
<MetricCard label="Healthy" value={healthy} tone="success" icon={<Gauge className="h-5 w-5" />} />
<MetricCard label="Degraded" value={degraded} tone="warning" icon={<ShieldAlert className="h-5 w-5" />} />
<MetricCard label="Offline" value={offline} tone="danger" icon={<ServerCog className="h-5 w-5" />} />
</section>
<SectionCard title="Tool and integration health" subtitle="Each item includes last success, failure rate, latency, and config warnings.">
<div className="grid gap-4 lg:grid-cols-2">
{agents.map((agent) => (
<div key={agent.id} className="rounded-3xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-lg font-semibold text-[var(--bl-text-primary)]">{agent.name}</p>
<p className="text-sm text-[var(--bl-text-secondary)]">{agent.type} · {agent.callsToday} calls today</p>
</div>
<Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'danger'}>{agent.status}</Badge>
</div>
<div className="mt-4 grid gap-3 text-sm text-[var(--bl-text-secondary)] md:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">Last success: {agent.lastSuccessAt ? new Date(agent.lastSuccessAt).toLocaleString() : '—'}</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">Last failure: {agent.lastFailureAt ? new Date(agent.lastFailureAt).toLocaleString() : '—'}</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">Failure rate: {(agent.failureRate * 100).toFixed(1)}%</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">Latency: {agent.averageLatencyMs ?? '—'}ms</div>
</div>
{agent.configIssue ? <div className="mt-3 rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-warning-muted)] p-3 text-sm text-[var(--bl-warning)]">{agent.configIssue}</div> : null}
</div>
))}
</div>
</SectionCard>
<SectionCard title="Ecosystem coverage" subtitle="The dashboard should make each subsystem accountable and observable.">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{['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) => (
<div key={item} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 text-sm text-[var(--bl-text-secondary)]">{item}</div>
))}
</div>
</SectionCard>
</HermesShell>
);
}

View File

@ -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 (
<HermesShell
title="Historical Activity"
description="Trendlines and summary analytics for completed, failed, blocked, and active work over time."
actions={<Button asChild><Link href="/hermes"><ArrowLeft className="mr-2 h-4 w-4" />Back to mission control</Link></Button>}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Completed over window" value={weeklyCompleted} tone="success" icon={<TrendingUp className="h-5 w-5" />} />
<MetricCard label="Failed over window" value={weeklyFailed} tone="danger" icon={<TrendingDown className="h-5 w-5" />} />
<MetricCard label="Blocked over window" value={blocked} tone="warning" icon={<Flame className="h-5 w-5" />} />
<MetricCard label="Avg task duration" value={`${avgDuration}m`} tone="info" icon={<Clock3 className="h-5 w-5" />} />
</section>
<SectionCard title="Weekly activity chart" subtitle="Accessible bar chart built with standard layout primitives.">
<div className="overflow-x-auto">
<div className="flex min-w-[48rem] items-end gap-4 rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-5">
{history.map((point) => (
<div key={point.label} className="flex flex-1 flex-col items-center gap-2">
<div className="flex h-64 w-full items-end gap-1">
<div className="w-1/3 rounded-t-md bg-[var(--bl-success)]" style={{ height: `${(point.completed / maxValue) * 100}%` }} />
<div className="w-1/3 rounded-t-md bg-[var(--bl-danger)]" style={{ height: `${(point.failed / maxValue) * 100}%` }} />
<div className="w-1/3 rounded-t-md bg-[var(--bl-warning)]" style={{ height: `${(point.blocked / maxValue) * 100}%` }} />
</div>
<p className="text-xs font-medium text-[var(--bl-text-secondary)]">{point.label}</p>
<Badge variant="neutral">{point.active} active</Badge>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-sm text-[var(--bl-text-secondary)]">
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded-full bg-[var(--bl-success)]" />Completed</span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded-full bg-[var(--bl-danger)]" />Failed</span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded-full bg-[var(--bl-warning)]" />Blocked</span>
</div>
</div>
</SectionCard>
<div className="grid gap-6 lg:grid-cols-2">
<SectionCard title="Failure categories" subtitle="What keeps showing up in the retry and incident queues.">
<div className="space-y-3">
{failureReasons.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-[var(--bl-text-primary)]">{label}</span>
<span className="text-[var(--bl-text-secondary)]">{value}</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[var(--bl-surface-card)]">
<div className="h-2 rounded-full bg-[var(--bl-accent)]" style={{ width: `${(value / 12) * 100}%` }} />
</div>
</div>
))}
</div>
</SectionCard>
<SectionCard title="Weekly summary" subtitle="Founder-friendly rollup of the operational trendline.">
<div className="space-y-3 text-sm text-[var(--bl-text-secondary)]">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">Most active products are cycling through deploy and audit work.</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">Neglected products are flagged when activity falls past the 14-day threshold.</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">Average task duration is trending stable, but failure bursts still concentrate in CI-heavy work.</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">Recommended next action: attack the repeated failure cluster and clear the highest-priority blocked item.</div>
</div>
</SectionCard>
</div>
</HermesShell>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { SidebarNav } from '@/components/sidebar-nav';
export default function HermesLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-[var(--bl-bg-canvas)] text-[var(--bl-text-primary)]">
<SidebarNav />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-4 lg:p-8">{children}</div>
</main>
</div>
);
}

View File

@ -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<string, 'success' | 'warning' | 'danger' | 'info' | 'default'> = {
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 (
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium text-[var(--bl-text-primary)]">{product.name}</p>
<p className="text-xs text-[var(--bl-text-secondary)]">{product.category} · {product.priority}</p>
</div>
<Badge variant={product.needsAttention ? 'warning' : 'success'}>{product.needsAttention ? 'Attention' : 'Healthy'}</Badge>
</div>
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-xs text-[var(--bl-text-secondary)]">
<span>Health</span>
<span>{product.healthScore}/100</span>
</div>
<div className="h-2 rounded-full bg-[var(--bl-surface-muted)]">
<div className={`h-2 rounded-full ${healthColor}`} style={{ width: `${product.healthScore}%` }} />
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{product.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="neutral">{tag}</Badge>
))}
</div>
</div>
);
}
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 (
<HermesShell
title="Hermes Mission Control"
description="A production-style command center for tracking what Hermes is doing now, what it already shipped, what is blocked, and what needs founder attention."
actions={(
<>
<Button asChild variant="secondary"><Link href="/hermes/tasks"><LayoutDashboard className="mr-2 h-4 w-4" />Task Ledger</Link></Button>
<Button asChild variant="primary"><Link href="/hermes/products"><Rocket className="mr-2 h-4 w-4" />Product Portfolio</Link></Button>
</>
)}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Hermes status" value={overview.status.toUpperCase()} tone={overview.status === 'error' ? 'danger' : overview.status === 'degraded' ? 'warning' : overview.status === 'running' ? 'success' : 'default'} icon={<Bot className="h-5 w-5" />} helpText={overview.lastAction} />
<MetricCard label="Active tasks" value={overview.activeTasks} tone="info" icon={<Sparkles className="h-5 w-5" />} helpText={`${overview.upcomingJobs} queued jobs waiting to run`} />
<MetricCard label="Completed today" value={overview.completedToday} tone="success" icon={<CheckCircle2 className="h-5 w-5" />} helpText={`${overview.completedThisWeek} completed this week`} />
<MetricCard label="Founder attention" value={overview.founderAttentionCount} tone="warning" icon={<ShieldAlert className="h-5 w-5" />} helpText={overview.nextRecommendedAction} />
<MetricCard label="Failed tasks" value={overview.failedTasks} tone="danger" icon={<TriangleAlert className="h-5 w-5" />} helpText="Failure clusters are being tracked in the task ledger" />
<MetricCard label="Blocked tasks" value={overview.blockedTasks} tone="warning" icon={<OctagonAlert className="h-5 w-5" />} helpText="These items need a human decision or credential fix" />
<MetricCard label="Avg task duration" value={`${Math.round(overview.averageDurationMs / 60000)}m`} tone="info" icon={<Clock3 className="h-5 w-5" />} helpText="Average across completed tasks" />
<MetricCard label="Success rate" value={`${overview.successRate}%`} tone="success" icon={<BadgeCheck className="h-5 w-5" />} helpText={`${overview.productsTouchedRecently} products touched in the last 14 days`} />
</section>
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
<SectionCard title="Active Missions" subtitle="What Hermes is currently running or waiting on." actions={<Button asChild variant="ghost" size="sm"><Link href="/hermes/tasks">View all tasks <ArrowRight className="ml-2 h-4 w-4" /></Link></Button>}>
<div className="space-y-3">
{activeTasks.map((task) => {
const product = hermesProducts.find((item) => item.id === task.productId);
return (
<article key={task.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link>
<Badge variant={getTaskTone(task)}>{taskStatusLabel(task)}</Badge>
<Badge variant="neutral">{task.priority}</Badge>
</div>
<p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{product?.name ?? 'Unknown product'} · {task.assignedAgent} · {task.type}</p>
</div>
<div className="text-right text-xs text-[var(--bl-text-secondary)]">
<p>Started {fmtDate.format(new Date(task.startedAt ?? task.createdAt))}</p>
<p>{task.currentStep ?? task.nextAction}</p>
</div>
</div>
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-xs text-[var(--bl-text-secondary)]">
<span>Progress</span>
<span>{task.progressPercent}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--bl-surface-card)]">
<div className="h-2 rounded-full bg-[var(--bl-accent)]" style={{ width: `${task.progressPercent}%` }} />
</div>
</div>
</article>
);
})}
</div>
</SectionCard>
<SectionCard title="Founder Attention Queue" subtitle="Items Hermes cannot safely complete without your help." actions={<Badge variant="warning">Needs decision</Badge>}>
<div className="space-y-3">
{attentionTasks.slice(0, 5).map((task) => (
<div key={task.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-center justify-between gap-2">
<div>
<Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link>
<p className="text-sm text-[var(--bl-text-secondary)]">{task.blockerReason ?? task.error ?? task.nextAction}</p>
</div>
<Badge variant={task.status === 'failed' ? 'danger' : 'warning'}>{task.status}</Badge>
</div>
</div>
))}
{actionableProducts.slice(0, 2).map((product) => (
<div key={product.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-medium text-[var(--bl-text-primary)]">{product.name}</p>
<p className="text-sm text-[var(--bl-text-secondary)]">{product.description}</p>
</div>
<Badge variant="warning">Product attention</Badge>
</div>
</div>
))}
</div>
</SectionCard>
</div>
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<SectionCard title="What Hermes did for me" subtitle="Operational summary of recent work." actions={<Badge variant="success">Evidence-backed</Badge>}>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Today</p>
<p className="mt-2 text-2xl font-semibold">{completedToday.length}</p>
<p className="text-sm text-[var(--bl-text-secondary)]">Tasks completed or closed today.</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">This week</p>
<p className="mt-2 text-2xl font-semibold">{completedThisWeek.length}</p>
<p className="text-sm text-[var(--bl-text-secondary)]">Shipped, repaired, or documented this week.</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Last 30 days</p>
<p className="mt-2 text-2xl font-semibold">{hermesTasks.length}</p>
<p className="text-sm text-[var(--bl-text-secondary)]">Tracked execution events across the portfolio.</p>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
{[
'Fixed bugs and failure loops',
'Created PRs and commit-ready changes',
'Deployed services and validated health',
'Updated docs and audit summaries',
].map((item) => (
<div key={item} className="flex items-center gap-3 rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3 text-sm text-[var(--bl-text-secondary)]">
<CheckCircle2 className="h-4 w-4 text-[var(--bl-success)]" />
{item}
</div>
))}
</div>
</SectionCard>
<SectionCard title="Next Best Actions" subtitle="Split between automation and founder decisions." actions={<Badge variant="info">Prioritized</Badge>}>
<div className="space-y-4">
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Hermes can do automatically</p>
<div className="space-y-2">
{autoActions.map((item) => (
<div key={item} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3 text-sm text-[var(--bl-text-secondary)]">{item}</div>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Needs Saravana's decision</p>
<div className="space-y-2">
{founderActions.map((item) => (
<div key={item} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3 text-sm text-[var(--bl-text-secondary)]">{item}</div>
))}
</div>
</div>
</div>
</SectionCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<SectionCard title="Product Health Snapshot" subtitle="50-product portfolio view with recent activity and attention flags." actions={<Button asChild variant="ghost" size="sm"><Link href="/hermes/products">Open portfolio <ArrowRight className="ml-2 h-4 w-4" /></Link></Button>}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{recentProducts.map((product) => (
<ProductMiniCard key={product.id} product={product} />
))}
</div>
</SectionCard>
<SectionCard title="Ecosystem Health" subtitle="Core agents and integrations are scored with recent status." actions={<Badge variant="neutral">Telemetry placeholder</Badge>}>
<div className="space-y-3">
{agentStatuses.map((agent) => (
<div key={agent.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-[var(--bl-text-primary)]">{agent.name}</p>
<p className="text-sm text-[var(--bl-text-secondary)]">{agent.type} · {agent.callsToday} calls today</p>
{agent.configIssue ? <p className="mt-1 text-sm text-[var(--bl-warning)]">{agent.configIssue}</p> : null}
</div>
<Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'danger'}>{agent.status}</Badge>
</div>
</div>
))}
</div>
</SectionCard>
</div>
<SectionCard title="Weekly digest" subtitle="A founder-friendly summary of the current operational week.">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Shipped this week</p>
<p className="mt-2 text-2xl font-semibold">{completedThisWeek.length}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Failed this week</p>
<p className="mt-2 text-2xl font-semibold">{failedTasks.filter((task) => task.completedAt ? new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000 : true).length}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Repeated failure products</p>
<p className="mt-2 text-2xl font-semibold">{repeatedFailures.length}</p>
</div>
</div>
</SectionCard>
</HermesShell>
);
}

View File

@ -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 (
<div className="rounded-3xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-5 shadow-[var(--bl-shadow-sm)]">
<div className="flex items-start justify-between gap-3">
<div>
<Link href={`/hermes/tasks?productId=${product.id}`} className="font-semibold text-[var(--bl-text-primary)] hover:underline">{product.name}</Link>
<p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{product.category} · {product.owner}</p>
</div>
<Badge variant={product.needsAttention ? 'warning' : 'success'}>{product.status}</Badge>
</div>
<p className="mt-3 text-sm text-[var(--bl-text-secondary)]">{product.description}</p>
<div className="mt-4 flex flex-wrap gap-2">
{product.tags.map((tag) => <Badge key={tag} variant="neutral">{tag}</Badge>)}
</div>
<div className="mt-4 space-y-3 text-sm text-[var(--bl-text-secondary)]">
<div className="flex items-center justify-between"><span>Health score</span><span className="font-medium text-[var(--bl-text-primary)]">{product.healthScore}</span></div>
<div className="h-2 rounded-full bg-[var(--bl-surface-muted)]"><div className="h-2 rounded-full bg-[var(--bl-accent)]" style={{ width: `${product.healthScore}%` }} /></div>
<div className="flex items-center justify-between"><span>Active tasks</span><span>{activeTasks}</span></div>
<div className="flex items-center justify-between"><span>Failed tasks</span><span>{failedTasks}</span></div>
<div className="flex items-center justify-between"><span>Last activity</span><span>{product.lastHermesActivityAt ? new Date(product.lastHermesActivityAt).toLocaleDateString() : '—'}</span></div>
</div>
</div>
);
}
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 (
<HermesShell
title="Product Portfolio"
description="Portfolio view for all 50+ products, apps, services, and internal tools with health, recent activity, attention flags, and view filters."
actions={<Button asChild><Link href="/hermes"><Orbit className="mr-2 h-4 w-4" />Back to mission control</Link></Button>}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="All products" value={hermesProducts.length} tone="info" icon={<Sparkles className="h-5 w-5" />} />
<MetricCard label="High priority" value={highPriorityCount} tone="warning" icon={<ShieldAlert className="h-5 w-5" />} />
<MetricCard label="Needs attention" value={attentionCount} tone="danger" icon={<TrendingUp className="h-5 w-5" />} />
<MetricCard label="Recently active" value={recentCount} tone="success" icon={<Rocket className="h-5 w-5" />} />
</section>
<SectionCard title="Portfolio filters" subtitle="Use the view chips to focus on risk, momentum, or recovery work.">
<div className="grid gap-3 lg:grid-cols-[1fr_auto]">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search products..." aria-label="Search products" />
<div className="flex flex-wrap gap-2">
{views.map((item) => (
<Button key={item.key} variant={view === item.key ? 'primary' : 'secondary'} size="sm" onClick={() => setView(item.key)}>{item.label}</Button>
))}
</div>
</div>
<div className="mt-3 flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]"><Filter className="h-4 w-4" />{products.length} products match the current filters.</div>
</SectionCard>
<SectionCard title="Product cards" subtitle="Each card shows the current health signal and the next thing Hermes should do.">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{products.map((product) => <ProductCard key={product.id} product={product} />)}
{products.length === 0 ? <p className="text-sm text-[var(--bl-text-secondary)]">No products matched the current filters.</p> : null}
</div>
</SectionCard>
<SectionCard title="Recently shipped and repeated failures" subtitle="Useful slices for founder review and weekly prioritization.">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Recently shipped</p>
<div className="mt-3 space-y-2">
{getHermesProducts('recently-shipped').slice(0, 5).map((product) => <div key={product.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3 text-sm">{product.name}</div>)}
</div>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Repeated failures</p>
<div className="mt-3 space-y-2">
{getHermesProducts('repeated-failures').slice(0, 5).map((product) => <div key={product.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3 text-sm">{product.name}</div>)}
</div>
</div>
</div>
</SectionCard>
</HermesShell>
);
}

View File

@ -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 (
<HermesShell
title="Settings & Configuration"
description="Editable-looking control panels for registry data, policy knobs, notification rules, and import/export workflow."
actions={<Button variant="secondary" onClick={exportSettings}><Download className="mr-2 h-4 w-4" />Export JSON</Button>}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Demo mode" value={settings.demoMode ? 'Enabled' : 'Off'} tone={settings.demoMode ? 'warning' : 'success'} icon={<ToggleLeft className="h-5 w-5" />} />
<MetricCard label="Retention" value={`${settings.retentionDays} days`} tone="info" icon={<ShieldCheck className="h-5 w-5" />} />
<MetricCard label="Approval threshold" value={settings.approvalThreshold} tone="default" icon={<ShieldCheck className="h-5 w-5" />} />
<MetricCard label="Auto-retry limit" value={settings.autoRetryLimit} tone="default" icon={<ShieldCheck className="h-5 w-5" />} />
</section>
<div className="grid gap-6 xl:grid-cols-2">
<SectionCard title="Products registry" subtitle="A mockable registry of products, categories, and policy settings.">
<div className="space-y-3">
{settings.registry.map((item) => (
<div key={item.id} className="flex items-center justify-between rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 text-sm">
<div>
<p className="font-medium text-[var(--bl-text-primary)]">{item.name}</p>
<p className="text-[var(--bl-text-secondary)]">{item.id}</p>
</div>
<Badge variant={item.enabled ? 'success' : 'neutral'}>{item.enabled ? 'Enabled' : 'Disabled'}</Badge>
</div>
))}
</div>
</SectionCard>
<SectionCard title="Notification rules" subtitle="Editable-looking policy rules for alerts and founder pings.">
<div className="space-y-3">
{settings.notificationRules.map((rule) => (
<div key={rule.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 text-sm">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-medium text-[var(--bl-text-primary)]">{rule.label}</p>
<p className="text-[var(--bl-text-secondary)]">Target: {rule.target}</p>
</div>
<Badge variant={rule.enabled ? 'success' : 'neutral'}>{rule.enabled ? 'On' : 'Off'}</Badge>
</div>
</div>
))}
</div>
</SectionCard>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<SectionCard title="Priority rules" subtitle="How Hermes should think about P0 to P3 work.">
<div className="space-y-3">
{settings.priorityRules.map((rule) => (
<div key={rule.priority} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="font-medium text-[var(--bl-text-primary)]">{rule.priority}</p>
<p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{rule.rule}</p>
</div>
))}
</div>
</SectionCard>
<SectionCard title="Import / export & demo data" subtitle="Mock controls for the future live configuration path.">
<div className="space-y-3 text-sm text-[var(--bl-text-secondary)]">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">Import JSON configuration from a future API, local file, or telemetry bootstrap.</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">Export the current settings snapshot to seed another environment.</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">Demo/mock toggle is enabled so the dashboard remains safe without backend persistence.</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<Button variant="secondary" onClick={exportSettings}><Download className="mr-2 h-4 w-4" />Export JSON</Button>
<Button variant="secondary"><Upload className="mr-2 h-4 w-4" />Import JSON</Button>
</div>
</SectionCard>
</div>
</HermesShell>
);
}

View File

@ -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 (
<HermesShell
title="Task not found"
description={`No Hermes task matched the id ${params.id}.`}
actions={<Button asChild variant="secondary"><Link href="/hermes/tasks"><ArrowLeft className="mr-2 h-4 w-4" />Back to task ledger</Link></Button>}
>
<SectionCard title="Missing task" subtitle="The mock service did not contain a matching record.">
<p className="text-sm text-[var(--bl-text-secondary)]">Check the task id or return to the ledger and select another item.</p>
</SectionCard>
</HermesShell>
);
}
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 (
<HermesShell
title={task.title}
description={task.description}
actions={<Button asChild variant="secondary"><Link href="/hermes/tasks"><ArrowLeft className="mr-2 h-4 w-4" />Back to task ledger</Link></Button>}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Status" value={task.status.toUpperCase()} tone={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'danger' : task.status === 'blocked' ? 'warning' : 'info'} icon={<CircleDashed className="h-5 w-5" />} helpText={task.currentStep ?? 'Awaiting next step'} />
<MetricCard label="Priority" value={task.priority} tone={task.priority === 'P0' ? 'danger' : task.priority === 'P1' ? 'warning' : 'default'} icon={<ShieldAlert className="h-5 w-5" />} helpText={task.type} />
<MetricCard label="Duration" value={task.durationMs ? `${Math.round(task.durationMs / 60000)}m` : '—'} tone="info" icon={<Clock3 className="h-5 w-5" />} helpText={task.retryCount ? `${task.retryCount} retries` : 'No retries recorded'} />
<MetricCard label="Product" value={product?.name ?? 'Unknown'} tone="default" icon={<Sparkles className="h-5 w-5" />} helpText={product?.category ?? 'No product metadata'} />
</section>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<SectionCard title="Summary" subtitle="Everything Hermes knows about this task in one place.">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'danger' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge>
<Badge variant="neutral">{task.source}</Badge>
</div>
<div className="space-y-2 text-sm text-[var(--bl-text-secondary)]">
<p><span className="text-[var(--bl-text-tertiary)]">Product:</span> {product?.name ?? 'Unknown'}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Assigned agent:</span> {task.assignedAgent}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Current step:</span> {task.currentStep ?? 'n/a'}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Result:</span> {task.result ?? 'n/a'}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Blocker:</span> {task.blockerReason ?? 'n/a'}</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Execution details</p>
<div className="space-y-2 text-sm text-[var(--bl-text-secondary)]">
<p><span className="text-[var(--bl-text-tertiary)]">Created:</span> {fmt.format(new Date(task.createdAt))}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Started:</span> {task.startedAt ? fmt.format(new Date(task.startedAt)) : '—'}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Completed:</span> {task.completedAt ? fmt.format(new Date(task.completedAt)) : '—'}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Last action:</span> {task.lastAction ?? 'n/a'}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Next action:</span> {task.nextAction ?? 'n/a'}</p>
</div>
<div className="flex flex-wrap gap-2">
{task.tags.map((tag) => <Badge key={tag} variant="neutral">{tag}</Badge>)}
</div>
</div>
</div>
</SectionCard>
<SectionCard title="Hermes learning" subtitle="A place to capture the memory and prevention pattern for next time.">
<div className="space-y-3 text-sm text-[var(--bl-text-secondary)]">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Lesson learned</p>
<p className="mt-2">{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.'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Suggested memory update</p>
<p className="mt-2">{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.'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Prevention for next time</p>
<p className="mt-2">{task.nextAction ?? 'Keep telemetry wired into the dashboard for follow-up visibility.'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Recurring issue detection</p>
<p className="mt-2">{task.retryCount > 0 ? 'Multiple retries detected; this lane should be watched for recurrence.' : 'No recurring pattern detected for this task.'}</p>
</div>
</div>
</SectionCard>
</div>
<SectionCard title="Timeline" subtitle="Chronological event stream for the task lifecycle.">
<ol className="space-y-4">
{timeline.map((event) => (
<li key={event.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={levelTone(event.level)}>{event.eventType}</Badge>
<span className="text-sm font-medium text-[var(--bl-text-primary)]">{event.message}</span>
</div>
{event.command ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Command:</span> {event.command}</p> : null}
{event.toolName ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Tool:</span> {event.toolName}</p> : null}
{event.artifactUrl ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Artifact:</span> {event.artifactUrl}</p> : null}
</div>
<p className="text-xs text-[var(--bl-text-tertiary)]">{fmt.format(new Date(event.timestamp))}</p>
</div>
</li>
))}
</ol>
</SectionCard>
<div className="grid gap-6 lg:grid-cols-2">
<SectionCard title="Commands executed" subtitle="Execution evidence captured by Hermes.">
<div className="space-y-3">
{events.filter((event) => event.command || event.toolName).map((event) => (
<div key={event.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 text-sm text-[var(--bl-text-secondary)]">
<p className="font-medium text-[var(--bl-text-primary)]">{event.message}</p>
<p className="mt-2 font-mono text-xs">{event.command ?? event.toolName ?? 'No command captured'}</p>
</div>
))}
{events.every((event) => !event.command && !event.toolName) ? <p className="text-sm text-[var(--bl-text-secondary)]">No command logs were captured for this task.</p> : null}
</div>
</SectionCard>
<SectionCard title="File and branch context" subtitle="Code and Git artifacts associated with the run.">
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Git branch</p>
<p className="mt-2 font-medium">hermes/{task.id}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Commit SHA</p>
<p className="mt-2 font-medium">{task.completedAt ? task.id.replace('task', 'commit').slice(0, 16) : 'pending'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">PR URL</p>
<p className="mt-2 text-sm text-[var(--bl-text-secondary)]">{task.status === 'completed' ? `https://github.com/bytelyst/hermes/pull/${task.id.replace('task-', '')}` : 'Not created yet'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Deployment URL</p>
<p className="mt-2 text-sm text-[var(--bl-text-secondary)]">{product?.productionUrl ?? 'Not deployed yet'}</p>
</div>
</div>
</SectionCard>
</div>
</HermesShell>
);
}

View File

@ -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<HermesTaskStatus | 'all'> = ['all', 'queued', 'running', 'blocked', 'completed', 'failed', 'skipped', 'cancelled'];
const priorities: Array<HermesPriority | 'all'> = ['all', 'P0', 'P1', 'P2', 'P3'];
const taskTypes: Array<HermesTaskType | 'all'> = ['all', 'build', 'deploy', 'bugfix', 'monitoring', 'audit', 'refactor', 'documentation', 'research', 'security', 'cost-optimization', 'release', 'maintenance', 'product-planning'];
const sources: Array<HermesTaskSource | 'all'> = ['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<HermesTaskStatus | 'all'>('all');
const [productId, setProductId] = useState<string | 'all'>('all');
const [priority, setPriority] = useState<HermesPriority | 'all'>('all');
const [type, setType] = useState<HermesTaskType | 'all'>('all');
const [source, setSource] = useState<HermesTaskSource | 'all'>('all');
const [updatedWithinDays, setUpdatedWithinDays] = useState<number | 'all'>('all');
const [sort, setSort] = useState<(typeof sortOptions)[number]>('newest');
const [page, setPage] = useState(1);
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(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 (
<HermesShell
title="Task Ledger"
description="Searchable, filterable execution ledger for everything Hermes is doing or has done. Use this view to inspect the work queue, failure clusters, and next actions."
actions={(
<>
<Button variant="secondary" onClick={() => exportTasks(tasks)}><Download className="mr-2 h-4 w-4" />Export JSON</Button>
<Button asChild><Link href="/hermes"><ArrowLeftRight className="mr-2 h-4 w-4" />Back to overview</Link></Button>
</>
)}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Queued" value={counts.queued} tone="default" />
<MetricCard label="Running" value={counts.running} tone="info" />
<MetricCard label="Blocked" value={counts.blocked} tone="warning" />
<MetricCard label="Failed" value={counts.failed} tone="danger" />
</section>
<SectionCard title="Filters" subtitle="Find work by status, product, priority, type, source, or age.">
<div className="grid gap-3 lg:grid-cols-4 xl:grid-cols-7">
<Input value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder="Search tasks..." aria-label="Search tasks" className="xl:col-span-2" />
<select value={status} onChange={(event) => { setStatus(event.target.value as HermesTaskStatus | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{statuses.map((item) => <option key={item} value={item}>{item === 'all' ? 'All statuses' : item}</option>)}
</select>
<select value={productId} onChange={(event) => { setProductId(event.target.value); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
<option value="all">All products</option>
{visibleProducts.map((product) => <option key={product.id} value={product.id}>{product.name}</option>)}
</select>
<select value={priority} onChange={(event) => { setPriority(event.target.value as HermesPriority | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{priorities.map((item) => <option key={item} value={item}>{item === 'all' ? 'All priorities' : item}</option>)}
</select>
<select value={type} onChange={(event) => { setType(event.target.value as HermesTaskType | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{taskTypes.map((item) => <option key={item} value={item}>{item === 'all' ? 'All types' : item}</option>)}
</select>
<select value={source} onChange={(event) => { setSource(event.target.value as HermesTaskSource | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{sources.map((item) => <option key={item} value={item}>{item === 'all' ? 'All sources' : item}</option>)}
</select>
<select value={updatedWithinDays} onChange={(event) => { setUpdatedWithinDays(event.target.value === 'all' ? 'all' : Number(event.target.value)); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
<option value="all">Any time</option>
<option value="1">Last 24h</option>
<option value="7">Last 7d</option>
<option value="30">Last 30d</option>
</select>
<select value={sort} onChange={(event) => { setSort(event.target.value as typeof sort); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{sortOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-sm text-[var(--bl-text-secondary)]">
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-1"><Filter className="h-3.5 w-3.5" />{tasks.length} matches</span>
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-1"><Search className="h-3.5 w-3.5" />Search is applied across task titles, products, agents, and notes</span>
</div>
</SectionCard>
<SectionCard title="Task table" subtitle="Click any task title to inspect the full detail view." actions={<Badge variant="neutral">Page {page} of {totalPages}</Badge>}>
<div className="overflow-hidden rounded-2xl border border-[var(--bl-border)]">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--bl-border)] text-left text-sm">
<thead className="bg-[var(--bl-surface-muted)] text-xs uppercase tracking-[0.18em] text-[var(--bl-text-tertiary)]">
<tr>
<th className="px-4 py-3">Task</th>
<th className="px-4 py-3">Product</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Priority</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3">Duration</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--bl-border)] bg-[var(--bl-surface-card)]">
{pagedTasks.map((task) => {
const product = getHermesProductById(task.productId);
const expanded = expandedTaskId === task.id;
return (
<Fragment key={task.id}>
<tr className="align-top hover:bg-[var(--bl-surface-muted)]/60">
<td className="px-4 py-4">
<div className="max-w-[24rem] space-y-1">
<Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link>
<p className="line-clamp-2 text-xs leading-5 text-[var(--bl-text-secondary)]">{task.description}</p>
</div>
</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{product?.name ?? 'Unknown'}</td>
<td className="px-4 py-4"><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'danger' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></td>
<td className="px-4 py-4"><Badge variant={task.priority === 'P0' ? 'danger' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.type}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.source}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{prettyDate(task.createdAt)}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.durationMs ? `${Math.round(task.durationMs / 60000)}m` : '—'}</td>
<td className="px-4 py-4 text-right">
<div className="inline-flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setExpandedTaskId(expanded ? null : task.id)}>
{expanded ? <ChevronUp className="mr-1 h-4 w-4" /> : <ChevronDown className="mr-1 h-4 w-4" />}
Details
</Button>
<Button asChild variant="secondary" size="sm"><Link href={`/hermes/tasks/${task.id}`}>Open</Link></Button>
</div>
</td>
</tr>
{expanded ? (
<tr key={`${task.id}-details`}>
<td colSpan={9} className="bg-[var(--bl-surface-muted)] px-4 py-4">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Summary</p>
<p className="mt-2 text-sm text-[var(--bl-text-secondary)]">{task.summary}</p>
<div className="mt-3 space-y-2 text-sm text-[var(--bl-text-secondary)]">
<div>Current step: {task.currentStep ?? 'n/a'}</div>
<div>Last action: {task.lastAction ?? 'n/a'}</div>
<div>Next action: {task.nextAction ?? 'n/a'}</div>
</div>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Signals</p>
<div className="mt-3 flex flex-wrap gap-2">
{task.tags.map((tag) => <Badge key={tag} variant="neutral">{tag}</Badge>)}
</div>
<div className="mt-4 space-y-2 text-sm text-[var(--bl-text-secondary)]">
<div>Retry count: {task.retryCount}</div>
<div>Assigned agent: {task.assignedAgent}</div>
<div>Started: {prettyDate(task.startedAt)}</div>
<div>Completed: {prettyDate(task.completedAt)}</div>
</div>
</div>
</div>
</td>
</tr>
) : null}
</Fragment>
);
})}
{pagedTasks.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-10 text-center text-[var(--bl-text-secondary)]">No tasks matched the current filters.</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-[var(--bl-text-secondary)]">Showing {pagedTasks.length} of {tasks.length} filtered tasks.</p>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>Prev</Button>
<Button variant="secondary" size="sm" disabled={page >= totalPages} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>Next</Button>
</div>
</div>
</SectionCard>
</HermesShell>
);
}

View File

@ -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<{

View File

@ -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);

View File

@ -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 (
<div className={cn('space-y-6', className)}>
<header className="rounded-3xl border border-[var(--bl-border)] bg-[linear-gradient(135deg,rgba(90,140,255,0.18),rgba(46,230,214,0.08))] p-6 shadow-[var(--bl-shadow-md)] backdrop-blur-sm lg:p-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl space-y-3">
<Badge variant="info">{badge}</Badge>
<div>
<h1 className="text-3xl font-semibold tracking-tight text-[var(--bl-text-primary)] lg:text-4xl">{title}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--bl-text-secondary)] lg:text-base">{description}</p>
</div>
</div>
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
</div>
</header>
{children}
</div>
);
}
interface SectionCardProps {
title: string;
subtitle?: string;
children: ReactNode;
className?: string;
actions?: ReactNode;
}
export function SectionCard({ title, subtitle, actions, children, className }: SectionCardProps) {
return (
<section className={cn('rounded-3xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-5 shadow-[var(--bl-shadow-sm)] lg:p-6', className)}>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-[var(--bl-text-primary)]">{title}</h2>
{subtitle ? <p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{subtitle}</p> : null}
</div>
{actions}
</div>
{children}
</section>
);
}
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<NonNullable<MetricCardProps['tone']>, 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 (
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4 shadow-[var(--bl-shadow-sm)]">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-[0.22em] text-[var(--bl-text-tertiary)]">{label}</p>
<p className={cn('mt-2 text-3xl font-semibold tracking-tight', toneStyles[tone])}>{value}</p>
{helpText ? <p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{helpText}</p> : null}
</div>
{icon ? <div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-2 text-[var(--bl-text-secondary)]">{icon}</div> : null}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { api, streamDeploymentLogs, type SseEvent } from '@/lib/api';
import { api } from '@/lib/api';
import { X, Maximize2, Minimize2 } from 'lucide-react';
interface LogViewerProps {
@ -13,45 +13,57 @@ export function LogViewer({ deploymentId, onClose }: LogViewerProps) {
const [logs, setLogs] = useState<string[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cleanup: (() => void) | null = null;
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
const loadInitialLogs = async () => {
const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
setIsRefreshing(false);
};
const loadLogs = async () => {
try {
const deployment = await api.getDeployment(deploymentId);
if (deployment.logs) {
setLogs(deployment.logs.split('\n'));
const deployment = await api.getDeploymentLogs(deploymentId);
if (cancelled) {
return;
}
setError(null);
setLogs(deployment.logs ? deployment.logs.split('\n') : []);
if (deployment.status === 'running') {
setIsRefreshing(true);
} else {
stopPolling();
}
} catch (err) {
if (cancelled) {
return;
}
console.error('Failed to load initial logs:', err);
setError(err instanceof Error ? err.message : 'Failed to load logs');
setIsRefreshing(true);
}
};
loadInitialLogs();
cleanup = streamDeploymentLogs(
deploymentId,
(event: SseEvent) => {
setIsConnected(true);
setError(null);
if (event.data) {
setLogs((prev) => [...prev, event.data]);
}
},
(err: Error) => {
setError(err.message);
setIsConnected(false);
},
() => {
setIsConnected(false);
}
);
setError(null);
setLogs([]);
setIsRefreshing(true);
intervalId = setInterval(() => {
void loadLogs();
}, 2000);
void loadLogs();
return () => {
if (cleanup) cleanup();
cancelled = true;
stopPolling();
};
}, [deploymentId]);
@ -69,10 +81,10 @@ export function LogViewer({ deploymentId, onClose }: LogViewerProps) {
<div className="flex items-center gap-3">
<span className="font-medium">Deployment Logs</span>
<span className={`flex items-center gap-1 text-xs ${
isConnected ? 'text-green-400' : 'text-gray-500'
isRefreshing ? 'text-green-400' : 'text-gray-500'
}`} aria-live="polite">
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-gray-500'}`} aria-hidden="true" />
{isConnected ? 'Live' : 'Disconnected'}
<span className={`w-2 h-2 rounded-full ${isRefreshing ? 'bg-green-400' : 'bg-gray-500'}`} aria-hidden="true" />
{isRefreshing ? 'Updating' : 'Stopped'}
</span>
{error && <span className="text-xs text-red-400" role="alert">{error}</span>}
</div>

View File

@ -17,18 +17,18 @@ import {
Sun,
Moon,
HeartPulse,
Server,
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 },
{ href: '/env', label: 'Environment', icon: Key },
{ href: '/code-quality', label: 'Code Quality', icon: Code2 },
{ href: '/devops', label: 'DevOps', icon: Server },
{ href: '/settings/cosmos', label: 'Settings', icon: Settings },
];

View File

@ -5,17 +5,18 @@ import { cn } from '@/lib/utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'link';
size?: 'sm' | 'md' | 'lg';
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ 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<HTMLButtonElement, ButtonProps>(
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 (
<button
ref={ref}
className={cn(baseStyles, variantStyles[variant], sizeStyles[size], className)}
className={classes}
{...props}
/>
>
{children}
</button>
);
},
);

View File

@ -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> = {}): 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',
}),
})
);
});

View File

@ -40,6 +40,11 @@ export interface ApiError {
status?: number;
}
export interface DeploymentLogsResponse {
logs: string;
status: 'running' | 'success' | 'failed';
}
export interface EnvVar {
id: string;
name: string;
@ -73,10 +78,14 @@ async function getCsrfToken(): Promise<string | null> {
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}`,
},
});
@ -162,56 +171,6 @@ export async function apiRequest<T>(
return response.json();
}
export interface SseEvent {
event: string;
data: string;
}
export function streamDeploymentLogs(
deploymentId: string,
onEvent: (event: SseEvent) => void,
onError: (error: Error) => void,
onComplete: () => void
): () => void {
const token = getAccessToken();
const headers: HeadersInit = {
'Accept': 'text/event-stream',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const eventSource = new EventSource(
`${devopsApiUrl}/api/deployments/${deploymentId}/logs`
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onEvent({ event: event.type || 'message', data: event.data });
if (event.type === 'complete' || event.type === 'error') {
onComplete();
eventSource.close();
}
} catch (error) {
onError(error as Error);
}
};
eventSource.onerror = (error) => {
onError(new Error('SSE connection error'));
eventSource.close();
onComplete();
};
// Return cleanup function
return () => {
eventSource.close();
};
}
export const api = {
// Services
getServices: () => apiRequest<Service[]>('/api/services'),
@ -236,6 +195,8 @@ export const api = {
getServiceDeployments: (serviceId: string, limit = 50) =>
apiRequest<Deployment[]>(`/api/deployments/service/${serviceId}?limit=${limit}`),
getDeployment: (id: string) => apiRequest<Deployment>(`/api/deployments/${id}`),
getDeploymentLogs: (id: string) =>
apiRequest<DeploymentLogsResponse>(`/api/deployments/${id}/logs`),
triggerDeployment: (serviceId: string) =>
apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, {
method: 'POST',

View File

@ -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();
});
});

View File

@ -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<string, unknown>;
toolName?: string;
command?: string;
artifactUrl?: string;
}
export interface HermesRun {
id: string;
taskId: string;
startedAt: string;
endedAt?: string;
status: HermesTaskStatus;
logs: string[];
metrics?: Record<string, number>;
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<string, HermesEvent[]>(
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<HermesPriority, number> = { 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.';
}

View File

@ -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"
]
}

View File

@ -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,
},
});

View File

@ -4,17 +4,21 @@ set -euo pipefail
# ═══════════════════════════════════════════════════════════════════════
# ByteLyst ChronoMind - Production Deployment Script
# ═══════════════════════════════════════════════════════════════════════
# Usage: ./deploy-clock.sh [--force] [--skip-health-check]
# Usage: ./deploy-clock.sh [option]
#
# What it does:
# 1. Dirty check: uncommitted changes, unpushed commits
# 2. Pull and rebase origin/main
# 3. Build and deploy Docker containers
# 4. Verify endpoints: https://api.bytelyst.com/chronomind, http://localhost:3030
# Options (interactive menu when no arguments):
# 1 - Normal deployment (with cache, with health checks)
# 2 - Force deployment (skip dirty checks, with cache)
# 3 - Skip health checks (with cache)
# 4 - No-cache build (force rebuild, with health checks)
# 5 - Force + No-cache (skip checks, force rebuild)
# 6 - Force + Skip health checks (skip both)
# 7 - All options: Force + Skip health + No-cache
#
# Options:
# Command-line options:
# --force Skip dirty checks and force deployment
# --skip-health-check Skip endpoint health verification
# --no-cache Force rebuild without cache
# ═══════════════════════════════════════════════════════════════════════
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
@ -31,15 +35,47 @@ cd "$SCRIPT_DIR"
FORCE=false
SKIP_HEALTH_CHECK=false
NO_CACHE=false
# Parse command-line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--force) FORCE=true; shift ;;
--skip-health-check) SKIP_HEALTH_CHECK=true; shift ;;
*) fail "Unknown option: $1" ;;
--no-cache) NO_CACHE=true; shift ;;
*) fail "Unknown option: $1";;
esac
done
# ── Interactive Menu ─────────────────────────────────────────────────
if [ "$FORCE" = false ] && [ "$SKIP_HEALTH_CHECK" = false ] && [ "$NO_CACHE" = false ]; then
echo ""
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}${NC} ByteLyst ChronoMind - Deployment Options ${CYAN}${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${GREEN}1${NC} - Normal deployment (with cache, with health checks)"
echo -e " ${GREEN}2${NC} - Force deployment (skip dirty checks, with cache)"
echo -e " ${GREEN}3${NC} - Skip health checks (with cache)"
echo -e " ${GREEN}4${NC} - No-cache build (force rebuild, with health checks)"
echo -e " ${GREEN}5${NC} - Force + No-cache (skip checks, force rebuild)"
echo -e " ${GREEN}6${NC} - Force + Skip health checks (skip both)"
echo -e " ${GREEN}7${NC} - All options: Force + Skip health + No-cache"
echo ""
read -r -p "Select option (1-7): " choice
case $choice in
1) ;;
2) FORCE=true ;;
3) SKIP_HEALTH_CHECK=true ;;
4) NO_CACHE=true ;;
5) FORCE=true; NO_CACHE=true ;;
6) FORCE=true; SKIP_HEALTH_CHECK=true ;;
7) FORCE=true; SKIP_HEALTH_CHECK=true; NO_CACHE=true ;;
*) fail "Invalid option. Please select 1-7." ;;
esac
fi
# ── Prerequisites ────────────────────────────────────────────────────
if [ ! -d "$REPO_DIR" ]; then
fail "Repo directory not found: $REPO_DIR"
@ -105,6 +141,48 @@ if [ "$SKIP_HEALTH_CHECK" = false ]; then
fi
fi
# ── Package Publication Check ───────────────────────────────────────────
log "Checking @bytelyst package publication status..."
# Read Gitea token from file
unset GITEA_NPM_TOKEN
if [ -f "/opt/bytelyst/.gitea_token" ]; then
GITEA_NPM_TOKEN="$(< /opt/bytelyst/.gitea_token)"
elif [ -f "$HOME/.gitea_npm_token" ]; then
GITEA_NPM_TOKEN="$(< "$HOME/.gitea_npm_token")"
fi
if [ -z "$GITEA_NPM_TOKEN" ]; then
warn "Gitea token not found, skipping package publication check"
else
GITEA_REGISTRY="http://localhost:3300/api/packages/ByteLyst/npm/"
CRITICAL_PACKAGES=(
"@bytelyst/config|@bytelyst%2fconfig"
"@bytelyst/cosmos|@bytelyst%2fcosmos"
"@bytelyst/errors|@bytelyst%2ferrors"
"@bytelyst/fastify-core|@bytelyst%2ffastify-core"
)
MISSING_PACKAGES=()
for entry in "${CRITICAL_PACKAGES[@]}"; do
package="${entry%%|*}"
encoded="${entry##*|}"
if ! curl -sf -H "Authorization: token ${GITEA_NPM_TOKEN}" "${GITEA_REGISTRY}${encoded}" > /dev/null 2>&1; then
MISSING_PACKAGES+=("$package")
fi
done
if [ "${#MISSING_PACKAGES[@]}" -eq 0 ]; then
ok "All critical @bytelyst packages are published"
else
warn "Some @bytelyst packages may not be published: ${MISSING_PACKAGES[*]}"
read -r -p "Continue anyway? (y/N): " continue_anyway
if [[ ! "$continue_anyway" =~ ^[Yy]$ ]]; then
fail "Deployment cancelled"
fi
fi
fi
# ── Build and Deploy ──────────────────────────────────────────────────
log "Building and deploying Docker containers..."
@ -114,8 +192,15 @@ if [ ! -f "docker-compose.yml" ]; then
fi
# Build and start services
log "Building Docker images..."
docker compose build || fail "Docker build failed"
BUILD_ARGS=()
if [ "$NO_CACHE" = true ]; then
BUILD_ARGS+=(--no-cache)
log "Building Docker images without cache..."
else
log "Building Docker images..."
fi
docker compose build "${BUILD_ARGS[@]}" || fail "Docker build failed"
log "Starting services..."
docker compose up -d || fail "Docker compose up failed"
@ -171,7 +256,7 @@ fi
log "Verifying production endpoints..."
API_ENDPOINT="https://api.bytelyst.com/chronomind"
WEB_ENDPOINT="http://localhost:3030"
WEB_ENDPOINT="https://clock.bytelyst.com"
# Check API endpoint
if curl -sf "$API_ENDPOINT/health" > /dev/null 2>&1; then
@ -219,5 +304,5 @@ fi
log "══════════════════════════════════════════════════════════════════════"
ok "Deployment completed successfully!"
log "Backend: http://localhost:4011 → $API_ENDPOINT"
log "Web: http://localhost:3030"
log "Web: http://localhost:3030$WEB_ENDPOINT"
log "══════════════════════════════════════════════════════════════════════"

View File

@ -4,17 +4,21 @@ set -euo pipefail
# ═══════════════════════════════════════════════════════════════════════
# ByteLyst NoteLett - Production Deployment Script
# ═══════════════════════════════════════════════════════════════════════
# Usage: ./deploy-notes.sh [--force] [--skip-health-check]
# Usage: ./deploy-notes.sh [option]
#
# What it does:
# 1. Dirty check: uncommitted changes, unpushed commits
# 2. Pull and rebase origin/main
# 3. Build and deploy Docker containers
# 4. Verify endpoints: https://api.bytelyst.com/notelett, http://localhost:3000
# Options (interactive menu when no arguments):
# 1 - Normal deployment (with cache, with health checks)
# 2 - Force deployment (skip dirty checks, with cache)
# 3 - Skip health checks (with cache)
# 4 - No-cache build (force rebuild, with health checks)
# 5 - Force + No-cache (skip checks, force rebuild)
# 6 - Force + Skip health checks (skip both)
# 7 - All options: Force + Skip health + No-cache
#
# Options:
# Command-line options:
# --force Skip dirty checks and force deployment
# --skip-health-check Skip endpoint health verification
# --no-cache Force rebuild without cache
# ═══════════════════════════════════════════════════════════════════════
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
@ -31,15 +35,47 @@ cd "$SCRIPT_DIR"
FORCE=false
SKIP_HEALTH_CHECK=false
NO_CACHE=false
# Parse command-line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--force) FORCE=true; shift ;;
--skip-health-check) SKIP_HEALTH_CHECK=true; shift ;;
*) fail "Unknown option: $1" ;;
--no-cache) NO_CACHE=true; shift ;;
*) fail "Unknown option: $1";;
esac
done
# ── Interactive Menu ─────────────────────────────────────────────────
if [ "$FORCE" = false ] && [ "$SKIP_HEALTH_CHECK" = false ] && [ "$NO_CACHE" = false ]; then
echo ""
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}${NC} ByteLyst NoteLett - Deployment Options ${CYAN}${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${GREEN}1${NC} - Normal deployment (with cache, with health checks)"
echo -e " ${GREEN}2${NC} - Force deployment (skip dirty checks, with cache)"
echo -e " ${GREEN}3${NC} - Skip health checks (with cache)"
echo -e " ${GREEN}4${NC} - No-cache build (force rebuild, with health checks)"
echo -e " ${GREEN}5${NC} - Force + No-cache (skip checks, force rebuild)"
echo -e " ${GREEN}6${NC} - Force + Skip health checks (skip both)"
echo -e " ${GREEN}7${NC} - All options: Force + Skip health + No-cache"
echo ""
read -r -p "Select option (1-7): " choice
case $choice in
1) ;;
2) FORCE=true ;;
3) SKIP_HEALTH_CHECK=true ;;
4) NO_CACHE=true ;;
5) FORCE=true; NO_CACHE=true ;;
6) FORCE=true; SKIP_HEALTH_CHECK=true ;;
7) FORCE=true; SKIP_HEALTH_CHECK=true; NO_CACHE=true ;;
*) fail "Invalid option. Please select 1-7." ;;
esac
fi
# ── Prerequisites ────────────────────────────────────────────────────
if [ ! -d "$REPO_DIR" ]; then
fail "Repo directory not found: $REPO_DIR"
@ -105,6 +141,48 @@ if [ "$SKIP_HEALTH_CHECK" = false ]; then
fi
fi
# ── Package Publication Check ───────────────────────────────────────────
log "Checking @bytelyst package publication status..."
# Read Gitea token from file
unset GITEA_NPM_TOKEN
if [ -f "/opt/bytelyst/.gitea_token" ]; then
GITEA_NPM_TOKEN="$(< /opt/bytelyst/.gitea_token)"
elif [ -f "$HOME/.gitea_npm_token" ]; then
GITEA_NPM_TOKEN="$(< "$HOME/.gitea_npm_token")"
fi
if [ -z "$GITEA_NPM_TOKEN" ]; then
warn "Gitea token not found, skipping package publication check"
else
GITEA_REGISTRY="http://localhost:3300/api/packages/ByteLyst/npm/"
CRITICAL_PACKAGES=(
"@bytelyst/config|@bytelyst%2fconfig"
"@bytelyst/cosmos|@bytelyst%2fcosmos"
"@bytelyst/errors|@bytelyst%2ferrors"
"@bytelyst/fastify-core|@bytelyst%2ffastify-core"
)
MISSING_PACKAGES=()
for entry in "${CRITICAL_PACKAGES[@]}"; do
package="${entry%%|*}"
encoded="${entry##*|}"
if ! curl -sf -H "Authorization: token ${GITEA_NPM_TOKEN}" "${GITEA_REGISTRY}${encoded}" > /dev/null 2>&1; then
MISSING_PACKAGES+=("$package")
fi
done
if [ "${#MISSING_PACKAGES[@]}" -eq 0 ]; then
ok "All critical @bytelyst packages are published"
else
warn "Some @bytelyst packages may not be published: ${MISSING_PACKAGES[*]}"
read -r -p "Continue anyway? (y/N): " continue_anyway
if [[ ! "$continue_anyway" =~ ^[Yy]$ ]]; then
fail "Deployment cancelled"
fi
fi
fi
# ── Build and Deploy ──────────────────────────────────────────────────
log "Building and deploying Docker containers..."
@ -114,8 +192,15 @@ if [ ! -f "docker-compose.yml" ]; then
fi
# Build and start services
log "Building Docker images..."
docker compose build || fail "Docker build failed"
BUILD_ARGS=()
if [ "$NO_CACHE" = true ]; then
BUILD_ARGS+=(--no-cache)
log "Building Docker images without cache..."
else
log "Building Docker images..."
fi
docker compose build "${BUILD_ARGS[@]}" || fail "Docker build failed"
log "Starting services..."
docker compose up -d || fail "Docker compose up failed"
@ -152,7 +237,7 @@ fi
# Check web health
WEB_HEALTH=false
for _ in {1..30}; do
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
if curl -sf http://localhost:3045 > /dev/null 2>&1; then
WEB_HEALTH=true
break
fi
@ -162,7 +247,7 @@ done
echo ""
if [ "$WEB_HEALTH" = true ]; then
ok "Web health check passed (http://localhost:3000)"
ok "Web health check passed (http://localhost:3045)"
else
warn "Web health check failed (may be starting up)"
fi
@ -171,7 +256,7 @@ fi
log "Verifying production endpoints..."
API_ENDPOINT="https://api.bytelyst.com/notelett"
WEB_ENDPOINT="http://localhost:3000"
WEB_ENDPOINT="https://notes.bytelyst.com"
# Check API endpoint
if curl -sf "$API_ENDPOINT/health" > /dev/null 2>&1; then
@ -202,7 +287,7 @@ else
fi
# Test web is serving content
WEB_URL="http://localhost:3000"
WEB_URL="http://localhost:3045"
if curl -sf "$WEB_URL" > /dev/null 2>&1; then
ok "Web frontend is serving content"
# Check if it's actually HTML
@ -219,5 +304,5 @@ fi
log "══════════════════════════════════════════════════════════════════════"
ok "Deployment completed successfully!"
log "Backend: http://localhost:4016 → $API_ENDPOINT"
log "Web: http://localhost:3000"
log "Web: http://localhost:3045 → $WEB_ENDPOINT"
log "══════════════════════════════════════════════════════════════════════"

View File

@ -0,0 +1,314 @@
# Hermes Setup Upgrade Roadmap
**Date:** 2026-05-26
**Owner:** ByteLyst / S
**Repo:** `bytelyst-devops-tools`
**Video reference:** [Hermes Agent is the greatest AI tool ever made. Here's how to set it up](https://youtu.be/RoBD7Lc-0MI) by Alex Finn
## Purpose
Turn the Hermes setup ideas from the referenced video into a practical ByteLyst upgrade checklist for this VM-backed, Telegram-driven Hermes installation.
This roadmap is intentionally operational: every item should either improve reliability, safety, agent capability, observability, or restore/migration readiness.
## Transcript Review Status
Automated transcript retrieval was attempted through multiple paths:
- Hermes `youtube-content` transcript helper using `youtube-transcript-api`
- `yt-dlp` subtitle extraction
- direct YouTube page/player metadata inspection
- Invidious caption endpoints
- third-party transcript endpoint probing
The video title and metadata were reachable, but transcript/subtitle retrieval was blocked by YouTube anti-bot checks from this VM/cloud IP. One Invidious endpoint confirmed an English auto-generated caption track exists, but returned an empty caption body.
Because the full transcript was not retrievable from the VM, this roadmap combines:
1. the accessible video metadata and setup theme,
2. Hermes Agent's current documented capabilities,
3. the live health/status of this ByteLyst Hermes installation, and
4. ByteLyst's existing operational preferences and safety constraints.
If a manual transcript is later pasted or uploaded, re-run this review and append a `Transcript-Derived Delta` section with any new actions.
## Current ByteLyst Hermes Baseline
Observed on 2026-05-26:
- Hermes version: `v0.14.0 (2026.5.16)`
- Project path: `/usr/local/lib/hermes-agent`
- Active model/provider: `gpt-5.4` via OpenAI Codex OAuth
- Telegram gateway: configured and running under systemd
- Scheduled jobs: `1 active, 1 total`
- `Sync Hermes persistent-data backup to GitHub`
- schedule: every 30 minutes
- delivery: local
- script: `sync_hermes_persistent_backup.py`
- last status: ok
- Config version: `23`
- Telegram credentials are present
- Most optional provider/API keys are not configured, including OpenRouter, Google/Gemini, Anthropic, Firecrawl/Tavily/Exa, Browserbase/Browser Use, GitHub token, FAL, and ElevenLabs
- `hermes doctor` timed out during this review and needs a dedicated diagnostic pass
- User preference: do **not** expose the Hermes dashboard publicly
## Target State
A healthy ByteLyst Hermes setup should be:
- **Private by default:** no public dashboard exposure; private access through local shell, Telegram DM, SSH tunnel, Tailscale, or equivalent.
- **Recoverable:** configuration, skills, memory, sessions, cron jobs, and scripts are backed up and periodically restore-tested.
- **Observable:** gateway, cron, disk, memory, and backup failures surface to Telegram quickly.
- **Capable:** web search/extraction, browser automation, GitHub/Gitea operations, vision, file, terminal, cron, memory, session search, and delegation are all configured where useful.
- **Safe:** secrets are not committed, destructive commands remain approval-gated, public Caddy exposure is explicitly reviewed, and profiles isolate risky experiments.
- **Self-improving:** recurring procedures are captured as skills; stale or wrong skills are patched immediately.
## Roadmap Checklist
### Phase 0 — Safety Freeze And Guardrails
- [ ] Confirm no Caddy route exposes a Hermes dashboard or Hermes API server publicly.
- [ ] Add a negative-control check to operational docs: `Hermes dashboard/API must not be public without explicit approval`.
- [ ] Verify firewall/Caddy routes for any hostnames pointing to Hermes ports.
- [ ] Decide private access pattern for any future dashboard:
- [ ] local-only binding
- [ ] SSH tunnel
- [ ] Tailscale/WireGuard
- [ ] Cloudflare Access or equivalent identity gate
- [ ] basic auth plus IP allowlist only if a public route is unavoidable
- [ ] Keep command approvals at `manual` or `smart`; do not globally use approval bypass for the gateway.
### Phase 1 — Health Baseline And Diagnostics
- [ ] Run and capture `hermes --version`.
- [ ] Run and capture `hermes config check`.
- [ ] Investigate why `hermes doctor` timed out.
- [ ] Re-run with a longer timeout from a foreground shell.
- [ ] If still hanging, isolate the step by checking logs and dependencies.
- [ ] File or fix a Hermes bug if the timeout is reproducible.
- [ ] Run `hermes status --all` and save a sanitized baseline summary.
- [ ] Check gateway service health:
- [ ] `systemctl status hermes-gateway` or the actual installed service unit
- [ ] recent gateway logs under `~/.hermes/logs/`
- [ ] Telegram send/receive smoke test
- [ ] Check cron scheduler health and last-run status.
- [ ] Check disk, memory, CPU, open ports, and long-running Hermes processes.
- [ ] Create a recurring monthly `Hermes setup review` checklist from this baseline.
### Phase 2 — Backup, Restore, And Migration Readiness
- [ ] Keep the existing persistent-data backup cron active.
- [ ] Verify the backup repository receives fresh commits after real state changes.
- [ ] Confirm the backup intentionally excludes raw secrets and `state.db`.
- [ ] Add a restore rehearsal checklist:
- [ ] clone backup repo into a temporary directory
- [ ] run restore script in dry-run mode if available
- [ ] verify config, skills, sessions, cron, memory, and scripts restore into a test profile
- [ ] confirm no raw `.env`, OAuth token, or credential file appears in git
- [ ] Add a quarterly restore drill reminder cron job or calendar task.
- [ ] Document exact restore commands in a ByteLyst ops doc.
### Phase 3 — Upgrade Strategy
- [ ] Check whether Hermes is already at the latest stable release before each upgrade.
- [ ] Before upgrading:
- [ ] run backup sync manually
- [ ] capture `hermes --version`, `hermes status --all`, and `hermes config check`
- [ ] snapshot config and cron job list
- [ ] Upgrade Hermes from an interactive shell, not from a public-facing workflow.
- [ ] After upgrade:
- [ ] restart gateway
- [ ] run Telegram smoke test
- [ ] verify cron still runs
- [ ] run one safe terminal/file task
- [ ] run one memory/session-search task
- [ ] Record upgrade date, version, and any manual fixups in `docs/operations.md` or a Hermes-specific ops note.
### Phase 4 — Provider And Model Resilience
- [ ] Keep OpenAI Codex OAuth as the primary provider if it remains stable.
- [ ] Add at least one fallback provider for resilience:
- [ ] OpenRouter
- [ ] Google/Gemini
- [ ] Anthropic
- [ ] local/Ollama if useful for low-risk offline tasks
- [ ] Configure provider credentials through Hermes auth/config flows; do not commit keys.
- [ ] Define model routing tiers:
- [ ] fast/cheap model for routine summaries and simple ops
- [ ] strong coding model for repo work
- [ ] vision-capable model for screenshots/images
- [ ] long-context model for large transcripts and audits
- [ ] Test fallback behavior by switching models in a new session.
- [ ] Document the preferred default model and fallback order.
### Phase 5 — Tooling Capability Upgrade
- [ ] Enable/configure at least one reliable web search/extract backend:
- [ ] Exa
- [ ] Tavily
- [ ] Firecrawl
- [ ] SearXNG self-hosted option
- [ ] Configure browser automation only if needed and keep it private/safe:
- [ ] local Chromium/Camofox, or
- [ ] Browserbase/Browser Use
- [ ] Configure GitHub/Gitea automation credentials with least privilege.
- [ ] Add vision/image capability if screenshots, diagrams, or UI reviews are common.
- [ ] Validate the active Telegram toolset includes the capabilities ByteLyst expects:
- [ ] terminal
- [ ] file
- [ ] search/session_search
- [ ] memory
- [ ] skills
- [ ] cronjob
- [ ] messaging
- [ ] delegation
- [ ] browser/web if configured
- [ ] Document tool enablement changes and restart/reset requirements.
### Phase 6 — Telegram Gateway Workflow
- [ ] Keep Telegram as the primary control plane.
- [ ] Preserve the user's preferred progress prefix convention: `1⃣`, `2⃣`, etc.
- [ ] Ensure home channel and allowed user settings are correct.
- [ ] Add smoke-test steps for:
- [ ] inbound Telegram command
- [ ] outbound completion message
- [ ] approval prompt flow
- [ ] media/file delivery
- [ ] Decide whether Telegram topic/session handling should be enabled or documented.
- [ ] Add a runbook for gateway restart/recovery.
### Phase 7 — Memory, Skills, And Knowledge Capture
- [ ] Review persistent memory for stale entries and trim anything no longer useful.
- [ ] Keep memories declarative and durable; avoid storing task-completion artifacts.
- [ ] Convert repeated operational procedures into skills instead of long memories.
- [ ] Pin critical ByteLyst/Hermes skills that should not be archived.
- [ ] Schedule or manually run curator reviews if enabled.
- [ ] Add skills for recurring ByteLyst workflows:
- [ ] Gitea Actions troubleshooting
- [ ] Caddy + Docker routing changes
- [ ] Hermes backup/restore drill
- [ ] Telegram gateway recovery
- [ ] safe multi-repo commit/push workflow
### Phase 8 — Cron, Watchdogs, And Autonomous Maintenance
- [ ] Keep current Hermes backup cron job enabled.
- [ ] Add watchdogs that notify Telegram only on actionable failures:
- [ ] gateway down
- [ ] cron scheduler stale
- [ ] backup job failed or no fresh commit within threshold
- [ ] disk usage high
- [ ] memory pressure high
- [ ] Caddy/Gitea critical services down
- [ ] Prefer `no_agent=True` script-only watchdogs for fixed health checks.
- [ ] Keep noisy health checks silent on success.
- [ ] Use self-contained prompts for any LLM-driven cron jobs.
- [ ] Avoid recursive cron creation from cron-run sessions.
### Phase 9 — Private Dashboard / Mission Control Direction
- [ ] Do not expose Hermes dashboard publicly.
- [ ] If a dashboard is useful, make it private-only and operationally scoped.
- [ ] Dashboard should show:
- [ ] gateway status
- [ ] active sessions
- [ ] cron job state
- [ ] backup freshness
- [ ] recent sanitized alerts
- [ ] quick links to docs/runbooks
- [ ] Any dashboard actions must require authentication and ideally remain reachable only over private network/tunnel.
- [ ] Add a Caddy review step before adding any new hostname.
### Phase 10 — Multi-Agent And Project Execution Workflow
- [ ] Use `delegate_task` for bounded subtasks inside a parent session.
- [ ] Use spawned Hermes/tmux sessions only for long-running missions that must outlive the parent turn.
- [ ] Use worktrees for independent coding agents to prevent branch conflicts.
- [ ] For durable multi-agent coordination, evaluate Hermes Kanban.
- [ ] Document when to use:
- [ ] direct tool call
- [ ] delegate_task
- [ ] background terminal process
- [ ] cron job
- [ ] Kanban worker
- [ ] Add a ByteLyst convention for progress/completion Telegram notifications from concurrent sessions.
### Phase 11 — Security And Secret Hygiene
- [ ] Reconfirm raw `.env`, OAuth credentials, tokens, logs, and SQLite WAL/SHM files are excluded from git backups.
- [ ] Consider enabling `security.redact_secrets` if the operational tradeoff is acceptable.
- [ ] Keep `privacy.redact_pii` decision documented for gateway sessions.
- [ ] Rotate old credentials after migration or accidental exposure risk.
- [ ] Use least-privilege tokens for GitHub/Gitea, web APIs, and provider keys.
- [ ] Add a pre-commit or manual scan step before pushing Hermes backup/config changes.
- [ ] Keep approval mode at `manual` or `smart` for Telegram-driven work.
### Phase 12 — Documentation And Runbooks
- [ ] Add a Hermes operations index under `docs/`.
- [ ] Link this roadmap from `docs/repo-map.md`.
- [ ] Create or update runbooks for:
- [ ] installing/upgrading Hermes
- [ ] restarting the gateway
- [ ] restoring persistent data from backup
- [ ] configuring providers/models
- [ ] enabling/disabling tools
- [ ] adding safe cron watchdogs
- [ ] private-only dashboard access
- [ ] Keep commands copy-pasteable and include expected outputs.
- [ ] Store secrets only as placeholder variable names or `.env.example` entries.
## Priority Execution Plan
### Immediate — Today / Next Session
- [ ] Confirm no public Hermes dashboard route exists.
- [ ] Investigate `hermes doctor` timeout.
- [ ] Verify backup cron freshness and remote push status.
- [ ] Add one Telegram watchdog for gateway/backup failure.
- [ ] Choose and configure one web search backend.
### Near-Term — This Week
- [ ] Add fallback model/provider.
- [ ] Document provider routing and model defaults.
- [ ] Add gateway recovery runbook.
- [ ] Add restore drill runbook and perform one test-profile restore.
- [ ] Add Gitea/GitHub least-privilege automation credential path.
### Medium-Term — This Month
- [ ] Evaluate private-only dashboard/mission-control UX.
- [ ] Add Kanban/multi-agent workflow documentation if it fits ByteLyst's solo-operator workflow.
- [ ] Add silent-on-success system watchdogs.
- [ ] Clean up stale memory/skills and pin critical skills.
- [ ] Schedule quarterly restore drills.
## Acceptance Criteria
This roadmap is complete when:
- [ ] Hermes can be upgraded and rolled back/restored with a documented process.
- [ ] Gateway failures and backup failures notify Telegram.
- [ ] At least one fallback model/provider is configured and tested.
- [ ] Web/search tooling works for current research tasks.
- [ ] No Hermes dashboard/API is publicly exposed.
- [ ] Backup restore has been tested into a non-production profile.
- [ ] Core ByteLyst Hermes procedures exist as docs or skills.
- [ ] Sensitive files remain untracked and backup-safe.
## Notes For Future Transcript Pass
When the transcript is available, specifically check whether the video recommends any of the following and update this roadmap accordingly:
- exact provider/model choices
- recommended Hermes install path
- gateway platform setup details
- dashboard or web UI exposure guidance
- memory/skill workflows
- MCP server recommendations
- cron/background agent patterns
- voice/STT/TTS setup
- any security warnings or anti-patterns

View File

@ -0,0 +1,703 @@
# Hermes Mission Control Dashboard Roadmap
This roadmap defines the production-grade Hermes Mission Control web UI for the `bytelyst-devops-tools` repo.
Repo path used by the scheduled implementation job:
`/root/bytelyst.ai/repos/bytelyst-devops-tools`
Role:
Principal Fullstack Engineer + DevOps Architect.
Task:
Build a rich, production-grade web UI called **Hermes Mission Control** inside the existing dashboard app, reusing existing architecture and conventions.
Context:
I am a solopreneur/founder running ByteLyst and planning to launch/manage 50+ products, apps, services, internal tools, automations, and AI agents. Hermes is my DevOps/automation/agent execution brain. I need a dashboard that clearly shows what Hermes is working on, what it completed, what failed, what is blocked, what needs my attention, and what should happen next.
This should not be a toy logs page. Build it like an AI DevOps command center for a solo founder managing many products.
Before coding:
1. `cd /root/bytelyst.ai/repos/bytelyst-devops-tools`
2. Inspect the repo structure.
3. Identify frontend/backend framework, package manager, routes, styling, existing dashboard/admin patterns, auth, API conventions, test setup, lint/build commands.
4. Reuse existing architecture wherever possible.
5. Do not rewrite the whole repo.
6. Commit incrementally to `origin main` unless repo policy says otherwise.
7. Update or create a roadmap/checklist file tracking progress.
Primary route:
Create a Hermes dashboard entry point:
- `/hermes`
- or the closest matching route based on existing app structure.
Dashboard name:
**Hermes Mission Control**
Core dashboard goals:
- Show live Hermes status.
- Show active work.
- Show completed work.
- Show failed/stuck/blocked work.
- Show historical activity.
- Show product/app/service-level progress.
- Show what needs founder attention.
- Show next best actions.
- Make Hermes accountable and observable.
Build these sections/pages.
---
# 1. `/hermes` — Executive Mission Control
Show top-level cards:
- Hermes status: Running / Idle / Degraded / Error
- Active tasks
- Completed today
- Completed this week
- Failed tasks
- Blocked tasks
- Average task duration
- Success rate
- Products touched recently
- Tasks needing founder attention
- Upcoming scheduled jobs
- Last Hermes action
- Next recommended action
Add major dashboard panels:
## A. Active Missions
Show what Hermes is currently doing:
- task title
- product/app
- status
- progress %
- current step
- started time
- estimated remaining placeholder
- assigned agent/tool
- priority
## B. Founder Attention Queue
Show items where I must act:
- approval needed
- missing credentials
- failed deployment
- failing tests
- blocked by unclear requirement
- cost/risk warning
- expired token/API key
- product inactive too long
- repeated failures
## C. What Hermes Did For Me
Summaries:
- today
- this week
- last 30 days
Examples:
- fixed bugs
- created PRs
- deployed services
- ran audits
- updated docs
- checked health
- investigated failures
- generated reports
## D. Product Health Snapshot
For 50+ products/apps/services:
- product name
- health score
- latest activity
- open tasks
- failed tasks
- last deployment
- last commit
- production status
- attention needed badge
## E. Next Best Actions
Split into:
- “Hermes can do automatically”
- “Needs Saravanas decision”
---
# 2. `/hermes/tasks` — Task Ledger
Create a searchable/filterable task table.
Columns:
- Task
- Product
- Status
- Priority
- Type
- Source trigger
- Assigned agent
- Created
- Started
- Completed
- Duration
- Retry count
- Last action
- Next action
Statuses:
- queued
- running
- blocked
- completed
- failed
- skipped
- cancelled
Priorities:
- P0
- P1
- P2
- P3
Task types:
- build
- deploy
- bugfix
- monitoring
- audit
- refactor
- documentation
- research
- security
- cost-optimization
- release
- maintenance
- product-planning
Source triggers:
- manual
- cron
- GitHub
- monitoring alert
- email
- CLI
- webhook
- local agent
- Hermes planner
Features:
- Search
- Status filter
- Product filter
- Priority filter
- Date filter
- Sort
- Pagination
- Expandable row details
- Export JSON button if simple to implement
---
# 3. `/hermes/tasks/[id]` — Task Detail
For each task show:
## Summary
- title
- description
- product
- status
- priority
- owner
- current step
- result
- blocker reason
- final output
## Timeline
Chronological events:
- created
- planned
- tool selected
- command executed
- file changed
- test run
- error detected
- retry attempted
- PR created
- deployment completed
- task completed
## Execution Details
Show:
- commands executed
- tools called
- files modified
- Git branch
- commit SHA
- PR URL
- deployment URL
- test result
- logs
- error stack/message
- retry history
- artifacts generated
## Hermes Learning
Add section:
- lesson learned
- suggested memory update
- prevention for next time
- recurring issue detection
---
# 4. `/hermes/products` — 50-Product Portfolio View
Create product registry dashboard.
Each product card/table row should show:
- name
- slug
- category
- priority
- health score
- status
- repo URL
- production URL
- staging URL
- last Hermes activity
- last commit placeholder
- last deployment placeholder
- active tasks
- completed tasks
- failed tasks
- blocked tasks
- tags
- needs attention
Categories:
- SaaS
- AI app
- mobile app
- internal tool
- website
- API
- automation
- browser extension
- data pipeline
- DevOps tool
Add views:
- All products
- High priority
- Needs attention
- No recent activity
- Repeated failures
- Recently shipped
---
# 5. `/hermes/history` — Historical View
Show historical analytics:
- completed tasks over time
- failed tasks over time
- blocked tasks over time
- most active products
- most neglected products
- common failure categories
- average task duration trend
- weekly progress summary
- monthly progress summary
Use charts if the repo already has a chart library.
If no chart library exists, implement simple clean visual bars/cards first.
---
# 6. `/hermes/agents` — Agent & Tool Observability
Show Hermes ecosystem health:
- Hermes core
- OpenClaw integration placeholder
- GitHub integration
- local VM runner
- CLI runner
- scheduler/cron
- deployment tools
- monitoring tools
- notification tools
- model/LLM provider placeholder
- secrets/config health
For each tool/agent show:
- status
- last success
- last failure
- calls today
- failure rate
- average latency
- config issue flag
---
# 7. `/hermes/settings` — Config
Create clean settings UI for:
- products registry
- task categories
- priority rules
- notification rules
- auto-retry rules
- approval thresholds
- retention period
- import/export JSON config
- demo/mock data toggle
No need to build real persistence if backend is not present yet. Use mock service/data layer first.
---
# Data model
Create TypeScript types/interfaces in a clean location such as:
- `src/types/hermes.ts`
- `types/hermes.ts`
- or existing repo convention
Use these models:
```ts
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 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:
| "manual"
| "cron"
| "github"
| "monitoring-alert"
| "email"
| "cli"
| "webhook"
| "local-agent"
| "hermes-planner";
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<string, unknown>;
toolName?: string;
command?: string;
artifactUrl?: string;
}
export interface HermesRun {
id: string;
taskId: string;
startedAt: string;
endedAt?: string;
status: HermesTaskStatus;
logs: string[];
metrics?: Record<string, number>;
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;
}
```
---
# Service layer
Create a clean data abstraction, for example:
getHermesOverview()
getHermesTasks(filters)
getHermesTaskById(id)
getHermesTaskEvents(taskId)
getHermesProducts()
getHermesHistory()
getHermesAgents()
getHermesSettings()
Initially back this with seed/mock data.
Important:
Do not put mock data directly inside UI components.
Use files like:
* src/lib/hermes/mock-data.ts
* src/lib/hermes/service.ts
* or equivalent repo convention.
Later this should be swappable with:
* local JSON logs
* SQLite
* Supabase
* Postgres
* API endpoint
* Hermes daemon telemetry
---
# UI requirements
Make it beautiful and professional.
Use:
* cards
* tables
* tabs
* badges
* filters
* progress bars
* timeline
* charts or visual bars
* empty states
* loading states
* error states
* responsive layout
Style:
* modern
* dark-mode friendly
* DevOps command-center feel
* readable for founder/executive view
* not cluttered
* clear status colors
* polished spacing and typography
Suggested components:
* HermesStatusCard
* MetricCard
* ActiveTasksPanel
* FounderAttentionQueue
* ProductHealthGrid
* TaskStatusBadge
* PriorityBadge
* TaskTimeline
* AgentStatusPanel
* HistoricalActivityChart
* FailureCategoryChart
* ProductActivityTable
* FilterBar
* EmptyState
* ErrorState
* LoadingSkeleton
---
# Founder-specific intelligence
Add computed insights:
Neglected products
Products with no Hermes activity in 14+ days.
Repeated failure products
Products with 3+ failed tasks recently.
Attention needed
Tasks blocked by:
* user approval
* missing credentials
* failing tests
* deployment failure
* high cost risk
* unclear requirements
Momentum score
Simple calculated product score using:
* recent completed tasks
* active tasks
* failed tasks
* last activity age
* priority
Weekly digest summary
Display:
* shipped this week
* failed this week
* blocked this week
* products advanced
* recommended next actions
---
# README update
Update README or create docs/hermes-dashboard.md.
Document:
* routes added
* how to run
* data model
* mock data location
* service abstraction
* how to connect real Hermes telemetry later
* future integration plan
Include real telemetry integration plan:
1. Hermes writes task events as JSONL.
2. Dashboard service ingests JSONL.
3. Store in SQLite/Postgres.
4. Add API endpoints.
5. Add websocket/SSE live updates.
6. Add GitHub/CI/CD/deployment integrations.
7. Add notifications.
---
# Quality bar
Before final response:
* run install if needed
* run lint
* run typecheck
* run tests if available
* run build
* fix all issues
* verify /hermes renders
* verify all new routes work
* verify no console errors
* verify responsive layout
* verify mock data loads
---
# Implementation status checklist
Update this checklist only after each item has evidence from source review, tests, build output, or browser verification.
- [ ] Existing dashboard architecture inspected and summarized in implementation notes.
- [ ] Data model and mock service implemented outside UI components.
- [ ] `/hermes` mission control route renders from the service layer.
- [ ] `/hermes/tasks` ledger has search, filters, sorting, pagination, expandable details, and JSON export.
- [ ] `/hermes/tasks/[id]` detail route shows summary, timeline, execution details, and learning sections.
- [ ] `/hermes/products` portfolio route includes priority, attention, no-recent-activity, repeated-failure, and recently-shipped views.
- [ ] `/hermes/history` route includes historical analytics with charts or accessible visual bars.
- [ ] `/hermes/agents` route shows agent/tool/integration health.
- [ ] `/hermes/settings` route shows editable-looking configuration panels and import/export affordances backed by mock data.
- [ ] Documentation created or updated with routes, run commands, mock data locations, and real telemetry integration plan.
- [ ] Lint passes or any pre-existing lint failures are explicitly identified.
- [ ] Typecheck passes.
- [ ] Unit/component tests pass.
- [ ] Production build passes.
- [ ] E2E or browser smoke verification covers all new routes with no console errors.
- [ ] Responsive layout checked at desktop and mobile widths.
Known roadmap assumptions to handle safely during implementation:
- Start with read-only mock data. Do not introduce credential, deployment, notification, or write-side operations without an explicit API and authorization plan.
- Treat telemetry integrations as documentation and service-boundary placeholders until real Hermes event sources are available.
- Do not mark live/real-time status as production telemetry unless it is backed by an actual source; label seed data clearly as mock/demo data.
- Avoid new third-party libraries unless existing dependencies cannot meet the UI requirement.
---
# Git workflow
Commit incrementally:
1. feat: add Hermes data model and mock service
2. feat: add Hermes mission control dashboard
3. feat: add Hermes task ledger and task detail views
4. feat: add Hermes product portfolio and history views
5. feat: add Hermes agents and settings views
6. docs: document Hermes dashboard
Push to origin main.
---
# Final response format
When done, report:
* Summary of what was built
* Routes added
* Key files changed
* How to run locally
* What mock data exists
* How to connect real Hermes data
* Tests/build/lint status
* Any known gaps
Use this as the shorter direct command too:
```bash
cd ~/repos/bytelyst-devops-tools
Then paste:
Build Hermes Mission Control as described above. Inspect existing architecture first, reuse repo conventions, implement with clean TypeScript, mock service layer, rich dashboard UI, docs, lint/build verification, and incremental commits to origin main.
```

View File

@ -50,6 +50,7 @@ Current key files:
- `docs/supported-scripts.md`
- `docs/operations.md`
- `docs/remove_user_interactive.md`
- `docs/hermes-setup-upgrade-roadmap.md`
### `.github/workflows/`
@ -165,6 +166,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.

View File

@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
# ════════════════════════════════════════════════════════════════
# Lucky25 Execution Monitoring Script
# ════════════════════════════════════════════════════════════════
# Runs every 15 minutes to monitor lucky25 test plan execution
# Logs status to /var/log/lucky25-monitoring.log
# ════════════════════════════════════════════════════════════════
LOG_FILE="/var/log/lucky25-monitoring.log"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_DIR="${SCRIPT_DIR}/../learning_ai_invt_trdg/backend"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
log "════════════════════════════════════════════════════════════════"
log "Starting Lucky25 Execution Monitoring"
log "════════════════════════════════════════════════════════════════"
# Create log file if it doesn't exist
touch "$LOG_FILE"
cd "$BACKEND_DIR"
# Run the status check using node with the compiled JS
node -e "
const { config } = require('./dist/src/config/index.js');
const { MANUAL_ENTRY_CONTAINER, queryDocuments } = require('./dist/src/services/tradingRecordStore.js');
async function main() {
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.created_at DESC';
const rows = await queryDocuments(MANUAL_ENTRY_CONTAINER, query, [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'manual_entry' },
]);
const lucky25Plans = rows.filter(row =>
row.hashtags && Array.isArray(row.hashtags) && row.hashtags.includes('lucky25') && row.active === true
);
const byStatus = {};
for (const plan of lucky25Plans) {
const status = plan.status || 'unknown';
byStatus[status] = (byStatus[status] || 0) + 1;
}
console.log('Lucky25 Plans Status:');
console.log('Total: ' + lucky25Plans.length);
console.log('Status breakdown:');
for (const [status, count] of Object.entries(byStatus)) {
console.log(' ' + status + ': ' + count);
}
const executedPlans = lucky25Plans.filter(p =>
p.status !== 'simple_armed_buy' && p.status !== 'deleted'
);
console.log('Execution Progress:');
console.log('Executed: ' + executedPlans.length + '/' + lucky25Plans.length);
console.log('Rate: ' + ((executedPlans.length / lucky25Plans.length) * 100).toFixed(1) + '%');
if (executedPlans.length > 0) {
console.log('Recent executions:');
const recent = executedPlans.slice(0, 3);
for (const plan of recent) {
console.log(' ' + plan.symbol + ' - ' + plan.status + ' - ' + plan.label);
}
}
}
main().catch(console.error);
"
log "Monitoring check completed"
log "════════════════════════════════════════════════════════════════"