Compare commits
10 Commits
bb39088f81
...
5a928b1925
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a928b1925 | ||
|
|
dea1546d9f | ||
|
|
62cf0c8c29 | ||
|
|
1ee9c54a54 | ||
|
|
62089a11cc | ||
|
|
47c96d5db4 | ||
|
|
1deb832b1a | ||
|
|
85f21ae9f6 | ||
|
|
4ae55fd3c8 | ||
|
|
d70accecb8 |
355
REPO_CONTEXT.md
Normal file
355
REPO_CONTEXT.md
Normal 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
|
||||
```
|
||||
@ -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
166
dashboard/ENDPOINTS.md
Normal 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`
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
55
dashboard/web/e2e/hermes.spec.ts
Normal file
55
dashboard/web/e2e/hermes.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
2
dashboard/web/next-env.d.ts
vendored
2
dashboard/web/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
59
dashboard/web/src/app/hermes/agents/page.tsx
Normal file
59
dashboard/web/src/app/hermes/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
dashboard/web/src/app/hermes/history/page.tsx
Normal file
94
dashboard/web/src/app/hermes/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
dashboard/web/src/app/hermes/layout.tsx
Normal file
14
dashboard/web/src/app/hermes/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
dashboard/web/src/app/hermes/page.tsx
Normal file
285
dashboard/web/src/app/hermes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
dashboard/web/src/app/hermes/products/page.tsx
Normal file
119
dashboard/web/src/app/hermes/products/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
dashboard/web/src/app/hermes/settings/page.tsx
Normal file
92
dashboard/web/src/app/hermes/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
dashboard/web/src/app/hermes/tasks/[id]/page.tsx
Normal file
167
dashboard/web/src/app/hermes/tasks/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
dashboard/web/src/app/hermes/tasks/page.tsx
Normal file
216
dashboard/web/src/app/hermes/tasks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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<{
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
86
dashboard/web/src/components/hermes-shell.tsx
Normal file
86
dashboard/web/src/components/hermes-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
50
dashboard/web/src/lib/hermes.test.ts
Normal file
50
dashboard/web/src/lib/hermes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
781
dashboard/web/src/lib/hermes.ts
Normal file
781
dashboard/web/src/lib/hermes.ts
Normal 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.';
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
109
deploy-clock.sh
109
deploy-clock.sh
@ -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 "══════════════════════════════════════════════════════════════════════"
|
||||
|
||||
115
deploy-notes.sh
115
deploy-notes.sh
@ -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 "══════════════════════════════════════════════════════════════════════"
|
||||
|
||||
314
docs/hermes-setup-upgrade-roadmap.md
Normal file
314
docs/hermes-setup-upgrade-roadmap.md
Normal 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
|
||||
703
docs/hermes_dashboard_roadmap.md
Normal file
703
docs/hermes_dashboard_roadmap.md
Normal 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 Saravana’s 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.
|
||||
```
|
||||
@ -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.
|
||||
|
||||
|
||||
78
scripts/monitor-lucky25-execution.sh
Executable file
78
scripts/monitor-lucky25-execution.sh
Executable 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 "════════════════════════════════════════════════════════════════"
|
||||
Loading…
Reference in New Issue
Block a user