Compare commits
52 Commits
35dd623ab5
...
v0.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
| d218902cb0 | |||
| b43e860c40 | |||
| 716f230f72 | |||
| a5ed260fbd | |||
| 9b5c15ca56 | |||
| 74c8c376b7 | |||
| 9901fba61e | |||
| 17144b1c42 | |||
| a6f75cd587 | |||
| 06e54328d5 | |||
| 7480deff10 | |||
| 1b66417be5 | |||
| 23d610ba5b | |||
| 25ae14aba1 | |||
| 1425893318 | |||
| bc4c1f9c70 | |||
| d66451cf48 | |||
| c23ebca648 | |||
|
|
eae55bc4a3 | ||
| b5ac2630c1 | |||
| 8424a28faa | |||
| d2cec04cba | |||
| 9ac971e857 | |||
| 0c2a6b14cf | |||
| af299abdaf | |||
| fa9f173f8e | |||
| 7935d86015 | |||
| f43631671f | |||
| 8328f9509b | |||
| f72e8c2da9 | |||
| 1a668627a3 | |||
| bd3625ae1b | |||
| aeac188d40 | |||
| f219dd71a0 | |||
| 2c3c1f67ac | |||
| dedc1af080 | |||
| 3b16b2c743 | |||
|
|
6fd8e85266 | ||
|
|
d3474cdd74 | ||
| 157b702331 | |||
|
|
63c6a129bd | ||
| 4a4aee7b7c | |||
|
|
9d9a01f5f7 | ||
|
|
5bce7dbb05 | ||
|
|
ab902250f8 | ||
|
|
d34f097a5c | ||
|
|
f4ad7eba37 | ||
|
|
4d089cd020 | ||
|
|
3258cd4f4d | ||
| 0a780a5062 | |||
| a1515676db | |||
| b719fa0444 |
37
.env.example
37
.env.example
@@ -15,6 +15,14 @@ WEB_PORT=3000
|
|||||||
# ======================
|
# ======================
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
|
# Frontend auth mode:
|
||||||
|
# - real: Normal auth/session flow
|
||||||
|
# - mock: Local-only seeded user for FE development (blocked outside NODE_ENV=development)
|
||||||
|
# Use `mock` locally to continue FE work when auth flow is unstable.
|
||||||
|
# If omitted, web runtime defaults:
|
||||||
|
# - development -> mock
|
||||||
|
# - production -> real
|
||||||
|
NEXT_PUBLIC_AUTH_MODE=real
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
@@ -70,9 +78,9 @@ OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
|||||||
OIDC_CLIENT_ID=your-client-id-here
|
OIDC_CLIENT_ID=your-client-id-here
|
||||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||||
# Redirect URI must match what's configured in Authentik
|
# Redirect URI must match what's configured in Authentik
|
||||||
# Development: http://localhost:3001/auth/callback/authentik
|
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
# Production: https://api.mosaicstack.dev/auth/callback/authentik
|
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback/authentik
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# Authentik PostgreSQL Database
|
# Authentik PostgreSQL Database
|
||||||
AUTHENTIK_POSTGRES_USER=authentik
|
AUTHENTIK_POSTGRES_USER=authentik
|
||||||
@@ -116,6 +124,9 @@ JWT_EXPIRATION=24h
|
|||||||
# This is used by BetterAuth for session management and CSRF protection
|
# This is used by BetterAuth for session management and CSRF protection
|
||||||
# Example: openssl rand -base64 32
|
# Example: openssl rand -base64 32
|
||||||
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
|
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
|
||||||
|
# Optional explicit BetterAuth origin for callback/error URL generation.
|
||||||
|
# When empty, backend falls back to NEXT_PUBLIC_API_URL.
|
||||||
|
BETTER_AUTH_URL=
|
||||||
|
|
||||||
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
|
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
|
||||||
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
|
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
|
||||||
@@ -204,11 +215,9 @@ NODE_ENV=development
|
|||||||
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
|
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
|
||||||
# For local builds, use docker-compose.build.yml instead
|
# For local builds, use docker-compose.build.yml instead
|
||||||
# Options:
|
# Options:
|
||||||
# - dev: Pull development images from registry (default, built from develop branch)
|
# - latest: Pull latest images from registry (default, built from main branch)
|
||||||
# - latest: Pull latest stable images from registry (built from main branch)
|
|
||||||
# - <commit-sha>: Use specific commit SHA tag (e.g., 658ec077)
|
|
||||||
# - <version>: Use specific version tag (e.g., v1.0.0)
|
# - <version>: Use specific version tag (e.g., v1.0.0)
|
||||||
IMAGE_TAG=dev
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Docker Compose Profiles
|
# Docker Compose Profiles
|
||||||
@@ -381,6 +390,17 @@ ELEMENT_IMAGE_TAG=latest
|
|||||||
# Health endpoints (/health/*) remain unauthenticated
|
# Health endpoints (/health/*) remain unauthenticated
|
||||||
ORCHESTRATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
|
ORCHESTRATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
|
||||||
|
|
||||||
|
# Runtime safety defaults (recommended for low-memory hosts)
|
||||||
|
MAX_CONCURRENT_AGENTS=2
|
||||||
|
SESSION_CLEANUP_DELAY_MS=30000
|
||||||
|
ORCHESTRATOR_QUEUE_NAME=orchestrator-tasks
|
||||||
|
ORCHESTRATOR_QUEUE_CONCURRENCY=1
|
||||||
|
ORCHESTRATOR_QUEUE_MAX_RETRIES=3
|
||||||
|
ORCHESTRATOR_QUEUE_BASE_DELAY_MS=1000
|
||||||
|
ORCHESTRATOR_QUEUE_MAX_DELAY_MS=60000
|
||||||
|
SANDBOX_DEFAULT_MEMORY_MB=256
|
||||||
|
SANDBOX_DEFAULT_CPU_LIMIT=1.0
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# ======================
|
# ======================
|
||||||
@@ -395,8 +415,7 @@ AI_PROVIDER=ollama
|
|||||||
OLLAMA_MODEL=llama3.1:latest
|
OLLAMA_MODEL=llama3.1:latest
|
||||||
|
|
||||||
# Claude API Key
|
# Claude API Key
|
||||||
# Required by the orchestrator service in swarm deployment.
|
# Required only when AI_PROVIDER=claude.
|
||||||
# Also used when AI_PROVIDER=claude for other services.
|
|
||||||
# Get your API key from: https://console.anthropic.com/
|
# Get your API key from: https://console.anthropic.com/
|
||||||
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY
|
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY
|
||||||
|
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -59,3 +59,13 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Orchestrator reports (generated by QA automation, cleaned up after processing)
|
# Orchestrator reports (generated by QA automation, cleaned up after processing)
|
||||||
docs/reports/qa-automation/
|
docs/reports/qa-automation/
|
||||||
|
|
||||||
|
# Repo-local orchestrator runtime artifacts
|
||||||
|
.mosaic/orchestrator/orchestrator.pid
|
||||||
|
.mosaic/orchestrator/state.json
|
||||||
|
.mosaic/orchestrator/tasks.json
|
||||||
|
.mosaic/orchestrator/matrix_state.json
|
||||||
|
.mosaic/orchestrator/logs/*.log
|
||||||
|
.mosaic/orchestrator/results/*
|
||||||
|
!.mosaic/orchestrator/logs/.gitkeep
|
||||||
|
!.mosaic/orchestrator/results/.gitkeep
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ This repository is attached to the machine-wide Mosaic framework.
|
|||||||
|
|
||||||
## Load Order for Agents
|
## Load Order for Agents
|
||||||
|
|
||||||
1. `~/.mosaic/STANDARDS.md`
|
1. `~/.config/mosaic/STANDARDS.md`
|
||||||
2. `AGENTS.md` (this repository)
|
2. `AGENTS.md` (this repository)
|
||||||
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
|
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
- Keep universal standards in `~/.mosaic`
|
- Keep universal standards in `~/.config/mosaic`
|
||||||
- Keep repo-specific behavior in this repo
|
- Keep repo-specific behavior in this repo
|
||||||
- Avoid copying large runtime configs into each project
|
- Avoid copying large runtime configs into each project
|
||||||
|
|||||||
18
.mosaic/orchestrator/config.json
Normal file
18
.mosaic/orchestrator/config.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"transport": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"control_room_id": "",
|
||||||
|
"workspace_id": "",
|
||||||
|
"homeserver_url": "",
|
||||||
|
"access_token": "",
|
||||||
|
"bot_user_id": ""
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"runtime": "codex",
|
||||||
|
"command_template": "bash scripts/agent/orchestrator-worker.sh {task_file}",
|
||||||
|
"timeout_seconds": 7200,
|
||||||
|
"max_attempts": 1
|
||||||
|
},
|
||||||
|
"quality_gates": ["pnpm lint", "pnpm typecheck", "pnpm test"]
|
||||||
|
}
|
||||||
1
.mosaic/orchestrator/logs/.gitkeep
Normal file
1
.mosaic/orchestrator/logs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
.mosaic/orchestrator/results/.gitkeep
Normal file
1
.mosaic/orchestrator/results/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
10
.mosaic/quality-rails.yml
Normal file
10
.mosaic/quality-rails.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
enabled: false
|
||||||
|
template: ""
|
||||||
|
|
||||||
|
# Set enabled: true and choose one template:
|
||||||
|
# - typescript-node
|
||||||
|
# - typescript-nextjs
|
||||||
|
# - monorepo
|
||||||
|
#
|
||||||
|
# Apply manually:
|
||||||
|
# ~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target <repo>
|
||||||
13
.trivyignore
13
.trivyignore
@@ -6,7 +6,7 @@
|
|||||||
# - npm bundled CVEs (5): npm removed from production Node.js images
|
# - npm bundled CVEs (5): npm removed from production Node.js images
|
||||||
# - Node.js 20 → 24 LTS migration (#367): base images updated
|
# - Node.js 20 → 24 LTS migration (#367): base images updated
|
||||||
#
|
#
|
||||||
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar (3 CVEs)
|
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar/minimatch (5 CVEs)
|
||||||
# Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6.
|
# Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6.
|
||||||
|
|
||||||
# === OpenBao false positives ===
|
# === OpenBao false positives ===
|
||||||
@@ -17,15 +17,18 @@ CVE-2024-9180 # HIGH: privilege escalation (fixed in 2.0.3)
|
|||||||
CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1)
|
CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1)
|
||||||
CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4)
|
CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4)
|
||||||
|
|
||||||
# === Next.js bundled tar CVEs (upstream — waiting on Next.js release) ===
|
# === Next.js bundled tar/minimatch CVEs (upstream — waiting on Next.js release) ===
|
||||||
# Next.js 16.1.6 bundles tar@7.5.2 in next/dist/compiled/tar/ (pre-compiled).
|
# Next.js 16.1.6 bundles tar@7.5.2 and minimatch@9.0.5 in next/dist/compiled/ (pre-compiled).
|
||||||
# This is NOT a pnpm dependency — it's embedded in the Next.js package itself.
|
# These are NOT pnpm dependencies — they're embedded in the Next.js package itself.
|
||||||
|
# pnpm overrides cannot reach these; only a Next.js upgrade can fix them.
|
||||||
# Affects web image only (orchestrator and API are clean).
|
# Affects web image only (orchestrator and API are clean).
|
||||||
# npm was also removed from all production images, eliminating the npm-bundled copy.
|
# npm was also removed from all production images, eliminating the npm-bundled copy.
|
||||||
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.7.
|
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.8 and minimatch >= 10.2.1.
|
||||||
CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3)
|
CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3)
|
||||||
CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4)
|
CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4)
|
||||||
CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7)
|
CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7)
|
||||||
|
CVE-2026-26960 # HIGH: tar arbitrary file read/write via malicious archive hardlink (needs tar >= 7.5.8)
|
||||||
|
CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs minimatch >= 10.2.1)
|
||||||
|
|
||||||
# === OpenBao Go stdlib (waiting on upstream rebuild) ===
|
# === OpenBao Go stdlib (waiting on upstream rebuild) ===
|
||||||
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
|
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
|
||||||
|
|||||||
@@ -85,12 +85,11 @@ install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
|
|||||||
|
|
||||||
## Image Tagging
|
## Image Tagging
|
||||||
|
|
||||||
| Condition | Tag | Purpose |
|
| Condition | Tag | Purpose |
|
||||||
| ---------------- | -------------------------- | -------------------------- |
|
| ------------- | -------------------------- | -------------------------- |
|
||||||
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
|
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
|
||||||
| `main` branch | `latest` | Current production release |
|
| `main` branch | `latest` | Current latest build |
|
||||||
| `develop` branch | `dev` | Current development build |
|
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
|
||||||
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
|
|
||||||
|
|
||||||
## Required Secrets
|
## Required Secrets
|
||||||
|
|
||||||
@@ -138,5 +137,5 @@ Fails on blockers or critical/high severity security findings.
|
|||||||
|
|
||||||
### Pipeline runs Docker builds on pull requests
|
### Pipeline runs Docker builds on pull requests
|
||||||
|
|
||||||
- Docker build steps have `when: branch: [main, develop]` guards
|
- Docker build steps have `when: branch: [main]` guards
|
||||||
- PRs only run quality gates, not Docker builds
|
- PRs only run quality gates, not Docker builds
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ when:
|
|||||||
- "turbo.json"
|
- "turbo.json"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- ".woodpecker/api.yml"
|
- ".woodpecker/api.yml"
|
||||||
|
- ".trivyignore"
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image "node:24-alpine"
|
- &node_image "node:24-alpine"
|
||||||
@@ -151,12 +152,10 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:dev"
|
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
@@ -179,7 +178,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="dev"
|
SCAN_TAG="latest"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -187,7 +186,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-api
|
- docker-build-api
|
||||||
@@ -229,7 +228,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-api"
|
link_package "stack-api"
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-api
|
- security-trivy-api
|
||||||
|
|||||||
@@ -92,12 +92,10 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:dev"
|
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- ruff-check
|
- ruff-check
|
||||||
@@ -124,7 +122,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="dev"
|
SCAN_TAG="latest"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -132,7 +130,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-coordinator
|
- docker-build-coordinator
|
||||||
@@ -174,7 +172,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-coordinator"
|
link_package "stack-coordinator"
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-coordinator
|
- security-trivy-coordinator
|
||||||
|
|||||||
@@ -36,12 +36,10 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:dev"
|
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
docker-build-openbao:
|
docker-build-openbao:
|
||||||
@@ -61,12 +59,10 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:dev"
|
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
# === Container Security Scans ===
|
# === Container Security Scans ===
|
||||||
@@ -87,7 +83,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="dev"
|
SCAN_TAG="latest"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -95,7 +91,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-postgres
|
- docker-build-postgres
|
||||||
@@ -116,7 +112,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="dev"
|
SCAN_TAG="latest"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -124,7 +120,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-openbao
|
- docker-build-openbao
|
||||||
@@ -167,7 +163,7 @@ steps:
|
|||||||
link_package "stack-postgres"
|
link_package "stack-postgres"
|
||||||
link_package "stack-openbao"
|
link_package "stack-openbao"
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-postgres
|
- security-trivy-postgres
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ when:
|
|||||||
- "turbo.json"
|
- "turbo.json"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- ".woodpecker/orchestrator.yml"
|
- ".woodpecker/orchestrator.yml"
|
||||||
|
- ".trivyignore"
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image "node:24-alpine"
|
- &node_image "node:24-alpine"
|
||||||
@@ -108,12 +109,10 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:dev"
|
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
@@ -136,7 +135,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="dev"
|
SCAN_TAG="latest"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -144,7 +143,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-orchestrator
|
- docker-build-orchestrator
|
||||||
@@ -186,7 +185,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-orchestrator"
|
link_package "stack-orchestrator"
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-orchestrator
|
- security-trivy-orchestrator
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ when:
|
|||||||
- "turbo.json"
|
- "turbo.json"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- ".woodpecker/web.yml"
|
- ".woodpecker/web.yml"
|
||||||
|
- ".trivyignore"
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image "node:24-alpine"
|
- &node_image "node:24-alpine"
|
||||||
@@ -119,12 +120,10 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:dev"
|
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
@@ -147,7 +146,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="dev"
|
SCAN_TAG="latest"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -155,7 +154,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-web
|
- docker-build-web
|
||||||
@@ -197,7 +196,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-web"
|
link_package "stack-web"
|
||||||
when:
|
when:
|
||||||
- branch: [main, develop]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-web
|
- security-trivy-web
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Optional:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/agent/log-limitation.sh "Short Name"
|
bash scripts/agent/log-limitation.sh "Short Name"
|
||||||
|
bash scripts/agent/orchestrator-daemon.sh status
|
||||||
|
bash scripts/agent/orchestrator-events.sh recent --limit 50
|
||||||
```
|
```
|
||||||
|
|
||||||
## Repo Context
|
## Repo Context
|
||||||
|
|||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -1,14 +1,10 @@
|
|||||||
# Compatibility Pointer
|
# CLAUDE Compatibility Pointer
|
||||||
|
|
||||||
This repository uses an agent-neutral Mosaic standards model.
|
This file exists so Claude Code sessions load Mosaic standards.
|
||||||
|
|
||||||
Authoritative repo guidance is in `AGENTS.md`.
|
## MANDATORY — Read Before Any Response
|
||||||
|
|
||||||
Load order for Claude sessions:
|
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`.
|
||||||
|
|
||||||
1. `SOUL.md`
|
That file is the universal agent configuration. Do NOT respond until you have loaded it.
|
||||||
2. `~/.mosaic/STANDARDS.md`
|
Then read the project-local `AGENTS.md` in this repository for project-specific guidance.
|
||||||
3. `AGENTS.md`
|
|
||||||
4. `.mosaic/repo-hooks.sh`
|
|
||||||
|
|
||||||
If you were started from `CLAUDE.md`, continue by reading `AGENTS.md` now.
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -232,7 +232,7 @@ docker compose -f docker-compose.openbao.yml up -d
|
|||||||
sleep 30 # Wait for auto-initialization
|
sleep 30 # Wait for auto-initialization
|
||||||
|
|
||||||
# 5. Deploy swarm stack
|
# 5. Deploy swarm stack
|
||||||
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
|
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
|
||||||
|
|
||||||
# 6. Check deployment status
|
# 6. Check deployment status
|
||||||
docker stack services mosaic
|
docker stack services mosaic
|
||||||
@@ -526,10 +526,9 @@ KNOWLEDGE_CACHE_TTL=300 # 5 minutes
|
|||||||
|
|
||||||
### Branch Strategy
|
### Branch Strategy
|
||||||
|
|
||||||
- `main` — Stable releases only
|
- `main` — Trunk branch (all development merges here)
|
||||||
- `develop` — Active development (default working branch)
|
- `feature/*` — Feature branches from main
|
||||||
- `feature/*` — Feature branches from develop
|
- `fix/*` — Bug fix branches from main
|
||||||
- `fix/*` — Bug fix branches
|
|
||||||
|
|
||||||
### Running Locally
|
### Running Locally
|
||||||
|
|
||||||
@@ -739,7 +738,7 @@ See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for
|
|||||||
4. Run tests: `pnpm test`
|
4. Run tests: `pnpm test`
|
||||||
5. Build: `pnpm build`
|
5. Build: `pnpm build`
|
||||||
6. Commit with conventional format: `feat(#issue): Description`
|
6. Commit with conventional format: `feat(#issue): Description`
|
||||||
7. Push and create a pull request to `develop`
|
7. Push and create a pull request to `main`
|
||||||
|
|
||||||
### Commit Format
|
### Commit Format
|
||||||
|
|
||||||
|
|||||||
2
SOUL.md
2
SOUL.md
@@ -10,7 +10,7 @@ You are Jarvis for the Mosaic Stack repository, running on the current agent run
|
|||||||
- Be calm and clear: keep responses concise, chunked, and PDA-friendly.
|
- Be calm and clear: keep responses concise, chunked, and PDA-friendly.
|
||||||
- Respect canonical sources:
|
- Respect canonical sources:
|
||||||
- Repo operations and conventions: `AGENTS.md`
|
- Repo operations and conventions: `AGENTS.md`
|
||||||
- Machine-wide rails: `~/.mosaic/STANDARDS.md`
|
- Machine-wide rails: `~/.config/mosaic/STANDARDS.md`
|
||||||
- Repo lifecycle hooks: `.mosaic/repo-hooks.sh`
|
- Repo lifecycle hooks: `.mosaic/repo-hooks.sh`
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ vi.mock("better-auth/adapters/prisma", () => ({
|
|||||||
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isOidcEnabled, validateOidcConfig, createAuth, getTrustedOrigins } from "./auth.config";
|
import {
|
||||||
|
isOidcEnabled,
|
||||||
|
validateOidcConfig,
|
||||||
|
createAuth,
|
||||||
|
getTrustedOrigins,
|
||||||
|
getBetterAuthBaseUrl,
|
||||||
|
} from "./auth.config";
|
||||||
|
|
||||||
describe("auth.config", () => {
|
describe("auth.config", () => {
|
||||||
// Store original env vars to restore after each test
|
// Store original env vars to restore after each test
|
||||||
@@ -32,6 +38,7 @@ describe("auth.config", () => {
|
|||||||
delete process.env.OIDC_CLIENT_SECRET;
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
delete process.env.OIDC_REDIRECT_URI;
|
delete process.env.OIDC_REDIRECT_URI;
|
||||||
delete process.env.NODE_ENV;
|
delete process.env.NODE_ENV;
|
||||||
|
delete process.env.BETTER_AUTH_URL;
|
||||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||||
delete process.env.NEXT_PUBLIC_API_URL;
|
delete process.env.NEXT_PUBLIC_API_URL;
|
||||||
delete process.env.TRUSTED_ORIGINS;
|
delete process.env.TRUSTED_ORIGINS;
|
||||||
@@ -95,7 +102,7 @@ describe("auth.config", () => {
|
|||||||
it("should throw when OIDC_ISSUER is missing", () => {
|
it("should throw when OIDC_ISSUER is missing", () => {
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
|
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
|
||||||
@@ -104,7 +111,7 @@ describe("auth.config", () => {
|
|||||||
it("should throw when OIDC_CLIENT_ID is missing", () => {
|
it("should throw when OIDC_CLIENT_ID is missing", () => {
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
|
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
|
||||||
});
|
});
|
||||||
@@ -112,7 +119,7 @@ describe("auth.config", () => {
|
|||||||
it("should throw when OIDC_CLIENT_SECRET is missing", () => {
|
it("should throw when OIDC_CLIENT_SECRET is missing", () => {
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
|
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
|
||||||
});
|
});
|
||||||
@@ -146,7 +153,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = " ";
|
process.env.OIDC_ISSUER = " ";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||||
});
|
});
|
||||||
@@ -155,7 +162,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
|
||||||
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
|
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
|
||||||
@@ -165,7 +172,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).not.toThrow();
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
});
|
});
|
||||||
@@ -189,30 +196,30 @@ describe("auth.config", () => {
|
|||||||
expect(() => validateOidcConfig()).toThrow("Parse error:");
|
expect(() => validateOidcConfig()).toThrow("Parse error:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/callback", () => {
|
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/oauth2/callback", () => {
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow(
|
expect(() => validateOidcConfig()).toThrow(
|
||||||
'OIDC_REDIRECT_URI path must start with "/auth/callback"'
|
'OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback"'
|
||||||
);
|
);
|
||||||
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
|
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept a valid OIDC_REDIRECT_URI with /auth/callback path", () => {
|
it("should accept a valid OIDC_REDIRECT_URI with /auth/oauth2/callback path", () => {
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).not.toThrow();
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept OIDC_REDIRECT_URI with exactly /auth/callback path", () => {
|
it("should accept OIDC_REDIRECT_URI with exactly /auth/oauth2/callback path", () => {
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).not.toThrow();
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should warn but not throw when using localhost in production", () => {
|
it("should warn but not throw when using localhost in production", () => {
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -226,7 +233,7 @@ describe("auth.config", () => {
|
|||||||
|
|
||||||
it("should warn but not throw when using 127.0.0.1 in production", () => {
|
it("should warn but not throw when using 127.0.0.1 in production", () => {
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -240,7 +247,7 @@ describe("auth.config", () => {
|
|||||||
|
|
||||||
it("should not warn about localhost when not in production", () => {
|
it("should not warn about localhost when not in production", () => {
|
||||||
process.env.NODE_ENV = "development";
|
process.env.NODE_ENV = "development";
|
||||||
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -265,16 +272,19 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
createAuth(mockPrisma);
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
expect(mockGenericOAuth).toHaveBeenCalledOnce();
|
expect(mockGenericOAuth).toHaveBeenCalledOnce();
|
||||||
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
|
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
|
||||||
config: Array<{ pkce?: boolean }>;
|
config: Array<{ pkce?: boolean; redirectURI?: string }>;
|
||||||
};
|
};
|
||||||
expect(callArgs.config[0].pkce).toBe(true);
|
expect(callArgs.config[0].pkce).toBe(true);
|
||||||
|
expect(callArgs.config[0].redirectURI).toBe(
|
||||||
|
"https://app.example.com/auth/oauth2/callback/authentik"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not call genericOAuth when OIDC is disabled", () => {
|
it("should not call genericOAuth when OIDC is disabled", () => {
|
||||||
@@ -290,7 +300,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ENABLED = "true";
|
process.env.OIDC_ENABLED = "true";
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
// OIDC_CLIENT_ID deliberately not set
|
// OIDC_CLIENT_ID deliberately not set
|
||||||
|
|
||||||
// validateOidcConfig will throw first, so we need to bypass it
|
// validateOidcConfig will throw first, so we need to bypass it
|
||||||
@@ -307,7 +317,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ENABLED = "true";
|
process.env.OIDC_ENABLED = "true";
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
// OIDC_CLIENT_SECRET deliberately not set
|
// OIDC_CLIENT_SECRET deliberately not set
|
||||||
|
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
@@ -318,7 +328,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ENABLED = "true";
|
process.env.OIDC_ENABLED = "true";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
// OIDC_ISSUER deliberately not set
|
// OIDC_ISSUER deliberately not set
|
||||||
|
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
@@ -354,8 +364,7 @@ describe("auth.config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
|
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
|
||||||
process.env.TRUSTED_ORIGINS =
|
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,https://api.mosaicstack.dev";
|
||||||
"https://app.mosaicstack.dev,https://api.mosaicstack.dev";
|
|
||||||
|
|
||||||
const origins = getTrustedOrigins();
|
const origins = getTrustedOrigins();
|
||||||
|
|
||||||
@@ -364,8 +373,7 @@ describe("auth.config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
|
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
|
||||||
process.env.TRUSTED_ORIGINS =
|
process.env.TRUSTED_ORIGINS = " https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
|
||||||
" https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
|
|
||||||
|
|
||||||
const origins = getTrustedOrigins();
|
const origins = getTrustedOrigins();
|
||||||
|
|
||||||
@@ -516,6 +524,21 @@ describe("auth.config", () => {
|
|||||||
expect(config.session.updateAge).toBe(7200);
|
expect(config.session.updateAge).toBe(7200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should configure BetterAuth database ID generation as UUID", () => {
|
||||||
|
const mockPrisma = {} as PrismaClient;
|
||||||
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
||||||
|
const config = mockBetterAuth.mock.calls[0][0] as {
|
||||||
|
advanced: {
|
||||||
|
database: {
|
||||||
|
generateId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(config.advanced.database.generateId).toBe("uuid");
|
||||||
|
});
|
||||||
|
|
||||||
it("should set httpOnly cookie attribute to true", () => {
|
it("should set httpOnly cookie attribute to true", () => {
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
createAuth(mockPrisma);
|
createAuth(mockPrisma);
|
||||||
@@ -552,6 +575,7 @@ describe("auth.config", () => {
|
|||||||
|
|
||||||
it("should set secure cookie attribute to true in production", () => {
|
it("should set secure cookie attribute to true in production", () => {
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
createAuth(mockPrisma);
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
@@ -624,4 +648,69 @@ describe("auth.config", () => {
|
|||||||
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
|
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getBetterAuthBaseUrl", () => {
|
||||||
|
it("should prefer BETTER_AUTH_URL when set", () => {
|
||||||
|
process.env.BETTER_AUTH_URL = "https://auth-base.example.com";
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||||
|
|
||||||
|
expect(getBetterAuthBaseUrl()).toBe("https://auth-base.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to NEXT_PUBLIC_API_URL when BETTER_AUTH_URL is not set", () => {
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||||
|
|
||||||
|
expect(getBetterAuthBaseUrl()).toBe("https://api.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when base URL is invalid", () => {
|
||||||
|
process.env.BETTER_AUTH_URL = "not-a-url";
|
||||||
|
|
||||||
|
expect(() => getBetterAuthBaseUrl()).toThrow("BetterAuth base URL must be a valid URL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when base URL is missing in production", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
expect(() => getBetterAuthBaseUrl()).toThrow("Missing BetterAuth base URL in production");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when base URL is not https in production", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
process.env.BETTER_AUTH_URL = "http://api.example.com";
|
||||||
|
|
||||||
|
expect(() => getBetterAuthBaseUrl()).toThrow(
|
||||||
|
"BetterAuth base URL must use https in production"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAuth - baseURL wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBetterAuth.mockClear();
|
||||||
|
mockPrismaAdapter.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass BETTER_AUTH_URL into BetterAuth config", () => {
|
||||||
|
process.env.BETTER_AUTH_URL = "https://api.mosaicstack.dev";
|
||||||
|
|
||||||
|
const mockPrisma = {} as PrismaClient;
|
||||||
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
||||||
|
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
|
||||||
|
expect(config.baseURL).toBe("https://api.mosaicstack.dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass NEXT_PUBLIC_API_URL into BetterAuth config when BETTER_AUTH_URL is absent", () => {
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.fallback.dev";
|
||||||
|
|
||||||
|
const mockPrisma = {} as PrismaClient;
|
||||||
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
||||||
|
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
|
||||||
|
expect(config.baseURL).toBe("https://api.fallback.dev");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
@@ -14,6 +13,41 @@ const REQUIRED_OIDC_ENV_VARS = [
|
|||||||
"OIDC_REDIRECT_URI",
|
"OIDC_REDIRECT_URI",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve BetterAuth base URL from explicit auth URL or API URL.
|
||||||
|
* BetterAuth uses this to generate absolute callback/error URLs.
|
||||||
|
*/
|
||||||
|
export function getBetterAuthBaseUrl(): string | undefined {
|
||||||
|
const configured = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
if (!configured || configured.trim() === "") {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
throw new Error(
|
||||||
|
"Missing BetterAuth base URL in production. Set BETTER_AUTH_URL (preferred) or NEXT_PUBLIC_API_URL."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(configured);
|
||||||
|
} catch (urlError: unknown) {
|
||||||
|
const detail = urlError instanceof Error ? urlError.message : String(urlError);
|
||||||
|
throw new Error(
|
||||||
|
`BetterAuth base URL must be a valid URL. Current value: "${configured}". Parse error: ${detail}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error(
|
||||||
|
`BetterAuth base URL must use https in production. Current value: "${configured}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.origin;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if OIDC authentication is enabled via environment variable
|
* Check if OIDC authentication is enabled via environment variable
|
||||||
*/
|
*/
|
||||||
@@ -59,17 +93,17 @@ export function validateOidcConfig(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/callback path
|
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path
|
||||||
validateRedirectUri();
|
validateRedirectUri();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the OIDC_REDIRECT_URI environment variable.
|
* Validates the OIDC_REDIRECT_URI environment variable.
|
||||||
* - Must be a parseable URL
|
* - Must be a parseable URL
|
||||||
* - Path must start with /auth/callback
|
* - Path must start with /auth/oauth2/callback
|
||||||
* - Warns (but does not throw) if using localhost in production
|
* - Warns (but does not throw) if using localhost in production
|
||||||
*
|
*
|
||||||
* @throws Error if URL is invalid or path does not start with /auth/callback
|
* @throws Error if URL is invalid or path does not start with /auth/oauth2/callback
|
||||||
*/
|
*/
|
||||||
function validateRedirectUri(): void {
|
function validateRedirectUri(): void {
|
||||||
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||||
@@ -86,14 +120,14 @@ function validateRedirectUri(): void {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
|
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
|
||||||
`Parse error: ${detail}. ` +
|
`Parse error: ${detail}. ` +
|
||||||
`Example: "https://app.example.com/auth/callback/authentik".`
|
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.pathname.startsWith("/auth/callback")) {
|
if (!parsed.pathname.startsWith("/auth/oauth2/callback")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`OIDC_REDIRECT_URI path must start with "/auth/callback". Current path: "${parsed.pathname}". ` +
|
`OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` +
|
||||||
`Example: "https://app.example.com/auth/callback/authentik".`
|
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +154,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
|||||||
const clientId = process.env.OIDC_CLIENT_ID;
|
const clientId = process.env.OIDC_CLIENT_ID;
|
||||||
const clientSecret = process.env.OIDC_CLIENT_SECRET;
|
const clientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||||
const issuer = process.env.OIDC_ISSUER;
|
const issuer = process.env.OIDC_ISSUER;
|
||||||
|
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
|
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
|
||||||
@@ -130,6 +165,9 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
|||||||
if (!issuer) {
|
if (!issuer) {
|
||||||
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
|
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
|
||||||
}
|
}
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new Error("OIDC_REDIRECT_URI is required when OIDC is enabled but was not set.");
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
@@ -139,6 +177,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
discoveryUrl: `${issuer}.well-known/openid-configuration`,
|
discoveryUrl: `${issuer}.well-known/openid-configuration`,
|
||||||
|
redirectURI: redirectUri,
|
||||||
pkce: true,
|
pkce: true,
|
||||||
scopes: ["openid", "profile", "email"],
|
scopes: ["openid", "profile", "email"],
|
||||||
},
|
},
|
||||||
@@ -203,7 +242,10 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
// Validate OIDC configuration at startup - fail fast if misconfigured
|
// Validate OIDC configuration at startup - fail fast if misconfigured
|
||||||
validateOidcConfig();
|
validateOidcConfig();
|
||||||
|
|
||||||
|
const baseURL = getBetterAuthBaseUrl();
|
||||||
|
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
|
baseURL,
|
||||||
basePath: "/auth",
|
basePath: "/auth",
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: "postgresql",
|
provider: "postgresql",
|
||||||
@@ -217,7 +259,10 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
generateId: () => randomUUID(),
|
database: {
|
||||||
|
// BetterAuth's default ID generator emits opaque strings; our auth tables use UUID PKs.
|
||||||
|
generateId: "uuid",
|
||||||
|
},
|
||||||
defaultCookieAttributes: {
|
defaultCookieAttributes: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
|||||||
@@ -102,11 +102,46 @@ describe("AuthController", () => {
|
|||||||
expect(err).toBeInstanceOf(HttpException);
|
expect(err).toBeInstanceOf(HttpException);
|
||||||
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
expect((err as HttpException).getResponse()).toBe(
|
expect((err as HttpException).getResponse()).toBe(
|
||||||
"Unable to complete authentication. Please try again in a moment.",
|
"Unable to complete authentication. Please try again in a moment."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should preserve better-call status and body for handler APIError", async () => {
|
||||||
|
const apiError = {
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
message: "Invalid OAuth configuration",
|
||||||
|
body: {
|
||||||
|
message: "Invalid OAuth configuration",
|
||||||
|
code: "INVALID_OAUTH_CONFIGURATION",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockNodeHandler.mockRejectedValueOnce(apiError);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/sign-in/oauth2",
|
||||||
|
headers: {},
|
||||||
|
ip: "192.168.1.10",
|
||||||
|
socket: { remoteAddress: "192.168.1.10" },
|
||||||
|
} as unknown as ExpressRequest;
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
headersSent: false,
|
||||||
|
} as unknown as ExpressResponse;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
expect.unreachable("Expected HttpException to be thrown");
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(HttpException);
|
||||||
|
expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
|
||||||
|
expect((err as HttpException).getResponse()).toMatchObject({
|
||||||
|
message: "Invalid OAuth configuration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("should log warning and not throw when handler throws after headers sent", async () => {
|
it("should log warning and not throw when handler throws after headers sent", async () => {
|
||||||
const handlerError = new Error("Stream interrupted");
|
const handlerError = new Error("Stream interrupted");
|
||||||
mockNodeHandler.mockRejectedValueOnce(handlerError);
|
mockNodeHandler.mockRejectedValueOnce(handlerError);
|
||||||
@@ -142,9 +177,7 @@ describe("AuthController", () => {
|
|||||||
headersSent: false,
|
headersSent: false,
|
||||||
} as unknown as ExpressResponse;
|
} as unknown as ExpressResponse;
|
||||||
|
|
||||||
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(
|
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
|
||||||
HttpException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +220,7 @@ describe("AuthController", () => {
|
|||||||
OIDC_CLIENT_SECRET: "test-client-secret",
|
OIDC_CLIENT_SECRET: "test-client-secret",
|
||||||
OIDC_CLIENT_ID: "test-client-id",
|
OIDC_CLIENT_ID: "test-client-id",
|
||||||
OIDC_ISSUER: "https://auth.test.com/",
|
OIDC_ISSUER: "https://auth.test.com/",
|
||||||
OIDC_REDIRECT_URI: "https://app.test.com/auth/callback/authentik",
|
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
|
||||||
BETTER_AUTH_SECRET: "test-better-auth-secret",
|
BETTER_AUTH_SECRET: "test-better-auth-secret",
|
||||||
JWT_SECRET: "test-jwt-secret",
|
JWT_SECRET: "test-jwt-secret",
|
||||||
CSRF_SECRET: "test-csrf-secret",
|
CSRF_SECRET: "test-csrf-secret",
|
||||||
@@ -296,11 +329,9 @@ describe("AuthController", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||||
UnauthorizedException,
|
"Missing authentication context"
|
||||||
);
|
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
||||||
"Missing authentication context",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,22 +344,18 @@ describe("AuthController", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||||
UnauthorizedException,
|
"Missing authentication context"
|
||||||
);
|
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
||||||
"Missing authentication context",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
|
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
|
||||||
const mockRequest = {};
|
const mockRequest = {};
|
||||||
|
|
||||||
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||||
UnauthorizedException,
|
"Missing authentication context"
|
||||||
);
|
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
||||||
"Missing authentication context",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -401,9 +428,7 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||||
expect.stringContaining("203.0.113.50"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
|
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
|
||||||
@@ -423,13 +448,9 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||||
expect.stringContaining("203.0.113.50"),
|
|
||||||
);
|
|
||||||
// Ensure it does NOT contain the second IP in the extracted position
|
// Ensure it does NOT contain the second IP in the extracted position
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
|
||||||
expect.not.stringContaining("70.41.3.18"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract first IP from X-Forwarded-For as array", async () => {
|
it("should extract first IP from X-Forwarded-For as array", async () => {
|
||||||
@@ -449,9 +470,7 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||||
expect.stringContaining("203.0.113.50"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
|
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
|
||||||
@@ -471,9 +490,7 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
|
||||||
expect.stringContaining("192.168.1.100"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export class AuthController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
const mappedError = this.mapToHttpException(error);
|
||||||
|
if (mappedError) {
|
||||||
|
throw mappedError;
|
||||||
|
}
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Unable to complete authentication. Please try again in a moment.",
|
"Unable to complete authentication. Please try again in a moment.",
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR
|
HttpStatus.INTERNAL_SERVER_ERROR
|
||||||
@@ -159,4 +164,45 @@ export class AuthController {
|
|||||||
// Fall back to direct IP
|
// Fall back to direct IP
|
||||||
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve known HTTP errors from BetterAuth/better-call instead of converting
|
||||||
|
* every failure into a generic 500.
|
||||||
|
*/
|
||||||
|
private mapToHttpException(error: unknown): HttpException | null {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error || typeof error !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = "statusCode" in error ? error.statusCode : undefined;
|
||||||
|
if (!this.isHttpStatus(statusCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = "body" in error && error.body !== undefined ? error.body : undefined;
|
||||||
|
if (
|
||||||
|
responseBody !== undefined &&
|
||||||
|
responseBody !== null &&
|
||||||
|
(typeof responseBody === "string" || typeof responseBody === "object")
|
||||||
|
) {
|
||||||
|
return new HttpException(responseBody, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
"message" in error && typeof error.message === "string" && error.message.length > 0
|
||||||
|
? error.message
|
||||||
|
: "Authentication request failed";
|
||||||
|
return new HttpException(message, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isHttpStatus(value: unknown): value is number {
|
||||||
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value >= 400 && value <= 599;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ describe("AuthService", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should return session data for valid token", async () => {
|
it("should validate session token using secure BetterAuth cookie header", async () => {
|
||||||
const auth = service.getAuth();
|
const auth = service.getAuth();
|
||||||
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
|
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
|
||||||
auth.api = { getSession: mockGetSession } as any;
|
auth.api = { getSession: mockGetSession } as any;
|
||||||
@@ -418,7 +418,58 @@ describe("AuthService", () => {
|
|||||||
const result = await service.verifySession("valid-token");
|
const result = await service.verifySession("valid-token");
|
||||||
|
|
||||||
expect(result).toEqual(mockSessionData);
|
expect(result).toEqual(mockSessionData);
|
||||||
|
expect(mockGetSession).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGetSession).toHaveBeenCalledWith({
|
expect(mockGetSession).toHaveBeenCalledWith({
|
||||||
|
headers: {
|
||||||
|
cookie: "__Secure-better-auth.session_token=valid-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve raw cookie token value without URL re-encoding", async () => {
|
||||||
|
const auth = service.getAuth();
|
||||||
|
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
|
||||||
|
auth.api = { getSession: mockGetSession } as any;
|
||||||
|
|
||||||
|
const result = await service.verifySession("tok/with+=chars=");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSessionData);
|
||||||
|
expect(mockGetSession).toHaveBeenCalledWith({
|
||||||
|
headers: {
|
||||||
|
cookie: "__Secure-better-auth.session_token=tok/with+=chars=",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to Authorization header when cookie-based lookups miss", async () => {
|
||||||
|
const auth = service.getAuth();
|
||||||
|
const mockGetSession = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(mockSessionData);
|
||||||
|
auth.api = { getSession: mockGetSession } as any;
|
||||||
|
|
||||||
|
const result = await service.verifySession("valid-token");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSessionData);
|
||||||
|
expect(mockGetSession).toHaveBeenNthCalledWith(1, {
|
||||||
|
headers: {
|
||||||
|
cookie: "__Secure-better-auth.session_token=valid-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockGetSession).toHaveBeenNthCalledWith(2, {
|
||||||
|
headers: {
|
||||||
|
cookie: "better-auth.session_token=valid-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockGetSession).toHaveBeenNthCalledWith(3, {
|
||||||
|
headers: {
|
||||||
|
cookie: "__Host-better-auth.session_token=valid-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockGetSession).toHaveBeenNthCalledWith(4, {
|
||||||
headers: {
|
headers: {
|
||||||
authorization: "Bearer valid-token",
|
authorization: "Bearer valid-token",
|
||||||
},
|
},
|
||||||
@@ -517,14 +568,10 @@ describe("AuthService", () => {
|
|||||||
|
|
||||||
it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
|
it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
|
||||||
const auth = service.getAuth();
|
const auth = service.getAuth();
|
||||||
const mockGetSession = vi
|
const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired"));
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(new Error("certificate has expired"));
|
|
||||||
auth.api = { getSession: mockGetSession } as any;
|
auth.api = { getSession: mockGetSession } as any;
|
||||||
|
|
||||||
await expect(service.verifySession("any-token")).rejects.toThrow(
|
await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired");
|
||||||
"certificate has expired"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {
|
it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ interface VerifiedSession {
|
|||||||
session: Record<string, unknown>;
|
session: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionHeaderCandidate {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
@@ -103,36 +107,27 @@ export class AuthService {
|
|||||||
* Only known-safe auth errors return null; everything else propagates as 500.
|
* Only known-safe auth errors return null; everything else propagates as 500.
|
||||||
*/
|
*/
|
||||||
async verifySession(token: string): Promise<VerifiedSession | null> {
|
async verifySession(token: string): Promise<VerifiedSession | null> {
|
||||||
try {
|
let sawNonError = false;
|
||||||
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
|
|
||||||
const session = await this.auth.api.getSession({
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
for (const candidate of this.buildSessionHeaderCandidates(token)) {
|
||||||
return null;
|
try {
|
||||||
}
|
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
|
||||||
|
const session = await this.auth.api.getSession(candidate);
|
||||||
|
|
||||||
return {
|
if (!session) {
|
||||||
user: session.user as Record<string, unknown>,
|
continue;
|
||||||
session: session.session as Record<string, unknown>,
|
}
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
return {
|
||||||
// Only known-safe auth errors return null
|
user: session.user as Record<string, unknown>,
|
||||||
if (error instanceof Error) {
|
session: session.session as Record<string, unknown>,
|
||||||
const msg = error.message.toLowerCase();
|
};
|
||||||
const isExpectedAuthError =
|
} catch (error: unknown) {
|
||||||
msg.includes("invalid token") ||
|
if (error instanceof Error) {
|
||||||
msg.includes("token expired") ||
|
if (this.isExpectedAuthError(error.message)) {
|
||||||
msg.includes("session expired") ||
|
continue;
|
||||||
msg.includes("session not found") ||
|
}
|
||||||
msg.includes("invalid session") ||
|
|
||||||
msg === "unauthorized" ||
|
|
||||||
msg === "expired";
|
|
||||||
|
|
||||||
if (!isExpectedAuthError) {
|
|
||||||
// Infrastructure or unexpected — propagate as 500
|
// Infrastructure or unexpected — propagate as 500
|
||||||
const safeMessage = (error.stack ?? error.message).replace(
|
const safeMessage = (error.stack ?? error.message).replace(
|
||||||
/Bearer\s+\S+/gi,
|
/Bearer\s+\S+/gi,
|
||||||
@@ -141,14 +136,55 @@ export class AuthService {
|
|||||||
this.logger.error("Session verification failed due to unexpected error", safeMessage);
|
this.logger.error("Session verification failed due to unexpected error", safeMessage);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-Error thrown values — log once for observability, treat as auth failure
|
||||||
|
if (!sawNonError) {
|
||||||
|
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
|
||||||
|
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
|
||||||
|
sawNonError = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Non-Error thrown values — log for observability, treat as auth failure
|
|
||||||
if (!(error instanceof Error)) {
|
|
||||||
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
|
|
||||||
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSessionHeaderCandidates(token: string): SessionHeaderCandidate[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
cookie: `__Secure-better-auth.session_token=${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
cookie: `better-auth.session_token=${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
cookie: `__Host-better-auth.session_token=${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private isExpectedAuthError(message: string): boolean {
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes("invalid token") ||
|
||||||
|
normalized.includes("token expired") ||
|
||||||
|
normalized.includes("session expired") ||
|
||||||
|
normalized.includes("session not found") ||
|
||||||
|
normalized.includes("invalid session") ||
|
||||||
|
normalized === "unauthorized" ||
|
||||||
|
normalized === "expired"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { AuthService } from "../auth.service";
|
import { AuthService } from "../auth.service";
|
||||||
import type { AuthUser } from "@mosaic/shared";
|
import type { AuthUser } from "@mosaic/shared";
|
||||||
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
|
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(AuthGuard.name);
|
||||||
|
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@@ -59,7 +67,8 @@ export class AuthGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract token from cookie (BetterAuth stores session token in better-auth.session_token cookie)
|
* Extract token from cookie.
|
||||||
|
* BetterAuth may prefix the cookie name with "__Secure-" when running on HTTPS.
|
||||||
*/
|
*/
|
||||||
private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined {
|
private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined {
|
||||||
// Express types `cookies` as `any`; cast to a known shape for type safety.
|
// Express types `cookies` as `any`; cast to a known shape for type safety.
|
||||||
@@ -68,8 +77,23 @@ export class AuthGuard implements CanActivate {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BetterAuth uses 'better-auth.session_token' as the cookie name by default
|
// BetterAuth default cookie name is "better-auth.session_token"
|
||||||
return cookies["better-auth.session_token"];
|
// When Secure cookies are enabled, BetterAuth prefixes with "__Secure-".
|
||||||
|
const candidates = [
|
||||||
|
"__Secure-better-auth.session_token",
|
||||||
|
"better-auth.session_token",
|
||||||
|
"__Host-better-auth.session_token",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const name of candidates) {
|
||||||
|
const token = cookies[name];
|
||||||
|
if (token) {
|
||||||
|
this.logger.debug(`Session cookie found: ${name}`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -137,13 +137,13 @@ describe("RLS Context Integration", () => {
|
|||||||
queries: ["findMany"],
|
queries: ["findMany"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify SET LOCAL was called
|
// Verify transaction-local set_config calls were made
|
||||||
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
|
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
|
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
|
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
|
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]),
|
||||||
workspaceId
|
workspaceId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe("RlsContextInterceptor", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({ data: "test response" });
|
expect(result).toEqual({ data: "test response" });
|
||||||
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
|
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
|
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -111,13 +111,13 @@ describe("RlsContextInterceptor", () => {
|
|||||||
// Check that user context was set
|
// Check that user context was set
|
||||||
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
|
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
|
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
// Check that workspace context was set
|
// Check that workspace context was set
|
||||||
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
|
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
|
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]),
|
||||||
workspaceId
|
workspaceId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,12 +100,12 @@ export class RlsContextInterceptor implements NestInterceptor {
|
|||||||
this.prisma
|
this.prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
// Set user context (always present for authenticated requests)
|
// Use set_config(..., true) so values are transaction-local and parameterized safely.
|
||||||
await tx.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
// Direct SET LOCAL with bind parameters produces invalid SQL on PostgreSQL.
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
|
||||||
|
|
||||||
// Set workspace context (if present)
|
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
await tx.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
await tx.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Propagate the transaction client via AsyncLocalStorage
|
// Propagate the transaction client via AsyncLocalStorage
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ describe("TelemetryInterceptor", () => {
|
|||||||
getResponse: vi.fn().mockReturnValue({
|
getResponse: vi.fn().mockReturnValue({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
setHeader: vi.fn(),
|
setHeader: vi.fn(),
|
||||||
|
headersSent: false,
|
||||||
|
writableEnded: false,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
getClass: vi.fn().mockReturnValue({ name: "TestController" }),
|
getClass: vi.fn().mockReturnValue({ name: "TestController" }),
|
||||||
@@ -101,6 +103,35 @@ describe("TelemetryInterceptor", () => {
|
|||||||
expect(mockResponse.setHeader).toHaveBeenCalledWith("x-trace-id", "test-trace-id");
|
expect(mockResponse.setHeader).toHaveBeenCalledWith("x-trace-id", "test-trace-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not set trace header when response is already committed", async () => {
|
||||||
|
const committedResponseContext = {
|
||||||
|
...mockContext,
|
||||||
|
switchToHttp: vi.fn().mockReturnValue({
|
||||||
|
getRequest: vi.fn().mockReturnValue({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/test",
|
||||||
|
path: "/api/test",
|
||||||
|
}),
|
||||||
|
getResponse: vi.fn().mockReturnValue({
|
||||||
|
statusCode: 200,
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
headersSent: true,
|
||||||
|
writableEnded: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
mockHandler = {
|
||||||
|
handle: vi.fn().mockReturnValue(of({ data: "test" })),
|
||||||
|
} as unknown as CallHandler;
|
||||||
|
|
||||||
|
const committedResponse = committedResponseContext.switchToHttp().getResponse();
|
||||||
|
|
||||||
|
await lastValueFrom(interceptor.intercept(committedResponseContext, mockHandler));
|
||||||
|
|
||||||
|
expect(committedResponse.setHeader).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("should record exception on error", async () => {
|
it("should record exception on error", async () => {
|
||||||
const error = new Error("Test error");
|
const error = new Error("Test error");
|
||||||
mockHandler = {
|
mockHandler = {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export class TelemetryInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
// Add trace context to response headers for distributed tracing
|
// Add trace context to response headers for distributed tracing
|
||||||
const spanContext = span.spanContext();
|
const spanContext = span.spanContext();
|
||||||
if (spanContext.traceId) {
|
if (spanContext.traceId && !response.headersSent && !response.writableEnded) {
|
||||||
response.setHeader("x-trace-id", spanContext.traceId);
|
response.setHeader("x-trace-id", spanContext.traceId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Orchestrator Configuration
|
# Orchestrator Configuration
|
||||||
ORCHESTRATOR_PORT=3001
|
ORCHESTRATOR_PORT=3001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
# AI provider for orchestrator agents: ollama, claude, openai
|
||||||
|
AI_PROVIDER=ollama
|
||||||
|
|
||||||
# Valkey
|
# Valkey
|
||||||
VALKEY_HOST=localhost
|
VALKEY_HOST=localhost
|
||||||
@@ -8,6 +10,7 @@ VALKEY_PORT=6379
|
|||||||
VALKEY_URL=redis://localhost:6379
|
VALKEY_URL=redis://localhost:6379
|
||||||
|
|
||||||
# Claude API
|
# Claude API
|
||||||
|
# Required only when AI_PROVIDER=claude.
|
||||||
CLAUDE_API_KEY=your-api-key-here
|
CLAUDE_API_KEY=your-api-key-here
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
|
|||||||
@@ -45,12 +45,22 @@ Monitored via `apps/web/` (Agent Dashboard).
|
|||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------- | ---------------------- |
|
| ------ | ------------------------- | ------------------------- |
|
||||||
| POST | `/agents/spawn` | Spawn a new agent |
|
| POST | `/agents/spawn` | Spawn a new agent |
|
||||||
| GET | `/agents/:agentId/status` | Get agent status |
|
| GET | `/agents/:agentId/status` | Get agent status |
|
||||||
| POST | `/agents/:agentId/kill` | Kill a single agent |
|
| POST | `/agents/:agentId/kill` | Kill a single agent |
|
||||||
| POST | `/agents/kill-all` | Kill all active agents |
|
| POST | `/agents/kill-all` | Kill all active agents |
|
||||||
|
| GET | `/agents/events` | SSE lifecycle/task events |
|
||||||
|
| GET | `/agents/events/recent` | Recent events (polling) |
|
||||||
|
|
||||||
|
### Queue
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------- | ---------------------------- |
|
||||||
|
| GET | `/queue/stats` | Queue depth and worker stats |
|
||||||
|
| POST | `/queue/pause` | Pause queue processing |
|
||||||
|
| POST | `/queue/resume` | Resume queue processing |
|
||||||
|
|
||||||
#### POST /agents/spawn
|
#### POST /agents/spawn
|
||||||
|
|
||||||
@@ -176,14 +186,18 @@ pnpm --filter @mosaic/orchestrator lint
|
|||||||
|
|
||||||
Environment variables loaded via `@nestjs/config`. Key variables:
|
Environment variables loaded via `@nestjs/config`. Key variables:
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ------------------- | -------------------------------------- |
|
| -------------------------------- | ------------------------------------------------------------ |
|
||||||
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
|
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
|
||||||
| `CLAUDE_API_KEY` | Claude API key for agents |
|
| `AI_PROVIDER` | LLM provider for orchestrator (`ollama`, `claude`, `openai`) |
|
||||||
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
|
| `CLAUDE_API_KEY` | Required only when `AI_PROVIDER=claude` |
|
||||||
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
|
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
|
||||||
| `COORDINATOR_URL` | Quality Coordinator base URL |
|
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
|
||||||
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
|
| `COORDINATOR_URL` | Quality Coordinator base URL |
|
||||||
|
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
|
||||||
|
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
|
||||||
|
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
|
||||||
|
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ LABEL com.mosaic.security.non-root=true
|
|||||||
|
|
||||||
Sensitive configuration is passed via environment variables:
|
Sensitive configuration is passed via environment variables:
|
||||||
|
|
||||||
- `CLAUDE_API_KEY`: Claude API credentials
|
- `AI_PROVIDER`: Orchestrator LLM provider
|
||||||
|
- `CLAUDE_API_KEY`: Claude credentials (required only for `AI_PROVIDER=claude`)
|
||||||
- `VALKEY_URL`: Cache connection string
|
- `VALKEY_URL`: Cache connection string
|
||||||
|
|
||||||
**Best Practices:**
|
**Best Practices:**
|
||||||
|
|||||||
89
apps/orchestrator/src/api/agents/agent-events.service.ts
Normal file
89
apps/orchestrator/src/api/agents/agent-events.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { ValkeyService } from "../../valkey/valkey.service";
|
||||||
|
import type { EventHandler, OrchestratorEvent } from "../../valkey/types";
|
||||||
|
|
||||||
|
type UnsubscribeFn = () => void;
|
||||||
|
const MAX_RECENT_EVENTS = 500;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentEventsService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AgentEventsService.name);
|
||||||
|
private readonly subscribers = new Map<string, EventHandler>();
|
||||||
|
private readonly recentEvents: OrchestratorEvent[] = [];
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
constructor(private readonly valkeyService: ValkeyService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
if (this.connected) return;
|
||||||
|
|
||||||
|
await this.valkeyService.subscribeToEvents(
|
||||||
|
(event) => {
|
||||||
|
this.appendRecentEvent(event);
|
||||||
|
this.subscribers.forEach((handler) => {
|
||||||
|
void handler(event);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error, _raw, channel) => {
|
||||||
|
this.logger.warn(`Event stream parse/validation warning on ${channel}: ${error.message}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.connected = true;
|
||||||
|
this.logger.log("Agent event stream subscription active");
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(handler: EventHandler): UnsubscribeFn {
|
||||||
|
const id = randomUUID();
|
||||||
|
this.subscribers.set(id, handler);
|
||||||
|
return () => {
|
||||||
|
this.subscribers.delete(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInitialSnapshot(): Promise<{
|
||||||
|
type: "stream.snapshot";
|
||||||
|
timestamp: string;
|
||||||
|
agents: number;
|
||||||
|
tasks: number;
|
||||||
|
}> {
|
||||||
|
const [agents, tasks] = await Promise.all([
|
||||||
|
this.valkeyService.listAgents(),
|
||||||
|
this.valkeyService.listTasks(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "stream.snapshot",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
agents: agents.length,
|
||||||
|
tasks: tasks.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createHeartbeat(): OrchestratorEvent {
|
||||||
|
return {
|
||||||
|
type: "task.processing",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data: {
|
||||||
|
heartbeat: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentEvents(limit = 100): OrchestratorEvent[] {
|
||||||
|
const safeLimit = Math.min(Math.max(Math.floor(limit), 1), MAX_RECENT_EVENTS);
|
||||||
|
if (safeLimit >= this.recentEvents.length) {
|
||||||
|
return [...this.recentEvents];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.recentEvents.slice(-safeLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendRecentEvent(event: OrchestratorEvent): void {
|
||||||
|
this.recentEvents.push(event);
|
||||||
|
if (this.recentEvents.length > MAX_RECENT_EVENTS) {
|
||||||
|
this.recentEvents.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { QueueService } from "../../queue/queue.service";
|
|||||||
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
|
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
|
||||||
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
|
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
|
||||||
import { KillswitchService } from "../../killswitch/killswitch.service";
|
import { KillswitchService } from "../../killswitch/killswitch.service";
|
||||||
|
import { AgentEventsService } from "./agent-events.service";
|
||||||
import type { KillAllResult } from "../../killswitch/killswitch.service";
|
import type { KillAllResult } from "../../killswitch/killswitch.service";
|
||||||
|
|
||||||
describe("AgentsController - Killswitch Endpoints", () => {
|
describe("AgentsController - Killswitch Endpoints", () => {
|
||||||
@@ -20,6 +21,12 @@ describe("AgentsController - Killswitch Endpoints", () => {
|
|||||||
};
|
};
|
||||||
let mockLifecycleService: {
|
let mockLifecycleService: {
|
||||||
getAgentLifecycleState: ReturnType<typeof vi.fn>;
|
getAgentLifecycleState: ReturnType<typeof vi.fn>;
|
||||||
|
registerSpawnedAgent: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockEventsService: {
|
||||||
|
subscribe: ReturnType<typeof vi.fn>;
|
||||||
|
getInitialSnapshot: ReturnType<typeof vi.fn>;
|
||||||
|
createHeartbeat: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,13 +45,30 @@ describe("AgentsController - Killswitch Endpoints", () => {
|
|||||||
|
|
||||||
mockLifecycleService = {
|
mockLifecycleService = {
|
||||||
getAgentLifecycleState: vi.fn(),
|
getAgentLifecycleState: vi.fn(),
|
||||||
|
registerSpawnedAgent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockEventsService = {
|
||||||
|
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||||
|
getInitialSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
type: "stream.snapshot",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
agents: 0,
|
||||||
|
tasks: 0,
|
||||||
|
}),
|
||||||
|
createHeartbeat: vi.fn().mockReturnValue({
|
||||||
|
type: "task.processing",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data: { heartbeat: true },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
controller = new AgentsController(
|
controller = new AgentsController(
|
||||||
mockQueueService as unknown as QueueService,
|
mockQueueService as unknown as QueueService,
|
||||||
mockSpawnerService as unknown as AgentSpawnerService,
|
mockSpawnerService as unknown as AgentSpawnerService,
|
||||||
mockLifecycleService as unknown as AgentLifecycleService,
|
mockLifecycleService as unknown as AgentLifecycleService,
|
||||||
mockKillswitchService as unknown as KillswitchService
|
mockKillswitchService as unknown as KillswitchService,
|
||||||
|
mockEventsService as unknown as AgentEventsService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { QueueService } from "../../queue/queue.service";
|
|||||||
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
|
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
|
||||||
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
|
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
|
||||||
import { KillswitchService } from "../../killswitch/killswitch.service";
|
import { KillswitchService } from "../../killswitch/killswitch.service";
|
||||||
|
import { AgentEventsService } from "./agent-events.service";
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
describe("AgentsController", () => {
|
describe("AgentsController", () => {
|
||||||
@@ -17,11 +18,18 @@ describe("AgentsController", () => {
|
|||||||
};
|
};
|
||||||
let lifecycleService: {
|
let lifecycleService: {
|
||||||
getAgentLifecycleState: ReturnType<typeof vi.fn>;
|
getAgentLifecycleState: ReturnType<typeof vi.fn>;
|
||||||
|
registerSpawnedAgent: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let killswitchService: {
|
let killswitchService: {
|
||||||
killAgent: ReturnType<typeof vi.fn>;
|
killAgent: ReturnType<typeof vi.fn>;
|
||||||
killAllAgents: ReturnType<typeof vi.fn>;
|
killAllAgents: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
let eventsService: {
|
||||||
|
subscribe: ReturnType<typeof vi.fn>;
|
||||||
|
getInitialSnapshot: ReturnType<typeof vi.fn>;
|
||||||
|
createHeartbeat: ReturnType<typeof vi.fn>;
|
||||||
|
getRecentEvents: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create mock services
|
// Create mock services
|
||||||
@@ -37,6 +45,7 @@ describe("AgentsController", () => {
|
|||||||
|
|
||||||
lifecycleService = {
|
lifecycleService = {
|
||||||
getAgentLifecycleState: vi.fn(),
|
getAgentLifecycleState: vi.fn(),
|
||||||
|
registerSpawnedAgent: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
killswitchService = {
|
killswitchService = {
|
||||||
@@ -44,12 +53,29 @@ describe("AgentsController", () => {
|
|||||||
killAllAgents: vi.fn(),
|
killAllAgents: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
eventsService = {
|
||||||
|
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||||
|
getInitialSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
type: "stream.snapshot",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
agents: 0,
|
||||||
|
tasks: 0,
|
||||||
|
}),
|
||||||
|
createHeartbeat: vi.fn().mockReturnValue({
|
||||||
|
type: "task.processing",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data: { heartbeat: true },
|
||||||
|
}),
|
||||||
|
getRecentEvents: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
// Create controller with mocked services
|
// Create controller with mocked services
|
||||||
controller = new AgentsController(
|
controller = new AgentsController(
|
||||||
queueService as unknown as QueueService,
|
queueService as unknown as QueueService,
|
||||||
spawnerService as unknown as AgentSpawnerService,
|
spawnerService as unknown as AgentSpawnerService,
|
||||||
lifecycleService as unknown as AgentLifecycleService,
|
lifecycleService as unknown as AgentLifecycleService,
|
||||||
killswitchService as unknown as KillswitchService
|
killswitchService as unknown as KillswitchService,
|
||||||
|
eventsService as unknown as AgentEventsService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,6 +221,10 @@ describe("AgentsController", () => {
|
|||||||
expect(queueService.addTask).toHaveBeenCalledWith(validRequest.taskId, validRequest.context, {
|
expect(queueService.addTask).toHaveBeenCalledWith(validRequest.taskId, validRequest.context, {
|
||||||
priority: 5,
|
priority: 5,
|
||||||
});
|
});
|
||||||
|
expect(lifecycleService.registerSpawnedAgent).toHaveBeenCalledWith(
|
||||||
|
agentId,
|
||||||
|
validRequest.taskId
|
||||||
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
agentId,
|
agentId,
|
||||||
status: "spawning",
|
status: "spawning",
|
||||||
@@ -334,4 +364,39 @@ describe("AgentsController", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getRecentEvents", () => {
|
||||||
|
it("should return recent events with default limit", () => {
|
||||||
|
eventsService.getRecentEvents.mockReturnValue([
|
||||||
|
{
|
||||||
|
type: "task.completed",
|
||||||
|
timestamp: "2026-02-17T15:00:00.000Z",
|
||||||
|
taskId: "task-123",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = controller.getRecentEvents();
|
||||||
|
|
||||||
|
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(100);
|
||||||
|
expect(result).toEqual({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "task.completed",
|
||||||
|
timestamp: "2026-02-17T15:00:00.000Z",
|
||||||
|
taskId: "task-123",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse and pass custom limit", () => {
|
||||||
|
controller.getRecentEvents("25");
|
||||||
|
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to default when limit is invalid", () => {
|
||||||
|
controller.getRecentEvents("invalid");
|
||||||
|
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Sse,
|
||||||
|
MessageEvent,
|
||||||
|
Query,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
import { QueueService } from "../../queue/queue.service";
|
import { QueueService } from "../../queue/queue.service";
|
||||||
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
|
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
|
||||||
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
|
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
|
||||||
@@ -20,6 +24,7 @@ import { KillswitchService } from "../../killswitch/killswitch.service";
|
|||||||
import { SpawnAgentDto, SpawnAgentResponseDto } from "./dto/spawn-agent.dto";
|
import { SpawnAgentDto, SpawnAgentResponseDto } from "./dto/spawn-agent.dto";
|
||||||
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
||||||
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
|
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
|
||||||
|
import { AgentEventsService } from "./agent-events.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for agent management endpoints
|
* Controller for agent management endpoints
|
||||||
@@ -41,7 +46,8 @@ export class AgentsController {
|
|||||||
private readonly queueService: QueueService,
|
private readonly queueService: QueueService,
|
||||||
private readonly spawnerService: AgentSpawnerService,
|
private readonly spawnerService: AgentSpawnerService,
|
||||||
private readonly lifecycleService: AgentLifecycleService,
|
private readonly lifecycleService: AgentLifecycleService,
|
||||||
private readonly killswitchService: KillswitchService
|
private readonly killswitchService: KillswitchService,
|
||||||
|
private readonly eventsService: AgentEventsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +73,9 @@ export class AgentsController {
|
|||||||
context: dto.context,
|
context: dto.context,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist initial lifecycle state in Valkey.
|
||||||
|
await this.lifecycleService.registerSpawnedAgent(spawnResponse.agentId, dto.taskId);
|
||||||
|
|
||||||
// Queue task in Valkey
|
// Queue task in Valkey
|
||||||
await this.queueService.addTask(dto.taskId, dto.context, {
|
await this.queueService.addTask(dto.taskId, dto.context, {
|
||||||
priority: 5, // Default priority
|
priority: 5, // Default priority
|
||||||
@@ -85,6 +94,55 @@ export class AgentsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream orchestrator events as server-sent events (SSE)
|
||||||
|
*/
|
||||||
|
@Sse("events")
|
||||||
|
@Throttle({ status: { limit: 200, ttl: 60000 } })
|
||||||
|
streamEvents(): Observable<MessageEvent> {
|
||||||
|
return new Observable<MessageEvent>((subscriber) => {
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
const unsubscribe = this.eventsService.subscribe((event) => {
|
||||||
|
if (!isClosed) {
|
||||||
|
subscriber.next({ data: event });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void this.eventsService.getInitialSnapshot().then((snapshot) => {
|
||||||
|
if (!isClosed) {
|
||||||
|
subscriber.next({ data: snapshot });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (!isClosed) {
|
||||||
|
subscriber.next({ data: this.eventsService.createHeartbeat() });
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isClosed = true;
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return recent orchestrator events for non-streaming consumers.
|
||||||
|
*/
|
||||||
|
@Get("events/recent")
|
||||||
|
@Throttle({ status: { limit: 200, ttl: 60000 } })
|
||||||
|
getRecentEvents(@Query("limit") limit?: string): {
|
||||||
|
events: ReturnType<AgentEventsService["getRecentEvents"]>;
|
||||||
|
} {
|
||||||
|
const parsedLimit = Number.parseInt(limit ?? "100", 10);
|
||||||
|
return {
|
||||||
|
events: this.eventsService.getRecentEvents(Number.isNaN(parsedLimit) ? 100 : parsedLimit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all agents
|
* List all agents
|
||||||
* @returns Array of all agent sessions with their status
|
* @returns Array of all agent sessions with their status
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { SpawnerModule } from "../../spawner/spawner.module";
|
|||||||
import { KillswitchModule } from "../../killswitch/killswitch.module";
|
import { KillswitchModule } from "../../killswitch/killswitch.module";
|
||||||
import { ValkeyModule } from "../../valkey/valkey.module";
|
import { ValkeyModule } from "../../valkey/valkey.module";
|
||||||
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
||||||
|
import { AgentEventsService } from "./agent-events.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule],
|
imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule],
|
||||||
controllers: [AgentsController],
|
controllers: [AgentsController],
|
||||||
providers: [OrchestratorApiKeyGuard],
|
providers: [OrchestratorApiKeyGuard, AgentEventsService],
|
||||||
})
|
})
|
||||||
export class AgentsModule {}
|
export class AgentsModule {}
|
||||||
|
|||||||
11
apps/orchestrator/src/api/queue/queue-api.module.ts
Normal file
11
apps/orchestrator/src/api/queue/queue-api.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { QueueController } from "./queue.controller";
|
||||||
|
import { QueueModule } from "../../queue/queue.module";
|
||||||
|
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [QueueModule],
|
||||||
|
controllers: [QueueController],
|
||||||
|
providers: [OrchestratorApiKeyGuard],
|
||||||
|
})
|
||||||
|
export class QueueApiModule {}
|
||||||
65
apps/orchestrator/src/api/queue/queue.controller.spec.ts
Normal file
65
apps/orchestrator/src/api/queue/queue.controller.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { QueueController } from "./queue.controller";
|
||||||
|
import { QueueService } from "../../queue/queue.service";
|
||||||
|
|
||||||
|
describe("QueueController", () => {
|
||||||
|
let controller: QueueController;
|
||||||
|
let queueService: {
|
||||||
|
getStats: ReturnType<typeof vi.fn>;
|
||||||
|
pause: ReturnType<typeof vi.fn>;
|
||||||
|
resume: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queueService = {
|
||||||
|
getStats: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
resume: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
controller = new QueueController(queueService as unknown as QueueService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return queue stats", async () => {
|
||||||
|
queueService.getStats.mockResolvedValue({
|
||||||
|
pending: 5,
|
||||||
|
active: 1,
|
||||||
|
completed: 10,
|
||||||
|
failed: 2,
|
||||||
|
delayed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.getStats();
|
||||||
|
|
||||||
|
expect(queueService.getStats).toHaveBeenCalledOnce();
|
||||||
|
expect(result).toEqual({
|
||||||
|
pending: 5,
|
||||||
|
active: 1,
|
||||||
|
completed: 10,
|
||||||
|
failed: 2,
|
||||||
|
delayed: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pause queue processing", async () => {
|
||||||
|
queueService.pause.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await controller.pause();
|
||||||
|
|
||||||
|
expect(queueService.pause).toHaveBeenCalledOnce();
|
||||||
|
expect(result).toEqual({ message: "Queue processing paused" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resume queue processing", async () => {
|
||||||
|
queueService.resume.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await controller.resume();
|
||||||
|
|
||||||
|
expect(queueService.resume).toHaveBeenCalledOnce();
|
||||||
|
expect(result).toEqual({ message: "Queue processing resumed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/orchestrator/src/api/queue/queue.controller.ts
Normal file
39
apps/orchestrator/src/api/queue/queue.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Controller, Get, HttpCode, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { QueueService } from "../../queue/queue.service";
|
||||||
|
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
||||||
|
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
|
||||||
|
|
||||||
|
@Controller("queue")
|
||||||
|
@UseGuards(OrchestratorApiKeyGuard, OrchestratorThrottlerGuard)
|
||||||
|
export class QueueController {
|
||||||
|
constructor(private readonly queueService: QueueService) {}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
@Throttle({ status: { limit: 200, ttl: 60000 } })
|
||||||
|
async getStats(): Promise<{
|
||||||
|
pending: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
delayed: number;
|
||||||
|
}> {
|
||||||
|
return this.queueService.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("pause")
|
||||||
|
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
||||||
|
@HttpCode(200)
|
||||||
|
async pause(): Promise<{ message: string }> {
|
||||||
|
await this.queueService.pause();
|
||||||
|
return { message: "Queue processing paused" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("resume")
|
||||||
|
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
||||||
|
@HttpCode(200)
|
||||||
|
async resume(): Promise<{ message: string }> {
|
||||||
|
await this.queueService.resume();
|
||||||
|
return { message: "Queue processing resumed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { BullModule } from "@nestjs/bullmq";
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
import { ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { HealthModule } from "./api/health/health.module";
|
import { HealthModule } from "./api/health/health.module";
|
||||||
import { AgentsModule } from "./api/agents/agents.module";
|
import { AgentsModule } from "./api/agents/agents.module";
|
||||||
|
import { QueueApiModule } from "./api/queue/queue-api.module";
|
||||||
import { CoordinatorModule } from "./coordinator/coordinator.module";
|
import { CoordinatorModule } from "./coordinator/coordinator.module";
|
||||||
import { BudgetModule } from "./budget/budget.module";
|
import { BudgetModule } from "./budget/budget.module";
|
||||||
import { CIModule } from "./ci";
|
import { CIModule } from "./ci";
|
||||||
@@ -21,11 +22,15 @@ import { orchestratorConfig } from "./config/orchestrator.config";
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [orchestratorConfig],
|
load: [orchestratorConfig],
|
||||||
}),
|
}),
|
||||||
BullModule.forRoot({
|
BullModule.forRootAsync({
|
||||||
connection: {
|
inject: [ConfigService],
|
||||||
host: process.env.VALKEY_HOST ?? "localhost",
|
useFactory: (configService: ConfigService) => ({
|
||||||
port: parseInt(process.env.VALKEY_PORT ?? "6379"),
|
connection: {
|
||||||
},
|
host: configService.get<string>("orchestrator.valkey.host", "localhost"),
|
||||||
|
port: configService.get<number>("orchestrator.valkey.port", 6379),
|
||||||
|
password: configService.get<string>("orchestrator.valkey.password"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
ThrottlerModule.forRoot([
|
ThrottlerModule.forRoot([
|
||||||
{
|
{
|
||||||
@@ -46,6 +51,7 @@ import { orchestratorConfig } from "./config/orchestrator.config";
|
|||||||
]),
|
]),
|
||||||
HealthModule,
|
HealthModule,
|
||||||
AgentsModule,
|
AgentsModule,
|
||||||
|
QueueApiModule,
|
||||||
CoordinatorModule,
|
CoordinatorModule,
|
||||||
BudgetModule,
|
BudgetModule,
|
||||||
CIModule,
|
CIModule,
|
||||||
|
|||||||
@@ -120,6 +120,42 @@ describe("orchestratorConfig", () => {
|
|||||||
expect(config.valkey.port).toBe(6379);
|
expect(config.valkey.port).toBe(6379);
|
||||||
expect(config.valkey.url).toBe("redis://localhost:6379");
|
expect(config.valkey.url).toBe("redis://localhost:6379");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should derive valkey host and port from VALKEY_URL when VALKEY_HOST/VALKEY_PORT are not set", () => {
|
||||||
|
delete process.env.VALKEY_HOST;
|
||||||
|
delete process.env.VALKEY_PORT;
|
||||||
|
process.env.VALKEY_URL = "redis://valkey:6380";
|
||||||
|
|
||||||
|
const config = orchestratorConfig();
|
||||||
|
|
||||||
|
expect(config.valkey.host).toBe("valkey");
|
||||||
|
expect(config.valkey.port).toBe(6380);
|
||||||
|
expect(config.valkey.url).toBe("redis://valkey:6380");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should derive valkey password from VALKEY_URL when VALKEY_PASSWORD is not set", () => {
|
||||||
|
delete process.env.VALKEY_PASSWORD;
|
||||||
|
delete process.env.VALKEY_HOST;
|
||||||
|
delete process.env.VALKEY_PORT;
|
||||||
|
process.env.VALKEY_URL = "redis://:url-secret@valkey:6379";
|
||||||
|
|
||||||
|
const config = orchestratorConfig();
|
||||||
|
|
||||||
|
expect(config.valkey.password).toBe("url-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer explicit valkey env vars over VALKEY_URL values", () => {
|
||||||
|
process.env.VALKEY_HOST = "explicit-host";
|
||||||
|
process.env.VALKEY_PORT = "6390";
|
||||||
|
process.env.VALKEY_PASSWORD = "explicit-password";
|
||||||
|
process.env.VALKEY_URL = "redis://:url-secret@valkey:6380";
|
||||||
|
|
||||||
|
const config = orchestratorConfig();
|
||||||
|
|
||||||
|
expect(config.valkey.host).toBe("explicit-host");
|
||||||
|
expect(config.valkey.port).toBe(6390);
|
||||||
|
expect(config.valkey.password).toBe("explicit-password");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("valkey timeout config (SEC-ORCH-28)", () => {
|
describe("valkey timeout config (SEC-ORCH-28)", () => {
|
||||||
@@ -157,12 +193,12 @@ describe("orchestratorConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("spawner config", () => {
|
describe("spawner config", () => {
|
||||||
it("should use default maxConcurrentAgents of 20 when not set", () => {
|
it("should use default maxConcurrentAgents of 2 when not set", () => {
|
||||||
delete process.env.MAX_CONCURRENT_AGENTS;
|
delete process.env.MAX_CONCURRENT_AGENTS;
|
||||||
|
|
||||||
const config = orchestratorConfig();
|
const config = orchestratorConfig();
|
||||||
|
|
||||||
expect(config.spawner.maxConcurrentAgents).toBe(20);
|
expect(config.spawner.maxConcurrentAgents).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use provided maxConcurrentAgents when MAX_CONCURRENT_AGENTS is set", () => {
|
it("should use provided maxConcurrentAgents when MAX_CONCURRENT_AGENTS is set", () => {
|
||||||
@@ -181,4 +217,30 @@ describe("orchestratorConfig", () => {
|
|||||||
expect(config.spawner.maxConcurrentAgents).toBe(10);
|
expect(config.spawner.maxConcurrentAgents).toBe(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("AI provider config", () => {
|
||||||
|
it("should default aiProvider to ollama when unset", () => {
|
||||||
|
delete process.env.AI_PROVIDER;
|
||||||
|
|
||||||
|
const config = orchestratorConfig();
|
||||||
|
|
||||||
|
expect(config.aiProvider).toBe("ollama");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize AI provider to lowercase", () => {
|
||||||
|
process.env.AI_PROVIDER = " cLaUdE ";
|
||||||
|
|
||||||
|
const config = orchestratorConfig();
|
||||||
|
|
||||||
|
expect(config.aiProvider).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback unsupported AI provider to ollama", () => {
|
||||||
|
process.env.AI_PROVIDER = "bad-provider";
|
||||||
|
|
||||||
|
const config = orchestratorConfig();
|
||||||
|
|
||||||
|
expect(config.aiProvider).toBe("ollama");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,55 +1,96 @@
|
|||||||
import { registerAs } from "@nestjs/config";
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
export const orchestratorConfig = registerAs("orchestrator", () => ({
|
const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
|
||||||
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
|
const provider = process.env.AI_PROVIDER?.trim().toLowerCase();
|
||||||
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
|
|
||||||
valkey: {
|
if (!provider) {
|
||||||
host: process.env.VALKEY_HOST ?? "localhost",
|
return "ollama";
|
||||||
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
|
}
|
||||||
password: process.env.VALKEY_PASSWORD,
|
|
||||||
url: process.env.VALKEY_URL ?? "redis://localhost:6379",
|
if (provider !== "ollama" && provider !== "claude" && provider !== "openai") {
|
||||||
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
|
return "ollama";
|
||||||
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
|
}
|
||||||
},
|
|
||||||
claude: {
|
return provider;
|
||||||
apiKey: process.env.CLAUDE_API_KEY,
|
};
|
||||||
},
|
|
||||||
docker: {
|
const parseValkeyUrl = (url: string): { host?: string; port?: number; password?: string } => {
|
||||||
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
|
try {
|
||||||
},
|
const parsed = new URL(url);
|
||||||
git: {
|
const port = parsed.port ? parseInt(parsed.port, 10) : undefined;
|
||||||
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
|
|
||||||
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
|
return {
|
||||||
},
|
host: parsed.hostname || undefined,
|
||||||
killswitch: {
|
port: Number.isNaN(port) ? undefined : port,
|
||||||
enabled: process.env.KILLSWITCH_ENABLED === "true",
|
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
||||||
},
|
};
|
||||||
sandbox: {
|
} catch {
|
||||||
enabled: process.env.SANDBOX_ENABLED !== "false",
|
return {};
|
||||||
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
|
}
|
||||||
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "512", 10),
|
};
|
||||||
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
|
|
||||||
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "none",
|
export const orchestratorConfig = registerAs("orchestrator", () => {
|
||||||
},
|
const valkeyUrl = process.env.VALKEY_URL ?? "redis://localhost:6379";
|
||||||
coordinator: {
|
const parsedValkeyUrl = parseValkeyUrl(valkeyUrl);
|
||||||
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
|
|
||||||
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
|
return {
|
||||||
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
|
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
|
||||||
apiKey: process.env.COORDINATOR_API_KEY,
|
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
|
||||||
},
|
valkey: {
|
||||||
yolo: {
|
host: process.env.VALKEY_HOST ?? parsedValkeyUrl.host ?? "localhost",
|
||||||
enabled: process.env.YOLO_MODE === "true",
|
port: parseInt(process.env.VALKEY_PORT ?? String(parsedValkeyUrl.port ?? 6379), 10),
|
||||||
},
|
password: process.env.VALKEY_PASSWORD ?? parsedValkeyUrl.password,
|
||||||
spawner: {
|
url: valkeyUrl,
|
||||||
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "20", 10),
|
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
|
||||||
},
|
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
|
||||||
queue: {
|
},
|
||||||
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
|
claude: {
|
||||||
completedRetentionAgeSeconds: parseInt(
|
apiKey: process.env.CLAUDE_API_KEY,
|
||||||
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
|
},
|
||||||
10
|
aiProvider: normalizeAiProvider(),
|
||||||
),
|
docker: {
|
||||||
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
|
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
|
||||||
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
|
},
|
||||||
},
|
git: {
|
||||||
}));
|
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
|
||||||
|
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
|
||||||
|
},
|
||||||
|
killswitch: {
|
||||||
|
enabled: process.env.KILLSWITCH_ENABLED === "true",
|
||||||
|
},
|
||||||
|
sandbox: {
|
||||||
|
enabled: process.env.SANDBOX_ENABLED !== "false",
|
||||||
|
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
|
||||||
|
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "256", 10),
|
||||||
|
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
|
||||||
|
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "none",
|
||||||
|
},
|
||||||
|
coordinator: {
|
||||||
|
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
|
||||||
|
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
|
||||||
|
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
|
||||||
|
apiKey: process.env.COORDINATOR_API_KEY,
|
||||||
|
},
|
||||||
|
yolo: {
|
||||||
|
enabled: process.env.YOLO_MODE === "true",
|
||||||
|
},
|
||||||
|
spawner: {
|
||||||
|
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "2", 10),
|
||||||
|
sessionCleanupDelayMs: parseInt(process.env.SESSION_CLEANUP_DELAY_MS ?? "30000", 10),
|
||||||
|
},
|
||||||
|
queue: {
|
||||||
|
name: process.env.ORCHESTRATOR_QUEUE_NAME ?? "orchestrator-tasks",
|
||||||
|
maxRetries: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_RETRIES ?? "3", 10),
|
||||||
|
baseDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_BASE_DELAY_MS ?? "1000", 10),
|
||||||
|
maxDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_DELAY_MS ?? "60000", 10),
|
||||||
|
concurrency: parseInt(process.env.ORCHESTRATOR_QUEUE_CONCURRENCY ?? "1", 10),
|
||||||
|
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
|
||||||
|
completedRetentionAgeSeconds: parseInt(
|
||||||
|
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
|
||||||
|
10
|
||||||
|
),
|
||||||
|
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
|
||||||
|
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
|
|||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { QueueService } from "./queue.service";
|
import { QueueService } from "./queue.service";
|
||||||
import { ValkeyModule } from "../valkey/valkey.module";
|
import { ValkeyModule } from "../valkey/valkey.module";
|
||||||
|
import { SpawnerModule } from "../spawner/spawner.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, ValkeyModule],
|
imports: [ConfigModule, ValkeyModule, SpawnerModule],
|
||||||
providers: [QueueService],
|
providers: [QueueService],
|
||||||
exports: [QueueService],
|
exports: [QueueService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -991,12 +991,17 @@ describe("QueueService", () => {
|
|||||||
success: true,
|
success: true,
|
||||||
metadata: { attempt: 1 },
|
metadata: { attempt: 1 },
|
||||||
});
|
});
|
||||||
expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith("task-123", "executing");
|
expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith(
|
||||||
|
"task-123",
|
||||||
|
"executing",
|
||||||
|
undefined
|
||||||
|
);
|
||||||
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({
|
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({
|
||||||
type: "task.processing",
|
type: "task.executing",
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
taskId: "task-123",
|
taskId: "task-123",
|
||||||
data: { attempt: 1 },
|
agentId: undefined,
|
||||||
|
data: { attempt: 1, dispatchedByQueue: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
import { Injectable, OnModuleDestroy, OnModuleInit, Optional, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Queue, Worker, Job } from "bullmq";
|
import { Queue, Worker, Job } from "bullmq";
|
||||||
import { ValkeyService } from "../valkey/valkey.service";
|
import { ValkeyService } from "../valkey/valkey.service";
|
||||||
|
import { AgentSpawnerService } from "../spawner/agent-spawner.service";
|
||||||
|
import { AgentLifecycleService } from "../spawner/agent-lifecycle.service";
|
||||||
import type { TaskContext } from "../valkey/types";
|
import type { TaskContext } from "../valkey/types";
|
||||||
import type {
|
import type {
|
||||||
QueuedTask,
|
QueuedTask,
|
||||||
@@ -16,6 +18,7 @@ import type {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QueueService implements OnModuleInit, OnModuleDestroy {
|
export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(QueueService.name);
|
||||||
private queue!: Queue<QueuedTask>;
|
private queue!: Queue<QueuedTask>;
|
||||||
private worker!: Worker<QueuedTask, TaskProcessingResult>;
|
private worker!: Worker<QueuedTask, TaskProcessingResult>;
|
||||||
private readonly queueName: string;
|
private readonly queueName: string;
|
||||||
@@ -23,7 +26,9 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly valkeyService: ValkeyService,
|
private readonly valkeyService: ValkeyService,
|
||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService,
|
||||||
|
@Optional() private readonly spawnerService?: AgentSpawnerService,
|
||||||
|
@Optional() private readonly lifecycleService?: AgentLifecycleService
|
||||||
) {
|
) {
|
||||||
this.queueName = this.configService.get<string>(
|
this.queueName = this.configService.get<string>(
|
||||||
"orchestrator.queue.name",
|
"orchestrator.queue.name",
|
||||||
@@ -132,6 +137,16 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
context,
|
context,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ensure task state exists before queue lifecycle updates.
|
||||||
|
const getTaskState = (this.valkeyService as Partial<ValkeyService>).getTaskState;
|
||||||
|
const createTask = (this.valkeyService as Partial<ValkeyService>).createTask;
|
||||||
|
if (typeof getTaskState === "function" && typeof createTask === "function") {
|
||||||
|
const existingTask = await getTaskState.call(this.valkeyService, taskId);
|
||||||
|
if (!existingTask) {
|
||||||
|
await createTask.call(this.valkeyService, taskId, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add to BullMQ queue
|
// Add to BullMQ queue
|
||||||
await this.queue.add(taskId, queuedTask, {
|
await this.queue.add(taskId, queuedTask, {
|
||||||
priority: 10 - priority + 1, // BullMQ: lower number = higher priority, so invert
|
priority: 10 - priority + 1, // BullMQ: lower number = higher priority, so invert
|
||||||
@@ -214,23 +229,35 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { taskId } = job.data;
|
const { taskId } = job.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
|
||||||
|
const agentId = session?.agentId;
|
||||||
|
|
||||||
|
if (agentId) {
|
||||||
|
if (this.lifecycleService) {
|
||||||
|
await this.lifecycleService.transitionToRunning(agentId);
|
||||||
|
}
|
||||||
|
this.spawnerService?.setSessionState(agentId, "running");
|
||||||
|
}
|
||||||
|
|
||||||
// Update task state to executing
|
// Update task state to executing
|
||||||
await this.valkeyService.updateTaskStatus(taskId, "executing");
|
await this.valkeyService.updateTaskStatus(taskId, "executing", agentId);
|
||||||
|
|
||||||
// Publish event
|
// Publish event
|
||||||
await this.valkeyService.publishEvent({
|
await this.valkeyService.publishEvent({
|
||||||
type: "task.processing",
|
type: "task.executing",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
taskId,
|
taskId,
|
||||||
data: { attempt: job.attemptsMade + 1 },
|
agentId,
|
||||||
|
data: {
|
||||||
|
attempt: job.attemptsMade + 1,
|
||||||
|
dispatchedByQueue: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task processing will be handled by agent spawner
|
|
||||||
// For now, just mark as processing
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
attempt: job.attemptsMade + 1,
|
attempt: job.attemptsMade + 1,
|
||||||
|
...(agentId && { agentId }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -270,6 +297,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Handle task failure
|
* Handle task failure
|
||||||
*/
|
*/
|
||||||
private async handleTaskFailure(taskId: string, error: Error): Promise<void> {
|
private async handleTaskFailure(taskId: string, error: Error): Promise<void> {
|
||||||
|
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
|
||||||
|
if (session) {
|
||||||
|
this.spawnerService?.setSessionState(session.agentId, "failed", error.message, new Date());
|
||||||
|
if (this.lifecycleService) {
|
||||||
|
await this.lifecycleService.transitionToFailed(session.agentId, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.valkeyService.updateTaskStatus(taskId, "failed", undefined, error.message);
|
await this.valkeyService.updateTaskStatus(taskId, "failed", undefined, error.message);
|
||||||
|
|
||||||
await this.valkeyService.publishEvent({
|
await this.valkeyService.publishEvent({
|
||||||
@@ -284,12 +319,25 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Handle task completion
|
* Handle task completion
|
||||||
*/
|
*/
|
||||||
private async handleTaskCompletion(taskId: string): Promise<void> {
|
private async handleTaskCompletion(taskId: string): Promise<void> {
|
||||||
|
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
|
||||||
|
if (session) {
|
||||||
|
this.spawnerService?.setSessionState(session.agentId, "completed", undefined, new Date());
|
||||||
|
if (this.lifecycleService) {
|
||||||
|
await this.lifecycleService.transitionToCompleted(session.agentId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`Queue completed task ${taskId} but no session was found; using queue-only completion state`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.valkeyService.updateTaskStatus(taskId, "completed");
|
await this.valkeyService.updateTaskStatus(taskId, "completed");
|
||||||
|
|
||||||
await this.valkeyService.publishEvent({
|
await this.valkeyService.publishEvent({
|
||||||
type: "task.completed",
|
type: "task.completed",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
taskId,
|
taskId,
|
||||||
|
...(session && { agentId: session.agentId }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,24 @@ export class AgentLifecycleService {
|
|||||||
this.logger.log("AgentLifecycleService initialized");
|
this.logger.log("AgentLifecycleService initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a newly spawned agent in persistent state and emit spawned event.
|
||||||
|
*/
|
||||||
|
async registerSpawnedAgent(agentId: string, taskId: string): Promise<AgentState> {
|
||||||
|
await this.valkeyService.createAgent(agentId, taskId);
|
||||||
|
const createdState = await this.getAgentState(agentId);
|
||||||
|
|
||||||
|
const event: AgentEvent = {
|
||||||
|
type: "agent.spawned",
|
||||||
|
agentId,
|
||||||
|
taskId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.valkeyService.publishEvent(event);
|
||||||
|
|
||||||
|
return createdState;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire a per-agent mutex to serialize state transitions.
|
* Acquire a per-agent mutex to serialize state transitions.
|
||||||
* Uses promise chaining: each caller chains onto the previous lock,
|
* Uses promise chaining: each caller chains onto the previous lock,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ describe("AgentSpawnerService", () => {
|
|||||||
// Create mock ConfigService
|
// Create mock ConfigService
|
||||||
mockConfigService = {
|
mockConfigService = {
|
||||||
get: vi.fn((key: string) => {
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === "orchestrator.aiProvider") {
|
||||||
|
return "ollama";
|
||||||
|
}
|
||||||
if (key === "orchestrator.claude.apiKey") {
|
if (key === "orchestrator.claude.apiKey") {
|
||||||
return "test-api-key";
|
return "test-api-key";
|
||||||
}
|
}
|
||||||
@@ -31,19 +34,80 @@ describe("AgentSpawnerService", () => {
|
|||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should initialize with Claude API key from config", () => {
|
it("should initialize with default AI provider when API key is omitted", () => {
|
||||||
|
const noClaudeConfigService = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === "orchestrator.aiProvider") {
|
||||||
|
return "ollama";
|
||||||
|
}
|
||||||
|
if (key === "orchestrator.spawner.maxConcurrentAgents") {
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
if (key === "orchestrator.spawner.sessionCleanupDelayMs") {
|
||||||
|
return 30000;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
|
||||||
|
const serviceNoKey = new AgentSpawnerService(noClaudeConfigService);
|
||||||
|
expect(serviceNoKey).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize with Claude provider when key is present", () => {
|
||||||
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey");
|
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error if Claude API key is missing", () => {
|
it("should initialize with CLAUDE provider when API key is present", () => {
|
||||||
|
const claudeConfigService = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === "orchestrator.aiProvider") {
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
if (key === "orchestrator.claude.apiKey") {
|
||||||
|
return "test-api-key";
|
||||||
|
}
|
||||||
|
if (key === "orchestrator.spawner.maxConcurrentAgents") {
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
|
||||||
|
const claudeService = new AgentSpawnerService(claudeConfigService);
|
||||||
|
expect(claudeService).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if Claude API key is missing when provider is claude", () => {
|
||||||
const badConfigService = {
|
const badConfigService = {
|
||||||
get: vi.fn(() => undefined),
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === "orchestrator.aiProvider") {
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
} as unknown as ConfigService;
|
} as unknown as ConfigService;
|
||||||
|
|
||||||
expect(() => new AgentSpawnerService(badConfigService)).toThrow(
|
expect(() => new AgentSpawnerService(badConfigService)).toThrow(
|
||||||
"CLAUDE_API_KEY is not configured"
|
"CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should still initialize when CLAUDE_API_KEY is missing for non-Claude provider", () => {
|
||||||
|
const nonClaudeConfigService = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === "orchestrator.aiProvider") {
|
||||||
|
return "ollama";
|
||||||
|
}
|
||||||
|
if (key === "orchestrator.spawner.maxConcurrentAgents") {
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
|
||||||
|
expect(() => new AgentSpawnerService(nonClaudeConfigService)).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("spawnAgent", () => {
|
describe("spawnAgent", () => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
* This allows time for status queries before the session is removed
|
* This allows time for status queries before the session is removed
|
||||||
*/
|
*/
|
||||||
const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
|
const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
|
||||||
|
const SUPPORTED_AI_PROVIDERS = ["ollama", "claude", "openai"] as const;
|
||||||
|
type SupportedAiProvider = (typeof SUPPORTED_AI_PROVIDERS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service responsible for spawning Claude agents using Anthropic SDK
|
* Service responsible for spawning Claude agents using Anthropic SDK
|
||||||
@@ -21,22 +23,38 @@ const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AgentSpawnerService implements OnModuleDestroy {
|
export class AgentSpawnerService implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger(AgentSpawnerService.name);
|
private readonly logger = new Logger(AgentSpawnerService.name);
|
||||||
private readonly anthropic: Anthropic;
|
private readonly anthropic: Anthropic | undefined;
|
||||||
|
private readonly aiProvider: SupportedAiProvider;
|
||||||
private readonly sessions = new Map<string, AgentSession>();
|
private readonly sessions = new Map<string, AgentSession>();
|
||||||
private readonly maxConcurrentAgents: number;
|
private readonly maxConcurrentAgents: number;
|
||||||
private readonly sessionCleanupDelayMs: number;
|
private readonly sessionCleanupDelayMs: number;
|
||||||
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
|
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
const configuredProvider = this.configService.get<string>("orchestrator.aiProvider");
|
||||||
|
this.aiProvider = this.normalizeAiProvider(configuredProvider);
|
||||||
|
|
||||||
|
this.logger.log(`AgentSpawnerService resolved AI provider: ${this.aiProvider}`);
|
||||||
|
|
||||||
const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
|
const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
|
||||||
|
|
||||||
if (!apiKey) {
|
if (this.aiProvider === "claude") {
|
||||||
throw new Error("CLAUDE_API_KEY is not configured");
|
if (!apiKey) {
|
||||||
}
|
throw new Error("CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'");
|
||||||
|
}
|
||||||
|
|
||||||
this.anthropic = new Anthropic({
|
this.logger.log("CLAUDE_API_KEY is configured. Initializing Anthropic client.");
|
||||||
apiKey,
|
this.anthropic = new Anthropic({ apiKey });
|
||||||
});
|
} else {
|
||||||
|
if (apiKey) {
|
||||||
|
this.logger.debug(
|
||||||
|
`CLAUDE_API_KEY is set but ignored because AI provider is '${this.aiProvider}'`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`CLAUDE_API_KEY not required for AI provider '${this.aiProvider}'.`);
|
||||||
|
}
|
||||||
|
this.anthropic = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Default to 20 if not configured
|
// Default to 20 if not configured
|
||||||
this.maxConcurrentAgents =
|
this.maxConcurrentAgents =
|
||||||
@@ -48,10 +66,27 @@ export class AgentSpawnerService implements OnModuleDestroy {
|
|||||||
DEFAULT_SESSION_CLEANUP_DELAY_MS;
|
DEFAULT_SESSION_CLEANUP_DELAY_MS;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`AgentSpawnerService initialized with Claude SDK (max concurrent agents: ${String(this.maxConcurrentAgents)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
|
`AgentSpawnerService initialized with ${this.aiProvider} AI provider (max concurrent agents: ${String(
|
||||||
|
this.maxConcurrentAgents
|
||||||
|
)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeAiProvider(provider?: string): SupportedAiProvider {
|
||||||
|
const normalizedProvider = provider?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedProvider) {
|
||||||
|
return "ollama";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_AI_PROVIDERS.includes(normalizedProvider as SupportedAiProvider)) {
|
||||||
|
this.logger.warn(`Unsupported AI provider '${normalizedProvider}'. Defaulting to 'ollama'.`);
|
||||||
|
return "ollama";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedProvider as SupportedAiProvider;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up all pending cleanup timers on module destroy
|
* Clean up all pending cleanup timers on module destroy
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +151,33 @@ export class AgentSpawnerService implements OnModuleDestroy {
|
|||||||
return this.sessions.get(agentId);
|
return this.sessions.get(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an active session by task ID.
|
||||||
|
*/
|
||||||
|
findAgentSessionByTaskId(taskId: string): AgentSession | undefined {
|
||||||
|
return Array.from(this.sessions.values()).find((session) => session.taskId === taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update in-memory session state for visibility in list/status endpoints.
|
||||||
|
*/
|
||||||
|
setSessionState(
|
||||||
|
agentId: string,
|
||||||
|
state: AgentSession["state"],
|
||||||
|
error?: string,
|
||||||
|
completedAt?: Date
|
||||||
|
): void {
|
||||||
|
const session = this.sessions.get(agentId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.state = state;
|
||||||
|
session.error = error;
|
||||||
|
if (completedAt) {
|
||||||
|
session.completedAt = completedAt;
|
||||||
|
}
|
||||||
|
this.sessions.set(agentId, session);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all agent sessions
|
* List all agent sessions
|
||||||
* @returns Array of all agent sessions
|
* @returns Array of all agent sessions
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ COPY apps/web/package.json ./apps/web/
|
|||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Production dependencies stage
|
||||||
|
# ======================
|
||||||
|
FROM base AS prod-deps
|
||||||
|
|
||||||
|
# Copy all package.json files for workspace resolution
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
|
COPY packages/config/package.json ./packages/config/
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Builder stage
|
# Builder stage
|
||||||
# ======================
|
# ======================
|
||||||
@@ -81,14 +95,13 @@ ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x
|
|||||||
|
|
||||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
&& corepack enable && corepack prepare pnpm@10.27.0 --activate \
|
|
||||||
&& chmod 755 /usr/local/bin/dumb-init \
|
&& chmod 755 /usr/local/bin/dumb-init \
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy node_modules from builder (includes all dependencies in pnpm store)
|
# Copy node_modules from builder (includes all dependencies in pnpm store)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy built packages (includes dist/ directories)
|
# Copy built packages (includes dist/ directories)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/packages ./packages
|
COPY --from=builder --chown=nextjs:nodejs /app/packages ./packages
|
||||||
@@ -99,7 +112,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/next.config.ts ./apps/web/
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/next.config.ts ./apps/web/
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
|
||||||
# Copy app's node_modules which contains symlinks to root node_modules
|
# Copy app's node_modules which contains symlinks to root node_modules
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
|
COPY --from=prod-deps --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
|
|
||||||
# Set working directory to web app
|
# Set working directory to web app
|
||||||
WORKDIR /app/apps/web
|
WORKDIR /app/apps/web
|
||||||
@@ -113,6 +126,7 @@ EXPOSE ${PORT:-3000}
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PATH="/app/apps/web/node_modules/.bin:${PATH}"
|
||||||
|
|
||||||
# Health check uses PORT env var (set by docker-compose or defaults to 3000)
|
# Health check uses PORT env var (set by docker-compose or defaults to 3000)
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
@@ -122,4 +136,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
CMD ["pnpm", "start"]
|
CMD ["next", "start"]
|
||||||
|
|||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const defaultAuthMode = process.env.NODE_ENV === "development" ? "mock" : "real";
|
||||||
|
const authMode = (process.env.NEXT_PUBLIC_AUTH_MODE ?? defaultAuthMode).toLowerCase();
|
||||||
|
|
||||||
|
if (!["real", "mock"].includes(authMode)) {
|
||||||
|
throw new Error(`Invalid NEXT_PUBLIC_AUTH_MODE "${authMode}". Expected one of: real, mock.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authMode === "mock" && process.env.NODE_ENV !== "development") {
|
||||||
|
throw new Error("NEXT_PUBLIC_AUTH_MODE=mock is only allowed for local development.");
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
transpilePackages: ["@mosaic/ui", "@mosaic/shared"],
|
transpilePackages: ["@mosaic/ui", "@mosaic/shared"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,7 +47,10 @@
|
|||||||
"@types/react-grid-layout": "^2.1.0",
|
"@types/react-grid-layout": "^2.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vitest": "^3.0.8"
|
"vitest": "^3.0.8"
|
||||||
}
|
}
|
||||||
|
|||||||
8
apps/web/postcss.config.mjs
Normal file
8
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
87
apps/web/src/app/(auth)/login/page.mock-mode.test.tsx
Normal file
87
apps/web/src/app/(auth)/login/page.mock-mode.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import LoginPage from "./page";
|
||||||
|
|
||||||
|
const { mockPush, mockReplace, mockSearchParams, authState } = vi.hoisted(() => ({
|
||||||
|
mockPush: vi.fn(),
|
||||||
|
mockReplace: vi.fn(),
|
||||||
|
mockSearchParams: new URLSearchParams(),
|
||||||
|
authState: {
|
||||||
|
isAuthenticated: false,
|
||||||
|
refreshSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockFetchWithRetry } = vi.hoisted(() => ({
|
||||||
|
mockFetchWithRetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: (): { push: Mock; replace: Mock } => ({
|
||||||
|
push: mockPush,
|
||||||
|
replace: mockReplace,
|
||||||
|
}),
|
||||||
|
useSearchParams: (): URLSearchParams => mockSearchParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/config", () => ({
|
||||||
|
API_BASE_URL: "http://localhost:3001",
|
||||||
|
IS_MOCK_AUTH_MODE: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth-client", () => ({
|
||||||
|
signIn: {
|
||||||
|
oauth2: vi.fn(),
|
||||||
|
email: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
|
useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({
|
||||||
|
isAuthenticated: authState.isAuthenticated,
|
||||||
|
refreshSession: authState.refreshSession,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/fetch-with-retry", () => ({
|
||||||
|
fetchWithRetry: mockFetchWithRetry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("LoginPage (mock auth mode)", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSearchParams.delete("error");
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
authState.refreshSession.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render mock auth controls", (): void => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/local mock auth mode is active/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mock-auth-login")).toBeInTheDocument();
|
||||||
|
expect(mockFetchWithRetry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should continue with mock session and navigate to tasks", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId("mock-auth-login"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authState.refreshSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/tasks");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should auto-redirect authenticated mock users to tasks", async (): Promise<void> => {
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockReplace).toHaveBeenCalledWith("/tasks");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,11 @@ const { mockOAuth2, mockSignInEmail, mockPush, mockReplace, mockSearchParams } =
|
|||||||
mockSearchParams: new URLSearchParams(),
|
mockSearchParams: new URLSearchParams(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { mockRefreshSession, mockIsAuthenticated } = vi.hoisted(() => ({
|
||||||
|
mockRefreshSession: vi.fn(),
|
||||||
|
mockIsAuthenticated: false,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: (): { push: Mock; replace: Mock } => ({
|
useRouter: (): { push: Mock; replace: Mock } => ({
|
||||||
push: mockPush,
|
push: mockPush,
|
||||||
@@ -33,6 +38,14 @@ vi.mock("@/lib/auth-client", () => ({
|
|||||||
|
|
||||||
vi.mock("@/lib/config", () => ({
|
vi.mock("@/lib/config", () => ({
|
||||||
API_BASE_URL: "http://localhost:3001",
|
API_BASE_URL: "http://localhost:3001",
|
||||||
|
IS_MOCK_AUTH_MODE: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
|
useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({
|
||||||
|
isAuthenticated: mockIsAuthenticated,
|
||||||
|
refreshSession: mockRefreshSession,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock fetchWithRetry to behave like fetch for test purposes
|
// Mock fetchWithRetry to behave like fetch for test purposes
|
||||||
@@ -91,6 +104,7 @@ describe("LoginPage", (): void => {
|
|||||||
mockSearchParams.delete("error");
|
mockSearchParams.delete("error");
|
||||||
// Default: OAuth2 returns a resolved promise (fire-and-forget redirect)
|
// Default: OAuth2 returns a resolved promise (fire-and-forget redirect)
|
||||||
mockOAuth2.mockResolvedValue(undefined);
|
mockOAuth2.mockResolvedValue(undefined);
|
||||||
|
mockRefreshSession.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders loading state initially", (): void => {
|
it("renders loading state initially", (): void => {
|
||||||
@@ -113,8 +127,8 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Command Center");
|
||||||
expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument();
|
expect(screen.getByText(/Sign in to your orchestration platform/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has proper layout styling", async (): Promise<void> => {
|
it("has proper layout styling", async (): Promise<void> => {
|
||||||
@@ -172,7 +186,7 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText(/or continue with email/i)).toBeInTheDocument();
|
expect(screen.getByText(/or continue with/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -186,7 +200,11 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
|
// The divider element should not appear (no credentials provider)
|
||||||
|
const dividerTexts = screen.queryAllByText(/or continue with/i);
|
||||||
|
// OAuthButton text contains "Continue with" so filter for the divider specifically
|
||||||
|
const dividerOnly = dividerTexts.filter((el) => el.textContent === "or continue with");
|
||||||
|
expect(dividerOnly).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
|
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
|
||||||
@@ -201,7 +219,6 @@ describe("LoginPage", (): void => {
|
|||||||
// Should NOT silently fall back to email form
|
// Should NOT silently fall back to email form
|
||||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// Should show the error banner with helpful message
|
// Should show the error banner with helpful message
|
||||||
expect(
|
expect(
|
||||||
@@ -276,7 +293,7 @@ describe("LoginPage", (): void => {
|
|||||||
|
|
||||||
expect(mockOAuth2).toHaveBeenCalledWith({
|
expect(mockOAuth2).toHaveBeenCalledWith({
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
callbackURL: "/",
|
callbackURL: "http://localhost:3000/",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,7 +456,7 @@ describe("LoginPage", (): void => {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
describe("responsive layout", (): void => {
|
describe("responsive layout", (): void => {
|
||||||
it("applies mobile-first padding to main element", async (): Promise<void> => {
|
it("applies AuthShell layout classes to main element", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
const { container } = render(<LoginPage />);
|
const { container } = render(<LoginPage />);
|
||||||
@@ -449,8 +466,7 @@ describe("LoginPage", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const main = container.querySelector("main");
|
const main = container.querySelector("main");
|
||||||
|
expect(main).toHaveClass("min-h-screen", "items-center", "justify-center");
|
||||||
expect(main).toHaveClass("p-4", "sm:p-8");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies responsive text size to heading", async (): Promise<void> => {
|
it("applies responsive text size to heading", async (): Promise<void> => {
|
||||||
@@ -463,10 +479,10 @@ describe("LoginPage", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const heading = screen.getByRole("heading", { level: 1 });
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
expect(heading).toHaveClass("text-2xl", "sm:text-4xl");
|
expect(heading).toHaveClass("text-xl", "sm:text-2xl");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies responsive padding to card container", async (): Promise<void> => {
|
it("AuthCard applies card styling with padding", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
const { container } = render(<LoginPage />);
|
const { container } = render(<LoginPage />);
|
||||||
@@ -475,12 +491,12 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const card = container.querySelector(".bg-white");
|
// AuthCard uses rounded-b-2xl and p-6 sm:p-10
|
||||||
|
const card = container.querySelector(".rounded-b-2xl");
|
||||||
expect(card).toHaveClass("p-4", "sm:p-8");
|
expect(card).toHaveClass("p-6", "sm:p-10");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("card container has full width with max-width constraint", async (): Promise<void> => {
|
it("AuthShell constrains card width", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
const { container } = render(<LoginPage />);
|
const { container } = render(<LoginPage />);
|
||||||
@@ -489,9 +505,9 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const wrapper = container.querySelector(".max-w-md");
|
// AuthShell wraps children in max-w-[27rem]
|
||||||
|
const wrapper = container.querySelector(".max-w-\\[27rem\\]");
|
||||||
expect(wrapper).toHaveClass("w-full", "max-w-md");
|
expect(wrapper).toHaveClass("w-full");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import type { ReactElement } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
||||||
import { API_BASE_URL } from "@/lib/config";
|
import { AuthShell, AuthCard, AuthBrand, AuthStatusPill } from "@mosaic/ui";
|
||||||
|
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||||
import { signIn } from "@/lib/auth-client";
|
import { signIn } from "@/lib/auth-client";
|
||||||
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
||||||
import { parseAuthError } from "@/lib/auth/auth-errors";
|
import { parseAuthError } from "@/lib/auth/auth-errors";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { OAuthButton } from "@/components/auth/OAuthButton";
|
import { OAuthButton } from "@/components/auth/OAuthButton";
|
||||||
import { LoginForm } from "@/components/auth/LoginForm";
|
import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
import { AuthDivider } from "@/components/auth/AuthDivider";
|
import { AuthDivider } from "@/components/auth/AuthDivider";
|
||||||
@@ -18,23 +20,21 @@ export default function LoginPage(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
<AuthShell>
|
||||||
<div className="w-full max-w-md space-y-8">
|
<AuthCard>
|
||||||
<div className="text-center">
|
<div className="flex flex-col items-center gap-6">
|
||||||
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
<AuthBrand />
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center py-8"
|
className="flex items-center justify-center py-8"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading authentication options"
|
aria-label="Loading authentication options"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
||||||
<span className="sr-only">Loading authentication options</span>
|
<span className="sr-only">Loading authentication options</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AuthCard>
|
||||||
</main>
|
</AuthShell>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LoginPageContent />
|
<LoginPageContent />
|
||||||
@@ -45,6 +45,7 @@ export default function LoginPage(): ReactElement {
|
|||||||
function LoginPageContent(): ReactElement {
|
function LoginPageContent(): ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { isAuthenticated, refreshSession } = useAuth();
|
||||||
const [config, setConfig] = useState<AuthConfigResponse | null | undefined>(undefined);
|
const [config, setConfig] = useState<AuthConfigResponse | null | undefined>(undefined);
|
||||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
@@ -68,6 +69,18 @@ function LoginPageContent(): ReactElement {
|
|||||||
}, [searchParams, router]);
|
}, [searchParams, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (IS_MOCK_AUTH_MODE && isAuthenticated) {
|
||||||
|
router.replace("/tasks");
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (IS_MOCK_AUTH_MODE) {
|
||||||
|
setConfig({ providers: [] });
|
||||||
|
setLoadingConfig(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function fetchConfig(): Promise<void> {
|
async function fetchConfig(): Promise<void> {
|
||||||
@@ -113,7 +126,9 @@ function LoginPageContent(): ReactElement {
|
|||||||
const handleOAuthLogin = useCallback((providerId: string): void => {
|
const handleOAuthLogin = useCallback((providerId: string): void => {
|
||||||
setOauthLoading(providerId);
|
setOauthLoading(providerId);
|
||||||
setError(null);
|
setError(null);
|
||||||
signIn.oauth2({ providerId, callbackURL: "/" }).catch((err: unknown) => {
|
const callbackURL =
|
||||||
|
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
||||||
|
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
@@ -156,18 +171,64 @@ function LoginPageContent(): ReactElement {
|
|||||||
setRetryCount((c) => c + 1);
|
setRetryCount((c) => c + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleMockLogin = useCallback(async (): Promise<void> => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await refreshSession();
|
||||||
|
router.push("/tasks");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const parsed = parseAuthError(err);
|
||||||
|
setError(parsed.message);
|
||||||
|
}
|
||||||
|
}, [refreshSession, router]);
|
||||||
|
|
||||||
|
if (IS_MOCK_AUTH_MODE) {
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<AuthCard>
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<AuthBrand />
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
||||||
|
Local mock auth mode is active
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<AuthStatusPill label="Mock mode" tone="warning" className="w-full justify-center" />
|
||||||
|
{error && <AuthErrorBanner message={error} />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleMockLogin();
|
||||||
|
}}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
||||||
|
data-testid="mock-auth-login"
|
||||||
|
>
|
||||||
|
Continue with Mock Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AuthCard>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
<AuthShell>
|
||||||
<div className="w-full max-w-md space-y-8">
|
<AuthCard>
|
||||||
<div className="text-center">
|
<div className="flex flex-col items-center gap-6">
|
||||||
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
<AuthBrand />
|
||||||
<p className="text-base sm:text-lg text-gray-600">
|
<div className="text-center">
|
||||||
Your personal assistant platform. Organize tasks, events, and projects with a
|
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
||||||
PDA-friendly approach.
|
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
||||||
</p>
|
Sign in to your orchestration platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
|
<div className="mt-6">
|
||||||
{loadingConfig ? (
|
{loadingConfig ? (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center py-8"
|
className="flex items-center justify-center py-8"
|
||||||
@@ -175,7 +236,7 @@ function LoginPageContent(): ReactElement {
|
|||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading authentication options"
|
aria-label="Loading authentication options"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
||||||
<span className="sr-only">Loading authentication options</span>
|
<span className="sr-only">Loading authentication options</span>
|
||||||
</div>
|
</div>
|
||||||
) : config === null ? (
|
) : config === null ? (
|
||||||
@@ -185,47 +246,35 @@ function LoginPageContent(): ReactElement {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRetry}
|
onClick={handleRetry}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-0">
|
||||||
{urlError && (
|
{urlError && (
|
||||||
<AuthErrorBanner
|
<div className="mb-4">
|
||||||
message={urlError}
|
<AuthErrorBanner
|
||||||
onDismiss={(): void => {
|
message={urlError}
|
||||||
setUrlError(null);
|
onDismiss={(): void => {
|
||||||
}}
|
setUrlError(null);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !hasCredentials && (
|
{error && !hasCredentials && (
|
||||||
<AuthErrorBanner
|
<div className="mb-4">
|
||||||
message={error}
|
<AuthErrorBanner
|
||||||
onDismiss={(): void => {
|
message={error}
|
||||||
setError(null);
|
onDismiss={(): void => {
|
||||||
}}
|
setError(null);
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasOAuth &&
|
|
||||||
oauthProviders.map((provider) => (
|
|
||||||
<OAuthButton
|
|
||||||
key={provider.id}
|
|
||||||
providerName={provider.name}
|
|
||||||
providerId={provider.id}
|
|
||||||
onClick={(): void => {
|
|
||||||
handleOAuthLogin(provider.id);
|
|
||||||
}}
|
}}
|
||||||
isLoading={oauthLoading === provider.id}
|
|
||||||
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
|
)}
|
||||||
{hasOAuth && hasCredentials && <AuthDivider />}
|
|
||||||
|
|
||||||
{hasCredentials && (
|
{hasCredentials && (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
@@ -234,10 +283,33 @@ function LoginPageContent(): ReactElement {
|
|||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
|
{hasOAuth && hasCredentials && <AuthDivider />}
|
||||||
|
|
||||||
|
{hasOAuth && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{oauthProviders.map((provider) => (
|
||||||
|
<OAuthButton
|
||||||
|
key={provider.id}
|
||||||
|
providerName={provider.name}
|
||||||
|
providerId={provider.id}
|
||||||
|
onClick={(): void => {
|
||||||
|
handleOAuthLogin(provider.id);
|
||||||
|
}}
|
||||||
|
isLoading={oauthLoading === provider.id}
|
||||||
|
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</main>
|
<div className="mt-6 flex justify-center">
|
||||||
|
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
|
||||||
|
</div>
|
||||||
|
</AuthCard>
|
||||||
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,80 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||||
|
import { AppHeader } from "@/components/layout/AppHeader";
|
||||||
|
import { AppSidebar } from "@/components/layout/AppSidebar";
|
||||||
|
import { SidebarProvider, useSidebar } from "@/components/layout/SidebarContext";
|
||||||
import { ChatOverlay } from "@/components/chat";
|
import { ChatOverlay } from "@/components/chat";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SIDEBAR_EXPANDED_WIDTH = "240px";
|
||||||
|
const SIDEBAR_COLLAPSED_WIDTH = "60px";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inner shell — must be a child of SidebarProvider to use useSidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShell({ children }: AppShellProps): React.JSX.Element {
|
||||||
|
const { collapsed, isMobile } = useSidebar();
|
||||||
|
|
||||||
|
// On tablet (md–lg), hide sidebar from the grid when the sidebar is collapsed.
|
||||||
|
// On mobile, the sidebar is fixed-position so the grid is always single-column.
|
||||||
|
const sidebarHidden = !isMobile && collapsed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="app-shell"
|
||||||
|
data-sidebar-hidden={sidebarHidden ? "true" : undefined}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
|
||||||
|
transition: "grid-template-columns 0.2s var(--ease, ease)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */}
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
{/* Sidebar — left column, row 2, via .app-sidebar CSS class */}
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
|
{/* Main content — right column, row 2, via .app-main CSS class */}
|
||||||
|
<main className="app-main" id="main-content">
|
||||||
|
{IS_MOCK_AUTH_MODE && (
|
||||||
|
<div
|
||||||
|
className="border-b px-4 py-2 text-xs font-medium flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--ms-amber-500)",
|
||||||
|
background: "rgba(245, 158, 11, 0.08)",
|
||||||
|
color: "var(--ms-amber-400)",
|
||||||
|
}}
|
||||||
|
data-testid="mock-auth-banner"
|
||||||
|
>
|
||||||
|
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Authenticated layout — handles auth guard + provides sidebar context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function AuthenticatedLayout({
|
export default function AuthenticatedLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -22,11 +92,7 @@ export default function AuthenticatedLayout({
|
|||||||
}, [isAuthenticated, isLoading, router]);
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <MosaicSpinner size={48} fullPage />;
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -34,10 +100,8 @@ export default function AuthenticatedLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<SidebarProvider>
|
||||||
<Navigation />
|
<AppShell>{children}</AppShell>
|
||||||
<div className="pt-16">{children}</div>
|
</SidebarProvider>
|
||||||
<ChatOverlay />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
|
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
||||||
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
|
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
||||||
import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget";
|
import { QuickActions } from "@/components/dashboard/QuickActions";
|
||||||
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
|
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
||||||
import { mockTasks } from "@/lib/api/tasks";
|
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
||||||
import { mockEvents } from "@/lib/api/events";
|
|
||||||
import type { Task, Event } from "@mosaic/shared";
|
|
||||||
|
|
||||||
export default function DashboardPage(): ReactElement {
|
export default function DashboardPage(): ReactElement {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadDashboardData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadDashboardData(): Promise<void> {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: Replace with real API calls when backend is ready
|
|
||||||
// const [tasksData, eventsData] = await Promise.all([fetchTasks(), fetchEvents()]);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
setTasks(mockTasks);
|
|
||||||
setEvents(mockEvents);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "We had trouble loading your dashboard. Please try again when you're ready."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<div className="mb-8">
|
<DashboardMetrics />
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
<div
|
||||||
<p className="text-gray-600 mt-2">Welcome back! Here's your overview</p>
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 320px",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||||
|
<OrchestratorSessions />
|
||||||
|
<QuickActions />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<ActivityFeed />
|
||||||
|
<TokenBudget />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{error !== null ? (
|
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
|
||||||
<p className="text-amber-800">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => void loadDashboardData()}
|
|
||||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Top row: Domain Overview and Quick Capture */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<DomainOverviewWidget tasks={tasks} isLoading={isLoading} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecentTasksWidget tasks={tasks} isLoading={isLoading} />
|
|
||||||
<UpcomingEventsWidget events={events} isLoading={isLoading} />
|
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<QuickCaptureWidget />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/web/src/app/api/orchestrator/events/recent/route.ts
Normal file
47
apps/web/src/app/api/orchestrator/events/recent/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
function getOrchestratorUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
DEFAULT_ORCHESTRATOR_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||||
|
if (!orchestratorApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = request.nextUrl.searchParams.get("limit");
|
||||||
|
const query = limit ? `?limit=${encodeURIComponent(limit)}` : "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOrchestratorUrl()}/agents/events/recent${query}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": orchestratorApiKey,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/web/src/app/api/orchestrator/events/route.ts
Normal file
50
apps/web/src/app/api/orchestrator/events/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
function getOrchestratorUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
DEFAULT_ORCHESTRATOR_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||||
|
if (!orchestratorApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${getOrchestratorUrl()}/agents/events`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": orchestratorApiKey,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
const text = await upstream.text();
|
||||||
|
return new NextResponse(text || "Failed to connect to orchestrator events stream", {
|
||||||
|
status: upstream.status || 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/src/app/api/orchestrator/health/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/health/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
function getOrchestratorUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
DEFAULT_ORCHESTRATOR_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||||
|
if (!orchestratorApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOrchestratorUrl()}/health/ready`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": orchestratorApiKey,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/src/app/api/orchestrator/queue/pause/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/queue/pause/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
function getOrchestratorUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
DEFAULT_ORCHESTRATOR_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(): Promise<NextResponse> {
|
||||||
|
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||||
|
if (!orchestratorApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOrchestratorUrl()}/queue/pause`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": orchestratorApiKey,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/src/app/api/orchestrator/queue/resume/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/queue/resume/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
function getOrchestratorUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
DEFAULT_ORCHESTRATOR_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(): Promise<NextResponse> {
|
||||||
|
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||||
|
if (!orchestratorApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOrchestratorUrl()}/queue/resume`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": orchestratorApiKey,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/src/app/api/orchestrator/queue/stats/route.ts
Normal file
43
apps/web/src/app/api/orchestrator/queue/stats/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
function getOrchestratorUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
DEFAULT_ORCHESTRATOR_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||||
|
if (!orchestratorApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOrchestratorUrl()}/queue/stats`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": orchestratorApiKey,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,147 +3,303 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
DESIGN C: PROFESSIONAL/ENTERPRISE DESIGN SYSTEM
|
MOSAIC DESIGN SYSTEM — Reference token system from dashboard design
|
||||||
Philosophy: "Good design is as little design as possible." - Dieter Rams
|
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
CSS Custom Properties - Light Theme (Default)
|
Primitive Tokens (Dark-first — dark is the default theme)
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
/* Base colors - increased contrast from surfaces */
|
/* Mosaic design tokens — dark palette (default) */
|
||||||
--color-background: 245 247 250;
|
--ms-bg-950: #080b12;
|
||||||
--color-foreground: 15 23 42;
|
--ms-bg-900: #0f141d;
|
||||||
|
--ms-bg-850: #151b26;
|
||||||
|
--ms-surface-800: #1b2331;
|
||||||
|
--ms-surface-750: #232d3f;
|
||||||
|
--ms-border-700: #2f3b52;
|
||||||
|
--ms-text-100: #eef3ff;
|
||||||
|
--ms-text-300: #c5d0e6;
|
||||||
|
--ms-text-500: #8f9db7;
|
||||||
|
--ms-blue-500: #2f80ff;
|
||||||
|
--ms-blue-400: #56a0ff;
|
||||||
|
--ms-red-500: #e5484d;
|
||||||
|
--ms-red-400: #f06a6f;
|
||||||
|
--ms-purple-500: #8b5cf6;
|
||||||
|
--ms-purple-400: #a78bfa;
|
||||||
|
--ms-teal-500: #14b8a6;
|
||||||
|
--ms-teal-400: #2dd4bf;
|
||||||
|
--ms-amber-500: #f59e0b;
|
||||||
|
--ms-amber-400: #fbbf24;
|
||||||
|
--ms-pink-500: #ec4899;
|
||||||
|
--ms-emerald-500: #10b981;
|
||||||
|
--ms-orange-500: #f97316;
|
||||||
|
--ms-cyan-500: #06b6d4;
|
||||||
|
--ms-indigo-500: #6366f1;
|
||||||
|
|
||||||
/* Surface hierarchy (elevation levels) - improved contrast */
|
/* Semantic aliases — dark theme is default */
|
||||||
--surface-0: 255 255 255;
|
--bg: var(--ms-bg-900);
|
||||||
--surface-1: 250 251 252;
|
--bg-deep: var(--ms-bg-950);
|
||||||
--surface-2: 241 245 249;
|
--bg-mid: var(--ms-bg-850);
|
||||||
--surface-3: 226 232 240;
|
--surface: var(--ms-surface-800);
|
||||||
|
--surface-2: var(--ms-surface-750);
|
||||||
|
--border: var(--ms-border-700);
|
||||||
|
--text: var(--ms-text-100);
|
||||||
|
--text-2: var(--ms-text-300);
|
||||||
|
--muted: var(--ms-text-500);
|
||||||
|
--primary: var(--ms-blue-500);
|
||||||
|
--primary-l: var(--ms-blue-400);
|
||||||
|
--danger: var(--ms-red-500);
|
||||||
|
--success: var(--ms-teal-500);
|
||||||
|
--warn: var(--ms-amber-500);
|
||||||
|
--purple: var(--ms-purple-500);
|
||||||
|
|
||||||
/* Text hierarchy */
|
/* Typography */
|
||||||
--text-primary: 15 23 42;
|
--font: var(--font-outfit, 'Outfit'), system-ui, sans-serif;
|
||||||
--text-secondary: 51 65 85;
|
--mono: var(--font-fira-code, 'Fira Code'), 'Cascadia Code', monospace;
|
||||||
--text-tertiary: 71 85 105;
|
|
||||||
--text-muted: 100 116 139;
|
|
||||||
|
|
||||||
/* Border colors - stronger borders for light mode */
|
/* Radius scale */
|
||||||
--border-default: 203 213 225;
|
--r: 8px;
|
||||||
--border-subtle: 226 232 240;
|
--r-sm: 5px;
|
||||||
--border-strong: 148 163 184;
|
--r-lg: 12px;
|
||||||
|
--r-xl: 16px;
|
||||||
|
|
||||||
/* Brand accent - Indigo (professional, trustworthy) */
|
/* Layout dimensions */
|
||||||
--accent-primary: 79 70 229;
|
--sidebar-w: 260px;
|
||||||
--accent-primary-hover: 67 56 202;
|
--topbar-h: 56px;
|
||||||
--accent-primary-light: 238 242 255;
|
--terminal-h: 220px;
|
||||||
--accent-primary-muted: 199 210 254;
|
|
||||||
|
|
||||||
/* Semantic colors - Success (Emerald) */
|
/* Easing */
|
||||||
--semantic-success: 16 185 129;
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--semantic-success-light: 209 250 229;
|
|
||||||
--semantic-success-dark: 6 95 70;
|
|
||||||
|
|
||||||
/* Semantic colors - Warning (Amber) */
|
/* Legacy shadow tokens (retained for component compat) */
|
||||||
--semantic-warning: 245 158 11;
|
|
||||||
--semantic-warning-light: 254 243 199;
|
|
||||||
--semantic-warning-dark: 146 64 14;
|
|
||||||
|
|
||||||
/* Semantic colors - Error (Rose) */
|
|
||||||
--semantic-error: 244 63 94;
|
|
||||||
--semantic-error-light: 255 228 230;
|
|
||||||
--semantic-error-dark: 159 18 57;
|
|
||||||
|
|
||||||
/* Semantic colors - Info (Sky) */
|
|
||||||
--semantic-info: 14 165 233;
|
|
||||||
--semantic-info-light: 224 242 254;
|
|
||||||
--semantic-info-dark: 3 105 161;
|
|
||||||
|
|
||||||
/* Focus ring */
|
|
||||||
--focus-ring: 99 102 241;
|
|
||||||
--focus-ring-offset: 255 255 255;
|
|
||||||
|
|
||||||
/* Shadows - visible but subtle */
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
CSS Custom Properties - Dark Theme
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
.dark {
|
|
||||||
--color-background: 3 7 18;
|
|
||||||
--color-foreground: 248 250 252;
|
|
||||||
|
|
||||||
/* Surface hierarchy (elevation levels) */
|
|
||||||
--surface-0: 15 23 42;
|
|
||||||
--surface-1: 30 41 59;
|
|
||||||
--surface-2: 51 65 85;
|
|
||||||
--surface-3: 71 85 105;
|
|
||||||
|
|
||||||
/* Text hierarchy */
|
|
||||||
--text-primary: 248 250 252;
|
|
||||||
--text-secondary: 203 213 225;
|
|
||||||
--text-tertiary: 148 163 184;
|
|
||||||
--text-muted: 100 116 139;
|
|
||||||
|
|
||||||
/* Border colors */
|
|
||||||
--border-default: 51 65 85;
|
|
||||||
--border-subtle: 30 41 59;
|
|
||||||
--border-strong: 71 85 105;
|
|
||||||
|
|
||||||
/* Brand accent adjustments for dark mode */
|
|
||||||
--accent-primary: 129 140 248;
|
|
||||||
--accent-primary-hover: 165 180 252;
|
|
||||||
--accent-primary-light: 30 27 75;
|
|
||||||
--accent-primary-muted: 55 48 163;
|
|
||||||
|
|
||||||
/* Semantic colors adjustments */
|
|
||||||
--semantic-success: 52 211 153;
|
|
||||||
--semantic-success-light: 6 78 59;
|
|
||||||
--semantic-success-dark: 167 243 208;
|
|
||||||
|
|
||||||
--semantic-warning: 251 191 36;
|
|
||||||
--semantic-warning-light: 120 53 15;
|
|
||||||
--semantic-warning-dark: 253 230 138;
|
|
||||||
|
|
||||||
--semantic-error: 251 113 133;
|
|
||||||
--semantic-error-light: 136 19 55;
|
|
||||||
--semantic-error-dark: 253 164 175;
|
|
||||||
|
|
||||||
--semantic-info: 56 189 248;
|
|
||||||
--semantic-info-light: 12 74 110;
|
|
||||||
--semantic-info-dark: 186 230 253;
|
|
||||||
|
|
||||||
/* Focus ring */
|
|
||||||
--focus-ring: 129 140 248;
|
|
||||||
--focus-ring-offset: 15 23 42;
|
|
||||||
|
|
||||||
/* Shadows - subtle glow in dark mode */
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Light Theme Override — applied via data-theme attribute on <html>
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--ms-bg-950: #f8faff;
|
||||||
|
--ms-bg-900: #f0f4fc;
|
||||||
|
--ms-bg-850: #e8edf8;
|
||||||
|
--ms-surface-800: #dde4f2;
|
||||||
|
--ms-surface-750: #d0d9ec;
|
||||||
|
--ms-border-700: #b8c4de;
|
||||||
|
--ms-text-100: #0f141d;
|
||||||
|
--ms-text-300: #2f3b52;
|
||||||
|
--ms-text-500: #5a6a87;
|
||||||
|
|
||||||
|
/* Re-alias semantics for light — identical structure, primitive tokens differ */
|
||||||
|
--bg: var(--ms-bg-900);
|
||||||
|
--bg-deep: var(--ms-bg-950);
|
||||||
|
--bg-mid: var(--ms-bg-850);
|
||||||
|
--surface: var(--ms-surface-800);
|
||||||
|
--surface-2: var(--ms-surface-750);
|
||||||
|
--border: var(--ms-border-700);
|
||||||
|
--text: var(--ms-text-100);
|
||||||
|
--text-2: var(--ms-text-300);
|
||||||
|
--muted: var(--ms-text-500);
|
||||||
|
|
||||||
|
/* Lighter shadows for light mode */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Base Styles
|
Base Styles
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
* {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
font-size: 15px;
|
||||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: rgb(var(--text-primary));
|
font-family: var(--font);
|
||||||
background: rgb(var(--color-background));
|
background: var(--bg);
|
||||||
font-size: 14px;
|
color: var(--text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle grain/noise overlay for texture */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.025;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Focus States - Accessible & Visible
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@layer base {
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--ms-blue-400);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Scrollbar Styling - Minimal & Professional
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
App Shell Grid Layout
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
|
grid-template-rows: var(--topbar-h) 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Responsive App Shell — Mobile (< 768px): single-column, sidebar as overlay
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: var(--topbar-h);
|
||||||
|
bottom: 0;
|
||||||
|
width: 240px;
|
||||||
|
z-index: 150;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar[data-mobile-open="true"] {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Responsive App Shell — Tablet (768px–1023px): sidebar toggleable, pushes content
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
|
.app-shell[data-sidebar-hidden="true"] {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-sidebar-hidden="true"] .app-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-sidebar-hidden="true"] .app-main {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-sidebar-hidden="true"] .app-header {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
@@ -182,102 +338,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-mono {
|
.text-mono {
|
||||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
font-family: var(--mono);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text color utilities */
|
|
||||||
.text-primary {
|
|
||||||
color: rgb(var(--text-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
color: rgb(var(--text-secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-tertiary {
|
|
||||||
color: rgb(var(--text-tertiary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: rgb(var(--text-muted));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Surface & Card Utilities
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
@layer utilities {
|
|
||||||
.surface-0 {
|
|
||||||
background-color: rgb(var(--surface-0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-1 {
|
|
||||||
background-color: rgb(var(--surface-1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-2 {
|
|
||||||
background-color: rgb(var(--surface-2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-3 {
|
|
||||||
background-color: rgb(var(--surface-3));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-default {
|
|
||||||
border-color: rgb(var(--border-default));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-subtle {
|
|
||||||
border-color: rgb(var(--border-subtle));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-strong {
|
|
||||||
border-color: rgb(var(--border-strong));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Focus States - Accessible & Visible
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
@layer base {
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid rgb(var(--focus-ring));
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove default focus for mouse users */
|
|
||||||
:focus:not(:focus-visible) {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Scrollbar Styling - Minimal & Professional
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgb(var(--text-muted) / 0.4);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgb(var(--text-muted) / 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox */
|
|
||||||
* {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgb(var(--text-muted) / 0.4) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
@@ -292,40 +356,46 @@ body {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: rgb(var(--accent-primary));
|
background: linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500));
|
||||||
color: white;
|
color: white;
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background-color: rgb(var(--accent-primary-hover));
|
box-shadow: 0 8px 28px rgba(47, 128, 255, 0.38);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface);
|
||||||
color: rgb(var(--text-primary));
|
color: var(--text-2);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background-color: rgb(var(--surface-3));
|
background-color: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply btn px-3 py-2;
|
@apply btn px-3 py-2;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: rgb(var(--text-secondary));
|
color: var(--muted);
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface);
|
||||||
color: rgb(var(--text-primary));
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: rgb(var(--semantic-error));
|
background-color: var(--danger);
|
||||||
color: white;
|
color: white;
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
@@ -346,34 +416,36 @@ body {
|
|||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
@layer components {
|
@layer components {
|
||||||
.input {
|
.input {
|
||||||
@apply w-full rounded-md px-3 py-2 text-sm transition-all duration-150;
|
@apply w-full text-sm transition-all duration-150;
|
||||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-0;
|
@apply focus:outline-none;
|
||||||
background-color: rgb(var(--surface-0));
|
background-color: var(--bg);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
color: rgb(var(--text-primary));
|
border-radius: var(--r);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
color: rgb(var(--text-muted));
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: rgb(var(--accent-primary));
|
border-color: var(--primary);
|
||||||
box-shadow: 0 0 0 3px rgb(var(--accent-primary) / 0.1);
|
box-shadow: 0 0 0 3px rgba(47, 128, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:disabled {
|
.input:disabled {
|
||||||
@apply opacity-50 cursor-not-allowed;
|
@apply opacity-50 cursor-not-allowed;
|
||||||
background-color: rgb(var(--surface-1));
|
background-color: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-error {
|
.input-error {
|
||||||
border-color: rgb(var(--semantic-error));
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-error:focus {
|
.input-error:focus {
|
||||||
border-color: rgb(var(--semantic-error));
|
border-color: var(--danger);
|
||||||
box-shadow: 0 0 0 3px rgb(var(--semantic-error) / 0.1);
|
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,8 +455,8 @@ body {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
@apply rounded-lg p-4;
|
@apply rounded-lg p-4;
|
||||||
background-color: rgb(var(--surface-0));
|
background-color: var(--surface);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +470,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-interactive:hover {
|
.card-interactive:hover {
|
||||||
border-color: rgb(var(--border-strong));
|
border-color: var(--muted);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,33 +484,33 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background-color: rgb(var(--semantic-success-light));
|
background-color: rgba(20, 184, 166, 0.15);
|
||||||
color: rgb(var(--semantic-success-dark));
|
color: var(--ms-teal-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-warning {
|
.badge-warning {
|
||||||
background-color: rgb(var(--semantic-warning-light));
|
background-color: rgba(245, 158, 11, 0.15);
|
||||||
color: rgb(var(--semantic-warning-dark));
|
color: var(--ms-amber-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-error {
|
.badge-error {
|
||||||
background-color: rgb(var(--semantic-error-light));
|
background-color: rgba(229, 72, 77, 0.15);
|
||||||
color: rgb(var(--semantic-error-dark));
|
color: var(--ms-red-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-info {
|
.badge-info {
|
||||||
background-color: rgb(var(--semantic-info-light));
|
background-color: rgba(47, 128, 255, 0.15);
|
||||||
color: rgb(var(--semantic-info-dark));
|
color: var(--ms-blue-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-neutral {
|
.badge-neutral {
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface-2);
|
||||||
color: rgb(var(--text-secondary));
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
background-color: rgb(var(--accent-primary-light));
|
background-color: rgba(47, 128, 255, 0.15);
|
||||||
color: rgb(var(--accent-primary));
|
color: var(--primary-l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,26 +523,29 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-success {
|
.status-dot-success {
|
||||||
background-color: rgb(var(--semantic-success));
|
background-color: var(--success);
|
||||||
|
box-shadow: 0 0 5px var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-warning {
|
.status-dot-warning {
|
||||||
background-color: rgb(var(--semantic-warning));
|
background-color: var(--warn);
|
||||||
|
box-shadow: 0 0 5px var(--warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-error {
|
.status-dot-error {
|
||||||
background-color: rgb(var(--semantic-error));
|
background-color: var(--danger);
|
||||||
|
box-shadow: 0 0 5px var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-info {
|
.status-dot-info {
|
||||||
background-color: rgb(var(--semantic-info));
|
background-color: var(--primary);
|
||||||
|
box-shadow: 0 0 5px var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-neutral {
|
.status-dot-neutral {
|
||||||
background-color: rgb(var(--text-muted));
|
background-color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pulsing indicator for live/active status */
|
|
||||||
.status-dot-pulse {
|
.status-dot-pulse {
|
||||||
@apply relative;
|
@apply relative;
|
||||||
}
|
}
|
||||||
@@ -489,12 +564,12 @@ body {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.kbd {
|
.kbd {
|
||||||
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface-2);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
color: rgb(var(--text-tertiary));
|
color: var(--muted);
|
||||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
font-family: var(--mono);
|
||||||
min-width: 1.5rem;
|
min-width: 1.5rem;
|
||||||
box-shadow: 0 1px 0 rgb(var(--border-strong));
|
box-shadow: 0 1px 0 var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kbd-group {
|
.kbd-group {
|
||||||
@@ -512,13 +587,13 @@ body {
|
|||||||
|
|
||||||
.table-pro thead {
|
.table-pro thead {
|
||||||
@apply sticky top-0;
|
@apply sticky top-0;
|
||||||
background-color: rgb(var(--surface-1));
|
background-color: var(--surface);
|
||||||
border-bottom: 1px solid rgb(var(--border-default));
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th {
|
.table-pro th {
|
||||||
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
||||||
color: rgb(var(--text-tertiary));
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th.sortable {
|
.table-pro th.sortable {
|
||||||
@@ -526,16 +601,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th.sortable:hover {
|
.table-pro th.sortable:hover {
|
||||||
color: rgb(var(--text-primary));
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro tbody tr {
|
.table-pro tbody tr {
|
||||||
border-bottom: 1px solid rgb(var(--border-subtle));
|
border-bottom: 1px solid var(--border);
|
||||||
transition: background-color 0.1s ease;
|
transition: background-color 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro tbody tr:hover {
|
.table-pro tbody tr:hover {
|
||||||
background-color: rgb(var(--surface-1));
|
background-color: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro td {
|
.table-pro td {
|
||||||
@@ -555,9 +630,9 @@ body {
|
|||||||
@apply animate-pulse rounded;
|
@apply animate-pulse rounded;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgb(var(--surface-2)) 0%,
|
var(--surface) 0%,
|
||||||
rgb(var(--surface-1)) 50%,
|
var(--surface-2) 50%,
|
||||||
rgb(var(--surface-2)) 100%
|
var(--surface) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
@@ -590,15 +665,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg;
|
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto;
|
||||||
background-color: rgb(var(--surface-0));
|
background-color: var(--surface);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@apply flex items-center justify-between p-4 border-b;
|
@apply flex items-center justify-between p-4 border-b;
|
||||||
border-color: rgb(var(--border-default));
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@@ -607,7 +683,7 @@ body {
|
|||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@apply flex items-center justify-end gap-3 p-4 border-t;
|
@apply flex items-center justify-end gap-3 p-4 border-t;
|
||||||
border-color: rgb(var(--border-default));
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,9 +693,10 @@ body {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
||||||
background-color: rgb(var(--text-primary));
|
background-color: var(--text);
|
||||||
color: rgb(var(--color-background));
|
color: var(--bg);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip::before {
|
.tooltip::before {
|
||||||
@@ -630,7 +707,7 @@ body {
|
|||||||
|
|
||||||
.tooltip-top::before {
|
.tooltip-top::before {
|
||||||
@apply left-1/2 top-full -translate-x-1/2;
|
@apply left-1/2 top-full -translate-x-1/2;
|
||||||
border-top-color: rgb(var(--text-primary));
|
border-top-color: var(--text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,12 +757,10 @@ body {
|
|||||||
animation: scaleIn 0.15s ease-out;
|
animation: scaleIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message animation - subtle for chat */
|
|
||||||
.message-animate {
|
.message-animate {
|
||||||
animation: slideIn 0.2s ease-out;
|
animation: slideIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Menu dropdown animation */
|
|
||||||
.animate-menu-enter {
|
.animate-menu-enter {
|
||||||
animation: scaleIn 0.1s ease-out;
|
animation: scaleIn 0.1s ease-out;
|
||||||
}
|
}
|
||||||
@@ -710,13 +785,8 @@ body {
|
|||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
@media (prefers-contrast: high) {
|
@media (prefers-contrast: high) {
|
||||||
:root {
|
:root {
|
||||||
--border-default: 100 116 139;
|
--border: #4a5a78;
|
||||||
--border-strong: 71 85 105;
|
--muted: #a0b0cc;
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--border-default: 148 163 184;
|
|
||||||
--border-strong: 203 213 225;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,56 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Outfit, Fira_Code } from "next/font/google";
|
||||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Mosaic Stack",
|
title: "Mosaic Stack",
|
||||||
description: "Mosaic Stack Web Application",
|
description: "Mosaic Stack Web Application",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const outfit = Outfit({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-outfit",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firaCode = Fira_Code({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-fira-code",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime env vars injected as a synchronous script so client-side modules
|
||||||
|
* can read them before React hydration. This allows Docker env vars to
|
||||||
|
* override the build-time baked NEXT_PUBLIC_* values.
|
||||||
|
*/
|
||||||
|
function runtimeEnvScript(): string {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const key of [
|
||||||
|
"NEXT_PUBLIC_API_URL",
|
||||||
|
"NEXT_PUBLIC_ORCHESTRATOR_URL",
|
||||||
|
"NEXT_PUBLIC_AUTH_MODE",
|
||||||
|
]) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) {
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `window.__MOSAIC_ENV__=${JSON.stringify(env)};`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${outfit.variable} ${firaCode.variable}`}>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: runtimeEnvScript() }} />
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AuthDivider } from "./AuthDivider";
|
|||||||
describe("AuthDivider", (): void => {
|
describe("AuthDivider", (): void => {
|
||||||
it("should render with default text", (): void => {
|
it("should render with default text", (): void => {
|
||||||
render(<AuthDivider />);
|
render(<AuthDivider />);
|
||||||
expect(screen.getByText("or continue with email")).toBeInTheDocument();
|
expect(screen.getByText("or continue with")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with custom text", (): void => {
|
it("should render with custom text", (): void => {
|
||||||
@@ -13,10 +13,10 @@ describe("AuthDivider", (): void => {
|
|||||||
expect(screen.getByText("or sign up")).toBeInTheDocument();
|
expect(screen.getByText("or sign up")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render a horizontal divider line", (): void => {
|
it("should render horizontal divider lines", (): void => {
|
||||||
const { container } = render(<AuthDivider />);
|
const { container } = render(<AuthDivider />);
|
||||||
const line = container.querySelector("span.border-t");
|
const lines = container.querySelectorAll("[aria-hidden='true'].h-px");
|
||||||
expect(line).toBeInTheDocument();
|
expect(lines.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply uppercase styling to text", (): void => {
|
it("should apply uppercase styling to text", (): void => {
|
||||||
|
|||||||
@@ -1,18 +1,2 @@
|
|||||||
interface AuthDividerProps {
|
export { AuthDivider } from "@mosaic/ui";
|
||||||
text?: string;
|
export type { AuthDividerProps } from "@mosaic/ui";
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthDivider({
|
|
||||||
text = "or continue with email",
|
|
||||||
}: AuthDividerProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="relative my-6">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-slate-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-white px-2 text-slate-500">{text}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,17 +18,10 @@ describe("AuthErrorBanner", (): void => {
|
|||||||
expect(alert).toHaveAttribute("aria-live", "polite");
|
expect(alert).toHaveAttribute("aria-live", "polite");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the info icon, not a warning icon", (): void => {
|
it("should render an icon", (): void => {
|
||||||
const { container } = render(<AuthErrorBanner message="Test message" />);
|
const { container } = render(<AuthErrorBanner message="Test message" />);
|
||||||
// Info icon from lucide-react renders as an SVG
|
|
||||||
const svgs = container.querySelectorAll("svg");
|
const svgs = container.querySelectorAll("svg");
|
||||||
expect(svgs.length).toBeGreaterThanOrEqual(1);
|
expect(svgs.length).toBeGreaterThanOrEqual(1);
|
||||||
// The container should use blue styling, not red/yellow
|
|
||||||
const alert = screen.getByRole("alert");
|
|
||||||
expect(alert.className).toContain("bg-blue-50");
|
|
||||||
expect(alert.className).toContain("text-blue-700");
|
|
||||||
expect(alert.className).not.toContain("red");
|
|
||||||
expect(alert.className).not.toContain("yellow");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render dismiss button when onDismiss is provided", (): void => {
|
it("should render dismiss button when onDismiss is provided", (): void => {
|
||||||
@@ -54,14 +47,6 @@ describe("AuthErrorBanner", (): void => {
|
|||||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use blue info styling, not red or alarming colors", (): void => {
|
|
||||||
render(<AuthErrorBanner message="Test" />);
|
|
||||||
const alert = screen.getByRole("alert");
|
|
||||||
expect(alert.className).toContain("bg-blue-50");
|
|
||||||
expect(alert.className).toContain("border-blue-200");
|
|
||||||
expect(alert.className).toContain("text-blue-700");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render all PDA-friendly error messages", (): void => {
|
it("should render all PDA-friendly error messages", (): void => {
|
||||||
const messages = [
|
const messages = [
|
||||||
"Authentication paused. Please try again when ready.",
|
"Authentication paused. Please try again when ready.",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
|
|||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="bg-blue-50 border border-blue-200 text-blue-700 rounded-lg p-4 flex items-start gap-3"
|
className="flex items-start gap-3 rounded-lg border border-[#f06a6f]/55 bg-[#fff1f2] p-4 text-[#9f1239] dark:border-[#e5484d]/55 dark:bg-[#3a111b]/70 dark:text-[#fecdd3]"
|
||||||
>
|
>
|
||||||
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||||
<span className="flex-1 text-sm">{message}</span>
|
<span className="flex-1 text-sm">{message}</span>
|
||||||
@@ -21,7 +21,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="flex-shrink-0 text-blue-500 hover:text-blue-700 transition-colors"
|
className="flex-shrink-0 text-[#be123c] transition-colors hover:text-[#881337] dark:text-[#fda4af] dark:hover:text-[#ffe4e6]"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" aria-hidden="true" />
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ export interface LoginFormProps {
|
|||||||
onSubmit: (email: string, password: string) => void | Promise<void>;
|
onSubmit: (email: string, password: string) => void | Promise<void>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = null,
|
error = null,
|
||||||
|
disabled = false,
|
||||||
}: LoginFormProps): ReactElement {
|
}: LoginFormProps): ReactElement {
|
||||||
const emailRef = useRef<HTMLInputElement>(null);
|
const emailRef = useRef<HTMLInputElement>(null);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -77,7 +79,10 @@ export function LoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1">
|
<label
|
||||||
|
htmlFor="login-email"
|
||||||
|
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
|
||||||
|
>
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -91,13 +96,17 @@ export function LoginForm({
|
|||||||
validateEmail(e.target.value);
|
validateEmail(e.target.value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading || disabled}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
className={[
|
className={[
|
||||||
"w-full px-3 py-2 border rounded-md",
|
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
|
||||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
|
||||||
emailError ? "border-blue-400" : "border-gray-300",
|
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
|
||||||
isLoading ? "opacity-50" : "",
|
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
|
||||||
|
emailError
|
||||||
|
? "border-[#f06a6f] focus:border-[#e5484d]"
|
||||||
|
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
|
||||||
|
isLoading || disabled ? "opacity-50" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
@@ -105,14 +114,21 @@ export function LoginForm({
|
|||||||
aria-describedby={emailError ? "login-email-error" : undefined}
|
aria-describedby={emailError ? "login-email-error" : undefined}
|
||||||
/>
|
/>
|
||||||
{emailError && (
|
{emailError && (
|
||||||
<p id="login-email-error" className="mt-1 text-sm text-blue-600" role="alert">
|
<p
|
||||||
|
id="login-email-error"
|
||||||
|
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{emailError}
|
{emailError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1">
|
<label
|
||||||
|
htmlFor="login-password"
|
||||||
|
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
|
||||||
|
>
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -125,13 +141,17 @@ export function LoginForm({
|
|||||||
validatePassword(e.target.value);
|
validatePassword(e.target.value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading || disabled}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className={[
|
className={[
|
||||||
"w-full px-3 py-2 border rounded-md",
|
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
|
||||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
|
||||||
passwordError ? "border-blue-400" : "border-gray-300",
|
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
|
||||||
isLoading ? "opacity-50" : "",
|
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
|
||||||
|
passwordError
|
||||||
|
? "border-[#f06a6f] focus:border-[#e5484d]"
|
||||||
|
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
|
||||||
|
isLoading || disabled ? "opacity-50" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
@@ -139,7 +159,11 @@ export function LoginForm({
|
|||||||
aria-describedby={passwordError ? "login-password-error" : undefined}
|
aria-describedby={passwordError ? "login-password-error" : undefined}
|
||||||
/>
|
/>
|
||||||
{passwordError && (
|
{passwordError && (
|
||||||
<p id="login-password-error" className="mt-1 text-sm text-blue-600" role="alert">
|
<p
|
||||||
|
id="login-password-error"
|
||||||
|
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{passwordError}
|
{passwordError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -147,13 +171,13 @@ export function LoginForm({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading || disabled}
|
||||||
className={[
|
className={[
|
||||||
"w-full inline-flex items-center justify-center gap-2",
|
"w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white",
|
||||||
"rounded-md px-4 py-2 text-base font-medium",
|
"bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)]",
|
||||||
"bg-blue-600 text-white hover:bg-blue-700",
|
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
|
||||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
"hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]",
|
||||||
isLoading ? "opacity-50 pointer-events-none" : "",
|
isLoading || disabled ? "opacity-50 pointer-events-none" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ export interface OAuthButtonProps {
|
|||||||
|
|
||||||
export function OAuthButton({
|
export function OAuthButton({
|
||||||
providerName,
|
providerName,
|
||||||
|
providerId,
|
||||||
onClick,
|
onClick,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: OAuthButtonProps): ReactElement {
|
}: OAuthButtonProps): ReactElement {
|
||||||
|
const accentColor = resolveProviderAccent(providerId);
|
||||||
const isDisabled = disabled || isLoading;
|
const isDisabled = disabled || isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,10 +29,12 @@ export function OAuthButton({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
|
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
|
||||||
className={[
|
className={[
|
||||||
"w-full inline-flex items-center justify-center gap-2",
|
"w-full inline-flex items-center justify-center gap-2 rounded-lg",
|
||||||
"rounded-md px-4 py-2 text-base font-medium",
|
"border border-[#b8c4de] bg-[#f8faff]/90 px-4 py-3 text-sm font-semibold text-[#2f3b52]",
|
||||||
"bg-blue-600 text-white hover:bg-blue-700",
|
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
|
||||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
"hover:border-[#2f80ff] hover:bg-[#dde4f2] hover:text-[#0f141d]",
|
||||||
|
"dark:border-[#2f3b52] dark:bg-[#0f141d]/75 dark:text-[#c5d0e6]",
|
||||||
|
"dark:hover:border-[#2f80ff] dark:hover:bg-[#232d3f] dark:hover:text-[#eef3ff]",
|
||||||
isDisabled ? "opacity-50 pointer-events-none" : "",
|
isDisabled ? "opacity-50 pointer-events-none" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -42,8 +46,33 @@ export function OAuthButton({
|
|||||||
<span>Connecting...</span>
|
<span>Connecting...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>Continue with {providerName}</span>
|
<>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
<span>Continue with {providerName}</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveProviderAccent(providerId: string): string {
|
||||||
|
const normalized = providerId.toLowerCase();
|
||||||
|
|
||||||
|
if (normalized.includes("github")) {
|
||||||
|
return "#8b5cf6";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("google")) {
|
||||||
|
return "#e5484d";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("ldap")) {
|
||||||
|
return "#14b8a6";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "#2f80ff";
|
||||||
|
}
|
||||||
|
|||||||
169
apps/web/src/components/dashboard/ActivityFeed.tsx
Normal file
169
apps/web/src/components/dashboard/ActivityFeed.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
||||||
|
|
||||||
|
type BadgeVariantType =
|
||||||
|
| "badge-amber"
|
||||||
|
| "badge-red"
|
||||||
|
| "badge-teal"
|
||||||
|
| "badge-blue"
|
||||||
|
| "badge-muted"
|
||||||
|
| "badge-purple"
|
||||||
|
| "badge-pulse";
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
iconBg: string;
|
||||||
|
title: string;
|
||||||
|
highlight: string;
|
||||||
|
rest: string;
|
||||||
|
timestamp: string;
|
||||||
|
badge?: {
|
||||||
|
text: string;
|
||||||
|
variant: BadgeVariantType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityItems: ActivityItem[] = [
|
||||||
|
{
|
||||||
|
id: "act-1",
|
||||||
|
icon: "✓",
|
||||||
|
iconBg: "rgba(20,184,166,0.15)",
|
||||||
|
title: "",
|
||||||
|
highlight: "planner-agent",
|
||||||
|
rest: " completed task analysis for infra-refactor",
|
||||||
|
timestamp: "2m ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-2",
|
||||||
|
icon: "⚠",
|
||||||
|
iconBg: "rgba(245,158,11,0.15)",
|
||||||
|
title: "",
|
||||||
|
highlight: "executor-agent",
|
||||||
|
rest: " hit rate limit on Terraform API",
|
||||||
|
timestamp: "5m ago",
|
||||||
|
badge: { text: "warn", variant: "badge-amber" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-3",
|
||||||
|
icon: "↑",
|
||||||
|
iconBg: "rgba(47,128,255,0.15)",
|
||||||
|
title: "",
|
||||||
|
highlight: "ORCH-002",
|
||||||
|
rest: " session started for api-v3-migration",
|
||||||
|
timestamp: "12m ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-4",
|
||||||
|
icon: "✗",
|
||||||
|
iconBg: "rgba(229,72,77,0.15)",
|
||||||
|
title: "",
|
||||||
|
highlight: "migrator-agent",
|
||||||
|
rest: " failed to connect to staging database",
|
||||||
|
timestamp: "18m ago",
|
||||||
|
badge: { text: "error", variant: "badge-red" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-5",
|
||||||
|
icon: "✓",
|
||||||
|
iconBg: "rgba(20,184,166,0.15)",
|
||||||
|
title: "",
|
||||||
|
highlight: "reviewer-agent",
|
||||||
|
rest: " approved PR #214 in infra-refactor",
|
||||||
|
timestamp: "34m ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-6",
|
||||||
|
icon: "⟳",
|
||||||
|
iconBg: "rgba(139,92,246,0.15)",
|
||||||
|
title: "Token budget reset for ",
|
||||||
|
highlight: "gpt-4o",
|
||||||
|
rest: " model",
|
||||||
|
timestamp: "1h ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-7",
|
||||||
|
icon: "★",
|
||||||
|
iconBg: "rgba(20,184,166,0.15)",
|
||||||
|
title: "Project ",
|
||||||
|
highlight: "data-pipeline",
|
||||||
|
rest: " marked as completed",
|
||||||
|
timestamp: "2h ago",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ActivityItemRowProps {
|
||||||
|
item: ActivityItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 10,
|
||||||
|
padding: "8px 0",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: item.iconBg,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
<strong style={{ color: "var(--text)" }}>{item.highlight}</strong>
|
||||||
|
{item.rest}
|
||||||
|
{item.badge !== undefined && (
|
||||||
|
<Badge variant={item.badge.variant} style={{ marginLeft: 6 }}>
|
||||||
|
{item.badge.text}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.timestamp}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeed(): ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
||||||
|
<div>
|
||||||
|
{activityItems.map((item) => (
|
||||||
|
<ActivityItemRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/web/src/components/dashboard/DashboardMetrics.tsx
Normal file
45
apps/web/src/components/dashboard/DashboardMetrics.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
||||||
|
|
||||||
|
const cells: MetricCell[] = [
|
||||||
|
{
|
||||||
|
label: "Active Agents",
|
||||||
|
value: "47",
|
||||||
|
color: "var(--ms-blue-400)",
|
||||||
|
trend: { direction: "up", text: "↑ +3 from yesterday" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tasks Completed",
|
||||||
|
value: "1,284",
|
||||||
|
color: "var(--ms-teal-400)",
|
||||||
|
trend: { direction: "up", text: "↑ +128 today" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg Response Time",
|
||||||
|
value: "2.4s",
|
||||||
|
color: "var(--ms-purple-400)",
|
||||||
|
trend: { direction: "down", text: "↓ -0.3s improved" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Token Usage",
|
||||||
|
value: "3.2M",
|
||||||
|
color: "var(--ms-amber-400)",
|
||||||
|
trend: { direction: "neutral", text: "78% of budget" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Error Rate",
|
||||||
|
value: "0.4%",
|
||||||
|
color: "var(--ms-red-400)",
|
||||||
|
trend: { direction: "down", text: "↓ -0.1% improved" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active Projects",
|
||||||
|
value: "8",
|
||||||
|
color: "var(--ms-cyan-500)",
|
||||||
|
trend: { direction: "neutral", text: "2 deploying" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardMetrics(): ReactElement {
|
||||||
|
return <MetricsStrip cells={cells} />;
|
||||||
|
}
|
||||||
241
apps/web/src/components/dashboard/OrchestratorSessions.tsx
Normal file
241
apps/web/src/components/dashboard/OrchestratorSessions.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
||||||
|
|
||||||
|
interface AgentNode {
|
||||||
|
id: string;
|
||||||
|
initials: string;
|
||||||
|
avatarColor: string;
|
||||||
|
name: string;
|
||||||
|
task: string;
|
||||||
|
status: "teal" | "blue" | "amber" | "red" | "muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrchestratorSession {
|
||||||
|
id: string;
|
||||||
|
orchId: string;
|
||||||
|
name: string;
|
||||||
|
badge: string;
|
||||||
|
badgeVariant:
|
||||||
|
| "badge-teal"
|
||||||
|
| "badge-amber"
|
||||||
|
| "badge-red"
|
||||||
|
| "badge-blue"
|
||||||
|
| "badge-muted"
|
||||||
|
| "badge-purple"
|
||||||
|
| "badge-pulse";
|
||||||
|
duration: string;
|
||||||
|
agents: AgentNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: OrchestratorSession[] = [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
orchId: "ORCH-001",
|
||||||
|
name: "infra-refactor",
|
||||||
|
badge: "running",
|
||||||
|
badgeVariant: "badge-teal",
|
||||||
|
duration: "2h 14m",
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
initials: "PL",
|
||||||
|
avatarColor: "rgba(47,128,255,0.15)",
|
||||||
|
name: "planner-agent",
|
||||||
|
task: "Analyzing network topology",
|
||||||
|
status: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2",
|
||||||
|
initials: "EX",
|
||||||
|
avatarColor: "rgba(20,184,166,0.15)",
|
||||||
|
name: "executor-agent",
|
||||||
|
task: "Applying Terraform modules",
|
||||||
|
status: "teal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a3",
|
||||||
|
initials: "QA",
|
||||||
|
avatarColor: "rgba(245,158,11,0.15)",
|
||||||
|
name: "reviewer-agent",
|
||||||
|
task: "Waiting for executor output",
|
||||||
|
status: "amber",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s2",
|
||||||
|
orchId: "ORCH-002",
|
||||||
|
name: "api-v3-migration",
|
||||||
|
badge: "running",
|
||||||
|
badgeVariant: "badge-teal",
|
||||||
|
duration: "45m",
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
id: "a4",
|
||||||
|
initials: "MG",
|
||||||
|
avatarColor: "rgba(139,92,246,0.15)",
|
||||||
|
name: "migrator-agent",
|
||||||
|
task: "Rewriting endpoint handlers",
|
||||||
|
status: "blue",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AgentNodeItemProps {
|
||||||
|
agent: AgentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={(): void => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "7px 10px",
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||||
|
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||||
|
transition: "border-color 0.15s, background 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
background: agent.avatarColor,
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.initials}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.task}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dot variant={agent.status} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrchCardProps {
|
||||||
|
session: OrchestratorSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrchCard({ session }: OrchCardProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
padding: "12px 14px",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dot variant="teal" />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--ms-purple-400)",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.orchId}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant={session.badgeVariant}>{session.badge}</Badge>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.duration}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{session.agents.map((agent) => (
|
||||||
|
<AgentNodeItem key={agent.id} agent={agent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrchestratorSessions(): ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<SectionHeader
|
||||||
|
title="Active Orchestrator Sessions"
|
||||||
|
subtitle="3 of 8 projects running"
|
||||||
|
actions={<Badge variant="badge-teal">3 active</Badge>}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<OrchCard key={session.id} session={session} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
apps/web/src/components/dashboard/QuickActions.tsx
Normal file
96
apps/web/src/components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { Card, SectionHeader } from "@mosaic/ui";
|
||||||
|
|
||||||
|
interface QuickAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
iconBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: QuickAction[] = [
|
||||||
|
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
|
||||||
|
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
|
||||||
|
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
|
||||||
|
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ActionButtonProps {
|
||||||
|
action: QuickAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ action }: ActionButtonProps): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={(): void => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "16px 12px",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||||
|
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 0.15s, background 0.15s",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: action.iconBg,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActions(): ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<SectionHeader title="Quick Actions" />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<ActionButton key={action.id} action={action} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/web/src/components/dashboard/TokenBudget.tsx
Normal file
98
apps/web/src/components/dashboard/TokenBudget.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
||||||
|
|
||||||
|
interface ModelBudget {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
usage: string;
|
||||||
|
value: number;
|
||||||
|
variant: ProgressBarVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models: ModelBudget[] = [
|
||||||
|
{
|
||||||
|
id: "sonnet",
|
||||||
|
label: "claude-3-5-sonnet",
|
||||||
|
usage: "2.1M / 3M",
|
||||||
|
value: 70,
|
||||||
|
variant: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "haiku",
|
||||||
|
label: "claude-3-haiku",
|
||||||
|
usage: "890K / 5M",
|
||||||
|
value: 18,
|
||||||
|
variant: "teal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt4o",
|
||||||
|
label: "gpt-4o",
|
||||||
|
usage: "320K / 1M",
|
||||||
|
value: 32,
|
||||||
|
variant: "purple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "llama",
|
||||||
|
label: "local/llama-3.3",
|
||||||
|
usage: "unlimited",
|
||||||
|
value: 55,
|
||||||
|
variant: "amber",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ModelRowProps {
|
||||||
|
model: ModelBudget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelRow({ model }: ModelRowProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{model.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{model.usage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
value={model.value}
|
||||||
|
variant={model.variant}
|
||||||
|
label={`${model.label} token usage`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenBudget(): ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
||||||
|
<div>
|
||||||
|
{models.map((model) => (
|
||||||
|
<ModelRow key={model.id} model={model} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,8 +46,8 @@ describe("FilterBar", (): void => {
|
|||||||
it("should debounce search input", async (): Promise<void> => {
|
it("should debounce search input", async (): Promise<void> => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
// Use a very short debounce to test the behavior without flaky timing
|
// Use a debounce long enough that CI environments don't fire it between keystrokes
|
||||||
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={100} />);
|
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={500} />);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText(/search/i);
|
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||||
mockOnFilterChange.mockClear();
|
mockOnFilterChange.mockClear();
|
||||||
@@ -71,7 +71,7 @@ describe("FilterBar", (): void => {
|
|||||||
expect.objectContaining({ search: "test" })
|
expect.objectContaining({ search: "test" })
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ timeout: 200 }
|
{ timeout: 1000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify it was only called once (debounced)
|
// Verify it was only called once (debounced)
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ const WIDGET_REGISTRY = {
|
|||||||
minWidth: 1,
|
minWidth: 1,
|
||||||
minHeight: 1,
|
minHeight: 1,
|
||||||
},
|
},
|
||||||
|
OrchestratorEventsWidget: {
|
||||||
|
name: "orchestrator-events",
|
||||||
|
displayName: "Orchestrator Events",
|
||||||
|
description: "Recent events and stream health for orchestration",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 1,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
||||||
@@ -73,7 +82,7 @@ export function HUD({ className = "" }: HUDProps): React.JSX.Element {
|
|||||||
|
|
||||||
const handleAddWidget = (widgetType: WidgetRegistryKey): void => {
|
const handleAddWidget = (widgetType: WidgetRegistryKey): void => {
|
||||||
const widgetConfig = WIDGET_REGISTRY[widgetType];
|
const widgetConfig = WIDGET_REGISTRY[widgetType];
|
||||||
const widgetId = `${widgetType.toLowerCase()}-${String(Date.now())}`;
|
const widgetId = `${widgetConfig.name}-${String(Date.now())}`;
|
||||||
|
|
||||||
// Find the next available position
|
// Find the next available position
|
||||||
const maxY = currentLayout?.layout.reduce((max, w): number => Math.max(max, w.y + w.h), 0) ?? 0;
|
const maxY = currentLayout?.layout.reduce((max, w): number => Math.max(max, w.y + w.h), 0) ?? 0;
|
||||||
|
|||||||
47
apps/web/src/components/hud/WidgetRenderer.test.tsx
Normal file
47
apps/web/src/components/hud/WidgetRenderer.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { WidgetRenderer } from "./WidgetRenderer";
|
||||||
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
|
vi.mock("@/components/widgets", () => ({
|
||||||
|
TasksWidget: ({ id }: { id: string }): React.JSX.Element => <div>Tasks Widget {id}</div>,
|
||||||
|
CalendarWidget: ({ id }: { id: string }): React.JSX.Element => <div>Calendar Widget {id}</div>,
|
||||||
|
QuickCaptureWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||||
|
<div>Quick Capture Widget {id}</div>
|
||||||
|
),
|
||||||
|
AgentStatusWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||||
|
<div>Agent Status Widget {id}</div>
|
||||||
|
),
|
||||||
|
OrchestratorEventsWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||||
|
<div>Orchestrator Events Widget {id}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWidgetPlacement(id: string): WidgetPlacement {
|
||||||
|
return {
|
||||||
|
i: id,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 2,
|
||||||
|
h: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WidgetRenderer", () => {
|
||||||
|
it("renders hyphenated quick-capture widget IDs correctly", () => {
|
||||||
|
render(<WidgetRenderer widget={createWidgetPlacement("quick-capture-123")} />);
|
||||||
|
expect(screen.getByText("Quick Capture Widget quick-capture-123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hyphenated agent-status widget IDs correctly", () => {
|
||||||
|
render(<WidgetRenderer widget={createWidgetPlacement("agent-status-123")} />);
|
||||||
|
expect(screen.getByText("Agent Status Widget agent-status-123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hyphenated orchestrator-events widget IDs correctly", () => {
|
||||||
|
render(<WidgetRenderer widget={createWidgetPlacement("orchestrator-events-123")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Orchestrator Events Widget orchestrator-events-123")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CalendarWidget,
|
CalendarWidget,
|
||||||
QuickCaptureWidget,
|
QuickCaptureWidget,
|
||||||
AgentStatusWidget,
|
AgentStatusWidget,
|
||||||
|
OrchestratorEventsWidget,
|
||||||
} from "@/components/widgets";
|
} from "@/components/widgets";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ const WIDGET_COMPONENTS = {
|
|||||||
calendar: CalendarWidget,
|
calendar: CalendarWidget,
|
||||||
"quick-capture": QuickCaptureWidget,
|
"quick-capture": QuickCaptureWidget,
|
||||||
"agent-status": AgentStatusWidget,
|
"agent-status": AgentStatusWidget,
|
||||||
|
"orchestrator-events": OrchestratorEventsWidget,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WIDGET_CONFIG = {
|
const WIDGET_CONFIG = {
|
||||||
@@ -43,6 +45,10 @@ const WIDGET_CONFIG = {
|
|||||||
displayName: "Agent Status",
|
displayName: "Agent Status",
|
||||||
description: "View running agent sessions",
|
description: "View running agent sessions",
|
||||||
},
|
},
|
||||||
|
"orchestrator-events": {
|
||||||
|
displayName: "Orchestrator Events",
|
||||||
|
description: "Recent orchestration events and stream health",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WidgetRenderer({
|
export function WidgetRenderer({
|
||||||
@@ -50,8 +56,12 @@ export function WidgetRenderer({
|
|||||||
isEditing = false,
|
isEditing = false,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: WidgetRendererProps): React.JSX.Element {
|
}: WidgetRendererProps): React.JSX.Element {
|
||||||
// Extract widget type from ID (e.g., "tasks-123" -> "tasks")
|
// Extract widget type from ID by removing the trailing unique suffix
|
||||||
const widgetType = widget.i.split("-")[0] as keyof typeof WIDGET_COMPONENTS;
|
// (e.g., "agent-status-123" -> "agent-status").
|
||||||
|
const separatorIndex = widget.i.lastIndexOf("-");
|
||||||
|
const widgetType = (
|
||||||
|
separatorIndex > 0 ? widget.i.substring(0, separatorIndex) : widget.i
|
||||||
|
) as keyof typeof WIDGET_COMPONENTS;
|
||||||
const WidgetComponent = WIDGET_COMPONENTS[widgetType];
|
const WidgetComponent = WIDGET_COMPONENTS[widgetType];
|
||||||
const config = WIDGET_CONFIG[widgetType] || { displayName: "Widget", description: "" };
|
const config = WIDGET_CONFIG[widgetType] || { displayName: "Widget", description: "" };
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ export function LinkAutocomplete({
|
|||||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
|
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
// Refs for event handler to avoid stale closures when effects re-attach listeners
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
const resultsRef = useRef(results);
|
||||||
|
const selectedIndexRef = useRef(selectedIndex);
|
||||||
|
const insertLinkRef = useRef<((result: SearchResult) => void) | null>(null);
|
||||||
|
stateRef.current = state;
|
||||||
|
resultsRef.current = results;
|
||||||
|
selectedIndexRef.current = selectedIndex;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for knowledge entries matching the query.
|
* Search for knowledge entries matching the query.
|
||||||
* Accepts an AbortSignal to allow cancellation of in-flight requests,
|
* Accepts an AbortSignal to allow cancellation of in-flight requests,
|
||||||
@@ -254,47 +263,48 @@ export function LinkAutocomplete({
|
|||||||
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
|
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle keyboard navigation in the dropdown
|
* Handle keyboard navigation in the dropdown.
|
||||||
|
* Reads from refs to avoid stale closures when the effect
|
||||||
|
* that attaches this listener hasn't re-run yet.
|
||||||
*/
|
*/
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback((e: KeyboardEvent): void => {
|
||||||
(e: KeyboardEvent): void => {
|
if (!stateRef.current.isOpen) return;
|
||||||
if (!state.isOpen) return;
|
|
||||||
|
|
||||||
switch (e.key) {
|
const currentResults = resultsRef.current;
|
||||||
case "ArrowDown":
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex((prev) => (prev + 1) % results.length);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "ArrowUp":
|
switch (e.key) {
|
||||||
e.preventDefault();
|
case "ArrowDown":
|
||||||
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
|
e.preventDefault();
|
||||||
break;
|
setSelectedIndex((prev) => (prev + 1) % currentResults.length);
|
||||||
|
break;
|
||||||
|
|
||||||
case "Enter":
|
case "ArrowUp":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (results.length > 0 && selectedIndex >= 0) {
|
setSelectedIndex((prev) => (prev - 1 + currentResults.length) % currentResults.length);
|
||||||
const selected = results[selectedIndex];
|
break;
|
||||||
if (selected) {
|
|
||||||
insertLink(selected);
|
case "Enter":
|
||||||
}
|
e.preventDefault();
|
||||||
|
if (currentResults.length > 0 && selectedIndexRef.current >= 0) {
|
||||||
|
const selected = currentResults[selectedIndexRef.current];
|
||||||
|
if (selected) {
|
||||||
|
insertLinkRef.current?.(selected);
|
||||||
}
|
}
|
||||||
break;
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "Escape":
|
case "Escape":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setState({
|
setState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
query: "",
|
query: "",
|
||||||
position: { top: 0, left: 0 },
|
position: { top: 0, left: 0 },
|
||||||
triggerIndex: -1,
|
triggerIndex: -1,
|
||||||
});
|
});
|
||||||
setResults([]);
|
setResults([]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}, []);
|
||||||
[state.isOpen, results, selectedIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert the selected link into the textarea
|
* Insert the selected link into the textarea
|
||||||
@@ -330,6 +340,7 @@ export function LinkAutocomplete({
|
|||||||
},
|
},
|
||||||
[textareaRef, state.triggerIndex, onInsert]
|
[textareaRef, state.triggerIndex, onInsert]
|
||||||
);
|
);
|
||||||
|
insertLinkRef.current = insertLink;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle click on a result
|
* Handle click on a result
|
||||||
|
|||||||
@@ -466,7 +466,9 @@ describe("LinkAutocomplete", (): void => {
|
|||||||
expect(firstItem).toHaveClass("bg-blue-50");
|
expect(firstItem).toHaveClass("bg-blue-50");
|
||||||
|
|
||||||
// Press ArrowDown
|
// Press ArrowDown
|
||||||
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
act(() => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||||
|
});
|
||||||
|
|
||||||
// Second item should now be selected
|
// Second item should now be selected
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -475,7 +477,9 @@ describe("LinkAutocomplete", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Press ArrowUp
|
// Press ArrowUp
|
||||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
act(() => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||||
|
});
|
||||||
|
|
||||||
// First item should be selected again
|
// First item should be selected again
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
647
apps/web/src/components/layout/AppHeader.tsx
Normal file
647
apps/web/src/components/layout/AppHeader.tsx
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
import { useSidebar } from "./SidebarContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-width application header (topbar).
|
||||||
|
* Logo/brand MUST live here — not in the sidebar — per MS15 design spec.
|
||||||
|
* Spans grid-column 1 / -1 in the app shell grid layout.
|
||||||
|
*
|
||||||
|
* Layout (left → right):
|
||||||
|
* [Logo/Brand] [Breadcrumb] [Search] [spacer]
|
||||||
|
* [System Status] [Terminal Toggle] [Notifications] [Theme Toggle] [User Avatar+Dropdown]
|
||||||
|
*/
|
||||||
|
export function AppHeader(): React.JSX.Element {
|
||||||
|
const { user, signOut } = useAuth();
|
||||||
|
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
const handleOutsideClick = useCallback((event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dropdownOpen) {
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
}
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [dropdownOpen, handleOutsideClick]);
|
||||||
|
|
||||||
|
// Derive breadcrumb segments from pathname
|
||||||
|
const breadcrumbSegments = pathname
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, " "));
|
||||||
|
|
||||||
|
// User initials for avatar fallback
|
||||||
|
const initials = user?.name
|
||||||
|
? user.name
|
||||||
|
.split(" ")
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
: user?.email
|
||||||
|
? (user.email[0] ?? "?").toUpperCase()
|
||||||
|
: "?";
|
||||||
|
|
||||||
|
const handleHamburgerClick = useCallback((): void => {
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileOpen(!mobileOpen);
|
||||||
|
} else {
|
||||||
|
toggleCollapsed();
|
||||||
|
}
|
||||||
|
}, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="app-header">
|
||||||
|
{/* ── Hamburger — visible below lg ── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lg:hidden"
|
||||||
|
onClick={handleHamburgerClick}
|
||||||
|
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
aria-controls="app-sidebar"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2 4h12M2 8h12M2 12h12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ── Brand / Logo ── */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 flex-shrink-0"
|
||||||
|
aria-label="Mosaic Stack home"
|
||||||
|
>
|
||||||
|
{/* Mosaic logo mark: four colored squares + center dot */}
|
||||||
|
<div
|
||||||
|
className="relative flex-shrink-0"
|
||||||
|
style={{ width: 28, height: 28 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute rounded-sm"
|
||||||
|
style={{ top: 0, left: 0, width: 11, height: 11, background: "var(--ms-blue-500)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute rounded-sm"
|
||||||
|
style={{ top: 0, right: 0, width: 11, height: 11, background: "var(--ms-purple-500)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute rounded-sm"
|
||||||
|
style={{
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
background: "var(--ms-teal-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute rounded-sm"
|
||||||
|
style={{
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
background: "var(--ms-amber-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute rounded-full"
|
||||||
|
style={{
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
background: "var(--ms-pink-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="text-sm font-bold"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
|
||||||
|
backgroundClip: "text",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mosaic Stack
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* ── Breadcrumb ── */}
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
className="hidden sm:flex items-center"
|
||||||
|
style={{ fontSize: "0.8rem", color: "var(--text-2)", marginLeft: 4 }}
|
||||||
|
>
|
||||||
|
{breadcrumbSegments.length === 0 ? (
|
||||||
|
<span>Dashboard</span>
|
||||||
|
) : (
|
||||||
|
breadcrumbSegments.map((seg, idx) => (
|
||||||
|
<span key={idx} className="flex items-center gap-1">
|
||||||
|
{idx > 0 && <span style={{ color: "var(--muted)", margin: "0 2px" }}>/</span>}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: idx === breadcrumbSegments.length - 1 ? "var(--text-2)" : "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seg}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── Search Bar ── */}
|
||||||
|
<div
|
||||||
|
className="hidden md:flex items-center"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
maxWidth: 340,
|
||||||
|
marginLeft: 16,
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${searchFocused ? "var(--primary)" : "var(--border)"}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "7px 12px",
|
||||||
|
transition: "border-color 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search icon */}
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ color: "var(--muted)", flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="7" cy="7" r="5" />
|
||||||
|
<path d="M11 11l3 3" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search projects, agents, tasks… (⌘K)"
|
||||||
|
onFocus={() => {
|
||||||
|
setSearchFocused(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setSearchFocused(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
aria-label="Search projects, agents, tasks"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Spacer ── */}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* ── Right side controls ── */}
|
||||||
|
<div className="flex items-center" style={{ gap: 8 }}>
|
||||||
|
{/* System Status */}
|
||||||
|
<div
|
||||||
|
className="hidden lg:flex items-center"
|
||||||
|
style={{
|
||||||
|
gap: 7,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
aria-label="System status: All Systems Operational"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--success)",
|
||||||
|
boxShadow: "0 0 5px var(--success)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--muted)" }}>All Systems</span>
|
||||||
|
<span style={{ color: "var(--success)" }}>Operational</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Toggle */}
|
||||||
|
<TerminalToggleButton />
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<button
|
||||||
|
title="Notifications"
|
||||||
|
aria-label="Notifications (1 unread)"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
position: "relative",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M8 1a5 5 0 0 1 5 5v2l1 2H2l1-2V6a5 5 0 0 1 5-5z" />
|
||||||
|
<path d="M6 13a2 2 0 0 0 4 0" />
|
||||||
|
</svg>
|
||||||
|
{/* Notification badge */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--danger)",
|
||||||
|
border: "2px solid var(--bg-deep)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{/* User Avatar + Dropdown */}
|
||||||
|
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
aria-label="Open user menu"
|
||||||
|
aria-expanded={dropdownOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: user?.image
|
||||||
|
? "none"
|
||||||
|
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.image ? (
|
||||||
|
<img
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || user.email || "User avatar"}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#fff",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label="User menu"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "calc(100% + 8px)",
|
||||||
|
right: 0,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 6,
|
||||||
|
minWidth: 200,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* User info header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.name ?? "User"}
|
||||||
|
</div>
|
||||||
|
{user?.email && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Profile link */}
|
||||||
|
<DropdownItem
|
||||||
|
href="/profile"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="5" r="3" />
|
||||||
|
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||||
|
</svg>
|
||||||
|
Profile
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
{/* Account Settings link */}
|
||||||
|
<DropdownItem
|
||||||
|
href="/settings"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.5" />
|
||||||
|
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
|
||||||
|
</svg>
|
||||||
|
Account Settings
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sign Out */}
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
void signOut();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--danger)",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface-2)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
|
||||||
|
</svg>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Terminal toggle button — visual only; no panel wired yet. */
|
||||||
|
function TerminalToggleButton(): React.JSX.Element {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
title="Toggle terminal"
|
||||||
|
aria-label="Toggle terminal panel"
|
||||||
|
className="hidden lg:flex items-center"
|
||||||
|
style={{
|
||||||
|
gap: 6,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${hovered ? "var(--success)" : "var(--border)"}`,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: hovered ? "var(--success)" : "var(--text-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 0.15s, color 0.15s",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="2" width="14" height="12" rx="1.5" />
|
||||||
|
<path d="M4 6l3 3-3 3M9 12h3" />
|
||||||
|
</svg>
|
||||||
|
Terminal
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownItemProps {
|
||||||
|
href: string;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A navigation link styled as a dropdown menu item. */
|
||||||
|
function DropdownItem({ href, onClick, children }: DropdownItemProps): React.JSX.Element {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
textDecoration: "none",
|
||||||
|
background: hovered ? "var(--surface-2)" : "none",
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
731
apps/web/src/components/layout/AppSidebar.tsx
Normal file
731
apps/web/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { useSidebar } from "./SidebarContext";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NavBadge {
|
||||||
|
label: string;
|
||||||
|
pulse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItemConfig {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.JSX.Element;
|
||||||
|
badge?: NavBadge;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavGroup {
|
||||||
|
label: string;
|
||||||
|
items: NavItemConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5")
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function IconDashboard(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||||
|
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconProjects(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="4" width="14" height="10" rx="1.5" />
|
||||||
|
<path d="M1 7h14" />
|
||||||
|
<path d="M5 4V2.5A.5.5 0 0 1 5.5 2h5a.5.5 0 0 1 .5.5V4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconProjectWorkspace(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="4" r="2" />
|
||||||
|
<circle cx="3" cy="12" r="2" />
|
||||||
|
<circle cx="13" cy="12" r="2" />
|
||||||
|
<path d="M8 6v2M5 12h6M6 8l-2 2M10 8l2 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconKanban(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="2" width="4" height="12" rx="1" />
|
||||||
|
<rect x="6" y="2" width="4" height="12" rx="1" />
|
||||||
|
<rect x="11" y="2" width="4" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconFileManager(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h4l2 2h3A1.5 1.5 0 0 1 14 5.5v7A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconLogs(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2 4h12M2 8h8M2 12h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTerminal(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="1" y="2" width="14" height="12" rx="1.5" />
|
||||||
|
<path d="M4 6l3 3-3 3M9 12h3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSettings(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.5" />
|
||||||
|
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChevronLeft(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M10 3L5 8l5 5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChevronRight(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 3l5 5-5 5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Nav groups definition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/",
|
||||||
|
label: "Dashboard",
|
||||||
|
icon: <IconDashboard />,
|
||||||
|
badge: { label: "live", pulse: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Workspace",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/projects",
|
||||||
|
label: "Projects",
|
||||||
|
icon: <IconProjects />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/workspace",
|
||||||
|
label: "Project Workspace",
|
||||||
|
icon: <IconProjectWorkspace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/kanban",
|
||||||
|
label: "Kanban",
|
||||||
|
icon: <IconKanban />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/files",
|
||||||
|
label: "File Manager",
|
||||||
|
icon: <IconFileManager />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Operations",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/logs",
|
||||||
|
label: "Logs & Telemetry",
|
||||||
|
icon: <IconLogs />,
|
||||||
|
badge: { label: "live", pulse: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#terminal",
|
||||||
|
label: "Terminal",
|
||||||
|
icon: <IconTerminal />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "System",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/settings",
|
||||||
|
label: "Settings",
|
||||||
|
icon: <IconSettings />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: derive initials from display name
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const first = parts[0] ?? "";
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return first.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
const last = parts[parts.length - 1] ?? "";
|
||||||
|
return ((first[0] ?? "") + (last[0] ?? "")).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NavBadge component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NavBadgeProps {
|
||||||
|
badge: NavBadge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavBadgeChip({ badge }: NavBadgeProps): React.JSX.Element {
|
||||||
|
const pulseStyle: React.CSSProperties = badge.pulse
|
||||||
|
? {
|
||||||
|
background: "rgba(47,128,255,0.15)",
|
||||||
|
color: "var(--primary-l)",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
flexShrink: 0,
|
||||||
|
...pulseStyle,
|
||||||
|
}}
|
||||||
|
aria-label={badge.pulse ? `${badge.label} indicator` : badge.label}
|
||||||
|
>
|
||||||
|
{badge.pulse && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--primary-l)",
|
||||||
|
boxShadow: "0 0 4px var(--primary)",
|
||||||
|
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NavItem component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NavItemProps {
|
||||||
|
item: NavItemConfig;
|
||||||
|
isActive: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element {
|
||||||
|
const baseStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "11px",
|
||||||
|
padding: "9px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: isActive ? "var(--text)" : "var(--muted)",
|
||||||
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
|
position: "relative",
|
||||||
|
transition: "background 0.12s ease, color 0.12s ease",
|
||||||
|
textDecoration: "none",
|
||||||
|
justifyContent: collapsed ? "center" : undefined,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconStyle: React.CSSProperties = {
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: isActive ? 1 : 0.7,
|
||||||
|
transition: "opacity 0.12s ease",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{/* Active left accent bar */}
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: "6px",
|
||||||
|
bottom: "6px",
|
||||||
|
width: "3px",
|
||||||
|
background: "var(--primary)",
|
||||||
|
borderRadius: "0 2px 2px 0",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<span style={iconStyle}>{item.icon}</span>
|
||||||
|
|
||||||
|
{/* Label and badge — hidden when collapsed */}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{item.badge !== undefined && <NavBadgeChip badge={item.badge} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharedProps = {
|
||||||
|
style: baseStyle,
|
||||||
|
"aria-current": isActive ? ("page" as const) : undefined,
|
||||||
|
title: collapsed ? item.label : undefined,
|
||||||
|
onMouseEnter: (e: React.MouseEvent<HTMLElement>): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||||
|
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||||
|
"[data-nav-icon]"
|
||||||
|
);
|
||||||
|
if (iconEl) iconEl.style.opacity = "1";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMouseLeave: (e: React.MouseEvent<HTMLElement>): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||||
|
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||||
|
"[data-nav-icon]"
|
||||||
|
);
|
||||||
|
if (iconEl) iconEl.style.opacity = "0.7";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.href.startsWith("#")) {
|
||||||
|
return (
|
||||||
|
<a href={item.href} {...sharedProps}>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={item.href} {...sharedProps}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UserCard component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const displayName = user?.name ?? "User";
|
||||||
|
const initials = getInitials(displayName);
|
||||||
|
const role = user?.workspaceRole ?? "Member";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: "12px 10px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.12s ease",
|
||||||
|
justifyContent: collapsed ? "center" : undefined,
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title={collapsed ? `${displayName} — ${role}` : undefined}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
onKeyDown={(e): void => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`User: ${displayName}, Role: ${role}`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "30px",
|
||||||
|
height: "30px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.image ? (
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={`${displayName} avatar`}
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true">{initials}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and role — hidden when collapsed */}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Online status dot */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
width: "7px",
|
||||||
|
height: "7px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--success)",
|
||||||
|
boxShadow: "0 0 6px var(--success)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-label="Online"
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CollapseToggle component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CollapseToggleProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px 8px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: collapsed ? "center" : "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "26px",
|
||||||
|
height: "26px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--muted)",
|
||||||
|
transition: "background 0.12s ease, color 0.12s ease",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main AppSidebar component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application sidebar — navigation groups, collapse toggle, and user card.
|
||||||
|
* Logo lives in AppHeader per MS15 design spec.
|
||||||
|
*/
|
||||||
|
export function AppSidebar(): React.JSX.Element {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile backdrop — rendered behind the sidebar when open on mobile */}
|
||||||
|
{isMobile && mobileOpen && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
top: "var(--topbar-h)",
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
zIndex: 140,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
id="app-sidebar"
|
||||||
|
className="app-sidebar"
|
||||||
|
data-collapsed={collapsed ? "true" : undefined}
|
||||||
|
data-mobile-open={mobileOpen ? "true" : undefined}
|
||||||
|
aria-label="Application navigation"
|
||||||
|
>
|
||||||
|
{/* Sidebar body — scrollable nav area */}
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden",
|
||||||
|
padding: "10px 10px",
|
||||||
|
}}
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
{NAV_GROUPS.map((group) => (
|
||||||
|
<div key={group.label} style={{ marginBottom: "18px" }}>
|
||||||
|
{/* Group label — hidden when collapsed */}
|
||||||
|
{!collapsed && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.67rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.09em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
padding: "0 10px",
|
||||||
|
marginBottom: "4px",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.href === "/"
|
||||||
|
? pathname === "/"
|
||||||
|
: item.href.startsWith("#")
|
||||||
|
? false
|
||||||
|
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Collapse toggle — anchored at bottom of nav */}
|
||||||
|
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User card footer */}
|
||||||
|
<UserCard collapsed={collapsed} />
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/web/src/components/layout/SidebarContext.tsx
Normal file
65
apps/web/src/components/layout/SidebarContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface SidebarContextValue {
|
||||||
|
collapsed: boolean;
|
||||||
|
toggleCollapsed: () => void;
|
||||||
|
mobileOpen: boolean;
|
||||||
|
setMobileOpen: (open: boolean) => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
/** Breakpoint below which we treat the viewport as "mobile" (matches CSS max-width: 767px). */
|
||||||
|
const MOBILE_MAX_WIDTH = 767;
|
||||||
|
|
||||||
|
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
// Initialise and track mobile breakpoint using matchMedia
|
||||||
|
useEffect((): (() => void) => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${String(MOBILE_MAX_WIDTH)}px)`);
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent): void => {
|
||||||
|
setIsMobile(e.matches);
|
||||||
|
// Close mobile sidebar when viewport grows out of mobile range
|
||||||
|
if (!e.matches) {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value synchronously
|
||||||
|
setIsMobile(mql.matches);
|
||||||
|
|
||||||
|
mql.addEventListener("change", handleChange);
|
||||||
|
return (): void => {
|
||||||
|
mql.removeEventListener("change", handleChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCollapsed = useCallback((): void => {
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: SidebarContextValue = {
|
||||||
|
collapsed,
|
||||||
|
toggleCollapsed,
|
||||||
|
mobileOpen,
|
||||||
|
setMobileOpen,
|
||||||
|
isMobile,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebar(): SidebarContextValue {
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useSidebar must be used within SidebarProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
|
|||||||
// Sun icon for dark mode (click to switch to light)
|
// Sun icon for dark mode (click to switch to light)
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
style={{ color: "rgb(var(--semantic-warning))" }}
|
style={{ color: "var(--warn)" }}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -33,7 +33,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
|
|||||||
// Moon icon for light mode (click to switch to dark)
|
// Moon icon for light mode (click to switch to dark)
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
style={{ color: "rgb(var(--text-secondary))" }}
|
style={{ color: "var(--text-2)" }}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
257
apps/web/src/components/terminal/TerminalPanel.tsx
Normal file
257
apps/web/src/components/terminal/TerminalPanel.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import type { ReactElement, CSSProperties } from "react";
|
||||||
|
|
||||||
|
export interface TerminalLine {
|
||||||
|
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tabs?: TerminalTab[];
|
||||||
|
activeTab?: string;
|
||||||
|
onTabChange?: (id: string) => void;
|
||||||
|
lines?: TerminalLine[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTabs: TerminalTab[] = [
|
||||||
|
{ id: "main", label: "main" },
|
||||||
|
{ id: "build", label: "build" },
|
||||||
|
{ id: "logs", label: "logs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const blinkKeyframes = `
|
||||||
|
@keyframes ms-terminal-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let blinkStyleInjected = false;
|
||||||
|
|
||||||
|
function ensureBlinkStyle(): void {
|
||||||
|
if (blinkStyleInjected || typeof document === "undefined") return;
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = blinkKeyframes;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
blinkStyleInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineColor(type: TerminalLine["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "prompt":
|
||||||
|
return "var(--success)";
|
||||||
|
case "command":
|
||||||
|
return "var(--text-2)";
|
||||||
|
case "output":
|
||||||
|
return "var(--muted)";
|
||||||
|
case "error":
|
||||||
|
return "var(--danger)";
|
||||||
|
case "warning":
|
||||||
|
return "var(--warn)";
|
||||||
|
case "success":
|
||||||
|
return "var(--success)";
|
||||||
|
default:
|
||||||
|
return "var(--muted)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
lines = [],
|
||||||
|
className = "",
|
||||||
|
}: TerminalPanelProps): ReactElement {
|
||||||
|
ensureBlinkStyle();
|
||||||
|
|
||||||
|
const resolvedTabs = tabs ?? defaultTabs;
|
||||||
|
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||||
|
|
||||||
|
const panelStyle: CSSProperties = {
|
||||||
|
background: "var(--bg-deep)",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: open ? 280 : 0,
|
||||||
|
transition: "height 0.3s ease",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "6px 16px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabBarStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionsStyle: CSSProperties = {
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyStyle: CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "10px 16px",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cursorStyle: CSSProperties = {
|
||||||
|
display: "inline-block",
|
||||||
|
width: 7,
|
||||||
|
height: 14,
|
||||||
|
background: "var(--success)",
|
||||||
|
marginLeft: 2,
|
||||||
|
animation: "ms-terminal-blink 1s step-end infinite",
|
||||||
|
verticalAlign: "text-bottom",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={panelStyle}
|
||||||
|
role="region"
|
||||||
|
aria-label="Terminal panel"
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={headerStyle}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||||
|
{resolvedTabs.map((tab) => {
|
||||||
|
const isActive = tab.id === resolvedActiveTab;
|
||||||
|
const tabStyle: CSSProperties = {
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
style={tabStyle}
|
||||||
|
onClick={(): void => {
|
||||||
|
onTabChange?.(tab.id);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={actionsStyle}>
|
||||||
|
<button
|
||||||
|
aria-label="Close terminal"
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close icon — simple X using SVG */}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1 1L11 11M11 1L1 11"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const isLast = index === lines.length - 1;
|
||||||
|
const lineStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
const contentStyle: CSSProperties = {
|
||||||
|
color: getLineColor(line.type),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} style={lineStyle}>
|
||||||
|
<span style={contentStyle}>
|
||||||
|
{line.content}
|
||||||
|
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Show cursor even when no lines */}
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<span style={{ color: "var(--success)" }}>
|
||||||
|
<span aria-hidden="true" style={cursorStyle} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
apps/web/src/components/terminal/index.ts
Normal file
2
apps/web/src/components/terminal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
||||||
|
export { TerminalPanel } from "./TerminalPanel";
|
||||||
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal file
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export interface MosaicLogoProps {
|
||||||
|
/** Width and height in pixels (default: 36) */
|
||||||
|
size?: number;
|
||||||
|
/** Whether to animate rotation (default: false) */
|
||||||
|
spinning?: boolean;
|
||||||
|
/** Seconds for one full rotation (default: 20) */
|
||||||
|
spinDuration?: number;
|
||||||
|
/** Additional CSS classes for the root element */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MosaicLogo renders the 5-element Mosaic logo icon:
|
||||||
|
* - 4 corner squares (blue, purple, teal, amber)
|
||||||
|
* - 1 center circle (pink)
|
||||||
|
*
|
||||||
|
* Colors use CSS custom properties so they respond to theme changes.
|
||||||
|
* When `spinning` is true the logo rotates continuously, making it
|
||||||
|
* suitable for use as a loading indicator.
|
||||||
|
*/
|
||||||
|
export function MosaicLogo({
|
||||||
|
size = 36,
|
||||||
|
spinning = false,
|
||||||
|
spinDuration = 20,
|
||||||
|
className = "",
|
||||||
|
}: MosaicLogoProps): React.JSX.Element {
|
||||||
|
// Scale factor relative to the 36px reference design
|
||||||
|
const scale = size / 36;
|
||||||
|
|
||||||
|
// Derived dimensions
|
||||||
|
const squareSize = Math.round(14 * scale);
|
||||||
|
const circleSize = Math.round(11 * scale);
|
||||||
|
const borderRadius = Math.round(3 * scale);
|
||||||
|
|
||||||
|
const animationValue = spinning
|
||||||
|
? `mosaicLogoSpin ${String(spinDuration)}s linear infinite`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const containerStyle: CSSProperties = {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
position: "relative",
|
||||||
|
flexShrink: 0,
|
||||||
|
animation: animationValue,
|
||||||
|
transformOrigin: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseSquareStyle: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
width: squareSize,
|
||||||
|
height: squareSize,
|
||||||
|
borderRadius,
|
||||||
|
};
|
||||||
|
|
||||||
|
const circleStyle: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: circleSize,
|
||||||
|
height: circleSize,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--ms-pink-500)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{spinning && (
|
||||||
|
<style>{`
|
||||||
|
@keyframes mosaicLogoSpin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
<div style={containerStyle} className={className} role="img" aria-label="Mosaic logo">
|
||||||
|
{/* Top-left: blue */}
|
||||||
|
<div style={{ ...baseSquareStyle, top: 0, left: 0, background: "var(--ms-blue-500)" }} />
|
||||||
|
{/* Top-right: purple */}
|
||||||
|
<div style={{ ...baseSquareStyle, top: 0, right: 0, background: "var(--ms-purple-500)" }} />
|
||||||
|
{/* Bottom-right: teal */}
|
||||||
|
<div
|
||||||
|
style={{ ...baseSquareStyle, bottom: 0, right: 0, background: "var(--ms-teal-500)" }}
|
||||||
|
/>
|
||||||
|
{/* Bottom-left: amber */}
|
||||||
|
<div
|
||||||
|
style={{ ...baseSquareStyle, bottom: 0, left: 0, background: "var(--ms-amber-500)" }}
|
||||||
|
/>
|
||||||
|
{/* Center: pink circle */}
|
||||||
|
<div style={circleStyle} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MosaicLogo;
|
||||||
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal file
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MosaicLogo } from "./MosaicLogo";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export interface MosaicSpinnerProps {
|
||||||
|
/** Width and height of the logo in pixels (default: 36) */
|
||||||
|
size?: number;
|
||||||
|
/** Seconds for one full rotation (default: 20) */
|
||||||
|
spinDuration?: number;
|
||||||
|
/** Optional text label displayed below the spinner */
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* When true, wraps the spinner in a full-page centered overlay.
|
||||||
|
* When false (default), renders inline.
|
||||||
|
*/
|
||||||
|
fullPage?: boolean;
|
||||||
|
/** Additional CSS classes for the wrapper element */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MosaicSpinner wraps MosaicLogo with spinning enabled.
|
||||||
|
* It can be used as a full-page loading overlay or as an inline indicator.
|
||||||
|
*/
|
||||||
|
export function MosaicSpinner({
|
||||||
|
size = 36,
|
||||||
|
spinDuration = 20,
|
||||||
|
label,
|
||||||
|
fullPage = false,
|
||||||
|
className = "",
|
||||||
|
}: MosaicSpinnerProps): ReactElement {
|
||||||
|
const inner = (
|
||||||
|
<div className={`flex flex-col items-center gap-3 ${className}`}>
|
||||||
|
<MosaicLogo size={size} spinning spinDuration={spinDuration} />
|
||||||
|
{label !== undefined && label !== "" && (
|
||||||
|
<span className="text-sm text-gray-500">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullPage) {
|
||||||
|
return <div className="flex min-h-screen items-center justify-center">{inner}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MosaicSpinner;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Agent Status Widget - shows running agents
|
* Agent Status Widget - shows running agents
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||||
import type { WidgetProps } from "@mosaic/shared";
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
@@ -21,46 +21,57 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAgents = useCallback(async (): Promise<void> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/orchestrator/agents", {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch agents: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as Agent[];
|
||||||
|
setAgents(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
console.error("Failed to fetch agents:", errorMessage);
|
||||||
|
setError(errorMessage);
|
||||||
|
setAgents([]); // Clear agents on error
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
// Fetch agents from orchestrator API
|
// Fetch agents from orchestrator API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAgents = async (): Promise<void> => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/orchestrator/agents", {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch agents: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as Agent[];
|
|
||||||
setAgents(data);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
console.error("Failed to fetch agents:", errorMessage);
|
|
||||||
setError(errorMessage);
|
|
||||||
setAgents([]); // Clear agents on error
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void fetchAgents();
|
void fetchAgents();
|
||||||
|
|
||||||
// Refresh every 30 seconds
|
// Refresh every 30 seconds
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
void fetchAgents();
|
void fetchAgents();
|
||||||
}, 30000);
|
}, 20000);
|
||||||
|
|
||||||
|
const eventSource =
|
||||||
|
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.onmessage = (): void => {
|
||||||
|
void fetchAgents();
|
||||||
|
};
|
||||||
|
eventSource.onerror = (): void => {
|
||||||
|
// polling remains fallback
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
eventSource?.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||||
const statusLower = status.toLowerCase();
|
const statusLower = status.toLowerCase();
|
||||||
|
|||||||
190
apps/web/src/components/widgets/OrchestratorEventsWidget.tsx
Normal file
190
apps/web/src/components/widgets/OrchestratorEventsWidget.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Activity, DatabaseZap, Loader2, Wifi, WifiOff } from "lucide-react";
|
||||||
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
|
interface OrchestratorEvent {
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
agentId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentEventsResponse {
|
||||||
|
events: OrchestratorEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatrixSignal(event: OrchestratorEvent): boolean {
|
||||||
|
const text = JSON.stringify(event).toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes("matrix") ||
|
||||||
|
text.includes("room") ||
|
||||||
|
text.includes("channel") ||
|
||||||
|
text.includes("thread")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrchestratorEventsWidget({
|
||||||
|
id: _id,
|
||||||
|
config: _config,
|
||||||
|
}: WidgetProps): React.JSX.Element {
|
||||||
|
const [events, setEvents] = useState<OrchestratorEvent[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [streamConnected, setStreamConnected] = useState(false);
|
||||||
|
const [backendReady, setBackendReady] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
const loadRecentEvents = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/orchestrator/events/recent?limit=25");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to load events: HTTP ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as unknown;
|
||||||
|
const events =
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"events" in payload &&
|
||||||
|
Array.isArray(payload.events)
|
||||||
|
? (payload.events as RecentEventsResponse["events"])
|
||||||
|
: [];
|
||||||
|
setEvents(events);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Unable to load events.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHealth = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/orchestrator/health");
|
||||||
|
setBackendReady(response.ok);
|
||||||
|
} catch {
|
||||||
|
setBackendReady(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRecentEvents();
|
||||||
|
void loadHealth();
|
||||||
|
|
||||||
|
const eventSource =
|
||||||
|
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.onopen = (): void => {
|
||||||
|
setStreamConnected(true);
|
||||||
|
};
|
||||||
|
eventSource.onmessage = (): void => {
|
||||||
|
void loadRecentEvents();
|
||||||
|
void loadHealth();
|
||||||
|
};
|
||||||
|
eventSource.onerror = (): void => {
|
||||||
|
setStreamConnected(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void loadRecentEvents();
|
||||||
|
void loadHealth();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
clearInterval(interval);
|
||||||
|
eventSource?.close();
|
||||||
|
};
|
||||||
|
}, [loadHealth, loadRecentEvents]);
|
||||||
|
|
||||||
|
const matrixSignals = useMemo(
|
||||||
|
() => events.filter((event) => isMatrixSignal(event)).length,
|
||||||
|
[events]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-5 h-5 text-gray-400 animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-500 text-sm">Loading orchestrator events...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<WifiOff className="w-5 h-5 text-amber-500 mb-2" />
|
||||||
|
<span className="text-sm text-amber-600">{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||||
|
{streamConnected ? (
|
||||||
|
<Wifi className="w-3 h-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-3 h-3 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span>{streamConnected ? "Live stream connected" : "Polling mode"}</span>
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 ${
|
||||||
|
backendReady === true
|
||||||
|
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300"
|
||||||
|
: backendReady === false
|
||||||
|
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
|
||||||
|
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{backendReady === true ? "ready" : backendReady === false ? "degraded" : "unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 rounded bg-blue-50 dark:bg-blue-950 px-2 py-1 text-blue-700 dark:text-blue-300">
|
||||||
|
<DatabaseZap className="w-3 h-3" />
|
||||||
|
<span>Matrix signals: {matrixSignals}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto space-y-2">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-4">
|
||||||
|
No recent orchestration events.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((event, index) => (
|
||||||
|
<div
|
||||||
|
key={`${event.timestamp}-${event.type}-${String(index)}`}
|
||||||
|
className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-2 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Activity className="w-3 h-3 text-blue-500 shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{event.type}
|
||||||
|
</span>
|
||||||
|
{isMatrixSignal(event) && (
|
||||||
|
<span className="text-[10px] rounded bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5">
|
||||||
|
matrix
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
{new Date(event.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
|
{event.taskId ? `Task ${event.taskId}` : "Task n/a"}
|
||||||
|
{event.agentId ? ` · Agent ${event.agentId.slice(0, 8)}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
* including status, elapsed time, and work item details.
|
* including status, elapsed time, and work item details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Activity, CheckCircle, XCircle, Clock, Loader2 } from "lucide-react";
|
import { Activity, CheckCircle, XCircle, Clock, Loader2, Pause, Play } from "lucide-react";
|
||||||
import type { WidgetProps } from "@mosaic/shared";
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
interface AgentTask {
|
interface AgentTask {
|
||||||
@@ -19,6 +19,21 @@ interface AgentTask {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueueStats {
|
||||||
|
pending: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
delayed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentOrchestratorEvent {
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
taskId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function getElapsedTime(spawnedAt: string, completedAt?: string): string {
|
function getElapsedTime(spawnedAt: string, completedAt?: string): string {
|
||||||
const start = new Date(spawnedAt).getTime();
|
const start = new Date(spawnedAt).getTime();
|
||||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||||
@@ -94,34 +109,108 @@ function getAgentTypeLabel(agentType: string): string {
|
|||||||
|
|
||||||
export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||||
|
const [queueStats, setQueueStats] = useState<QueueStats | null>(null);
|
||||||
|
const [recentEvents, setRecentEvents] = useState<RecentOrchestratorEvent[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isQueuePaused, setIsQueuePaused] = useState(false);
|
||||||
|
const [isActionPending, setIsActionPending] = useState(false);
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/orchestrator/agents");
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||||
|
const data = (await res.json()) as AgentTask[];
|
||||||
|
setTasks(data);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch {
|
||||||
|
setError("Unable to reach orchestrator");
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchQueueStats = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/orchestrator/queue/stats");
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||||
|
const data = (await res.json()) as QueueStats;
|
||||||
|
setQueueStats(data);
|
||||||
|
// Heuristic: active=0 with pending>0 for sustained windows usually means paused.
|
||||||
|
setIsQueuePaused(data.active === 0 && data.pending > 0);
|
||||||
|
} catch {
|
||||||
|
// Keep widget functional even if queue controls are temporarily unavailable.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRecentEvents = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/orchestrator/events/recent?limit=5");
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||||
|
const payload = (await res.json()) as unknown;
|
||||||
|
const events =
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"events" in payload &&
|
||||||
|
Array.isArray(payload.events)
|
||||||
|
? (payload.events as RecentOrchestratorEvent[])
|
||||||
|
: [];
|
||||||
|
setRecentEvents(events);
|
||||||
|
} catch {
|
||||||
|
// Optional enhancement path; do not fail widget if recent-events endpoint is unavailable.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setQueueState = useCallback(
|
||||||
|
async (action: "pause" | "resume"): Promise<void> => {
|
||||||
|
setIsActionPending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orchestrator/queue/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||||
|
setIsQueuePaused(action === "pause");
|
||||||
|
await fetchQueueStats();
|
||||||
|
} catch {
|
||||||
|
setError("Unable to control queue state");
|
||||||
|
} finally {
|
||||||
|
setIsActionPending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchQueueStats]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTasks = (): void => {
|
void fetchTasks();
|
||||||
fetch("/api/orchestrator/agents")
|
void fetchQueueStats();
|
||||||
.then((res) => {
|
void fetchRecentEvents();
|
||||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
|
||||||
return res.json() as Promise<AgentTask[]>;
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
setTasks(data);
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setError("Unable to reach orchestrator");
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTasks();
|
const interval = setInterval(() => {
|
||||||
const interval = setInterval(fetchTasks, 15000);
|
void fetchTasks();
|
||||||
|
void fetchQueueStats();
|
||||||
|
void fetchRecentEvents();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
const eventSource =
|
||||||
|
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.onmessage = (): void => {
|
||||||
|
void fetchTasks();
|
||||||
|
void fetchQueueStats();
|
||||||
|
void fetchRecentEvents();
|
||||||
|
};
|
||||||
|
eventSource.onerror = (): void => {
|
||||||
|
// Polling remains the resilience path.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
eventSource?.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [fetchTasks, fetchQueueStats, fetchRecentEvents]);
|
||||||
|
|
||||||
|
const latestEvent = recentEvents.length > 0 ? recentEvents[recentEvents.length - 1] : null;
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
@@ -151,6 +240,30 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full space-y-3">
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Queue: {isQueuePaused ? "Paused" : "Running"}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(): void => {
|
||||||
|
void setQueueState(isQueuePaused ? "resume" : "pause");
|
||||||
|
}}
|
||||||
|
disabled={isActionPending}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-gray-300 dark:border-gray-700 px-2 py-1 text-xs hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isQueuePaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
|
||||||
|
{isQueuePaused ? "Resume" : "Pause"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{latestEvent && (
|
||||||
|
<div className="rounded bg-gray-50 dark:bg-gray-800 px-2 py-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Latest: {latestEvent.type}
|
||||||
|
{latestEvent.taskId ? ` · ${latestEvent.taskId}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary stats */}
|
{/* Summary stats */}
|
||||||
<div className="grid grid-cols-4 gap-1 text-center text-xs">
|
<div className="grid grid-cols-4 gap-1 text-center text-xs">
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-2">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded p-2">
|
||||||
@@ -173,6 +286,29 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{queueStats && (
|
||||||
|
<div className="grid grid-cols-3 gap-1 text-center text-xs">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||||
|
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{queueStats.pending}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">Queued</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||||
|
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{queueStats.active}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">Workers</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||||
|
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{queueStats.failed}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">Failed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Task list */}
|
{/* Task list */}
|
||||||
<div className="flex-1 overflow-auto space-y-2">
|
<div className="flex-1 overflow-auto space-y-2">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { QuickCaptureWidget } from "./QuickCaptureWidget";
|
|||||||
import { AgentStatusWidget } from "./AgentStatusWidget";
|
import { AgentStatusWidget } from "./AgentStatusWidget";
|
||||||
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
||||||
import { TaskProgressWidget } from "./TaskProgressWidget";
|
import { TaskProgressWidget } from "./TaskProgressWidget";
|
||||||
|
import { OrchestratorEventsWidget } from "./OrchestratorEventsWidget";
|
||||||
|
|
||||||
export interface WidgetDefinition {
|
export interface WidgetDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -95,6 +96,17 @@ export const widgetRegistry: Record<string, WidgetDefinition> = {
|
|||||||
minHeight: 2,
|
minHeight: 2,
|
||||||
maxWidth: 3,
|
maxWidth: 3,
|
||||||
},
|
},
|
||||||
|
OrchestratorEventsWidget: {
|
||||||
|
name: "OrchestratorEventsWidget",
|
||||||
|
displayName: "Orchestrator Events",
|
||||||
|
description: "Recent orchestration events with stream/Matrix visibility",
|
||||||
|
component: OrchestratorEventsWidget,
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 4,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget";
|
||||||
|
|
||||||
|
describe("OrchestratorEventsWidget", () => {
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders loading state initially", () => {
|
||||||
|
mockFetch.mockImplementation(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
() => new Promise(() => {})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||||
|
expect(screen.getByText("Loading orchestrator events...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders events and matrix signal count", async () => {
|
||||||
|
mockFetch.mockImplementation((input: RequestInfo | URL) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
|
||||||
|
if (url.includes("/api/orchestrator/health")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ status: "ok" }),
|
||||||
|
} as unknown as Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "task.completed",
|
||||||
|
timestamp: "2026-02-17T16:40:00.000Z",
|
||||||
|
taskId: "TASK-1",
|
||||||
|
data: { channelId: "room-123" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "agent.running",
|
||||||
|
timestamp: "2026-02-17T16:41:00.000Z",
|
||||||
|
taskId: "TASK-2",
|
||||||
|
agentId: "agent-abc12345",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("task.completed")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("agent.running")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Matrix signals: 1/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ready")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders error state when API fails", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Unable to load events: HTTP 503/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -242,4 +242,58 @@ describe("TaskProgressWidget", (): void => {
|
|||||||
expect(taskElements[1]?.textContent).toBe("COMPLETED-TASK");
|
expect(taskElements[1]?.textContent).toBe("COMPLETED-TASK");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should display latest orchestrator event when available", async (): Promise<void> => {
|
||||||
|
mockFetch.mockImplementation((input: RequestInfo | URL) => {
|
||||||
|
let url = "";
|
||||||
|
if (typeof input === "string") {
|
||||||
|
url = input;
|
||||||
|
} else if (input instanceof URL) {
|
||||||
|
url = input.toString();
|
||||||
|
} else {
|
||||||
|
url = input.url;
|
||||||
|
}
|
||||||
|
if (url.includes("/api/orchestrator/agents")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
} as unknown as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/orchestrator/queue/stats")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
pending: 0,
|
||||||
|
active: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
delayed: 0,
|
||||||
|
}),
|
||||||
|
} as unknown as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/orchestrator/events/recent")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "task.executing",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
taskId: "TASK-123",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("Unknown endpoint"));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TaskProgressWidget id="task-progress-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Latest: task.executing · TASK-123/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { widgetRegistry } from "../WidgetRegistry";
|
|||||||
import { TasksWidget } from "../TasksWidget";
|
import { TasksWidget } from "../TasksWidget";
|
||||||
import { CalendarWidget } from "../CalendarWidget";
|
import { CalendarWidget } from "../CalendarWidget";
|
||||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
||||||
|
import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget";
|
||||||
|
|
||||||
describe("WidgetRegistry", (): void => {
|
describe("WidgetRegistry", (): void => {
|
||||||
it("should have a registry of widgets", (): void => {
|
it("should have a registry of widgets", (): void => {
|
||||||
@@ -32,6 +33,11 @@ describe("WidgetRegistry", (): void => {
|
|||||||
expect(widgetRegistry.QuickCaptureWidget!.component).toBe(QuickCaptureWidget);
|
expect(widgetRegistry.QuickCaptureWidget!.component).toBe(QuickCaptureWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should include OrchestratorEventsWidget in registry", (): void => {
|
||||||
|
expect(widgetRegistry.OrchestratorEventsWidget).toBeDefined();
|
||||||
|
expect(widgetRegistry.OrchestratorEventsWidget!.component).toBe(OrchestratorEventsWidget);
|
||||||
|
});
|
||||||
|
|
||||||
it("should have correct metadata for TasksWidget", (): void => {
|
it("should have correct metadata for TasksWidget", (): void => {
|
||||||
const tasksWidget = widgetRegistry.TasksWidget!;
|
const tasksWidget = widgetRegistry.TasksWidget!;
|
||||||
expect(tasksWidget.name).toBe("TasksWidget");
|
expect(tasksWidget.name).toBe("TasksWidget");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user