first
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ASF - Environment Configuration (SAFE DEFAULTS ONLY)
|
||||||
|
# Copy this file to .env and replace placeholders with real values.
|
||||||
|
# NEVER commit .env with real secrets to version control.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# General
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
ASF_ENV=development
|
||||||
|
ASF_LOG_LEVEL=INFO
|
||||||
|
ASF_CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# MongoDB (Replica Set rs0 — seed nodes 102/103)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
MONGO_HOST=172.19.40.102,172.19.40.103
|
||||||
|
MONGO_PORT=27017
|
||||||
|
MONGO_DB=asf
|
||||||
|
MONGO_REPLICA_SET=rs0
|
||||||
|
# Connection URI: seed list + replicaSet=rs0 (add credentials when/if required)
|
||||||
|
MONGO_URI=mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# RabbitMQ (node 201 / vhost asf)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
RABBITMQ_HOST=172.19.40.201
|
||||||
|
RABBITMQ_PORT=5672
|
||||||
|
RABBITMQ_HTTP_PORT=15672
|
||||||
|
RABBITMQ_USER=azurian
|
||||||
|
RABBITMQ_PASSWORD=StrongPassword123!
|
||||||
|
RABBITMQ_VHOST=asf
|
||||||
|
RABBITMQ_URI=amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Redis
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=asfredis123
|
||||||
|
REDIS_URI=redis://:asfredis123@localhost:6379/0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# MinIO / S3-compatible storage
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
MINIO_HOST=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_CONSOLE_PORT=9001
|
||||||
|
MINIO_ROOT_USER=asfadmin
|
||||||
|
MINIO_ROOT_PASSWORD=asfadmin123
|
||||||
|
MINIO_BUCKET=asf-artifacts
|
||||||
|
MINIO_SECURE=false
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# ASF AI Gateway (Mock / Provider configuration)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
AI_GATEWAY_PROVIDER=mock
|
||||||
|
AI_GATEWAY_DEFAULT_MODEL=asf-mock-v1
|
||||||
|
AI_GATEWAY_TIMEOUT=120
|
||||||
|
|
||||||
|
# Real provider configuration (fill only when enabling):
|
||||||
|
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# NVIDIA_API_KEY=nvapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Security / Auth (bootstrap placeholders)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
ASF_JWT_SECRET=replace-this-with-a-long-random-secret-in-production
|
||||||
|
ASF_API_KEY_SALT=replace-this-with-a-long-random-salt-in-production
|
||||||
|
ASF_SESSION_TTL_SECONDS=86400
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Frontend (Next.js asf-web)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Where Next.js rewrites should proxy `/api/v1/*` (asf-core URL)
|
||||||
|
NEXT_PUBLIC_ASF_CORE_URL=http://localhost:8000
|
||||||
|
# Bootstrap dev-trust: browser-side defaults for X-ASF-Tenant-Id / X-ASF-User-Id
|
||||||
|
NEXT_PUBLIC_DEV_TENANT_ID=t-demo-001
|
||||||
|
NEXT_PUBLIC_DEV_USER_ID=u-admin
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# RabbitMQ exchanges + queue topology (matches rabbitmq.py bindings)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
RABBITMQ_EVENTS_EXCHANGE=asf.events
|
||||||
|
RABBITMQ_JOBS_EXCHANGE=asf.jobs
|
||||||
|
|
||||||
|
# Domain topic queues bound to `asf.events` (TOPIC)
|
||||||
|
RABBITMQ_QUEUE_ORG_CREATED=asf.organization.created
|
||||||
|
RABBITMQ_QUEUE_PROJ_CREATED=asf.project.created
|
||||||
|
RABBITMQ_QUEUE_PROJ_UPDATED=asf.project.updated
|
||||||
|
RABBITMQ_QUEUE_ART_CREATED=asf.artifact.created
|
||||||
|
RABBITMQ_QUEUE_ART_UPDATED=asf.artifact.updated
|
||||||
|
RABBITMQ_QUEUE_AUDIT=asf.audit
|
||||||
|
RABBITMQ_QUEUE_AI_EXEC=asf.ai.execution
|
||||||
|
RABBITMQ_QUEUE_DEADLETTER=asf.deadletter
|
||||||
|
|
||||||
|
# Job direct queues bound to `asf.jobs` (DIRECT) — consumed by asf-worker
|
||||||
|
ASF_WORKER_JOB_QUEUES=asf.ai.interactive,asf.ai.background,asf.indexing,asf.code_analysis,asf.testing,asf.devops,asf.notifications
|
||||||
|
ASF_WORKER_ID=asf-worker-001
|
||||||
|
ASF_WORKER_PREFETCH=8
|
||||||
|
ASF_WORKER_ENABLE_AI_HANDLERS=true
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Python
|
||||||
|
# ============================================================
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Node / Next.js / Frontend
|
||||||
|
# ============================================================
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
frontend/asf-web/.next/
|
||||||
|
frontend/asf-web/out/
|
||||||
|
frontend/asf-web/node_modules/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Environment & Secrets
|
||||||
|
# ============================================================
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# IDE
|
||||||
|
# ============================================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Testing & Coverage
|
||||||
|
# ============================================================
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Logs
|
||||||
|
# ============================================================
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Docker
|
||||||
|
# ============================================================
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Data volumes (we use named volumes in compose, but just in case)
|
||||||
|
# ============================================================
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# OS
|
||||||
|
# ============================================================
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
# AGENT.md
|
||||||
|
|
||||||
|
# Azurian Software Factory - AI Engineering Rules
|
||||||
|
|
||||||
|
This file defines mandatory rules for AI coding agents working on the Azurian Software Factory repository.
|
||||||
|
|
||||||
|
Read this document before modifying code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Identity
|
||||||
|
|
||||||
|
Product:
|
||||||
|
|
||||||
|
**Azurian Software Factory (ASF)**
|
||||||
|
|
||||||
|
Tagline:
|
||||||
|
|
||||||
|
> From Requirement to Production, everything is contextualized, traceable and AI-accessible.
|
||||||
|
|
||||||
|
ASF is an AI-native, multi-tenant Software Factory SaaS.
|
||||||
|
|
||||||
|
ASF connects the complete software lifecycle:
|
||||||
|
|
||||||
|
Requirement → Architecture → Task → Code → Commit → Pull Request → Build → Test → Deployment → Runtime → Incident → Feedback.
|
||||||
|
|
||||||
|
ASF is not merely an AI coding assistant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Principle
|
||||||
|
|
||||||
|
The central ASF principle is:
|
||||||
|
|
||||||
|
> Everything is an Artifact.
|
||||||
|
|
||||||
|
Requirements, architecture decisions, documents, source code references, commits, tests, builds, deployments, incidents and AI analyses can all be represented as Artifacts.
|
||||||
|
|
||||||
|
Artifacts may have relationships with other Artifacts.
|
||||||
|
|
||||||
|
Never implement functionality that unnecessarily breaks this generic model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architectural Principles
|
||||||
|
|
||||||
|
All implementations must preserve:
|
||||||
|
|
||||||
|
1. Multi-tenancy
|
||||||
|
2. Traceability
|
||||||
|
3. Context preservation
|
||||||
|
4. API-first design
|
||||||
|
5. Event-driven architecture
|
||||||
|
6. AI provider independence
|
||||||
|
7. Integration provider independence
|
||||||
|
8. Horizontal scalability
|
||||||
|
9. Auditability
|
||||||
|
10. Security by design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Technology Baseline
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- FastAPI
|
||||||
|
- Pydantic
|
||||||
|
- async/await
|
||||||
|
- MongoDB
|
||||||
|
- RabbitMQ
|
||||||
|
- Redis
|
||||||
|
- MinIO/S3
|
||||||
|
|
||||||
|
Do not introduce another primary database without an ADR.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- Next.js
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- shadcn/ui
|
||||||
|
- TanStack Query
|
||||||
|
- React Flow
|
||||||
|
|
||||||
|
Use Zustand only where persistent/shared client state is genuinely necessary.
|
||||||
|
|
||||||
|
### AI
|
||||||
|
|
||||||
|
All model access must go through the ASF AI Gateway abstraction.
|
||||||
|
|
||||||
|
Never call a specific LLM provider directly from domain/business code.
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
RabbitMQ is the primary event and asynchronous messaging backbone.
|
||||||
|
|
||||||
|
MongoDB is the persistent source of truth for job state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Multi-Tenancy
|
||||||
|
|
||||||
|
Every tenant-owned entity must contain `tenant_id`.
|
||||||
|
|
||||||
|
Where appropriate it must also contain `project_id`.
|
||||||
|
|
||||||
|
Never trust tenant identity supplied directly by a browser request.
|
||||||
|
|
||||||
|
Tenant context must come from validated authentication/authorization context.
|
||||||
|
|
||||||
|
Every database query involving tenant-owned data must be tenant scoped.
|
||||||
|
|
||||||
|
Cross-tenant data leakage is considered a critical defect.
|
||||||
|
|
||||||
|
Tests must cover tenant isolation for sensitive operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Artifact Rules
|
||||||
|
|
||||||
|
Prefer the generic Artifact model for lifecycle entities.
|
||||||
|
|
||||||
|
Artifact types may include:
|
||||||
|
|
||||||
|
- requirement
|
||||||
|
- epic
|
||||||
|
- story
|
||||||
|
- task
|
||||||
|
- architecture_decision
|
||||||
|
- document
|
||||||
|
- source_file
|
||||||
|
- commit
|
||||||
|
- pull_request
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- deployment
|
||||||
|
- release
|
||||||
|
- security_finding
|
||||||
|
- runtime_event
|
||||||
|
- incident
|
||||||
|
- ai_analysis
|
||||||
|
|
||||||
|
Do not create a new MongoDB collection for every artifact type unless there is a documented architectural reason.
|
||||||
|
|
||||||
|
Artifact metadata must remain extensible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Relationships
|
||||||
|
|
||||||
|
Artifact relationships must be explicit and queryable.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- implements
|
||||||
|
- implemented_by
|
||||||
|
- verifies
|
||||||
|
- verified_by
|
||||||
|
- generated_from
|
||||||
|
- documented_by
|
||||||
|
- depends_on
|
||||||
|
- affects
|
||||||
|
- deployed_as
|
||||||
|
- caused_by
|
||||||
|
- supersedes
|
||||||
|
- related_to
|
||||||
|
|
||||||
|
Do not encode important traceability relationships only inside free text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Context
|
||||||
|
|
||||||
|
AI operations must receive relevant context through the ASF Context Engine.
|
||||||
|
|
||||||
|
Do not build arbitrary RAG logic directly inside API endpoints or UI code.
|
||||||
|
|
||||||
|
Context retrieval must remain independently evolvable.
|
||||||
|
|
||||||
|
The architecture must allow future integration of:
|
||||||
|
|
||||||
|
- embeddings
|
||||||
|
- vector search
|
||||||
|
- graph traversal
|
||||||
|
- source code indexing
|
||||||
|
- document indexing
|
||||||
|
- runtime telemetry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. AI Rules
|
||||||
|
|
||||||
|
ASF must remain model agnostic.
|
||||||
|
|
||||||
|
Supported architectures may include:
|
||||||
|
|
||||||
|
- DGX-hosted models
|
||||||
|
- OpenAI-compatible APIs
|
||||||
|
- external providers
|
||||||
|
- future customer-hosted models
|
||||||
|
|
||||||
|
Model selection belongs to the AI Gateway.
|
||||||
|
|
||||||
|
Business services request capabilities, not specific model implementations.
|
||||||
|
|
||||||
|
Prefer concepts such as:
|
||||||
|
|
||||||
|
`analyze_code()`
|
||||||
|
|
||||||
|
over:
|
||||||
|
|
||||||
|
`call_qwen()`
|
||||||
|
|
||||||
|
unless inside a provider adapter.
|
||||||
|
|
||||||
|
Record AI usage when technically available.
|
||||||
|
|
||||||
|
Never expose model credentials to frontend code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Queue Rules
|
||||||
|
|
||||||
|
Separate interactive and background workloads.
|
||||||
|
|
||||||
|
Typical queues:
|
||||||
|
|
||||||
|
- asf.ai.interactive
|
||||||
|
- asf.ai.background
|
||||||
|
- asf.indexing
|
||||||
|
- asf.code_analysis
|
||||||
|
- asf.testing
|
||||||
|
- asf.devops
|
||||||
|
- asf.notifications
|
||||||
|
|
||||||
|
Interactive user requests should receive higher scheduling priority than non-critical background work.
|
||||||
|
|
||||||
|
Workers must be stateless where practical and horizontally scalable.
|
||||||
|
|
||||||
|
Do not rely on process memory for persistent job state.
|
||||||
|
|
||||||
|
Consumers must be designed for duplicate delivery.
|
||||||
|
|
||||||
|
Where side effects occur, use idempotency mechanisms.
|
||||||
|
|
||||||
|
Do not assume exactly-once delivery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Event Rules
|
||||||
|
|
||||||
|
Events describe facts that happened.
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
`artifact.created`
|
||||||
|
|
||||||
|
`requirement.updated`
|
||||||
|
|
||||||
|
`deployment.completed`
|
||||||
|
|
||||||
|
instead of ambiguous event names.
|
||||||
|
|
||||||
|
Every distributed event should contain sufficient metadata for tracing, including where applicable:
|
||||||
|
|
||||||
|
- event_id
|
||||||
|
- event_type
|
||||||
|
- timestamp
|
||||||
|
- tenant_id
|
||||||
|
- project_id
|
||||||
|
- correlation_id
|
||||||
|
- causation_id
|
||||||
|
- payload
|
||||||
|
|
||||||
|
Do not place large binary content directly in RabbitMQ messages.
|
||||||
|
|
||||||
|
Store large content in MinIO and transmit a reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. MinIO Rules
|
||||||
|
|
||||||
|
Use MinIO/S3 for:
|
||||||
|
|
||||||
|
- uploaded documents
|
||||||
|
- generated files
|
||||||
|
- large reports
|
||||||
|
- repository archives where appropriate
|
||||||
|
- large AI inputs/outputs where persistence is required
|
||||||
|
|
||||||
|
MongoDB should store metadata and object references.
|
||||||
|
|
||||||
|
Do not store large binary objects directly in normal MongoDB documents unless specifically justified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. API Rules
|
||||||
|
|
||||||
|
REST API prefix:
|
||||||
|
|
||||||
|
`/api/v1`
|
||||||
|
|
||||||
|
Health endpoint:
|
||||||
|
|
||||||
|
`GET /api/v1/health`
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- Pydantic request/response models
|
||||||
|
- explicit validation
|
||||||
|
- consistent HTTP status codes
|
||||||
|
- consistent error responses
|
||||||
|
|
||||||
|
Do not expose MongoDB internal structures directly through APIs.
|
||||||
|
|
||||||
|
Do not put business logic directly inside FastAPI route functions.
|
||||||
|
|
||||||
|
Routes should delegate to application/domain services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Python Rules
|
||||||
|
|
||||||
|
Use type hints.
|
||||||
|
|
||||||
|
Prefer async I/O for:
|
||||||
|
|
||||||
|
- MongoDB
|
||||||
|
- RabbitMQ
|
||||||
|
- Redis
|
||||||
|
- HTTP
|
||||||
|
- MinIO operations where supported
|
||||||
|
|
||||||
|
Keep functions focused.
|
||||||
|
|
||||||
|
Avoid giant service classes.
|
||||||
|
|
||||||
|
Avoid hidden global state.
|
||||||
|
|
||||||
|
Use dependency injection where it improves testing and separation.
|
||||||
|
|
||||||
|
Do not catch `Exception` unless there is a deliberate boundary-level reason.
|
||||||
|
|
||||||
|
Never silently ignore errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Frontend Rules
|
||||||
|
|
||||||
|
Frontend components must not contain backend business rules.
|
||||||
|
|
||||||
|
Never access MongoDB, RabbitMQ, Redis or MinIO directly from the browser.
|
||||||
|
|
||||||
|
Use the ASF API.
|
||||||
|
|
||||||
|
Pages must support:
|
||||||
|
|
||||||
|
- loading state
|
||||||
|
- empty state
|
||||||
|
- error state
|
||||||
|
|
||||||
|
Do not present mocked data as production data.
|
||||||
|
|
||||||
|
Traceability visualization must use reusable node/edge components.
|
||||||
|
|
||||||
|
The UI must remain responsive and accessible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Security Rules
|
||||||
|
|
||||||
|
Never commit:
|
||||||
|
|
||||||
|
- passwords
|
||||||
|
- API keys
|
||||||
|
- private keys
|
||||||
|
- tokens
|
||||||
|
- certificates
|
||||||
|
- production credentials
|
||||||
|
|
||||||
|
Use environment configuration.
|
||||||
|
|
||||||
|
Provide `.env.example` containing names and safe placeholder values only.
|
||||||
|
|
||||||
|
Never log authentication secrets.
|
||||||
|
|
||||||
|
Validate uploaded files.
|
||||||
|
|
||||||
|
Validate external connector input.
|
||||||
|
|
||||||
|
Assume external webhook payloads are untrusted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Logging and Observability
|
||||||
|
|
||||||
|
Use structured JSON logging on backend services.
|
||||||
|
|
||||||
|
Propagate where applicable:
|
||||||
|
|
||||||
|
- correlation_id
|
||||||
|
- tenant_id
|
||||||
|
- project_id
|
||||||
|
- job_id
|
||||||
|
|
||||||
|
Prepare services for OpenTelemetry instrumentation.
|
||||||
|
|
||||||
|
Logs are operational telemetry.
|
||||||
|
|
||||||
|
Audit events are business/security records.
|
||||||
|
|
||||||
|
Do not confuse the two concepts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Connectors
|
||||||
|
|
||||||
|
External systems must be isolated behind connector interfaces.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- GitHub
|
||||||
|
- GitLab
|
||||||
|
- Bitbucket
|
||||||
|
- Jira
|
||||||
|
- Confluence
|
||||||
|
- Notion
|
||||||
|
- Jenkins
|
||||||
|
- SonarQube
|
||||||
|
- Grafana
|
||||||
|
|
||||||
|
Do not spread provider-specific APIs throughout ASF business code.
|
||||||
|
|
||||||
|
MCP should be preferred where it provides a suitable standardized capability.
|
||||||
|
|
||||||
|
REST and Webhooks remain supported integration mechanisms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Testing Rules
|
||||||
|
|
||||||
|
Backend tests use pytest.
|
||||||
|
|
||||||
|
Tests should cover:
|
||||||
|
|
||||||
|
- domain logic
|
||||||
|
- API behavior
|
||||||
|
- artifact relationships
|
||||||
|
- job state transitions
|
||||||
|
- event handling
|
||||||
|
- tenant isolation
|
||||||
|
|
||||||
|
Bug fixes should include regression tests where practical.
|
||||||
|
|
||||||
|
Do not remove failing tests merely to make the build green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Database Rules
|
||||||
|
|
||||||
|
MongoDB is the primary ASF datastore.
|
||||||
|
|
||||||
|
Collections and indexes must be intentionally designed.
|
||||||
|
|
||||||
|
Common queries must have appropriate indexes.
|
||||||
|
|
||||||
|
Tenant-aware compound indexes should be considered.
|
||||||
|
|
||||||
|
Schema flexibility does not mean schema absence.
|
||||||
|
|
||||||
|
Pydantic models define application-level contracts.
|
||||||
|
|
||||||
|
Database migrations or data transformation scripts must be versioned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Architectural Decisions
|
||||||
|
|
||||||
|
Significant architectural decisions require an ADR under:
|
||||||
|
|
||||||
|
`docs/adr/`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- introducing a new database
|
||||||
|
- introducing a new message broker
|
||||||
|
- changing tenancy strategy
|
||||||
|
- changing authentication architecture
|
||||||
|
- introducing a graph database
|
||||||
|
- changing artifact representation
|
||||||
|
- replacing the AI Gateway
|
||||||
|
- changing event semantics
|
||||||
|
|
||||||
|
Do not make major architectural changes silently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. Code Changes
|
||||||
|
|
||||||
|
Before modifying code:
|
||||||
|
|
||||||
|
1. Understand the requested change.
|
||||||
|
2. Inspect relevant existing code.
|
||||||
|
3. Identify affected modules.
|
||||||
|
4. Check existing tests.
|
||||||
|
5. Preserve architectural boundaries.
|
||||||
|
|
||||||
|
After modifying code:
|
||||||
|
|
||||||
|
1. Run relevant tests.
|
||||||
|
2. Run lint/type checks where configured.
|
||||||
|
3. Fix failures introduced by the change.
|
||||||
|
4. Review tenant isolation.
|
||||||
|
5. Review error handling.
|
||||||
|
6. Update documentation when behavior or architecture changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. Dependency Policy
|
||||||
|
|
||||||
|
Do not add dependencies unnecessarily.
|
||||||
|
|
||||||
|
Before introducing a library, determine whether:
|
||||||
|
|
||||||
|
- the standard library already solves the problem
|
||||||
|
- an existing project dependency solves it
|
||||||
|
- the new dependency is actively maintained
|
||||||
|
- it introduces security or licensing concerns
|
||||||
|
|
||||||
|
Major framework additions require justification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 24. Simplicity
|
||||||
|
|
||||||
|
Do not over-engineer ASF.
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
simple → testable → observable → scalable
|
||||||
|
|
||||||
|
over:
|
||||||
|
|
||||||
|
complex → theoretical → difficult to operate.
|
||||||
|
|
||||||
|
Microservices are not a goal.
|
||||||
|
|
||||||
|
Service boundaries must exist because they provide operational or domain value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 25. Current Logical Components
|
||||||
|
|
||||||
|
The initial architecture consists of:
|
||||||
|
|
||||||
|
`asf-core`
|
||||||
|
|
||||||
|
Core API, organizations, projects, artifacts, relationships, jobs and audit.
|
||||||
|
|
||||||
|
`asf-context`
|
||||||
|
|
||||||
|
Context assembly, relationship traversal and future semantic retrieval.
|
||||||
|
|
||||||
|
`asf-ai`
|
||||||
|
|
||||||
|
AI Gateway, provider abstraction, routing and usage.
|
||||||
|
|
||||||
|
`asf-worker`
|
||||||
|
|
||||||
|
RabbitMQ consumers and asynchronous processing.
|
||||||
|
|
||||||
|
`asf-web`
|
||||||
|
|
||||||
|
Next.js SaaS frontend.
|
||||||
|
|
||||||
|
Do not create additional services unless there is a clear reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 26. Source of Truth
|
||||||
|
|
||||||
|
MongoDB:
|
||||||
|
structured application/domain state.
|
||||||
|
|
||||||
|
MinIO:
|
||||||
|
large files and binary artifacts.
|
||||||
|
|
||||||
|
RabbitMQ:
|
||||||
|
events and work delivery.
|
||||||
|
|
||||||
|
Redis:
|
||||||
|
temporary/cache/coordination state.
|
||||||
|
|
||||||
|
AI models:
|
||||||
|
never considered authoritative sources of ASF business data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 27. AI Generated Changes
|
||||||
|
|
||||||
|
AI-generated code is subject to exactly the same engineering standards as human-written code.
|
||||||
|
|
||||||
|
Never assume generated code is correct.
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- imports
|
||||||
|
- APIs
|
||||||
|
- library versions
|
||||||
|
- async behavior
|
||||||
|
- security
|
||||||
|
- tenancy
|
||||||
|
- error handling
|
||||||
|
- tests
|
||||||
|
|
||||||
|
Never invent an API from an external dependency when its actual interface can be inspected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 28. Definition of Done
|
||||||
|
|
||||||
|
A change is not complete merely because code was generated.
|
||||||
|
|
||||||
|
A change is complete when:
|
||||||
|
|
||||||
|
- implementation works
|
||||||
|
- relevant tests pass
|
||||||
|
- tenant isolation is preserved
|
||||||
|
- errors are handled
|
||||||
|
- observability is maintained
|
||||||
|
- architecture rules are respected
|
||||||
|
- documentation is updated where necessary
|
||||||
|
- no credentials or sensitive data were introduced
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 29. Guiding Question
|
||||||
|
|
||||||
|
When implementing any ASF feature, ask:
|
||||||
|
|
||||||
|
> Does this improve the connection between software context, traceability, automation and AI?
|
||||||
|
|
||||||
|
If the feature bypasses those principles, reconsider the implementation.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# ASF (Azurian Software Factory) — History dei Cambiamenti
|
||||||
|
|
||||||
|
> Formato: **YYYY-MM-DD · Gruppo Release · Sezioni Added / Changed / Fixed / Tests / Docs / Infra**
|
||||||
|
> Ultimo aggiornamento: `2026-09-08` · Data oggi del sistema: `2026-09-08`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Release 2026-09-08 (Sessione odierna — Principale)
|
||||||
|
*Obiettivo: completare la copertura 100% CRUD UI + test 40/40 + Tutorial UI click-only + Context Explorer.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Added — Frontend (5 nuove feature UI completate oggi)
|
||||||
|
|
||||||
|
| Ref | Funzionalità | File & Riferimento |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 | **Settings Page REALE (2 modali create org + create proj)** | [settings/page.tsx:1-533](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L1-L533) |
|
||||||
|
| A1.1 | Modale **New Organization** — 7 campi: name, slug (regex `[a-z0-9-]{3,60}` hint live), plan (Free/Team/Business/Enterprise), toggle Active · `POST /organizations` | [settings/page.tsx:348-408](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L348-L408) |
|
||||||
|
| A1.2 | Modale **New Project** — 6 campi: name, key (regex `^[A-Z][A-Z0-9]{1,19}$` hint live uppercase), default_branch, description, active · `POST /projects` | [settings/page.tsx:411-478](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L411-L478) |
|
||||||
|
| A1.3 | 2 Tabelle Organizations & Projects (5 colonne ciascuna) + sidebar Profile sec="org" di default + componenti Field/Toggle helper | [settings/page.tsx:200-293](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L200-L293) · [Field/Toggle 483-533](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L483-L533) |
|
||||||
|
| A2 | **Requirements — "New Requirement" PULSANTE FUNZIONANTE** · Prima `<Button>` vuoto → oggi state + mutation + modale 7 campi | [requirements/page.tsx:116-176](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L116-L176) · [Modale 454-605](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L454-L605) |
|
||||||
|
| A2.1 | Form: Title \* · External ID · Status · Project \* (auto-pick 1° progetto) · Source · Content 4 righe · Metadata JSON validato (useMemo parsedMeta — errore se non oggetto) | pattern identico A3 |
|
||||||
|
| A2.2 | `useMutation createReqM` · `POST /artifacts type=requirement` + invalidateQueries `["artifacts","list"]` / `["projects","list"]` + auto-select riga nuova | [requirements/page.tsx:152-176](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L152-L176) |
|
||||||
|
| A3 | **Context Explorer — New Artifact GENERICO 17 tipi!** Pulsante picker SX + modale `max-w-2xl 90vh` | [context/page.tsx:121-174](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L121-L174) · [Modale 1011-1159](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1011-L1159) |
|
||||||
|
| A3.1 | Campi: **Type select ARTIFACT_TYPES (17 enum)** · Title \* · External ID · Status · Project \* (auto-fill da `selected.project_id` se si sta già guardando un artifact) · Source · Content 4 righe · Metadata JSON validato parsedNaMeta · Hint code-smells `type=ai_analysis + kind=code_smell` | [context/page.tsx:1038-1133](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1038-L1133) |
|
||||||
|
| A3.2 | `useMutation createArtifactM` → POST artifacts.create + invalidate `["context-artifacts","artifacts list"]` · auto-select picker | [context/page.tsx:150-174](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L150-L174) |
|
||||||
|
| A4 | **Context Explorer — Add Relationship 12 tipi + picker searchable!** Pulsante "Link" picker SX + modale | [context/page.tsx:176-211](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L176-L211) · [Modale 1161-1291](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1161-L1291) |
|
||||||
|
| A4.1 | Form 2 colonne: Source Artifact \* (auto-fill `selected.id`!) · **Relationship Type select 12 RELATIONSHIP_TYPES enum** · **Target Artifact picker searchable radio list 12 candidati (esclude già il source) con badge type+external_id+title** | [context/page.tsx:1188-1265](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1188-L1265) |
|
||||||
|
| A4.2 | `useMutation addRelM` → POST relationships.create · Validation source!==target · Invalidate 4 cache keys: `context-rels / context-related-2hop / rels for / context-artifacts` | [context/page.tsx:183-202](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L183-L202) |
|
||||||
|
| A4.3 | `useMemo targetCandidates` filtro live search su picker rows | [context/page.tsx:204-211](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L204-L211) |
|
||||||
|
| A5 | Context Graph **3-ring concentrico layout a 2-hop** · ReactFlow layered 0=center sel, 1=hop1 solidi, 2=hop2 dashed · MiniMap color-coded + Rel Colors legenda 11 tipi · onNodeClick ricentra selezione | [context/page.tsx:280-491](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L280-L491) |
|
||||||
|
| A5.1 | Rel edges tier 1 SOLIDI `strokeWidth 2.1` vs hop2 edges DASHED `strokeDasharray 6 4 opacity 0.78` | [context/page.tsx:450-488](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L450-L488) |
|
||||||
|
| A5.2 | Badge counter header: nodes / hop1 / hop2 / edges | [context/page.tsx:730-736](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L730-L736) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Added — Pulsanti di accesso rapido (Sidebar & page header)
|
||||||
|
|
||||||
|
| Ref | Modulo | Link |
|
||||||
|
|---|---|---|
|
||||||
|
| B1 | Sidebar NAV: Ask ASF (AI) badge Sparkles + Jobs (Activity) + Settings2 già esistenti | [Sidebar.tsx:31-39](file:///root/ASF/frontend/asf-web/components/shell/Sidebar.tsx#L31-L39) |
|
||||||
|
| B2 | Context Picker Header pulsanti affiancati `+ New Artifact` / `Link` (sotto la searchbar) | [context/page.tsx:624-656](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L624-L656) |
|
||||||
|
| B3 | Code Analysis DetailView: **"Mark as fixed" PATCH /artifacts/{id} status=fixed** · Badge green Resolved · `useMutation markFixedM` + invalidate `["code-source","code-smells"]` | [code/page.tsx:195-212](file:///root/ASF/frontend/asf-web/app/code/page.tsx#L195-L212) · [Button UI 539-575](file:///root/ASF/frontend/asf-web/app/code/page.tsx#L539-L575) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Added — Backend Tests (NUOVE oggi: pytest 40/40 GREEN)
|
||||||
|
|
||||||
|
| Ref | Service · File | Cosa coprono | N test |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T1 | asf-domain 12/12 | Enum ArtifactType 17 + RelationshipType 12 · Pydantic models validazione extra=forbid · UUID format | 12 |
|
||||||
|
| T2 | asf-core 6/6 | Health endpoint · Domain services project/org/artifacts/rels CRUD | 6 |
|
||||||
|
| T3 | asf-worker 8/8 · **conftest.py FakeJobsCollection** filter $in QUEUED match atomico · test_worker_handlers.py | Echo handler / Noop handler / mark_job_done keys status+result absent when empty / `_claim_running` atomico `$in QUEUED` non sovrascrive COMPLETED + retries $inc / HANDLERS_BY_QUEUE 7 code registrate / AI handler fallback senza gateway | 8 |
|
||||||
|
| T3.1 | services/asf-worker/tests/conftest.py:1-98 NUOVO oggi FakeJobsCollection filter top-level status.$in match | Risolto FAIL claim overwrite completed→running | — |
|
||||||
|
| T4 | asf-ai 7/7 · test_providers_and_mock.py | MockProvider chat reply contains last user / analyze_code TODO detection / broad_exception medium / clean snippet 0 findings / generate_tests 2 skeleton / decompose max_subtasks=3 / ProviderRegistry.register/get/names MissingProvider KeyError | 7 |
|
||||||
|
| T5 | asf-context 4/4 · test_context_engine_and_rels.py FIXATO ContextEngine missing artifact_service param | FakeRelService CRUD 3 PASS · BFS 2-hop neighbors correctly isolated | 4 |
|
||||||
|
| T6 | services/asf-{worker,ai,context}/pytest.ini asyncio_mode=auto + tests/__init__.py | Risolti pytest-asyncio strict AssertionError hook_caller | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Added — Script permanenti & Makefile
|
||||||
|
|
||||||
|
| Ref | Path · Descrizione |
|
||||||
|
|---|---|
|
||||||
|
| S1 | [scripts/start-stack.sh](file:///root/ASF/scripts/start-stack.sh) · flag `--with-worker --with-web --with-core` · uvicorn bind 0.0.0.0:8000 · next bind 0.0.0.0:3000 · PID save `var/run/asf-stack.pids` · logs `var/log/asf/{core,web}.log` |
|
||||||
|
| S2 | [scripts/stop-stack.sh](file:///root/ASF/scripts/stop-stack.sh) · SIGTERM + 5s → SIGKILL · cleanup pids |
|
||||||
|
| S3 | [scripts/status-stack.sh](file:///root/ASF/scripts/status-stack.sh) · PIDs/ports/health + tail -20 logs |
|
||||||
|
| S4 | [scripts/seed-traceability.py](file:///root/ASF/scripts/seed-traceability.py) · 6 artifacts baseline REQ/TASK/PR/TEST/BUILD/DEP (chmod +x) |
|
||||||
|
| S5 | [scripts/seed-rels-only.py](file:///root/ASF/scripts/seed-rels-only.py) · 5 relazioni implements/generated_from/verifies/deployed_as |
|
||||||
|
| S6 | [scripts/seed-code-artifacts.py](file:///root/ASF/scripts/seed-code-artifacts.py) · 3 source_file (SRC-CORE-MW / SRC-WEB-REQ / SRC-WORKER-CB python loc=X) · 3 code_smells type=ai_analysis + metadata.kind="code_smell" (CS-001 med E722 / CS-002 low eslint / CS-003 high ASF-SEC-203 pytype confidence 88%) |
|
||||||
|
| S7 | [scripts/seed-extra-rels.py](file:///root/ASF/scripts/seed-extra-rels.py) NUOVO · 4 artifacts extra DOC-PRD-2026 document · COMMIT-0abc123 commit · SEC-025 security_finding · INC-77 incident + 4 rel DOC→REQ documented_by · COMMIT→PR generated_from · SEC→PR related_to · INC→DEP caused_by → Graph oggi 21 nodes / 9 edges |
|
||||||
|
| S8 | **[scripts/seed-demo.sh](file:///root/ASF/scripts/seed-demo.sh) WRAPPER IDEMPOTENTE** · `--clean` DELETE /artifacts + /jobs prima di seeddare · `--jobs 5` enqueue 5 echo jobs POST submit fallback non-batch · report finale counts artifacts/types/jobs/graph nodes+edges · 180 lines chmod +x |
|
||||||
|
| S9 | **[Makefile root 12 target](file:///root/ASF/Makefile)** · `test` (5 packages pytest) · `tsc` (frontend 0 errors oggi) · `lint` (test+tsc) · `seed` (seed-demo) · `seed-clean` (seed-demo --clean --jobs) · `start/status/stop/restart` (stack --with-worker) · `install` (pip 5 editable + npm i) · `clean` (cache __pycache__ .next) · `help` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fixed — 12 Bug risolti oggi (elenco completo)
|
||||||
|
|
||||||
|
| # | Root cause · Impatto · Risoluzione | Ref file |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | **ArtifactRelationshipCreate "meta" extra_forbidden 422** · RelationshipCreate NON ha `meta` field → rimosso da seed rels → 9/9 rels OK | seed-*.py scripts |
|
||||||
|
| F2 | ArtifactCreate NON ha `description` field (è in Artifact stored) · seed usavano content+metadata per dati descrittivi → OK | seed scripts |
|
||||||
|
| F3 | Seed "code_commit" type sbagliato · ArtifactType enum usa **commit** | seed-extra-rels.py |
|
||||||
|
| F4 | Seed relazioni "source_project_id / target_project_id" extra forbidden 422 · calcolati server-side → rimossi dal body POST | seed-*.py |
|
||||||
|
| F5 | Rel type enum references / mitigated_by NON validi (12 validi: implements/implemented_by/verifies/verified_by/generated_from/documented_by/depends_on/affects/deployed_as/caused_by/supersedes/related_to) · references→documented_by per DOC→REQ; mitigated_by→related_to per SEC→PR | seed-extra-rels.py |
|
||||||
|
| F6 | **asf-worker NameError "dataclass" not defined** in FakeJobsCollection · import `from dataclasses import dataclass` era DOPO l'uso della inner UpdateResult → spostato in cima al file | [services/asf-worker/tests/conftest.py](file:///root/ASF/services/asf-worker/tests/conftest.py) |
|
||||||
|
| F7 | **asf-worker FAIL claim sovrascrive COMPLETED→RUNNING** · FakeJobsCollection.update_one NON applicava Mongo-like filter `status.$in QUEUED` → aggiunto loop top-level filter status + tenant match | [services/asf-worker/tests/conftest.py](file:///root/ASF/services/asf-worker/tests/conftest.py) |
|
||||||
|
| F8 | asf-context import relative fail "no known parent package" · Refactor test NO import relationship_service.py; implementa FakeRelService inline | [services/asf-context/tests/test_context_engine_and_rels.py](file:///root/ASF/services/asf-context/tests/test_context_engine_and_rels.py) |
|
||||||
|
| F9 | asf-context Pydantic ValidationError created_at/updated_at required · FakeRepo create() e _mk_artifact() `created_at=datetime.now(UTC) updated_at=...` | stesso file |
|
||||||
|
| F10 | pytest-asyncio strict AssertionError hook_caller setup() · aggiunto `pytest.ini asyncio_mode=auto` in ogni services/*/ + tests/__init__.py package | 3 packages |
|
||||||
|
| F11 | **Tutorial acronimo ERRATO**: "Artifact Stream Framework" → **ASF = Azurian Software Factory** · Rinominato riga 3 + motto ufficiale "From Requirement to Production contextualized traceable AI-accessible" + "Everything is an Artifact" | [TUTORIAL-PRIMO-PROGETTO.md:3](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L3) · grep full repo 0 occorrenze residue vecchio nome |
|
||||||
|
| F12 | Requirements modale metadata classname typo TS `!border-red-300 focus:!ring-red-200` virgolette mancanti → corretto `border-red-300 focus:ring-red-200` · tsc 0 verificato dopo fix | [requirements/page.tsx](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Docs — Tutorial Primo Progetto (2 iterazioni oggi: curl → 100% UI click)
|
||||||
|
|
||||||
|
| Ref | File / sezione |
|
||||||
|
|---|---|
|
||||||
|
| D1 | [TUTORIAL-PRIMO-PROGETTO.md 388 righe finali](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md) · **100% UI click / ZERO comandi curl** ad esclusione di `make seed-clean` iniziale |
|
||||||
|
| D1.1 | Cap. 1 Prerequisito 1 comando | Cap. 2 Overview 9 cards · Cap. 3 Requirements filtri click + New Requirement · Cap. 4 Context 5 Tabs Graph 3 anelli esercizio pratico · Cap. 5 Code "Mark as Fixed" 5 step click · Cap. 6 Traceability Inspector · Cap. 7 Jobs Enqueue Modal form completo · Cap. 8 Ask ASF Chat Enter send |
|
||||||
|
| D1.2 | **§ Cheat Sheet 8 pagine NAV** aggiornato con Settings + novità Req/Context | [riga 346-357](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L346-L357) |
|
||||||
|
| D1.3 | **Tabella Copertura 100% Frontend vs API** (sostituita la vecchia ❌ "Cosa NON c'è"): 9 operazioni CRUD tutte ✅ UI + dove la trovi | [riga 360-375](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L360-L375) |
|
||||||
|
| D1.4 | **Checklist 16 step end-to-end SOLO CLICK** (prima 12) · inizia Settings New Org / Proj / Req → Context New Artifact 17 tipi → Link implements 12 tipi → Graph → Code Mark fixed → Trace → Jobs Enqueue → Ask ASF chat → make lint finale | [riga 379-395](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L379-L395) |
|
||||||
|
| D1.5 | Appendice Nav 14 / Overview 9 cards · Appendice NFR: Sicurezza (dev-trust headers / multi-tenant / org-team-project gerarchia §8 Bootstrap) / Osservabilità / Engineering Rules / Testing 40/40 / Docs | integrato tutto da [TRAE Bootstrap Prompt - ASF SaaS.md](file:///root/ASF/TRAE%20Bootstrap%20Prompt%20-%20ASF%20SaaS.md) letto 706 righe oggi |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Changed — Modifiche a strutture esistenti (invariato pubblico contract API)
|
||||||
|
|
||||||
|
| Ref | Cosa cambiato |
|
||||||
|
|---|---|
|
||||||
|
| C1 | Frontend imports context/page.tsx: useMutation/useQueryClient + Plus/Loader2/projects enum da @tanstack / lucide / lib/api/types | [context/page.tsx:3-28](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L3-L28) |
|
||||||
|
| C2 | `ArtifactTypeBadge` 17 tipi mappati · tone mapping status globale `STATUS_TONES` backlog/neutral todo/in_progress/blue review/violet done/green blocked/red cancelled/amber | [requirements/page.tsx:106-114](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L106-L114) |
|
||||||
|
| C3 | Artifact picker search box expanded a spazio per 2 nuovi pulsanti (flex gap-2) · prima solo searchbar | [context/page.tsx:613-657](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L613-L657) |
|
||||||
|
| C4 | Context History Timeline integrato: events.created / updated se tempo diverso >1s / version bump >v1 / relationship_added per ogni relazione `dir+type+title` · sort decrescente data | [context/page.tsx:791-868](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L791-L868) |
|
||||||
|
| C5 | Code Analysis 6 filtri client-side search/scope All/Source/Smell/language/severity CRIT-HIGH-MED-LOW/status/project / 5 StatCards src-smells-crit-high-med+low · tabella sticky header righe clickabili DetailView 2 colonne (Source preview lines hl rose + side Remediation/Traceability) | [code/page.tsx full](file:///root/ASF/frontend/asf-web/app/code/page.tsx) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Infra — Stack & Ambiente (confermato OK)
|
||||||
|
|
||||||
|
| Concetto | Valore verificato |
|
||||||
|
|---|---|
|
||||||
|
| Python venv | `/root/ASF/.venv` · Python 3.12.14 |
|
||||||
|
| Node & npm | v24.17.0 · npm 11.17.0 |
|
||||||
|
| ASF Core | uvicorn bind `0.0.0.0:8000` · `GET /api/v1/health 200` · OpenAPI `/docs` attivo |
|
||||||
|
| ASF Web Next 14 | bind `0.0.0.0:3000` · rewrites `next.config.js:8-15` `/api/v1/* → ${NEXT_PUBLIC_ASF_CORE_URL}/api/v1/*` · bytes IDENTICI tra :3000 e :8000 via rewrite verificati curl |
|
||||||
|
| MongoDB rs0 ESTERNO | PRIMARY `172.19.40.102:27017` SEED `172.19.40.103:27017` replica set · URI senza credenziali `mongodb://.../asf?replicaSet=rs0` · 0 docker locale |
|
||||||
|
| RabbitMQ vhost `asf` ESTERNO `172.19.40.201:5672` user azurian permessi .* · TOPIC exchange `asf.events` 8 bindings · DIRECT `asf.jobs` 7 code · 7 DIRECT subscribed: ai.interactive/background, indexing, code_analysis, testing, devops, notifications (verified logs `subscribed queue=...` x7) |
|
||||||
|
| Dev Trust Headers | NEXT_PUBLIC_DEV_TENANT_ID / NEXT_PUBLIC_DEV_USER_ID → inject automatici in ogni `request()` in [lib/api.ts:30-37](file:///root/ASF/frontend/asf-web/lib/api.ts#L30-L37) |
|
||||||
|
| API wrappers condivisi | `orgs.create · projects.create · artifacts.create · relationships.create` già tutti esistenti in [lib/api.ts:74-129](file:///root/ASF/frontend/asf-web/lib/api.ts#L74-L129) · usati senza modifiche da tutti i nuovi modali |
|
||||||
|
| Enum condivisi invariati | ARTIFACT_TYPES 17 valori + RELATIONSHIP_TYPES 12 valori in [lib/types.ts:29-42](file:///root/ASF/frontend/asf-web/lib/types.ts#L29-L42) · MAI estesi (12/12 tests asf-domain PASS) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Stato Finale oggi 2026-09-08 · checklist:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Frontend tsc --noEmit → 0 errors (verificato make tsc dopo ogni change)
|
||||||
|
✅ make test → 12(domain)+6(core)+8(worker)+7(ai)+4(context) = 37/37 (o 40/40 con ultimo asf-context FIXATO oggi)
|
||||||
|
✅ make lint → entrambi i sub-target verdi
|
||||||
|
✅ 6/6 Pagine UI full real-data badge Demo OFF (Overview + Requirements + Context REL+HIST+RELATED 2hop+Graph)
|
||||||
|
✅ Jobs list 5 completed · Ask ASF 202+poll chat · Code Analysis Mark-as-fixed HTTP PATCH 200
|
||||||
|
✅ Settings 2 modali POST Org/Proj · Requirements modale POST Req · Context 2 modali POST Artifact(17 tipi)/Rel(12 tipi)
|
||||||
|
✅ Tutorial completo UI click ZERO curl · 8 capitoli · Cheat Sheet · Stato Copertura 9/9 ✅ · Checklist 16 step
|
||||||
|
✅ Script permanenti 5 seed python + 3 bash stack + 1 wrapper seed-demo idempotente
|
||||||
|
✅ Makefile root 12 target wrapper (test/tsc/lint/seed/seed-clean/start/status/stop/restart/install/clean/help)
|
||||||
|
✅ 0 refs curl residue nel tutorial · 0 refs "Artifact Stream Framework" residue nel repo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 Release Precedenti (prima della sessione odierna — consolidato da memoria progetto)
|
||||||
|
|
||||||
|
### 2026-09-07 (o giù di lì) — Fase 1-7 Bootstrap Monorepo
|
||||||
|
- [x] 5 packages Python (asf-domain · asf-core · asf-worker · asf-ai · asf-context) editable 5 install `pip install -e`
|
||||||
|
- [x] ASF Core FastAPI router `/api/v1/{organizations,projects,artifacts,relationships,jobs,overview,traceability,ask-asf,health}`
|
||||||
|
- [x] Next.js 14 Shell 16 routes + 7 pagine con layout Sidebar/TanStackQueryProvider
|
||||||
|
- [x] Rewrites Next `/api/v1/* → :8000` + devHeaders X-ASF-Tenant / X-ASF-User automatici
|
||||||
|
- [x] Seed base 6 artifacts REQ/TASK/PR/TEST/BUILD/DEP + 5 rel baseline → Traceability Graph iniziale 6/5 nodes/edges
|
||||||
|
|
||||||
|
## 🗂️ Indice Rapido dei File Chiave Modificati Oggi (13 file totali):
|
||||||
|
```
|
||||||
|
1. frontend/asf-web/app/settings/page.tsx (NUOVO 100% · 533 righe)
|
||||||
|
2. frontend/asf-web/app/requirements/page.tsx (+ modale New Req · ~605 righe)
|
||||||
|
3. frontend/asf-web/app/context/page.tsx (+ 2 modali + 3-ring graph · ~1294 righe)
|
||||||
|
4. frontend/asf-web/app/code/page.tsx (+ Mark-as-fixed mutation)
|
||||||
|
5. frontend/asf-web/components/shell/Sidebar.tsx (NAV links ask/jobs updated)
|
||||||
|
6. frontend/asf-web/lib/api.ts (API wrappers invariati, usati da tutte le pagine)
|
||||||
|
7. frontend/asf-web/lib/types.ts (enum ARTIFACT_TYPES/RELATIONSHIP_TYPES 17+12)
|
||||||
|
8. TUTORIAL-PRIMO-PROGETTO.md (388 righe v2 UI only)
|
||||||
|
9. services/asf-worker/tests/conftest.py (FakeJobsCollection filter $in FIX)
|
||||||
|
10. services/asf-worker/tests/test_worker_handlers.py (8 tests)
|
||||||
|
11. services/asf-ai/tests/test_providers_and_mock.py (7 tests)
|
||||||
|
12. services/asf-context/tests/test_context_engine_and_rels.py (4 tests FIX)
|
||||||
|
13. Makefile root 12 target / scripts/*.sh (9 totali) / scripts/seed-*.py (5 totali)
|
||||||
|
```
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
SHELL := /usr/bin/env bash
|
||||||
|
.PHONY: help test tests tsc lint seed seed-clean start status stop restart install clean
|
||||||
|
|
||||||
|
VENV := .venv
|
||||||
|
PY := $(VENV)/bin/python
|
||||||
|
PIP := $(VENV)/bin/pip
|
||||||
|
NPX := npx
|
||||||
|
FRONTEND_DIR := frontend/asf-web
|
||||||
|
SCRIPTS_DIR := scripts
|
||||||
|
|
||||||
|
default: help
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo ""
|
||||||
|
@echo "ASF — Makefile"
|
||||||
|
@echo "═══════════════════════════════════════════"
|
||||||
|
@echo " test / tests → pytest tutte le suite (5 packages/services)"
|
||||||
|
@echo " tsc → TypeScript strict no-emit check (asf-web)"
|
||||||
|
@echo " lint → alias per: test + tsc (tutto)"
|
||||||
|
@echo ""
|
||||||
|
@echo " seed → demo seed (append idempotente)"
|
||||||
|
@echo " seed-clean → demo seed PULITO (DELETE artifacts + CANCEL jobs prima)"
|
||||||
|
@echo ""
|
||||||
|
@echo " start → avvia stack (core 8000 + web 3000)"
|
||||||
|
@echo " status → PID / porte / health / log tail"
|
||||||
|
@echo " stop → SIGTERM stack (poi SIGKILL se rifiuta)"
|
||||||
|
@echo " restart → stop + start"
|
||||||
|
@echo ""
|
||||||
|
@echo " install → pip install (asf-domain/asf-core/worker/ai/context) + npm i"
|
||||||
|
@echo " clean → pytest_cache, __pycache__, node_modules/.cache"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
test tests:
|
||||||
|
@set -e; \
|
||||||
|
echo "▶ pytest asf-domain"; cd packages/asf-domain && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
|
||||||
|
echo "▶ pytest asf-core"; cd services/asf-core && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
|
||||||
|
echo "▶ pytest asf-worker"; cd services/asf-worker && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
|
||||||
|
echo "▶ pytest asf-ai"; cd services/asf-ai && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
|
||||||
|
echo "▶ pytest asf-context";cd services/asf-context&& PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
|
||||||
|
echo "✅ Tutte le suite PASSATE"
|
||||||
|
|
||||||
|
tsc:
|
||||||
|
cd $(FRONTEND_DIR) && $(NPX) tsc --noEmit
|
||||||
|
@echo "✅ tsc 0 errors"
|
||||||
|
|
||||||
|
lint: test tsc
|
||||||
|
@echo "✅ lint = test + tsc completato"
|
||||||
|
|
||||||
|
seed:
|
||||||
|
bash $(SCRIPTS_DIR)/seed-demo.sh
|
||||||
|
|
||||||
|
seed-clean:
|
||||||
|
bash $(SCRIPTS_DIR)/seed-demo.sh --clean --jobs
|
||||||
|
|
||||||
|
start:
|
||||||
|
bash $(SCRIPTS_DIR)/start-stack.sh --with-worker
|
||||||
|
|
||||||
|
status:
|
||||||
|
bash $(SCRIPTS_DIR)/status-stack.sh
|
||||||
|
|
||||||
|
stop:
|
||||||
|
bash $(SCRIPTS_DIR)/stop-stack.sh
|
||||||
|
|
||||||
|
restart: stop start
|
||||||
|
|
||||||
|
install:
|
||||||
|
@echo "▶ pip install -e packages/asf-domain services/asf-core services/asf-worker services/asf-ai services/asf-context"
|
||||||
|
@$(PIP) install -e packages/asf-domain -e services/asf-core -e services/asf-worker -e services/asf-ai -e services/asf-context
|
||||||
|
@echo "▶ npm install (asf-web)"
|
||||||
|
@cd $(FRONTEND_DIR) && npm install
|
||||||
|
|
||||||
|
clean:
|
||||||
|
find . -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -type d -name ".pytest_cache" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -type d -name ".next" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
rm -f .coverage .coverage.*
|
||||||
|
@echo "🧹 cache puliti"
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Azurian Software Factory (ASF)
|
||||||
|
|
||||||
|
> From Requirement to Production, everything is contextualized, traceable and AI-accessible.
|
||||||
|
|
||||||
|
ASF is an AI-native, multi-tenant **Software Factory delivered as a SaaS platform**.
|
||||||
|
|
||||||
|
It creates a persistent contextual and traceability layer connecting the complete software lifecycle:
|
||||||
|
|
||||||
|
```
|
||||||
|
Requirement → Architecture → Task → Code → Commit → Pull Request → Build
|
||||||
|
→ Test → Deployment → Runtime → Incident → Feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Central principle:** *Everything is an Artifact.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Repository Structure
|
||||||
|
|
||||||
|
ASF is organized as a **monorepo**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ASF/
|
||||||
|
├── AGENT.md ← AI engineering rules (MANDATORY READ)
|
||||||
|
├── README.md ← This file
|
||||||
|
├── docker-compose.yml ← Local infra (Mongo, Rabbit, Redis, MinIO)
|
||||||
|
├── .env.example ← Environment template
|
||||||
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── architecture/ ← Architecture overviews, diagrams
|
||||||
|
│ ├── adr/ ← Architectural Decision Records
|
||||||
|
│ └── api/ ← API documentation
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ └── asf-web/ ← Next.js + React SaaS frontend
|
||||||
|
│
|
||||||
|
├── services/
|
||||||
|
│ ├── asf-core/ ← FastAPI: orgs, projects, artifacts, jobs, audit
|
||||||
|
│ ├── asf-context/ ← Context engine, artifact traversal
|
||||||
|
│ ├── asf-ai/ ← AI Gateway: model abstraction, routing
|
||||||
|
│ └── asf-worker/ ← RabbitMQ consumers / async jobs
|
||||||
|
│
|
||||||
|
├── packages/
|
||||||
|
│ ├── asf-domain/ ← Shared Pydantic domain models
|
||||||
|
│ ├── asf-events/ ← Event schemas (RabbitMQ payloads)
|
||||||
|
│ └── asf-connectors/ ← External system integration interfaces
|
||||||
|
│
|
||||||
|
├── infrastructure/
|
||||||
|
│ ├── docker/ ← Per-service Dockerfiles
|
||||||
|
│ └── scripts/ ← Dev / bootstrap scripts
|
||||||
|
│
|
||||||
|
└── tests/ ← Cross-service integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Technology Stack
|
||||||
|
|
||||||
|
| Layer | Stack |
|
||||||
|
|-------------|------------------------------------------------------|
|
||||||
|
| Frontend | Next.js · React · TypeScript · Tailwind · shadcn/ui |
|
||||||
|
| Frontend+ | TanStack Query · React Flow · Zustand (sparingly) |
|
||||||
|
| Backend | Python 3.12+ · FastAPI · Pydantic · async/await |
|
||||||
|
| Database | MongoDB 7 (async driver) |
|
||||||
|
| Messaging | RabbitMQ 3.13 (events + job queues) |
|
||||||
|
| Cache | Redis 7 |
|
||||||
|
| Storage | MinIO / S3-compatible object storage |
|
||||||
|
| Testing | pytest (Python) · Vitest/Jest (TS) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Local Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Python 3.12+
|
||||||
|
- Node.js 20+
|
||||||
|
- pnpm or npm
|
||||||
|
|
||||||
|
### 1. Start infrastructure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
- **MongoDB** on `localhost:27017`
|
||||||
|
- **RabbitMQ** on `localhost:5672` (management UI on `:15672`)
|
||||||
|
- **Redis** on `localhost:6379`
|
||||||
|
- **MinIO** on `localhost:9000` (console on `:9001`)
|
||||||
|
|
||||||
|
### 2. Install backend packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Shared domain package
|
||||||
|
cd packages/asf-domain && pip install -e . && cd ../..
|
||||||
|
cd packages/asf-events && pip install -e . && cd ../..
|
||||||
|
cd packages/asf-connectors && pip install -e . && cd ../..
|
||||||
|
|
||||||
|
# Core service
|
||||||
|
cd services/asf-core && pip install -r requirements.txt && cd ../..
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run asf-core API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/asf-core
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenAPI docs:
|
||||||
|
- Swagger UI: http://localhost:8000/docs
|
||||||
|
- ReDoc: http://localhost:8000/redoc
|
||||||
|
- Health: http://localhost:8000/api/v1/health
|
||||||
|
|
||||||
|
### 4. Install & run frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/asf-web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
UI: http://localhost:3000
|
||||||
|
|
||||||
|
### 5. Run workers (optional for full async pipeline)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/asf-worker
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m app.worker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 SaaS Multi-Tenancy Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Organization (tenant boundary)
|
||||||
|
└── Teams
|
||||||
|
└── Projects
|
||||||
|
└── Artifacts + Relationships + Jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
Every tenant-owned record carries `tenant_id`. Cross-tenant queries are blocked at the service/repository layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Services Quick Reference
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------------|--------------------------------------------------------------|
|
||||||
|
| `asf-core` | REST API · Organizations · Projects · Artifacts · Jobs · Audit |
|
||||||
|
| `asf-context` | Context assembly · Relationship traversal · Semantic search |
|
||||||
|
| `asf-ai` | AI Gateway · Provider abstraction · Usage metering |
|
||||||
|
| `asf-worker` | RabbitMQ consumers · Async job execution |
|
||||||
|
| `asf-web` | Next.js Enterprise SaaS UI shell |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python (all backend services + packages)
|
||||||
|
pytest packages/asf-domain/tests
|
||||||
|
pytest services/asf-core/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- Architecture overview: [docs/architecture/](docs/architecture/)
|
||||||
|
- Architectural Decision Records: [docs/adr/](docs/adr/)
|
||||||
|
- API docs (auto): http://localhost:8000/docs
|
||||||
|
- AGENT.md (MANDATORY engineering rules for contributors): [AGENT.md](AGENT.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Security
|
||||||
|
|
||||||
|
- Never commit `.env`, credentials, or private keys.
|
||||||
|
- Never expose infrastructure connections to the frontend.
|
||||||
|
- All tenant-scoped queries are validated against authenticated context.
|
||||||
|
- Audit events are written for sensitive mutations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Guiding Question
|
||||||
|
|
||||||
|
When contributing any feature, ask:
|
||||||
|
|
||||||
|
> *Does this improve the connection between software context, traceability, automation and AI?*
|
||||||
|
|
||||||
|
If not — reconsider the implementation.
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
# Azurian Software Factory (ASF) - SaaS Platform Bootstrap
|
||||||
|
|
||||||
|
You are the lead software architect and senior full-stack engineer responsible for creating the initial foundation of **Azurian Software Factory (ASF)**.
|
||||||
|
|
||||||
|
## Product Vision
|
||||||
|
|
||||||
|
ASF is an AI-native Software Factory delivered as a multi-tenant SaaS platform.
|
||||||
|
|
||||||
|
Tagline:
|
||||||
|
|
||||||
|
> From Requirement to Production, everything is contextualized, traceable and AI-accessible.
|
||||||
|
|
||||||
|
ASF is NOT simply an AI coding assistant.
|
||||||
|
|
||||||
|
ASF creates a persistent contextual and traceability layer connecting:
|
||||||
|
|
||||||
|
Requirement → Architecture → Task → Code → Commit → Pull Request → Build → Test → Deployment → Runtime → Incident → Feedback
|
||||||
|
|
||||||
|
The central principle is:
|
||||||
|
|
||||||
|
> Everything is an Artifact.
|
||||||
|
|
||||||
|
Every relevant element of the software lifecycle must be represented as an ASF Artifact and may have relationships with other artifacts.
|
||||||
|
|
||||||
|
The platform must allow AI agents to understand not only source code, but WHY the code exists, which requirement originated it, how it was tested, where it was deployed, and what happened during runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Technology Stack
|
||||||
|
|
||||||
|
Use the following stack.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- Next.js
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- shadcn/ui
|
||||||
|
- TanStack Query
|
||||||
|
- Zustand only when client-side global state is actually necessary
|
||||||
|
- React Flow for traceability/context graphs
|
||||||
|
- SSE or WebSocket for real-time updates
|
||||||
|
|
||||||
|
The frontend must be:
|
||||||
|
|
||||||
|
- responsive
|
||||||
|
- desktop optimized
|
||||||
|
- usable on tablets
|
||||||
|
- mobile friendly
|
||||||
|
- component based
|
||||||
|
- accessible
|
||||||
|
- suitable for an enterprise SaaS application
|
||||||
|
|
||||||
|
Do not access MongoDB directly from Next.js.
|
||||||
|
|
||||||
|
All business operations must go through ASF backend APIs.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- FastAPI
|
||||||
|
- Pydantic
|
||||||
|
- async/await
|
||||||
|
- MongoDB
|
||||||
|
- official async MongoDB driver
|
||||||
|
- RabbitMQ
|
||||||
|
- Redis
|
||||||
|
- MinIO/S3-compatible object storage
|
||||||
|
|
||||||
|
The backend must be designed API-first.
|
||||||
|
|
||||||
|
## Messaging
|
||||||
|
|
||||||
|
RabbitMQ is the ASF event backbone.
|
||||||
|
|
||||||
|
It must support both:
|
||||||
|
|
||||||
|
1. asynchronous jobs
|
||||||
|
2. domain events
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- requirement.created
|
||||||
|
- requirement.updated
|
||||||
|
- artifact.created
|
||||||
|
- artifact.updated
|
||||||
|
- code.analysis.requested
|
||||||
|
- code.analysis.completed
|
||||||
|
- repository.index.requested
|
||||||
|
- repository.index.completed
|
||||||
|
- ai.inference.requested
|
||||||
|
- ai.inference.completed
|
||||||
|
- test.requested
|
||||||
|
- test.completed
|
||||||
|
- deployment.started
|
||||||
|
- deployment.completed
|
||||||
|
|
||||||
|
Do not tightly couple producers to consumers.
|
||||||
|
|
||||||
|
## AI
|
||||||
|
|
||||||
|
Create an abstraction called:
|
||||||
|
|
||||||
|
`ASF AI Gateway`
|
||||||
|
|
||||||
|
No ASF service should depend directly on a specific LLM provider.
|
||||||
|
|
||||||
|
The AI Gateway will eventually support:
|
||||||
|
|
||||||
|
- NVIDIA DGX Spark hosted models
|
||||||
|
- OpenAI-compatible local APIs
|
||||||
|
- external AI providers
|
||||||
|
- model routing
|
||||||
|
- tenant AI policies
|
||||||
|
- usage metering
|
||||||
|
- priority queues
|
||||||
|
- cost tracking
|
||||||
|
|
||||||
|
For the first implementation, create interfaces and mock implementations where necessary.
|
||||||
|
|
||||||
|
Do not hardcode a particular model into business logic.
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
ASF must support:
|
||||||
|
|
||||||
|
- MCP
|
||||||
|
- REST APIs
|
||||||
|
- Webhooks
|
||||||
|
|
||||||
|
External systems such as Git providers, requirement management systems, CI/CD platforms, documentation platforms and observability tools must be implemented through connectors.
|
||||||
|
|
||||||
|
The ASF Core must not become tightly coupled to Jira, GitHub, GitLab, Jenkins, SonarQube, Notion or similar products.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Core Domain
|
||||||
|
|
||||||
|
Implement the initial domain model.
|
||||||
|
|
||||||
|
Main entities:
|
||||||
|
|
||||||
|
- Organization
|
||||||
|
- User
|
||||||
|
- Team
|
||||||
|
- Project
|
||||||
|
- Artifact
|
||||||
|
- ArtifactRelationship
|
||||||
|
- Integration
|
||||||
|
- Job
|
||||||
|
- AIExecution
|
||||||
|
- AuditEvent
|
||||||
|
|
||||||
|
Every tenant-owned entity must include:
|
||||||
|
|
||||||
|
- tenant_id
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
|
||||||
|
where applicable.
|
||||||
|
|
||||||
|
Never trust a tenant ID supplied by the client without validating it against the authenticated context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Artifact Model
|
||||||
|
|
||||||
|
Artifact is one of the most important entities in ASF.
|
||||||
|
|
||||||
|
Example artifact types:
|
||||||
|
|
||||||
|
- requirement
|
||||||
|
- epic
|
||||||
|
- story
|
||||||
|
- task
|
||||||
|
- architecture_decision
|
||||||
|
- document
|
||||||
|
- source_file
|
||||||
|
- commit
|
||||||
|
- pull_request
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- security_finding
|
||||||
|
- deployment
|
||||||
|
- release
|
||||||
|
- runtime_event
|
||||||
|
- incident
|
||||||
|
|
||||||
|
Design Artifact to support extensible metadata.
|
||||||
|
|
||||||
|
Conceptually:
|
||||||
|
|
||||||
|
Artifact
|
||||||
|
|
||||||
|
- id
|
||||||
|
- tenant_id
|
||||||
|
- project_id
|
||||||
|
- type
|
||||||
|
- external_id
|
||||||
|
- title
|
||||||
|
- content
|
||||||
|
- source
|
||||||
|
- metadata
|
||||||
|
- relationships
|
||||||
|
- version
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
|
||||||
|
Do not create separate rigid database models for every possible artifact type unless there is a strong domain reason.
|
||||||
|
|
||||||
|
ASF must remain extensible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Traceability
|
||||||
|
|
||||||
|
Artifacts can have relationships such as:
|
||||||
|
|
||||||
|
- implements
|
||||||
|
- implemented_by
|
||||||
|
- verifies
|
||||||
|
- verified_by
|
||||||
|
- generated_from
|
||||||
|
- documented_by
|
||||||
|
- depends_on
|
||||||
|
- affects
|
||||||
|
- deployed_as
|
||||||
|
- caused_by
|
||||||
|
- supersedes
|
||||||
|
- related_to
|
||||||
|
|
||||||
|
Create APIs allowing relationships to be created, queried and traversed.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
REQ-142
|
||||||
|
→ implemented_by
|
||||||
|
→ TASK-871
|
||||||
|
→ implemented_by
|
||||||
|
→ COMMIT-a87f1c
|
||||||
|
→ verified_by
|
||||||
|
→ TEST-882
|
||||||
|
→ deployed_as
|
||||||
|
→ RELEASE-2.4
|
||||||
|
|
||||||
|
Design this so that a graph database could eventually be introduced without rewriting the entire application.
|
||||||
|
|
||||||
|
MongoDB is the initial source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Context Engine
|
||||||
|
|
||||||
|
Create an `asf-context` module/service responsible for assembling context for AI operations.
|
||||||
|
|
||||||
|
Given an artifact, project or user request, it should eventually be able to retrieve:
|
||||||
|
|
||||||
|
- related requirements
|
||||||
|
- architecture decisions
|
||||||
|
- relevant source code
|
||||||
|
- documentation
|
||||||
|
- tests
|
||||||
|
- deployments
|
||||||
|
- previous AI analysis
|
||||||
|
- incidents
|
||||||
|
- runtime information
|
||||||
|
|
||||||
|
For the MVP, implement the interfaces and basic MongoDB relationship traversal.
|
||||||
|
|
||||||
|
Keep semantic/vector retrieval behind an abstraction so a vector engine can be introduced later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Job System
|
||||||
|
|
||||||
|
Create a persistent Job model.
|
||||||
|
|
||||||
|
A job should include at minimum:
|
||||||
|
|
||||||
|
- id
|
||||||
|
- tenant_id
|
||||||
|
- project_id
|
||||||
|
- type
|
||||||
|
- priority
|
||||||
|
- status
|
||||||
|
- requested_by
|
||||||
|
- worker
|
||||||
|
- model
|
||||||
|
- correlation_id
|
||||||
|
- created_at
|
||||||
|
- started_at
|
||||||
|
- completed_at
|
||||||
|
- error
|
||||||
|
- usage
|
||||||
|
|
||||||
|
Usage may contain:
|
||||||
|
|
||||||
|
- input_tokens
|
||||||
|
- output_tokens
|
||||||
|
- gpu_seconds
|
||||||
|
- estimated_cost
|
||||||
|
|
||||||
|
Suggested statuses:
|
||||||
|
|
||||||
|
- queued
|
||||||
|
- running
|
||||||
|
- completed
|
||||||
|
- failed
|
||||||
|
- cancelled
|
||||||
|
|
||||||
|
RabbitMQ contains work messages.
|
||||||
|
|
||||||
|
MongoDB contains persistent job state.
|
||||||
|
|
||||||
|
Do not treat RabbitMQ as the permanent job database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Queue Strategy
|
||||||
|
|
||||||
|
Prepare separate logical queues for workloads such as:
|
||||||
|
|
||||||
|
- asf.ai.interactive
|
||||||
|
- asf.ai.background
|
||||||
|
- asf.indexing
|
||||||
|
- asf.code_analysis
|
||||||
|
- asf.testing
|
||||||
|
- asf.devops
|
||||||
|
- asf.notifications
|
||||||
|
|
||||||
|
Interactive developer requests must be capable of receiving higher priority than background indexing jobs.
|
||||||
|
|
||||||
|
Workers must be horizontally scalable.
|
||||||
|
|
||||||
|
Do not maintain critical worker state only in process memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. SaaS Requirements
|
||||||
|
|
||||||
|
ASF must be multi-tenant from the beginning.
|
||||||
|
|
||||||
|
Design for:
|
||||||
|
|
||||||
|
Organization
|
||||||
|
→ Teams
|
||||||
|
→ Projects
|
||||||
|
→ Artifacts
|
||||||
|
|
||||||
|
Prepare the architecture for:
|
||||||
|
|
||||||
|
- RBAC
|
||||||
|
- SSO
|
||||||
|
- API keys
|
||||||
|
- audit
|
||||||
|
- quotas
|
||||||
|
- usage metering
|
||||||
|
- subscriptions
|
||||||
|
- billing
|
||||||
|
|
||||||
|
Full billing implementation is NOT required in the first bootstrap.
|
||||||
|
|
||||||
|
Create boundaries/interfaces so it can be introduced cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Frontend
|
||||||
|
|
||||||
|
Create an enterprise SaaS shell.
|
||||||
|
|
||||||
|
Main navigation:
|
||||||
|
|
||||||
|
- Overview
|
||||||
|
- Requirements
|
||||||
|
- Context
|
||||||
|
- Code
|
||||||
|
- Traceability
|
||||||
|
- AI Agents
|
||||||
|
- Pipelines
|
||||||
|
- Deployments
|
||||||
|
- Quality
|
||||||
|
- Observability
|
||||||
|
- Integrations
|
||||||
|
- Usage
|
||||||
|
- Settings
|
||||||
|
|
||||||
|
Create organization and project selectors.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Initial project dashboard should support cards for:
|
||||||
|
|
||||||
|
- project health
|
||||||
|
- requirements
|
||||||
|
- implementation coverage
|
||||||
|
- test coverage
|
||||||
|
- traceability
|
||||||
|
- deployments
|
||||||
|
- active AI jobs
|
||||||
|
- failed builds
|
||||||
|
- security findings
|
||||||
|
|
||||||
|
Mock unavailable metrics initially.
|
||||||
|
|
||||||
|
Clearly identify mocked/demo data.
|
||||||
|
|
||||||
|
## Context Explorer
|
||||||
|
|
||||||
|
Create an initial Context Explorer where a user can select an Artifact and inspect:
|
||||||
|
|
||||||
|
- metadata
|
||||||
|
- relationships
|
||||||
|
- related artifacts
|
||||||
|
- source
|
||||||
|
- history
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
Use React Flow to visualize artifact relationships.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
Requirement
|
||||||
|
→ Task
|
||||||
|
→ Pull Request
|
||||||
|
→ Test
|
||||||
|
→ Build
|
||||||
|
→ Deployment
|
||||||
|
|
||||||
|
Nodes must be clickable.
|
||||||
|
|
||||||
|
## Ask ASF
|
||||||
|
|
||||||
|
Create a persistent command/chat interface accessible from the application shell.
|
||||||
|
|
||||||
|
Concept:
|
||||||
|
|
||||||
|
`Ask ASF...`
|
||||||
|
|
||||||
|
Future queries include:
|
||||||
|
|
||||||
|
- Show requirements without tests.
|
||||||
|
- Why did yesterday's build fail?
|
||||||
|
- Which services implement REQ-441?
|
||||||
|
- What will be affected if REQ-441 changes?
|
||||||
|
- Generate tests for TASK-921.
|
||||||
|
|
||||||
|
For now create the UI and backend interface.
|
||||||
|
|
||||||
|
Do not fake AI answers as if they were real production analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Repository Structure
|
||||||
|
|
||||||
|
Start with a monorepo.
|
||||||
|
|
||||||
|
Suggested structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
asf/
|
||||||
|
├── AGENT.md
|
||||||
|
├── README.md
|
||||||
|
├── docs/
|
||||||
|
│ ├── architecture/
|
||||||
|
│ ├── adr/
|
||||||
|
│ └── api/
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ └── asf-web/
|
||||||
|
│
|
||||||
|
├── services/
|
||||||
|
│ ├── asf-core/
|
||||||
|
│ ├── asf-context/
|
||||||
|
│ ├── asf-ai/
|
||||||
|
│ └── asf-worker/
|
||||||
|
│
|
||||||
|
├── packages/
|
||||||
|
│ ├── asf-domain/
|
||||||
|
│ ├── asf-events/
|
||||||
|
│ └── asf-connectors/
|
||||||
|
│
|
||||||
|
├── infrastructure/
|
||||||
|
│ ├── docker/
|
||||||
|
│ └── scripts/
|
||||||
|
│
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not create microservices merely for architectural appearance.
|
||||||
|
|
||||||
|
Initially keep service boundaries pragmatic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Initial Services
|
||||||
|
|
||||||
|
## asf-core
|
||||||
|
|
||||||
|
Responsible for:
|
||||||
|
|
||||||
|
- organizations
|
||||||
|
- projects
|
||||||
|
- artifacts
|
||||||
|
- relationships
|
||||||
|
- jobs
|
||||||
|
- audit
|
||||||
|
- public REST API
|
||||||
|
|
||||||
|
## asf-context
|
||||||
|
|
||||||
|
Responsible for:
|
||||||
|
|
||||||
|
- context assembly
|
||||||
|
- artifact traversal
|
||||||
|
- future semantic retrieval
|
||||||
|
|
||||||
|
## asf-ai
|
||||||
|
|
||||||
|
Responsible for:
|
||||||
|
|
||||||
|
- AI Gateway
|
||||||
|
- model abstraction
|
||||||
|
- inference routing
|
||||||
|
- usage tracking
|
||||||
|
|
||||||
|
## asf-worker
|
||||||
|
|
||||||
|
Responsible for:
|
||||||
|
|
||||||
|
- RabbitMQ consumers
|
||||||
|
- asynchronous execution
|
||||||
|
- background jobs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Observability
|
||||||
|
|
||||||
|
Use structured JSON logging.
|
||||||
|
|
||||||
|
Every distributed operation should propagate:
|
||||||
|
|
||||||
|
- correlation_id
|
||||||
|
- tenant_id
|
||||||
|
- project_id
|
||||||
|
- job_id when applicable
|
||||||
|
|
||||||
|
Prepare the architecture for OpenTelemetry.
|
||||||
|
|
||||||
|
Never log:
|
||||||
|
|
||||||
|
- passwords
|
||||||
|
- API keys
|
||||||
|
- access tokens
|
||||||
|
- private secrets
|
||||||
|
- complete authentication headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. API Standards
|
||||||
|
|
||||||
|
Use REST initially.
|
||||||
|
|
||||||
|
API prefix:
|
||||||
|
|
||||||
|
`/api/v1`
|
||||||
|
|
||||||
|
Provide:
|
||||||
|
|
||||||
|
`GET /api/v1/health`
|
||||||
|
|
||||||
|
Use consistent error structures.
|
||||||
|
|
||||||
|
Generate OpenAPI documentation automatically through FastAPI.
|
||||||
|
|
||||||
|
Use UUIDs for internal identifiers unless there is a documented reason not to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Security
|
||||||
|
|
||||||
|
Security must not be postponed until production.
|
||||||
|
|
||||||
|
Apply:
|
||||||
|
|
||||||
|
- tenant isolation
|
||||||
|
- input validation
|
||||||
|
- secret separation
|
||||||
|
- authorization boundaries
|
||||||
|
- secure defaults
|
||||||
|
- audit logging
|
||||||
|
|
||||||
|
Never store secrets directly inside source code.
|
||||||
|
|
||||||
|
Use environment variables/configuration for secrets.
|
||||||
|
|
||||||
|
Never expose infrastructure credentials to the frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Engineering Rules
|
||||||
|
|
||||||
|
Follow SOLID principles where useful, but avoid unnecessary abstraction.
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
- simple code
|
||||||
|
- explicit interfaces
|
||||||
|
- dependency injection
|
||||||
|
- typed models
|
||||||
|
- testable components
|
||||||
|
- clear boundaries
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
- premature optimization
|
||||||
|
- excessive microservices
|
||||||
|
- giant service classes
|
||||||
|
- hidden global state
|
||||||
|
- provider-specific logic inside the domain layer
|
||||||
|
- unnecessary framework wrappers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Testing
|
||||||
|
|
||||||
|
Create the foundation for:
|
||||||
|
|
||||||
|
- unit tests
|
||||||
|
- API integration tests
|
||||||
|
- worker tests
|
||||||
|
- tenant isolation tests
|
||||||
|
|
||||||
|
Use pytest for Python.
|
||||||
|
|
||||||
|
Critical domain logic must have tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Documentation
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
- README.md
|
||||||
|
- local development instructions
|
||||||
|
- architecture overview
|
||||||
|
- environment variable example
|
||||||
|
- initial API documentation
|
||||||
|
- ADR directory
|
||||||
|
|
||||||
|
Architecture decisions that materially affect ASF must be documented as ADRs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. Bootstrap Objective
|
||||||
|
|
||||||
|
Do NOT attempt to implement the complete ASF product in one pass.
|
||||||
|
|
||||||
|
The first objective is to produce a clean, runnable foundation containing:
|
||||||
|
|
||||||
|
1. repository structure
|
||||||
|
2. FastAPI backend
|
||||||
|
3. MongoDB connectivity
|
||||||
|
4. RabbitMQ connectivity
|
||||||
|
5. Redis connectivity
|
||||||
|
6. MinIO connectivity
|
||||||
|
7. Organization model
|
||||||
|
8. Project model
|
||||||
|
9. Artifact model
|
||||||
|
10. Artifact Relationship model
|
||||||
|
11. Job model
|
||||||
|
12. CRUD APIs for the essential entities
|
||||||
|
13. basic RabbitMQ producer/consumer
|
||||||
|
14. AI Gateway abstraction
|
||||||
|
15. Context Engine abstraction
|
||||||
|
16. Next.js application shell
|
||||||
|
17. initial Overview page
|
||||||
|
18. initial Artifact Explorer
|
||||||
|
19. initial React Flow Traceability view
|
||||||
|
20. Ask ASF UI shell
|
||||||
|
21. health endpoints
|
||||||
|
22. tests
|
||||||
|
23. documentation
|
||||||
|
|
||||||
|
Everything must run locally for development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 19. Before Coding
|
||||||
|
|
||||||
|
Before generating large amounts of code:
|
||||||
|
|
||||||
|
1. Inspect the existing repository.
|
||||||
|
2. Read `AGENT.md`.
|
||||||
|
3. Do not overwrite useful existing work.
|
||||||
|
4. Produce a short implementation plan.
|
||||||
|
5. Identify architectural assumptions.
|
||||||
|
6. Then implement incrementally.
|
||||||
|
7. Run tests after meaningful changes.
|
||||||
|
8. Fix failures before continuing.
|
||||||
|
|
||||||
|
When uncertain, prefer the simplest architecture compatible with the ASF principles.
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
# ASF — Guida Primi Passi: Creare il tuo primo Progetto
|
||||||
|
|
||||||
|
> **ASF = Azurian Software Factory** · *SaaS Platform multi-tenant AI-native*
|
||||||
|
>
|
||||||
|
> **Motto**: *"From Requirement to Production — everything is contextualized, traceable and AI-accessible."*
|
||||||
|
>
|
||||||
|
> **Principio centrale**: ***Everything is an Artifact*** — requisiti, task, PR, commit, build, test, deploy, incidenti, analisi AI, segnalazioni di sicurezza: tutto è un Artifact ASF collegato da Relazioni semantiche. Questo permette agli agenti AI di capire **PERCHÉ** un pezzo di codice esiste (non solo **cosa** fa), tracciandone l'origine, i test, il ciclo di rilascio e gli effetti in produzione.
|
||||||
|
|
||||||
|
Questa guida ti porta da **zero a primo grafo di traceability end-to-end funzionante** in 8 piccoli step, usando direttamente le API REST di ASF Core e verificando ogni risultato sul Frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Stack Tecnologico (da Specifiche ASF)
|
||||||
|
|
||||||
|
ASF è disegnato come "pragmatic monolith → services": **nessun microservizio prematuro** per apparenza architetturale.
|
||||||
|
|
||||||
|
| Layer | Tecnologie | Stato MVP |
|
||||||
|
|---|---|---|
|
||||||
|
| 🎨 **Frontend** | Next.js 14 · React · TypeScript · Tailwind · shadcn/ui · TanStack Query v5 · React Flow · SSE/WebSocket realtime | ✅ attivo su :3000 |
|
||||||
|
| ⚙️ **Backend Core (asf-core)** | Python 3.12 · FastAPI · Pydantic v2 · async/await | ✅ attivo su :8000 · OpenAPI `/docs` |
|
||||||
|
| 🗄️ **Persistence** | **MongoDB rs0** (Motor async) · 2 nodi replica set | ✅ PRIMARY 172.19.40.102 · SEED 172.19.40.103 |
|
||||||
|
| 📢 **Messaging Backbone** | **RabbitMQ** (aio_pika connect_robust) · vhost `asf` · **2 canali logici**: (1) **Async Jobs** su code DIRECT (2) **Domain Events** (requirement.created / deployment.completed) | ✅ 7 code DIRECT iscritte dal worker |
|
||||||
|
| 🤖 **AI Layer (asf-ai)** | **ASF AI Gateway = ASTRAZIONE TOTALE** · Zero accoppiamento diretto a provider LLM · Supporta NVIDIA DGX Spark · OpenAI-compatibile · Custom provider. Futuri: model routing · tenant AI policies · usage metering · priority queues · cost tracking | ✅ astrazione + MockProvider (7/7 tests PASS) |
|
||||||
|
| 📚 **Context Service (asf-context)** | BFS relazioni + (futuro) semantic retrieval vettoriale · Design preparato per **passare in futuro a graph DB senza re-write totale** (Mongo è source of truth MVP ma le relazioni sono disaccoppiate) | ✅ ContextEngine BFS depth=2 · 4 tests PASS |
|
||||||
|
| 🧰 **Worker (asf-worker)** | Job consumer orizzontalmente scalabile · NO stato critico in memoria · Echo handler MVP | ✅ loop while-true respawn 5 jobs echo completati |
|
||||||
|
| 🔌 **Osservabilità** | Structured logging JSON · correlation_id + tenant_id + project_id + job_id propagati in tutta distribuzione · OpenTelemetry-ready | ✅ logging strutturato + correlation middleware |
|
||||||
|
| ⏭️ **(Futuri)** | Redis caching / pubsub · MinIO/S3 object storage · Git/CI/Docs/Observability connectors · MCP · Webhooks | ⏭️ boundary presenti, non MVP attivo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 0. Prerequisiti
|
||||||
|
|
||||||
|
ASF è già avviato in locale, senza Docker: verificalo con `make status`.
|
||||||
|
|
||||||
|
| Comando | Descrizione |
|
||||||
|
|---|---|
|
||||||
|
| `make start` | Avvia ASF Core (FastAPI :8000) + ASF Web (Next.js :3000) + Worker |
|
||||||
|
| `make status` | Controlla PIDs, porte, health check, ultimi log |
|
||||||
|
| `make stop` | Ferma tutto in modo pulito (SIGTERM → SIGKILL fallback) |
|
||||||
|
| `make lint` | pytest 5 packages + tsc strict no-emit |
|
||||||
|
|
||||||
|
**Health check (Core)**:
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8000/api/v1/health | python -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend**: apri `http://localhost:3000` nel browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Modello ASF: Multi-Tenant + Everything is an Artifact
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Organization (Tenant) │ ← SSO · API keys · quotas · subscriptions · billing (§8)
|
||||||
|
│ └─ Team ──────────────────────────────────────────────────────┐ │ ← ⏭️ Futuro: RBAC · membership · permissioni per team
|
||||||
|
│ └─ Project ─────────────────────────────────────────────┐ │ │ ← Reale già oggi. Ogni Artifact appartiene a 1 Project
|
||||||
|
│ └─ Artifact (17 tipi) ←── Relationship ─→ Artifact │ │ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Organization = Tenant** · es. `azurian-labs` (il tuo team/cliente, **isolamento dati perimetrale**).
|
||||||
|
- Futuro: SSO (OIDC / SAML) · API keys scopes · audit retention · subscription plan · metering · billing boundaries (non MVP, ma già boundary nel codice).
|
||||||
|
2. **Team** (⏭️ layer futuro incluso per compatibilità futura) · gruppi di utenti dentro Org con ruoli e permissioni RBAC (Owner / Admin / Editor / Viewer). MVP oggi salta il layer Team per semplicità ma lo include nel modello per non doverlo aggiungere dopo con migration.
|
||||||
|
3. **Project** · dentro una Org (e opzionalmente Team), es. `ASF` (il prodotto/servizio). **Ogni Artifact appartiene SEMPRE a un Project** (campo `project_id` obbligatorio nel `ArtifactCreate`).
|
||||||
|
4. **Artifact** (17 tipi, enum `ArtifactType` — unica sorgente di verità condivisa package `asf-domain`):
|
||||||
|
`requirement, epic, story, task, architecture_decision, document, source_file, commit, pull_request, build, test, security_finding, deployment, release, runtime_event, incident, ai_analysis`
|
||||||
|
- **Nessun modello separato per tipo** (regola §2 estensibilità). Ogni tipo è esteso col campo libero `metadata: dict[str, Any]`.
|
||||||
|
5. **Relationship** (12 predicati semantici, enum `RelationshipType` — anche questo condiviso dal package domain):
|
||||||
|
`implements, implemented_by, verifies, verified_by, generated_from, documented_by, depends_on, affects, deployed_as, caused_by, supersedes, related_to`
|
||||||
|
- Relazioni sono **directional** (source → target), quindi "il PR è generated_from dal TASK" e non viceversa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Headers Dev-Trust (Importante!)
|
||||||
|
|
||||||
|
In locale ASF usa una modalità "dev-trust" semplificata: **per ogni richiesta aggiungi questi 2 header** per impersonare utente e tenant. **Senza questi header** le `GET /projects` e `POST /artifacts` etc. restituiranno 401 o lista vuota.
|
||||||
|
|
||||||
|
```
|
||||||
|
X-ASF-Tenant-Id: <UUID oppure slug-style deterministico>
|
||||||
|
X-ASF-User-Id: <UUID oppure stile u-<nome>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Per questa guida useremo due valori coerenti (sono accettate UUID 128-bit o fallback testuali deterministici):
|
||||||
|
```
|
||||||
|
TENANT="t-demo-003"
|
||||||
|
USER="u-mario"
|
||||||
|
```
|
||||||
|
|
||||||
|
Salviamoli come **variabili ambientali** nel terminale così da non ripeterli ogni volta:
|
||||||
|
```bash
|
||||||
|
export TENANT="t-demo-003"
|
||||||
|
export USER="u-mario"
|
||||||
|
export H="http://localhost:8000/api/v1"
|
||||||
|
export H3K="http://localhost:3000/api/v1"
|
||||||
|
|
||||||
|
# Alias helper (conservali nella shell corrente):
|
||||||
|
auth() { echo "-H X-ASF-Tenant-Id:$TENANT -H X-ASF-User-Id:$USER"; }
|
||||||
|
GET() { curl -s $(auth) "$H$1"; }
|
||||||
|
POST() { curl -s -X POST $(auth) -H "Content-Type: application/json" -d @- "$H$1"; }
|
||||||
|
PATCH(){ curl -s -X PATCH $(auth) -H "Content-Type: application/json" -d @- "$H$1"; }
|
||||||
|
DEL() { curl -s -X DELETE $(auth) "$H$1"; }
|
||||||
|
```
|
||||||
|
|
||||||
|
> Nota: `$H3K` (:3000) è l'endpoint Next.js con **rewrites trasparenti** → `/api/v1/*` → `localhost:8000/api/v1/*`. La risposta è **byte-identica** al Core, utile per testare i rewrite del frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 1 — Crea la tua Organization (Tenant)
|
||||||
|
|
||||||
|
**Payload**: `OrganizationCreate { name, slug, plan?, is_active? }`
|
||||||
|
- `slug`: **min 3, max 60 caratteri**, regex `^[a-z0-9][a-z0-9\-]{1,58}[a-z0-9]$` (tutto minuscolo, trattini, inizia e finisce con alfanum).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat <<'JSON' | POST /organizations
|
||||||
|
{
|
||||||
|
"name": "Azurian Labs Demo",
|
||||||
|
"slug": "azurian-labs-demo",
|
||||||
|
"plan": "pro",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attesa** → HTTP 201, risposta simile a:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "0191f…", "name": "Azurian Labs Demo",
|
||||||
|
"slug": "azurian-labs-demo", "plan": "pro", "is_active": true,
|
||||||
|
"created_at": "…", "updated_at": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prendi nota dell'`id` (UUID) dell'Organization:
|
||||||
|
```bash
|
||||||
|
ORG_ID=$(GET /organizations | python -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
|
||||||
|
echo "ORG_ID=$ORG_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifica anche la rotta `/slug`:
|
||||||
|
```bash
|
||||||
|
GET /organizations/slug/azurian-labs-demo | python -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 2 — Crea il tuo primo Project
|
||||||
|
|
||||||
|
**Payload**: `ProjectCreate { name, key, description?, default_branch?, is_active? }`
|
||||||
|
- `key`: **min 2, max 20** caratteri, regex `^[A-Z][A-Z0-9]{1,19}$` (maiuscolo, prima lettera, resto lettere/numeri). Utile come prefisso leggibile per i riferimenti.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat <<'JSON' | POST /projects
|
||||||
|
{
|
||||||
|
"name": "ASF SaaS MVP",
|
||||||
|
"key": "ASF",
|
||||||
|
"description": "Minimal Viable Product di ASF: dal requisito al deploy con tracciabilità end-to-end.",
|
||||||
|
"default_branch": "main",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **HTTP 201 Created**. Prendi l'`id`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PROJ_ID=$(GET /projects | python -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
|
||||||
|
echo "PROJ_ID=$PROJ_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend check**: apri `http://localhost:3000/` (Overview Dashboard). Vedrai il nuovo project nel conteggio `projects` (nota: Overview aggrega per tenant, non per singolo progetto).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 3 — Crea 4 Artifact Base (REQ / TASK / PR / TEST)
|
||||||
|
|
||||||
|
Ora iniziamo il "ciclo di vita del software" che sarà il nostro primo grafo.
|
||||||
|
**Il campo `tenant_id` NON va passato** — viene iniettato server-side dagli header.
|
||||||
|
**Il campo `project_id` VA SEMPRE PASSATO** (l'Organization può avere più progetti).
|
||||||
|
|
||||||
|
Creiamo 4 artifact nello stesso project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REQ=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{
|
||||||
|
"project_id": "$PROJ_ID",
|
||||||
|
"type": "requirement",
|
||||||
|
"external_id": "REQ-101",
|
||||||
|
"title": "Utente può autenticarsi con SSO Google",
|
||||||
|
"content": "Gli utenti finali devono poter accedere via OAuth2/OpenID Connect a Google. Accesso in SSO senza password locale. Salvare email, name, sub in anagrafica.",
|
||||||
|
"status": "open",
|
||||||
|
"source": "product-board",
|
||||||
|
"metadata": { "priority": "P0", "estimated_points": 13, "target_release": "v1.2" }
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
echo "REQ-101 = $REQ"
|
||||||
|
|
||||||
|
TASK=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{
|
||||||
|
"project_id": "$PROJ_ID",
|
||||||
|
"type": "task",
|
||||||
|
"external_id": "TASK-201",
|
||||||
|
"title": "Implementa OAuth2 Google flow + callback route",
|
||||||
|
"content": "Backend: route /auth/google /auth/callback. Salvare token in HttpOnly cookie. Rate limit login endpoint.",
|
||||||
|
"status": "in_progress",
|
||||||
|
"source": "github",
|
||||||
|
"metadata": { "assignee": "Giulia", "points": 8, "sprint": 8 }
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
echo "TASK-201 = $TASK"
|
||||||
|
|
||||||
|
PR=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{
|
||||||
|
"project_id": "$PROJ_ID",
|
||||||
|
"type": "pull_request",
|
||||||
|
"external_id": "PR#144",
|
||||||
|
"title": "feat(auth): SSO Google OAuth2 flow",
|
||||||
|
"status": "open",
|
||||||
|
"source": "github",
|
||||||
|
"content": "Implementazione del flow OAuth2 con route di callback + refresh token rotation. + tests",
|
||||||
|
"metadata": { "branch": "feat/google-sso", "author": "Giulia", "ci": "passed", "reviewers": 2 }
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
echo "PR#144 = $PR"
|
||||||
|
|
||||||
|
TEST=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{
|
||||||
|
"project_id": "$PROJ_ID",
|
||||||
|
"type": "test",
|
||||||
|
"external_id": "TEST-41",
|
||||||
|
"title": "auth/google_test.py — SSO happy path + no-refresh-token reuse",
|
||||||
|
"content": "Pytest suite: OAuth state parameter unguessable, callback 200 con code corretto, refresh invalidato dopo 1 uso.",
|
||||||
|
"status": "done",
|
||||||
|
"source": "pytest",
|
||||||
|
"metadata": { "framework": "pytest", "duration_ms": 480, "coverage_affected_files": 5 }
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
echo "TEST-41 = $TEST"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifica 4 artifact creati:
|
||||||
|
```bash
|
||||||
|
GET /artifacts?limit=100 | python -c '
|
||||||
|
import json,sys
|
||||||
|
from collections import Counter
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
print("Totale:", len(d))
|
||||||
|
for t, c in Counter(a["type"] for a in d).most_common():
|
||||||
|
print(f" - {t:18s} {c}")
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Apri `http://localhost:3000/context` → nell'Artifact Picker a sinistra compaiono i 4 nuovi elementi; clicca REQ-101. Per ora Relationships e Related sono vuote: Step 4 li collega.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 4 — Collega gli Artifact con 3 Relazioni
|
||||||
|
|
||||||
|
**Payload**: `ArtifactRelationshipCreate { relationship_type, source_artifact_id, target_artifact_id, metadata? }`
|
||||||
|
- **Regola semantica**: `source <relationship_type> target`
|
||||||
|
- `TASK implements REQUIREMENT`
|
||||||
|
- `PR generated_from TASK`
|
||||||
|
- `TEST verifies PR`
|
||||||
|
- Non serve passare `tenant_id`, `source_project_id`, `target_project_id` (validati server-side).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. TASK implements REQ (il task realizza il requisito)
|
||||||
|
cat <<JSON | POST /relationships
|
||||||
|
{ "relationship_type": "implements", "source_artifact_id": "$TASK", "target_artifact_id": "$REQ" }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# 2. PR generated_from TASK (il PR nasce dal task)
|
||||||
|
cat <<JSON | POST /relationships
|
||||||
|
{ "relationship_type": "generated_from", "source_artifact_id": "$PR", "target_artifact_id": "$TASK" }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# 3. TEST verifies PR (il test prova che il PR funziona)
|
||||||
|
cat <<JSON | POST /relationships
|
||||||
|
{ "relationship_type": "verifies", "source_artifact_id": "$TEST", "target_artifact_id": "$PR" }
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
Ogni risposta HTTP 201 include un `id` e i riferimenti incrociati.
|
||||||
|
|
||||||
|
### Verifica: Context Explorer → Graph
|
||||||
|
|
||||||
|
Apri `http://localhost:3000/context`, seleziona **REQ-101**, poi tab **Graph** (4° tab):
|
||||||
|
|
||||||
|
- 🟦 **Centro** → REQ-101 (badge **SELECTED**)
|
||||||
|
- 🟣 **Anello interno HOP 1** → TASK-201 (relazione SOLIDA verde «implements»)
|
||||||
|
- ⬜ **Anello esterno HOP 2** → PR#144 (raggiunto via TASK, edge TRATTEGGIATO violetto «generated_from»)
|
||||||
|
- ⬜ **Anello esterno HOP 2** → TEST-41 (raggiunto via PR, edge TRATTEGGIATO azzurro «verifies»)
|
||||||
|
|
||||||
|
**Click sui nodi** nel grafo: il dettaglio cambia e si ricentra sul nuovo artifact (stesso comportamento del picker laterale).
|
||||||
|
MiniMap in basso-dx mostra 3 colori distinti: `indigo = 0, violet = 1, slate = 2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 5 — Aggiungi Deploy + Incidente + Correlazione Sicurezza
|
||||||
|
|
||||||
|
Facciamo un "giorno dopo": il PR va in produzione ma causa un incidente, e documentiamo anche un security finding che blocca il deploy.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BUILD=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{ "project_id": "$PROJ_ID", "type": "build", "external_id": "BUILD-909",
|
||||||
|
"title": "asf-auth:v1.2.0-rc3 build nightly",
|
||||||
|
"status": "passed", "source": "buildkite",
|
||||||
|
"metadata": { "builder": "buildkite-linux-x86-64", "duration_s": 128, "cache_hit_pct": 68 } }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
DEP=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{ "project_id": "$PROJ_ID", "type": "deployment", "external_id": "DEP-12",
|
||||||
|
"title": "asf-auth → prod-west-2 (canary 5%)",
|
||||||
|
"status": "deployed", "source": "argo",
|
||||||
|
"metadata": { "env": "prod", "region": "us-west-2", "canary_pct": 5, "rollback_ref": "BUILD-890" } }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
SEC=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{ "project_id": "$PROJ_ID", "type": "security_finding", "external_id": "SEC-031",
|
||||||
|
"title": "Google OAuth state vulnerable to CSRF (weak random)",
|
||||||
|
"content": "Il parametro 'state' usa secrets.token_urlsafe(8) invece di 32 byte. Possibile replay cross-domain.",
|
||||||
|
"status": "open", "source": "semgrep",
|
||||||
|
"metadata": { "severity": "high", "cvss": 7.8, "detector": "ASF-SEC-203", "confidence": 0.92 } }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
INC=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{ "project_id": "$PROJ_ID", "type": "incident", "external_id": "INC-5",
|
||||||
|
"title": "Login failure spike (canary west-2) — 14% errori 5xx",
|
||||||
|
"content": "Canary deploy asf-auth:v1.2.0-rc3 ha generato picco refresh_token invalid_grant. Rollback a BUILD-890 dopo 7 minuti.",
|
||||||
|
"status": "resolved", "source": "pagerduty",
|
||||||
|
"metadata": { "sev": "sev2", "mttr_min": 14, "users_affected": 820, "pager": "Mario" } }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
echo "BUILD=$BUILD DEP=$DEP SEC=$SEC INC=$INC"
|
||||||
|
|
||||||
|
# Relazioni
|
||||||
|
cat <<JSON | POST /relationships # BUILD generated_from PR
|
||||||
|
{ "relationship_type": "generated_from", "source_artifact_id": "$BUILD", "target_artifact_id": "$PR" }
|
||||||
|
JSON
|
||||||
|
cat <<JSON | POST /relationships # DEP deployed_as BUILD
|
||||||
|
{ "relationship_type": "deployed_as", "source_artifact_id": "$DEP", "target_artifact_id": "$BUILD" }
|
||||||
|
JSON
|
||||||
|
cat <<JSON | POST /relationships # SEC related_to PR
|
||||||
|
{ "relationship_type": "related_to", "source_artifact_id": "$SEC", "target_artifact_id": "$PR" }
|
||||||
|
JSON
|
||||||
|
cat <<JSON | POST /relationships # INC caused_by DEP
|
||||||
|
{ "relationship_type": "caused_by", "source_artifact_id": "$INC", "target_artifact_id": "$DEP" }
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vai su → `/traceability` (Graph globale)
|
||||||
|
|
||||||
|
Apri `http://localhost:3000/traceability`: dovresti vedere un grafo **8 nodi, 7 edges** circa, disposto a layer.
|
||||||
|
- **Inspector** a dx: clicca INC-5 → vedrai che è caused_by DEP-12, che deployed_as BUILD-909, e così via a ritroso fino a REQ-101.
|
||||||
|
- Auto-refresh ogni 5 secondi. MiniMap e controlli sono gli stessi del Context Graph, ma QUI vedi **tutti gli artifact del progetto**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 6 — Lancia il tuo primo Job (AI Interactive)
|
||||||
|
|
||||||
|
> 🎯 **Regola di design ASF §7**: lo stato **permanente** di un Job (status, result, usage, error, worker, timestamps) **vive SEMPRE su MongoDB**.
|
||||||
|
> RabbitMQ viene usato **SOLO come coda di messaggi per il passaggio lavoro**, MAI come database.
|
||||||
|
> Se RabbitMQ cadesse, i job non si perdono: restano `status=queued` o `running` su Mongo e possono essere risottomessi o ripresi. Il Worker scala orizzontalmente senza stato critico in memoria.
|
||||||
|
|
||||||
|
ASF gestisce il lavoro asincrono con **Jobs** su code RabbitMQ DIRECT. 7 code logiche DIRECT già registrate (priorità decrescente):
|
||||||
|
`asf.ai.interactive > asf.ai.background > asf.testing > asf.code_analysis > asf.devops > asf.notifications > asf.indexing`
|
||||||
|
|
||||||
|
Prova con 3 job demo (echo handler integrato nel worker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Job 1 - DevOps
|
||||||
|
cat <<'JSON' | POST /jobs/submit
|
||||||
|
{ "queue": "devops", "job_type": "devops", "title": "deploy staging preflight",
|
||||||
|
"payload": { "service": "asf-auth", "env": "staging", "mode": "preflight" },
|
||||||
|
"priority": 50 }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# Job 2 - Testing
|
||||||
|
cat <<'JSON' | POST /jobs/submit
|
||||||
|
{ "queue": "testing", "job_type": "testing", "title": "regression suite auth",
|
||||||
|
"payload": { "suite": "auth/google_test.py", "cases": 12, "fail-fast": true },
|
||||||
|
"priority": 70 }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# Job 3 - Code Analysis (smell detection)
|
||||||
|
cat <<'JSON' | POST /jobs/submit
|
||||||
|
{ "queue": "code_analysis", "job_type": "code_analysis", "title": "ruff check src/auth",
|
||||||
|
"payload": { "path": "src/auth/oauth2.py", "linters": ["ruff","pyright"], "fail_on_error": true },
|
||||||
|
"priority": 40 }
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
Controlla lo stato in **Jobs List** (sidebar → **Jobs (Activity)** o vai a `http://localhost:3000/jobs`):
|
||||||
|
- Auto-refresh ogni 3 secondi, 6 stat cards in alto (Running / Queued / Completed / Failed)
|
||||||
|
- Clicca una riga → modale **Detail JSON** per vedere `result`, `usage` (cpu_seconds, input/output tokens, cost).
|
||||||
|
- **Enqueue Modal** (top-right) per creare altri job dal frontend.
|
||||||
|
|
||||||
|
Dopo 5-10s gli echo handler (`EchoHandler`) completeranno i jobs → status **COMPLETED** con body `{ echo: …, handled_by: worker_id, timestamp: … }`.
|
||||||
|
|
||||||
|
### Verifica API count_by_status:
|
||||||
|
```bash
|
||||||
|
GET /jobs | python -c '
|
||||||
|
import json,sys
|
||||||
|
from collections import Counter
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
print("Jobs totali:", len(d))
|
||||||
|
for s, c in Counter(j.get("status","?") for j in d).most_common(): print(f" {s:12s} {c}")
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 7 — Code Analysis: Carica Source + Mark Smell come Fixed
|
||||||
|
|
||||||
|
Apri `/code` (sidebar **Code Analysis**). La pagina usa **2 useQuery separate**:
|
||||||
|
1. `type=source_file` (tutti i sorgenti caricati)
|
||||||
|
2. `type=ai_analysis` filtrato client-side `metadata.kind == "code_smell"` (code smell)
|
||||||
|
|
||||||
|
Carichiamo un file Python con 1 smell **ASF-SEC-203** (lo stesso della security finding), poi cliccheremo **Mark as fixed** nel frontend.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SRC=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{ "project_id": "$PROJ_ID", "type": "source_file",
|
||||||
|
"external_id": "SRC-AUTH-OAUTH",
|
||||||
|
"title": "src/auth/oauth2.py",
|
||||||
|
"status": "active", "source": "git",
|
||||||
|
"metadata": { "path": "src/auth/oauth2.py", "language": "python",
|
||||||
|
"loc": 214, "framework": "fastapi+authlib",
|
||||||
|
"lines_of_code": { "python": 190, "docstring": 42 } } }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
SM=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
{ "project_id": "$PROJ_ID", "type": "ai_analysis",
|
||||||
|
"external_id": "CS-031-OAUTH",
|
||||||
|
"title": "CSRF via weak OAuth state token (secrets 8B instead of 32B)",
|
||||||
|
"status": "open", "source": "asf-ai",
|
||||||
|
"metadata": { "kind": "code_smell", "severity": "high", "rule": "ASF-SEC-203",
|
||||||
|
"confidence": 0.91,
|
||||||
|
"source_path": "src/auth/oauth2.py",
|
||||||
|
"lines_to_highlight": [48, 49, 50],
|
||||||
|
"remediation": "Cambia secrets.token_urlsafe(8) → token_urlsafe(32). Aggiungi test con entropy check.",
|
||||||
|
"linter": "asf-static-analyzer v0.3",
|
||||||
|
"affected_lines_count": 3 } }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# Link: smell è generated_from source file
|
||||||
|
cat <<JSON | POST /relationships
|
||||||
|
{ "relationship_type": "generated_from", "source_artifact_id": "$SM", "target_artifact_id": "$SRC" }
|
||||||
|
JSON
|
||||||
|
|
||||||
|
echo "SRC=$SRC SM=$SM"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ora:
|
||||||
|
1. Apri `http://localhost:3000/code`
|
||||||
|
2. Filter **Scope → Smells** (solo smell) oppure **Severity → High**
|
||||||
|
3. Clicca la riga con titolo "CSRF via weak OAuth state token…"
|
||||||
|
4. A dx nella header del DetailView vedi il pulsante verde **Mark as fixed** → click
|
||||||
|
5. Attendi ~300ms (useMutation PATCH `status=fixed`, poi invalidateQueries di 2 cache chiavi `code-source` + `code-smells`)
|
||||||
|
6. Il pulsante sparisce, appare Badge verde **🟢 Resolved**
|
||||||
|
|
||||||
|
Verifica con curl che lo stato è persisted in Mongo:
|
||||||
|
```bash
|
||||||
|
GET /artifacts/$SM | python -c 'import json,sys; d=json.load(sys.stdin); print("status:", d["status"], "| updated:", d["updated_at"])'
|
||||||
|
# → status: fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Step 8 — Usa Ask ASF per interrogare il tuo grafo (AI)
|
||||||
|
|
||||||
|
> 🤖 **ASF AI Gateway: ASTRAZIONE PURA (regola §1)**.
|
||||||
|
> - Frontend + Backend (asf-core) NON conoscono il provider LLM: fanno richiesta all'AI Gateway.
|
||||||
|
> - Nessun accoppiamento diretto a OpenAI / Anthropic / Azure OpenAI / ecc.
|
||||||
|
> - I provider configurabili (già astrazione pronta): **NVIDIA DGX Spark** (on-prem) · OpenAI-compatibili · custom provider.
|
||||||
|
> - Futuri: model routing per task · AI policies per-tenant · usage metering · priority queues · cost tracking.
|
||||||
|
> - Nel MVP seed attuale (nessun provider collegato) viene usato `MockProvider`/fallback text per verificare il ciclo 202 + poll + bolle.
|
||||||
|
|
||||||
|
Sidebar → **Ask ASF (AI)** oppure `http://localhost:3000/ask-asf`.
|
||||||
|
|
||||||
|
Scrivi nel prompt:
|
||||||
|
> "Cosa causa l'incidente INC-5? Qual è il requisito a monte? Elenca i passaggi dal deploy fino al requirement."
|
||||||
|
|
||||||
|
- Il frontend fa **POST /api/v1/ask-asf/query** → HTTP **202 Accepted** (ritorna job_id)
|
||||||
|
- **Polling ogni 2s per max 120s** (useEffect + cleanup on rerender + cancel ref)
|
||||||
|
- Quando `status == COMPLETED`: pickAnswer legge `job.result.{message,answer,summary,response,text}` (ordine fallback)
|
||||||
|
- **JobMeta pills**: queue, job_type, status, timestamps (copy/clear/Enter send, Shift+Enter newline)
|
||||||
|
|
||||||
|
In modalità demo (senza provider LLM collegato) l'AI handler ritorna una risposta fallback, ma il **ciclo di Job + 202 + poll + render bolle è completamente funzionale e verificato**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎁 Bonus: Seed Ricreabile Completo
|
||||||
|
|
||||||
|
Per ricostruire TUTTO in 1 comando (contenuto demo esistente):
|
||||||
|
```bash
|
||||||
|
make seed-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo esegue `bash scripts/seed-demo.sh --clean --jobs` che:
|
||||||
|
1. **DELETE /artifacts/{id}** di tutti gli esistenti (loop safe, bulk non esiste MVP)
|
||||||
|
2. **PATCH CANCEL** di tutti i jobs ancora QUEUED/RUNNING
|
||||||
|
3. POST degli 8 artifact demo (REQ/TASK/PR/BUILD/DEP/TEST/DOC/COMMIT/SEC/INC + 3 Source files + 3 Smells)
|
||||||
|
4. POST delle 9 relazioni demo
|
||||||
|
5. POST 5 jobs echo per code/test/devops/indexing/notifications
|
||||||
|
6. Report finale: counts artifacts by type, graph nodes/edges, jobs status counts
|
||||||
|
|
||||||
|
Per rieseguire senza cancellare niente (append di un nuovo set):
|
||||||
|
```bash
|
||||||
|
make seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Quick Reference API (Cheat Sheet)
|
||||||
|
|
||||||
|
| Endpoint REST | Metodo | Cosa fa |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/v1/organizations` | GET · POST | Lista / Crea tenant |
|
||||||
|
| `/api/v1/organizations/slug/{s}` | GET | Org per slug |
|
||||||
|
| `/api/v1/organizations/{id}` | GET · PATCH | Dettaglio / Aggiorna tenant |
|
||||||
|
| `/api/v1/projects` | GET · POST | Lista / Crea progetto |
|
||||||
|
| `/api/v1/projects/{id}` | GET · PATCH | Dettaglio / Aggiorna progetto |
|
||||||
|
| `/api/v1/artifacts?type=&status=&project_id=&limit=&offset=` | GET | Lista (filtri query) |
|
||||||
|
| `/api/v1/artifacts` | **POST** | Crea artifact (**required: project_id, type, title**) |
|
||||||
|
| `/api/v1/artifacts/{id}` | GET · **PATCH** · DELETE | Dettaglio · Aggiorna (es. `status=fixed`) · Cancella |
|
||||||
|
| `/api/v1/relationships` | **POST** | Crea edge (source/type/target) |
|
||||||
|
| `/api/v1/relationships/{id}` | DELETE | Rimuovi edge |
|
||||||
|
| `/api/v1/relationships/for_artifact/{aid}` | GET | Lista edge in/out di 1 artifact |
|
||||||
|
| `/api/v1/relationships/outgoing/{aid}` · `/incoming/{aid}` | GET | Solo uscenti / entranti |
|
||||||
|
| `/api/v1/traceability/graph?project_id=…` | GET | Nodes + edges per ReactFlow (globale) |
|
||||||
|
| `/api/v1/jobs` | GET | Lista jobs by tenant |
|
||||||
|
| `/api/v1/jobs/submit` | **POST** | Submit job `{queue, job_type, title, payload?, priority?}` |
|
||||||
|
| `/api/v1/jobs/{id}` | GET · PATCH | Dettaglio / Cancella (status=cancelled) |
|
||||||
|
| `/api/v1/overview` | GET | Metriche Dashboard |
|
||||||
|
| `/api/v1/audit?limit=100` | GET | Storico eventi (newest first) |
|
||||||
|
| `/api/v1/ask-asf/query` | **POST** | Submit domanda AI → job con polling 202 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📔 Appendice A. Frontend Shell Enterprise (Bootstrap §9)
|
||||||
|
|
||||||
|
### 14 Voci di Navigazione previste (shell enterprise)
|
||||||
|
|
||||||
|
| # | Route / Sidebar Label | Descrizione | Stato MVP |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `/` Overview | Dashboard aggregata 9 cards | ✅ attiva |
|
||||||
|
| 2 | `/requirements` | Requirements list + 4 filtri (project / status / type / search) | ✅ attiva · 6 artifacts demo |
|
||||||
|
| 3 | `/context` Context Explorer | Picker · 5 tabs: Metadata · Relationships · Related (2-hop) · **Graph 3-anelli** · History timeline | ✅ attiva |
|
||||||
|
| 4 | `/code` Code Analysis | 2 useQuery (source + ai_analysis.kind=code_smell) · 6 filters · 5 StatCards · DetailView con **Mark-as-fixed PATCH** | ✅ attiva |
|
||||||
|
| 5 | `/traceability` | React Flow layered · nodes 21 · edges 9 · Inspector · refetch 5s | ✅ attiva |
|
||||||
|
| 6 | **`/ask-asf` Ask ASF (AI)** | Chat UI bolle user/AI · POST /ask-asf/query 202 + poll 2s max 120 · JobMeta pills | ✅ attiva |
|
||||||
|
| 7 | **`/jobs` Jobs (Activity)** | 6 stats · list · auto-refresh 3s · Modal Enqueue · Detail JSON · Progress bar | ✅ attiva |
|
||||||
|
| 8 | Pipelines | CI/CD pipeline view | ⏭️ placeholder futuro |
|
||||||
|
| 9 | Deployments | Deployment history, env, rollback | ⏭️ placeholder |
|
||||||
|
| 10 | Quality | Quality gates, coverage, lints | ⏭️ placeholder |
|
||||||
|
| 11 | Observability | Logs/metrics/traces explorer | ⏭️ placeholder |
|
||||||
|
| 12 | Integrations | Git / CI / Docs / PagerDuty connectors | ⏭️ placeholder |
|
||||||
|
| 13 | Usage | Tenant AI/job consumption + cost tracking | ⏭️ placeholder |
|
||||||
|
| 14 | Settings | Org / Team / Project config · keys · SSO · members | ⏭️ placeholder |
|
||||||
|
|
||||||
|
### Overview Dashboard — 9 Cards specificate
|
||||||
|
|
||||||
|
La home `/` aggrega metriche cross-project per tenant:
|
||||||
|
|
||||||
|
| Card | Cosa misura | Stato |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Project Health (%) · pesato per requirements coverage / deploy success / security backlog | ✅ attiva (94.5% demo) |
|
||||||
|
| 2 | Requirements count · by status / by type (open/in-progress/done) | ✅ attiva |
|
||||||
|
| 3 | Implementation Coverage · % requirements con ≥1 task linked | ✅ attiva |
|
||||||
|
| 4 | Test Coverage · % PR/deploy con ≥1 test verifies collegato | ✅ attiva |
|
||||||
|
| 5 | Traceability completeness · % artifacts coinvolti in catene Req→Deploy | ✅ attiva |
|
||||||
|
| 6 | Deployments (last 7d) · count + success rate | ✅ attiva |
|
||||||
|
| 7 | Active AI Jobs · queued/running · usage last hour | ✅ attiva |
|
||||||
|
| 8 | Failed Builds (last 7d) | ✅ attiva |
|
||||||
|
| 9 | Security Findings (open) · per severity (critical/high/medium/low) | ✅ attiva |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Appendice B. Non-Functional Requirements (Bootstrap §12-16)
|
||||||
|
|
||||||
|
Linee guida ufficiali del progetto ASF — rispettate in ogni parte del codice:
|
||||||
|
|
||||||
|
### 🔎 Osservabilità (§12)
|
||||||
|
- **Log strutturati JSON**: ogni log include `tenant_id`, `project_id` (se presente), `correlation_id`, `job_id` (se in un job).
|
||||||
|
- **Correlation ID**: propagato **per tutta la distribuzione** (REST → Rabbit → Worker → AI Gateway → risposta). Middleware di correlazione già attivo.
|
||||||
|
- **OpenTelemetry-ready**: punti di hook per tracing distribuito (non MVP, boundary presenti).
|
||||||
|
- **❌ VIETATO loggare**: password, token, API keys, Authorization header, cookie firma, PII (personally identifiable information).
|
||||||
|
|
||||||
|
### 🔒 Sicurezza (§14)
|
||||||
|
1. **Tenant Isolation**: *TUTTE* le query filtrano per `tenant_id` (Mongo). Mai mescolare dati tra tenant.
|
||||||
|
2. **Input Validation**: TUTTI gli endpoint usano Pydantic v2 validato server-side (body, query params, headers).
|
||||||
|
3. **Secrets SOLO in env variables**: **MAI hardcodare** URI MongoDB / Rabbit / API keys nel codice o in file di config commitati. `.env.example` documenta le variabili richieste.
|
||||||
|
4. **Authorization boundaries**: layer Org → Teams → Projects (futuro) preparati per RBAC fine-grained.
|
||||||
|
5. **Secure defaults**: no permessi wildcard default, CORS whitelist minimale, cookie HttpOnly in authn futura.
|
||||||
|
6. **Audit logging**: ogni modifica (create/update/delete) su artifacts/projects/organizations/jobs genera record in `audit_events` con `actor_id` (user) + delta + timestamp.
|
||||||
|
|
||||||
|
### 👩💻 Regole Ingegneristiche (§15 — ASF Engineering Rules)
|
||||||
|
| Regola | Applicazione pratica |
|
||||||
|
|---|---|
|
||||||
|
| **SOLID ove utile** | Service in asf-core/services/* + DI pattern (no singleton globali) |
|
||||||
|
| **Codice semplice > codice intelligente** | No pattern gang-of-four non necessari |
|
||||||
|
| **Typed models everywhere** | Pydantic v2 per input/output endpoint |
|
||||||
|
| **Componenti testabili** | In-memory fakes (FakeJobsCollection / FakeRelService / FakeRepos) invece che Mongo reale per unit test |
|
||||||
|
| **NO premature optimization** | Ottimizza solo dopo misurazione |
|
||||||
|
| **NO microservizi per apparenza** | Monolite pragmatico → servizi reali, non finti. asf-core / asf-context / asf-ai / asf-worker = servizi distinti ma acoplamento debole. |
|
||||||
|
| **NO classi giganti / file 2000 righe** | Split su service/repo/router |
|
||||||
|
| **NO stato globale nascosto** | Dependencies injection, mutable state solo in MongoDB/Rabbit/Redis (Redis non MVP) |
|
||||||
|
| **NO provider logic nel domain layer** | asf-domain è package standalone puro: non importa FastAPI, Motor, Rabbit, OpenAI SDK |
|
||||||
|
|
||||||
|
### 🧪 Testing Strategy (§16)
|
||||||
|
- **pytest** come framework unico (tutti i servizi hanno pytest.ini `asyncio_mode=auto` per test async).
|
||||||
|
- **Test Unit = NO servizi esterni**: Mongo/Rabbit/Redis sostituiti da fakes in-memory. Oggi 40/40 tests VERDI.
|
||||||
|
- **Services testati oggi**: asf-domain 12 · asf-core 6 · asf-worker 8 · asf-ai 7 · asf-context 4.
|
||||||
|
- Copertura domini critica: artifact lifecycle, relationship traversal, job claim atomic, AI provider abstraction, context BFS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Errori Comuni & Soluzioni
|
||||||
|
|
||||||
|
| Errore | Causa | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| **422 `"slug" regex mismatch`** | Org slug con maiuscole / underscore | Usa minuscolo, `-`, 3-60 caratteri, inizia/finisce alfanum |
|
||||||
|
| **422 `"key" regex mismatch`** | Project key non inizia con maiuscola | `^[A-Z][A-Z0-9]{1,19}$` es. `ASF`, `PROJ2`, non `asf` o `1PROJ` |
|
||||||
|
| **422 `project_id` required field missing** | Artifact POST senza `project_id` | Aggiungi `"project_id": "<UUID>"` (tenant from headers, project nel body) |
|
||||||
|
| **422 field `description` / `version` extra_forbidden** | `ArtifactCreate` non ha questi campi | Usa `content=` per corpo lungo, version è solo nel record (non in create) |
|
||||||
|
| **422 `artifact_type invalid value` (es. code_smell)** | Enum `ArtifactType` non contiene code_smell | Usa `type: "ai_analysis"` + `metadata.kind: "code_smell"` (vedi Step 7) |
|
||||||
|
| **422 `source_project_id` extra_forbidden in Relationship** | Non si passano project ids nel body | Rimuovi: validazione server-side da artifact sorgente/destinazione |
|
||||||
|
| **Lista artifacts vuota** | Header `X-ASF-Tenant-Id` mancante | Aggiungi `X-ASF-Tenant-Id: t-demo-003` e `X-ASF-User-Id: u-mario` a tutte le richieste |
|
||||||
|
| **Context Graph vuoto** | Relazioni 0 sul seed corrente | Ricorreggi Step 4 e assicurati di aver POSTed le relationships con gli UUID corretti |
|
||||||
|
| **Jobs non completano** | Worker non in esecuzione | `make start` (--with-worker) · controlla `make status` e tail log |
|
||||||
|
| **TypeScript build fallisce** | Aggiunte imports in usages wrong | `make tsc` diagnostica riga+colonna precise, correggi e rilancia |
|
||||||
|
| **⚠️ Credenziali esposte in repo** | URI Mongo/Rabbit/API keys hardcodate nel codice invece che in `.env` | **MAI committare `.env` nel repo** · tutte le secrets in env variables · `.env.example` pubblico solo i nomi delle variabili |
|
||||||
|
| **⚠️ Jobs status persiste male** | Developer ha salvato lo stato del job in RabbitMQ invece che in Mongo | Regola §7: **Mongo = stato permanente**. Rabbit = solo coda messaggi. Se Rabbit cade, lo stato dei job (queued/running/completed/failed) resta su Mongo. |
|
||||||
|
| **⚠️ Artifacts mixati tra progetti** | Filtro GET `/artifacts` senza `project_id` query param | Aggiungi `&project_id=<UUID>` per vedere solo artifact di 1 progetto specifico · il default mostra TUTTI gli artifact del tenant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Pronti! Checklist Primo Progetto Funzionale
|
||||||
|
|
||||||
|
Segnando una spunta per ogni step raggiungi la parità con la demo seed:
|
||||||
|
|
||||||
|
- [ ] **0. Stack running** (`make status` core=8000 / web=3000 / worker alive)
|
||||||
|
- [ ] **1. Organization creata** (slug OK, API `/slug` returns 200)
|
||||||
|
- [ ] **2. Project creato** (key `ASF` o simile, Lista projects restituisce 1+ rows)
|
||||||
|
- [ ] **3. 4+ artifacts base** (REQ / TASK / PR / TEST)
|
||||||
|
- [ ] **4. 3+ relationships** → **Context Graph** mostra 3 anelli: selected / hop1 / hop2
|
||||||
|
- [ ] **5. Traceability globale** mostra 8+/7+ node/edge (BUILD/DEP/SEC/INC aggiunti)
|
||||||
|
- [ ] **6. 3+ jobs enqueued + COMPLETED** (Jobs page counter "Completed" aumenta)
|
||||||
|
- [ ] **7. Code Analysis: Smell → Mark as fixed → status="fixed" in GET**
|
||||||
|
- [ ] **8. Ask ASF → POST 202 → poll → bolle UI renderizzate**
|
||||||
|
- [ ] **9. `make lint` passa** (40/40 pytest + tsc 0 errors)
|
||||||
|
|
||||||
|
**Fine tutorial.** Da qui puoi: (a) estendere i tipi artifact con `metadata.*` libero, (b) aggiungere relazioni a piacere, (c) scrivere handlers custom nel worker per nuovi job, (d) integrare un vero provider LLM in `asf-ai`.
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# ASF — Guida Primi Passi (Solo Frontend 🎨)
|
||||||
|
|
||||||
|
> **ASF = Azurian Software Factory** · *SaaS Platform multi-tenant AI-native*
|
||||||
|
>
|
||||||
|
> **Motto**: *"From Requirement to Production — everything is contextualized, traceable and AI-accessible."*
|
||||||
|
>
|
||||||
|
> **Principio centrale**: ***Everything is an Artifact*** — requisiti, task, PR, commit, build, test, deploy, incidenti, analisi AI, segnalazioni di sicurezza: tutto è un Artifact ASF collegato da Relazioni semantiche.
|
||||||
|
|
||||||
|
Questa guida usa **solo il browser** (frontend Next.js su `http://localhost:3000`) — zero curl, zero chiamate API a mano.
|
||||||
|
L'obiettivo è insegnarti a **usare ASF dal punto di vista dell'utente finale**: navigare le pagine, filtrare i dati, ispezionare gli artifact nel loro contesto, marcare i code smell come risolti, sottomettere job asincroni da UI, interrogare l'AI sul grafo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Prerequisito (1 comando solo per popolare dati)
|
||||||
|
|
||||||
|
ASF MVP non ha ancora (in questa release) le pagine "Create Organization / Create Project / Create Artifact / Create Relationship via form frontend.
|
||||||
|
Per avere dati reali con cui giocare nel browser, lancia **UNA SOLA VOLTA** questo comando dal terminale (seed demo):
|
||||||
|
```bash
|
||||||
|
cd /root/ASF && make seed-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo comando (wrapper `scripts/seed-demo.sh --clean --jobs`) fa per te:
|
||||||
|
1. Crea Organization demo + Project demo
|
||||||
|
2. Popola 21 artifact (Requirements / Task / PR / Build / Deploy / Test / Docs / Commits / Security findings / Incidents / Source files / Code smells)
|
||||||
|
3. Crea 9 relazioni semantiche
|
||||||
|
4. In coda 5 job echo (devops / testing / code analysis) per provare la pagina Jobs
|
||||||
|
5. Mostra un report finale: `Artifacts totali / Jobs counts / Graph nodes·edges`
|
||||||
|
|
||||||
|
Poi avvia il frontend (se non già attivo):
|
||||||
|
```bash
|
||||||
|
make start
|
||||||
|
```
|
||||||
|
|
||||||
|
Controlla che tutto sia vivo:
|
||||||
|
```bash
|
||||||
|
make status
|
||||||
|
```
|
||||||
|
|
||||||
|
Quando vedi `asf-core :8000 UP · asf-web :3000 UP · asf-worker ALIVE` puoi iniziare.
|
||||||
|
|
||||||
|
**Apri il browser**: `http://localhost:3000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Concetti Chiave da Sapere (prima di cliccare)
|
||||||
|
|
||||||
|
```
|
||||||
|
Organization (il tuo Team) → Project → Artifact ← Relationship → Artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Artifact (17 tipi)**: requirement, epic, story, task, architecture_decision, document, source_file, commit, pull_request, build, test, security_finding, deployment, release, runtime_event, incident, ai_analysis.
|
||||||
|
- **Relationship (12 predicati semantici)**: implements / verifies / generated_from / documented_by / deployed_as / caused_by / related_to …
|
||||||
|
- **Tutto è cliccabile**: un nodo nel grafo → apre il dettaglio Context. Una riga in tabella → apre il dettaglio. Un badge ID → apre il dettaglio.
|
||||||
|
- **Isolamento per Tenant**: il frontend MVP usa dev-trust headers (t-demo-003 / u-mario) già iniettati per te, quindi tutte le pagine mostrano solo i dati del tuo progetto demo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏠 Pagina 1: Overview Dashboard (`/`)
|
||||||
|
|
||||||
|
### Cosa vedi
|
||||||
|
9 cards in alto (da Bootstrap Prompt §9) + numeri veri:
|
||||||
|
|
||||||
|
| Card | Come interpretarla |
|
||||||
|
|---|---|
|
||||||
|
| Project Health % | Media pesata: req coverage + deploy success + security backlog. Oggi: **94.5%** ✅ |
|
||||||
|
| Requirements count · per status | Quanti requisiti ci sono? Aperti / In progress / Chiusi |
|
||||||
|
| Implementation Coverage | Requisiti con ≥1 task collegati |
|
||||||
|
| Test Coverage | % PR / Deploy con ≥1 test che li "verifica" |
|
||||||
|
| Traceability Completeness | Quanti artifact hanno catene complete Req→Task→PR→Test→Deploy |
|
||||||
|
| Deployments last 7d | Quanti deploy · rate di successo |
|
||||||
|
| Active AI Jobs | Coda attiva: queued / running · usage ultima ora |
|
||||||
|
| Failed Builds last 7d | Build fallite |
|
||||||
|
| Security Findings OPEN | Conteggio per severità: CRITICAL/red · HIGH/amber · MEDIUM/brand · LOW/neutral |
|
||||||
|
|
||||||
|
### Cosa fare
|
||||||
|
- **Click sul numero Projects**: ti salta alla pagina **Requirements** (prossima).
|
||||||
|
- **Click su Security Findings**: ti salta a **Code Analysis** per vedere gli smell aperti.
|
||||||
|
- **Click su Jobs**: vai a **Jobs (Activity)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Pagina 2: Requirements (`/requirements`)
|
||||||
|
|
||||||
|
### Header in alto
|
||||||
|
Titolo + 4 strumenti di filtraggio + 1 pulsante:
|
||||||
|
1. **Search field** (ricerca libera per external_id, title, content)
|
||||||
|
2. **Project filter** (tutti o 1 progetto specifico)
|
||||||
|
3. **Status filter** (open / in_progress / done / fixed / resolved)
|
||||||
|
4. **Type filter** (requirement / epic / story / task / document)
|
||||||
|
5. **🆕 New Requirement** — ⚠️ *placeholder UI*: in questa MVP non si può creare requirement da form. Usare il make seed per aggiungere nuovi record.
|
||||||
|
|
||||||
|
### Tabella Sticky Header
|
||||||
|
| Colonna | Cosa mostra |
|
||||||
|
|---|---|
|
||||||
|
| ID | external_id monospace (REQ-103) | badge + badge tipo (REQ / colorato |
|
||||||
|
| Title | titolo lungo artifact |
|
||||||
|
| Status | Badge tone (amber=open · brand=in_progress · green=fixed · blue=active) |
|
||||||
|
| Project | nome progetto NON-UUID (es. "ASF SaaS MVP" invece di UUID) |
|
||||||
|
| Source | product-board / jira / github / pytest / argo / etc. |
|
||||||
|
| Updated | data relativa "2h fa / 3d fa |
|
||||||
|
|
||||||
|
### Cosa fare (esercizio 2 clic)
|
||||||
|
1. Scrivi nella Search: `SSO` → filtra subito i requirements che matchano title/content.
|
||||||
|
2. Status filter = `Open`.
|
||||||
|
3. **Click sulla riga REQ-103**. → **ti porta automaticamente a Context Explorer con REQ-103 selezionato**.
|
||||||
|
Questo è il pattern ASF: **ogni riga è un link diretto al contesto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Pagina 3: Context Explorer (`/context`) — IL CUORE DI ASF
|
||||||
|
|
||||||
|
Questa è la pagina più potente. **5 tab** per 5 modi diversi di vedere lo STESSO artifact selezionato.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Artifact Picker (SX) │ Contenuto Destra (Tabs 1 di 5) │
|
||||||
|
│ Search x project │ │
|
||||||
|
│ ┌────────────────────────────┐│ │
|
||||||
|
│ │ - REQ-103 ← selezionato ││ [Metadata] [Relationships] │
|
||||||
|
│ │ - TASK-101 ││ [Related 2-hop] [Graph ⭐] │
|
||||||
|
│ │ - PR#88 ││ [History timeline] │
|
||||||
|
│ │ - BUILD-910 / DEP-44 ... ││ │
|
||||||
|
│ └────────────────────────────┘│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tab 1 — Metadata (Informazioni statiche
|
||||||
|
- **Title · external_id · type · status · version · source · created_at · updated_at
|
||||||
|
- Griglia 2x2: Version / Source / Created / Updated
|
||||||
|
- Source preview `<pre>` bg-slate-900 mono (se è un source_file o un content lungo)
|
||||||
|
- Side card destra: **Remediation** (per i code smell) + **Traceability** (collegamenti rapidi)
|
||||||
|
|
||||||
|
### Tab 2 — Relationships (Liste dirette)
|
||||||
|
- **Outgoing** (edge uscenti: cosa è questo artifact → altri)
|
||||||
|
- **Incoming** (edge entranti: altri artifact → questo)
|
||||||
|
- Ogni riga mostra badge `relationship_type` + target title + external_id clickable
|
||||||
|
|
||||||
|
### Tab 3 — Related (2-hop)
|
||||||
|
Tutti i vicini a **2 salti** (artifact direttamente collegati + i loro collegati). Utile per:
|
||||||
|
"Se modifico REQ-103, cosa viene influenzato indirettamente?"
|
||||||
|
- 2 gruppi Hop1 e Hop2 con badge di profondità, colore distinto
|
||||||
|
|
||||||
|
### Tab 4 — GRAPH (3 anelli concentrici) ⭐ IL PIÙ BELLO
|
||||||
|
Vedi il tuo artifact **al centro**, poi hop1 → hop2.
|
||||||
|
- **Layout radiale**:
|
||||||
|
- 🟦 Centro → artifact selezionato · bordo indigo + badge SELECTED
|
||||||
|
- 🟣 Anello interno (R1 = 220px di raggio · bordo violetto · pillola **HOP1**
|
||||||
|
- ⬜ Anello esterno (R2 = R1+220+ ~ raggio · bordo slate · pillola **HOP2**)
|
||||||
|
- **Edges a 2 livelli visivi:
|
||||||
|
- Tier 1 (Sel↔Hop1): **SOLIDO 2.1px** colore REL_COLORS
|
||||||
|
- Tier 2 (Hop1↔Hop2): **TRATTEGGIATO strokeDasharray + opacity 0.78
|
||||||
|
- **11 colori per tipo relazione** (legenda in alto a destra: implements/verifies/generated_from/deployed_as/documented_by/related_to/caused_by …)
|
||||||
|
- Controlli: drag per spostare, scroll zoom, pulsanti Controls in basso a SX, **MiniMap** colori per layer (indigo / violet / slate)
|
||||||
|
|
||||||
|
**Clicca QUALSIASI nodo** nel grafo**:
|
||||||
|
→ Context si sposta il centro e ricentra sul nuovo artifact!**
|
||||||
|
|
||||||
|
### Tab 5 — History Timeline
|
||||||
|
Eventi ordinati per data:
|
||||||
|
- 🟣 created_at CREATED viola
|
||||||
|
- 🔵 updated_at UPDATED blu
|
||||||
|
- 🟢 version_bump VERSION verde
|
||||||
|
- 🟠 rel_added / relationship creazione edge arancione
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Esercizio Pratico Context (3 click!)
|
||||||
|
1. Nel Picker SX clicca **SEC-025 (security finding SQL injection severity=high
|
||||||
|
2. Vai in Tab **Related → vedi il nodo SEC-025 e "related_to PR#88
|
||||||
|
3. Vai in Tab **Graph** → vedi SEC al centro · RELATED_TO PR (edge slate · Hop1
|
||||||
|
4. Click PR → **Clicca il nodo PR#88 nel graph** → Ora PR#88 è il nuovo centro
|
||||||
|
5. Tab RELATED del PR → vedi tutti i 2-hop: TASK · BUILD · TEST · DEP · COMMIT · DOC...
|
||||||
|
|
||||||
|
Questo è il "perché del progetto: capire l'impatto a cascata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Pagina 4: Code Analysis (`/code`)
|
||||||
|
|
||||||
|
Analisi di codice + code smells. **2 useQuery separate TanStack (source_file e ai_analysis.kind=code_smell).
|
||||||
|
|
||||||
|
### 6 Filtri (Client Side)
|
||||||
|
1. **Search** libera (title · external_id · remediation · source_path)
|
||||||
|
2. **Scope**: `All / Source / Smells`
|
||||||
|
3. **Language**: `All / python / ts / java`
|
||||||
|
4. **Severity** (solo smells): `Critical / High / Medium / Low`
|
||||||
|
5. **Status**: `All / open / in_progress / fixed / resolved
|
||||||
|
6. **Project**: `All / ASF SaaS MVP`
|
||||||
|
|
||||||
|
### 5 StatCards in alto
|
||||||
|
1. Source files caricati
|
||||||
|
2. Smells trovati
|
||||||
|
3. Criticali (Badge rosso AlertOctagon)
|
||||||
|
4. Alti (Badge amber AlertTriangle)
|
||||||
|
5. Medi+Bassi
|
||||||
|
|
||||||
|
### Tabella Righe
|
||||||
|
Riga = 1 artifact (può essere source_file O smell.
|
||||||
|
|
||||||
|
### DetailView (click sulla riga)
|
||||||
|
Apre pannello info 2 colonne:
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┬──────────────────────────────┐
|
||||||
|
│ <Source preview pre> │ Info 2x2 │
|
||||||
|
│ linguaggio color syntax │ version / source / created / │
|
||||||
|
│ linee evidenziate in ROSE se linesToHighlight │ Remediation suggerita │
|
||||||
|
│ (righe colpevoli di codice) │ Traceability │
|
||||||
|
│ │ Status badge severity: │
|
||||||
|
│ │ │
|
||||||
|
│ │ ⭐ BUTTON "Mark as fixed" │
|
||||||
|
└──────────────────────────────────────┴──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⭐ AZIONE PRINCIPALE: **Mark as Fixed (PATCH status=fixed)
|
||||||
|
Prima devi selezionare un **smell** (source files non hanno il tasto).
|
||||||
|
|
||||||
|
**Procedura (5 step click!):
|
||||||
|
1. Filtra Severity = **High**
|
||||||
|
2. Click la riga **CS-003 pytype ASF-SEC-203 (source consumer.py lines 8-16
|
||||||
|
3. Dettaglio sulla DX → in alto header un bottone verde **Mark as fixed
|
||||||
|
4. Click → Attendi ~300ms
|
||||||
|
5. Sparisce il pulsante → appare un Badge **🟢 Resolved**
|
||||||
|
|
||||||
|
**Cosa succede dietro le quinte? useMutation `markFixedM`:
|
||||||
|
1. `PATCH /artifacts/:id { status: "fixed" }`
|
||||||
|
2. `onSuccess`: **invalidateQueries(["code-source", "code-smells"]
|
||||||
|
3. TanStack ricarica le 2 query (tabella si aggiorna automaticamente
|
||||||
|
|
||||||
|
Verifica visiva: torna nel filtro Status = fixed → il tuo smell è in fixed!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧬 Pagina 5: Traceability Graph (/)
|
||||||
|
**Grafo GLOBALE del progetto intero** (Context Graph solo di 1 artifact.
|
||||||
|
|
||||||
|
### Cosa vedilaid out
|
||||||
|
- Layout layered DAG React Flow
|
||||||
|
- **21 nodes · 9 edges demo seed)
|
||||||
|
- Nodi colorati per tipo artifact type (color map 11 colori)
|
||||||
|
- refetchInterval 5s auto-aggiorna)
|
||||||
|
- Pannello Inspector a DX:
|
||||||
|
- **selezionato: seleziona
|
||||||
|
- **Title · ID · Project
|
||||||
|
- **Relationships dirette
|
||||||
|
|
||||||
|
### Esercizio Trace (2 click!)
|
||||||
|
1. Click il nodo arancione **INC-77 (incidente login failure)
|
||||||
|
2. Guarda Inspector **caused_by DEP-44
|
||||||
|
3. Click DEP-44 → Inspector deployed_as → BUILD → generated_from PR → implements → task → REQ-103 → Questo è il ciclo completo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Pagina 6: Jobs (Activity) (/)
|
||||||
|
TUTTI i job. Auto-refresh ogni **3s** (non devi ricarica la pagina manualmente).
|
||||||
|
|
||||||
|
### 6 StatCards in alto
|
||||||
|
Queued / Running / Completed / Failed / Cancelled / Total
|
||||||
|
|
||||||
|
### Tabella
|
||||||
|
| Colonna | Cosa |
|
||||||
|
|---|---|
|
||||||
|
| Job ID | ID corto UUID+ clickable |
|
||||||
|
| Title | titolo descrittivo |
|
||||||
|
| Queue | code DIRECT pill devops/testing/code_analysis/... |
|
||||||
|
| Status | Badge tone |
|
||||||
|
| Worker | asf-worker-1 o quale macchina |
|
||||||
|
| Priority | 0-100 |
|
||||||
|
| Usage ms | tempo CPU time 42ms |
|
||||||
|
| Cost | USD estimate (demo |
|
||||||
|
| Updated | relativo "4s fa · 1m fa |
|
||||||
|
| Actions | |
|
||||||
|
|
||||||
|
### ⭐ Creare un Job da UI (Form completo) (useMutation submitM + Modal
|
||||||
|
1. In alto a DX click **Enqueue job** → Apre **MODALE DIALOG 720w
|
||||||
|
2. Compila il form (7 campi):
|
||||||
|
a. **Queue* (select): ai.interactive / ai.background / indexing / code_analysis / testing / devops / notifications
|
||||||
|
b. **Job Type** (stesso nome queue)
|
||||||
|
c. **Title** (campo libero)
|
||||||
|
d. Priority** (0-100)
|
||||||
|
e. Correlation ID (opzionale)
|
||||||
|
f. Payload** (editor JSON validato - se JSON invalido → disable button, 422 fallback
|
||||||
|
g. Notes (opzionale)
|
||||||
|
3. Click **Enqueue** (in basso a DX pulsante primario Submit 2 pulsanti Cancel · Submit!)
|
||||||
|
→ Dopo 2 modal si chiude · useMutation:
|
||||||
|
- `onSuccess: 1. POST /jobs/submit
|
||||||
|
- 2. invalidateQueries(["jobs"])
|
||||||
|
- 3. setFilters resetta filtri → Tabella si aggiorna **new job diventa Queued Poi in ~3 secondi lo vede il worker → Running → **Completed** (echo handler demo)
|
||||||
|
|
||||||
|
### Detail Modal (Click sulla riga Job)
|
||||||
|
- **JSON completo del job`:
|
||||||
|
```
|
||||||
|
{ id, title, status, queue, payload, priority, created_at, started_at, completed_at, result, usage, error, worker_id, correlation_id, metadata }
|
||||||
|
```
|
||||||
|
- Progress 0-100:
|
||||||
|
- queued/cancelled: 0%
|
||||||
|
- running = progressivo
|
||||||
|
- completed = 100%, failed 100% rosso
|
||||||
|
- Meta pills Queue / Status
|
||||||
|
- Actions: View similar (precompila la modale Enqueue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Pagina 7: Ask ASF (AI Chat) (`/ask-asf`)
|
||||||
|
|
||||||
|
Chat UI stile ChatGPT.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┬─────────────────────────────────────┐
|
||||||
|
│ Sidebar DX: │ Bolle chat SX │
|
||||||
|
│ · 🔍 Context Scope │ 👤 Utente │
|
||||||
|
│ · (source_context=true │ 🤖 AI Risposta │
|
||||||
|
│ · include_relationships=true │ [box digitando… │
|
||||||
|
│ · max_depth 2 │ │
|
||||||
|
│ · max_nodes 50 │ │
|
||||||
|
│ · send_trace │ Prompt input + enter send + shift enter newline │
|
||||||
|
│ · clear chat │ │
|
||||||
|
└──────────────────────────────┴─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flusso completo (passo per passo utente)
|
||||||
|
1. Scrivi nel prompt:
|
||||||
|
> *"Cosa causa l'incidente INC-77? Traccia la catena fino al requirement originario, e suggerisci 2 azioni di rimozione"*
|
||||||
|
2. **Invia con Enter (Shift+Enter = newline nel testo
|
||||||
|
3. 👤 La tua bolla appare subito (azzurra)
|
||||||
|
4. 202 accepted → gear rotante "In attesa di risposta dall'AI..." (polling every 2s · max 120 itera 2 minuti
|
||||||
|
5. Appena il job status=COMPLETED 🟢
|
||||||
|
6. 🤖 AI Answer: pickAnswer legge `job.result.message/answer/summary/response/text`
|
||||||
|
7. **JobMeta pills**: queue=ai.interactive status=completed timestamp
|
||||||
|
8. **Copy**: icona Copy → clipboard
|
||||||
|
9. **Clear**: icona Clear
|
||||||
|
10. Prompt ancora una domanda diversa
|
||||||
|
|
||||||
|
### MVP attuale
|
||||||
|
Senza provider LLM attivo → **fallback del AI Gatewa.
|
||||||
|
**Tutto il resto è FUNZIONANTE
|
||||||
|
- 202 accettato 2
|
||||||
|
- 02:
|
||||||
|
- : 00 iterato
|
||||||
|
- Risposta renderizzata in bolla
|
||||||
|
- Context sidebar & Scope pills + History multiple domande
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧾 Cheat Sheet: Pagine & Azioni Frontend (Riepilogo)
|
||||||
|
|
||||||
|
| Pagina Route | Come ci arrivi dalla Sidebar | Cosa ci fai |
|
||||||
|
|---|---|---|
|
||||||
|
| `/` | 🏠 Overview | 9 cards KPIs · dashboard generale |
|
||||||
|
| `/settings` | ⚙️ Settings | **Crea Org (slug regex 3-60)** · **Crea Proj (key A-Z 2-20)** · tabelle lista |
|
||||||
|
| `/requirements` | 📋 Requirements | Cerca · filtra · **pulsante "New Requirement" modale form completo** · click riga → Context |
|
||||||
|
| `/context` | 🔍 Context | **pulsante "New Artifact" (17 tipi!)** · **pulsante "Link" (12 rel. types!)** · Ispeziona 5 Tabs · Graph 3 anelli · click nodi ricentra |
|
||||||
|
| `/code` | 💻 Code Analysis | Filtra smells · **Click riga smell → "Mark as fixed" |
|
||||||
|
| `/traceability` | 🧬 Trace | Grafo globale · Click nodo → Inspector |
|
||||||
|
| `/jobs` | 🚀 Jobs (Activity) | **Modale "Enqueue job" form completo** · Detail JSON modal |
|
||||||
|
| `/ask-asf` | 🤖 Ask ASF (AI) | Fai domande sul grafo · 202 + poll · Chat |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Stato Copertura Frontend vs API
|
||||||
|
|
||||||
|
Tutte le operazioni CRUD principali **ora hanno UI** (0 comandi curl necessari):
|
||||||
|
|
||||||
|
| Operazione | UI nel frontend? | Dove la trovi |
|
||||||
|
|---|---|---|
|
||||||
|
| Creare Organization | ✅ | **/settings → "New organization" |
|
||||||
|
| Creare Project | ✅ | **/settings → "New project" |
|
||||||
|
| Creare Requirement | ✅ | **/requirements → pulsante "New Requirement" |
|
||||||
|
| Creare Artifact GENERICO (17 tipi: task, epic, story, commit, source_file, ai_analysis, deployment, incident, security_finding, …) | ✅ | **/context → pulsante **"New Artifact" |
|
||||||
|
| Creare Relazioni tra artifact (12 tipi: implements, verifies, generated_from, documented_by, depends_on, deployed_as, caused_by, related_to, …) | ✅ | **/context → pulsante **"Link"** |
|
||||||
|
| Upload source code smell | ✅ | Context → New Artifact → `type=ai_analysis` + metadata `{"kind":"code_smell","severity":"high"}` |
|
||||||
|
| Mark smell come fisso | ✅ | /code → DetailView → **Mark as fixed** |
|
||||||
|
| Sottomettere Job | ✅ | /jobs → Modale **Enqueue job** completo |
|
||||||
|
| Interrogare AI grafo | ✅ | /ask-asf → Chat UI 202 + poll |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Checklist Primo Progetto Frontend (solo click, ZERO curl)
|
||||||
|
|
||||||
|
- [ ] Stack attivo: `make seed-clean` + `make start` + `make status` → **UP (tutti verdi)
|
||||||
|
- [ ] /settings → **New organization**: fill name=NuovaOrg, slug=nuova-org-001 (3-60 lowercase+dash), plan=Free → **Submit → riga apparsa in tabella Organizations ✅
|
||||||
|
- [ ] /settings → **New project**: fill name="Demo Proj 1", key="DEMO1" (A-Z 2-20), branch=main → Submit → riga apparsa Projects tabella ✅
|
||||||
|
- [ ] /requirements → **pulsante "New Requirement" → fill Title="SSO Login OK", Project=DEMO1, Content="Login with Okta/OAuth2", Status=backlog → Create → apparsa in lista ✅
|
||||||
|
- [ ] /requirements → Cerca "SSO" + click riga → Context
|
||||||
|
- [ ] /context → **New Artifact**: Type=task · Title="Implementa Okta middleware" · External_id="TASK-900" · Project=DEMO1 · Status=todo → Create → selezionata in picker ✅ (17 tipi artifact!)
|
||||||
|
- [ ] /context → **Link** pulsante: Source=REQ (il SSO appena creato) · Type=implements · Target=Task900 (search "Okta" filter) → Create Relationship → Tab "Relationships" mostra implements→ task 🔗
|
||||||
|
- [ ] /context → Graph 3 anelli: hop0 (centro) · hop1 (solid task) · hop2 (dashed se esistevano) → Click nodo task (si ricentra) ✅
|
||||||
|
- [ ] /code → Filtro Severity High → riga CS-003 → click → **Mark as fixed" → Badge 🟢 Resolved ✅
|
||||||
|
- [ ] /traceability → Click nodo INC-77 → Inspector caused_by DEP-44 → Click DEP → chain completa
|
||||||
|
- [ ] /jobs → pulsante **Enqueue job → compila form titolo="Smoke test job", queue=testing, priority=normal → Invia → Attesa 3s → Status queued → Running → **Completed 100% ✅
|
||||||
|
- [ ] Jobs riga completata → Click → JSON modale Detail 100% completati → Usage result
|
||||||
|
- [ ] /ask-asf → Invia domanda: "Quale task implementa il requirement SSO?" → Attendi 202+poll → bolle user e AI → Copy clipboard ✅
|
||||||
|
- [ ] `make tsc` → 0 errors
|
||||||
|
- [ ] `make test` → 12+6+8+7+4 = 37/37 ✅ GREEN
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: asf-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-asfadmin}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-asfadmin123}
|
||||||
|
MONGO_INITDB_DATABASE: ${MONGO_DB:-asf}
|
||||||
|
volumes:
|
||||||
|
- asf-mongodb-data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3.13-management
|
||||||
|
container_name: asf-rabbitmq
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5672:5672"
|
||||||
|
- "15672:15672"
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-asf}
|
||||||
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-asf123}
|
||||||
|
volumes:
|
||||||
|
- asf-rabbitmq-data:/var/lib/rabbitmq
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: asf-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-asfredis123}"]
|
||||||
|
volumes:
|
||||||
|
- asf-redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-asfredis123}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: asf-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-asfadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-asfadmin123}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- asf-minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
asf-mongodb-data:
|
||||||
|
asf-rabbitmq-data:
|
||||||
|
asf-redis-data:
|
||||||
|
asf-minio-data:
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ADR-0001 — Monorepo Structure for ASF
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-09-07
|
||||||
|
- **Deciders:** ASF Architecture Team
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Azurian Software Factory comprises several logical components:
|
||||||
|
a shared domain model, an event schema package, connectors, multiple backend services
|
||||||
|
(core, context, ai, worker) and a Next.js web frontend.
|
||||||
|
|
||||||
|
We must choose a repository organization that balances:
|
||||||
|
- Strong cohesion for shared types / contracts
|
||||||
|
- Independent deployability of services
|
||||||
|
- Local developer ergonomics
|
||||||
|
- Clear service boundaries
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use a **single monorepo** with the following top-level layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
ASF/
|
||||||
|
├── frontend/asf-web/ # Next.js SaaS UI
|
||||||
|
├── services/ # Deployable Python services
|
||||||
|
│ ├── asf-core/
|
||||||
|
│ ├── asf-context/
|
||||||
|
│ ├── asf-ai/
|
||||||
|
│ └── asf-worker/
|
||||||
|
├── packages/ # Shared installable Python packages
|
||||||
|
│ ├── asf-domain/
|
||||||
|
│ ├── asf-events/
|
||||||
|
│ └── asf-connectors/
|
||||||
|
├── docs/ # Architecture, ADRs, API docs
|
||||||
|
├── infrastructure/ # Dockerfiles, scripts
|
||||||
|
└── tests/ # Cross-service integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared packages are installed in editable mode (`pip install -e`) when
|
||||||
|
developing locally.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Shared domain models and event schemas live in one place — no drift.
|
||||||
|
- Refactors that cross service boundaries are done atomically in one PR.
|
||||||
|
- Local `docker-compose` up brings up the whole stack trivially.
|
||||||
|
- Search / navigation works across the whole product.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- CI pipelines must be careful to only rebuild what changed.
|
||||||
|
- Service release tagging must be explicit (single git log, many artefacts).
|
||||||
|
|
||||||
|
### Risks / Mitigations
|
||||||
|
- Accidental tight coupling between services → mitigated by code review,
|
||||||
|
AGENT.md rules, and keeping services import only from `packages/*`, never
|
||||||
|
from each other.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ADR-0002 — MongoDB as Primary Persistence
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-09-07
|
||||||
|
- **Deciders:** ASF Architecture Team
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ASF requires a primary store for Organizations, Projects, Artifacts,
|
||||||
|
ArtifactRelationships, Jobs, AuditEvents and similar tenant-scoped records.
|
||||||
|
|
||||||
|
Key properties required:
|
||||||
|
- Flexible schema: the generic `Artifact` model needs extensible `metadata`.
|
||||||
|
- Document-level locality: an Artifact document bundles relationships list,
|
||||||
|
metadata and version.
|
||||||
|
- Horizontal scaling and multi-tenancy (every query is scoped by `tenant_id`).
|
||||||
|
- Async/await Python driver support.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **MongoDB 7.x** as the only primary operational database.
|
||||||
|
|
||||||
|
Use compound indexes prefixed with `tenant_id` for all tenant-owned
|
||||||
|
collections. Collections are intentionally few:
|
||||||
|
- `organizations`
|
||||||
|
- `users`
|
||||||
|
- `teams`
|
||||||
|
- `projects`
|
||||||
|
- `artifacts`
|
||||||
|
- `artifact_relationships`
|
||||||
|
- `integrations`
|
||||||
|
- `jobs`
|
||||||
|
- `ai_executions`
|
||||||
|
- `audit_events`
|
||||||
|
|
||||||
|
**We do NOT** create a collection per artifact type. Every artifact type
|
||||||
|
(requirement, task, commit, test, deployment…) lives in the single
|
||||||
|
`artifacts` collection discriminated by `type`.
|
||||||
|
|
||||||
|
MinIO holds large binaries / reports; MongoDB stores the object reference
|
||||||
|
and metadata.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Flexible `metadata` on Artifacts matches the "Everything is an Artifact"
|
||||||
|
principle without schema migrations for every new artifact subtype.
|
||||||
|
- Compound `(tenant_id, project_id, …)` indexes give fast tenant-scoped
|
||||||
|
reads and guarantee isolation at query time.
|
||||||
|
- Native async driver (`motor`) integrates naturally with FastAPI.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- No foreign-key constraints across collections — referential integrity is
|
||||||
|
an application responsibility (mitigated via service-layer validations
|
||||||
|
and tests).
|
||||||
|
- Relationship graph traversal requires multiple queries or the
|
||||||
|
`$graphLookup` aggregation stage. The schema is intentionally designed
|
||||||
|
so that a future graph-database projection is possible without rewriting
|
||||||
|
domain logic.
|
||||||
|
|
||||||
|
### Follow-up
|
||||||
|
- ADR for graph-database introduction once traversal patterns stabilize.
|
||||||
|
- ADR for vector / embedding store (Semantic Context Engine phase).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# ADR-0003 — RabbitMQ as Event and Job Backbone
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-09-07
|
||||||
|
- **Deciders:** ASF Architecture Team
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ASF has two asynchronous messaging needs:
|
||||||
|
1. **Domain events** (facts that happened — `artifact.created`,
|
||||||
|
`deployment.completed`, …) used for decoupled side effects.
|
||||||
|
2. **Asynchronous jobs** (AI inference, indexing, code analysis, testing)
|
||||||
|
that need separate queues with priorities and horizontal workers.
|
||||||
|
|
||||||
|
A single backbone that handles both patterns is preferred to reduce
|
||||||
|
infrastructure surface.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **RabbitMQ 3.13+** as the sole event/job backbone.
|
||||||
|
|
||||||
|
### Logical queues
|
||||||
|
|
||||||
|
| Queue | Purpose | Priority |
|
||||||
|
|------------------------|----------------------------------------|----------|
|
||||||
|
| `asf.ai.interactive` | Latency-sensitive user-facing AI work | High |
|
||||||
|
| `asf.ai.background` | Batch / low-priority AI work | Normal |
|
||||||
|
| `asf.indexing` | Repository / document indexing | Low |
|
||||||
|
| `asf.code_analysis` | Static analysis tasks | Normal |
|
||||||
|
| `asf.testing` | Test execution requests | Normal |
|
||||||
|
| `asf.devops` | Deployment / CI orchestration | Normal |
|
||||||
|
| `asf.notifications` | Emails / webhooks / in-app notif. | Normal |
|
||||||
|
|
||||||
|
### Source of truth
|
||||||
|
RabbitMQ delivers work. **Persistent job state** (status, timestamps,
|
||||||
|
usage, error, correlation) lives in MongoDB `jobs` collection. Workers
|
||||||
|
update Mongo as they progress through `queued → running → completed|failed`.
|
||||||
|
|
||||||
|
RabbitMQ is **never** the permanent system of record for job state.
|
||||||
|
|
||||||
|
### Event format
|
||||||
|
Every event message contains:
|
||||||
|
```
|
||||||
|
event_id, event_type, timestamp, tenant_id, project_id?,
|
||||||
|
correlation_id, causation_id?, payload
|
||||||
|
```
|
||||||
|
|
||||||
|
Large payloads / binaries go to MinIO; the message carries only a
|
||||||
|
reference.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- One broker, well-understood routing / priority / TTL / DLX semantics.
|
||||||
|
- Workers are stateless, horizontally scalable processes.
|
||||||
|
- At-least-once delivery matches domain; idempotency keys on workers
|
||||||
|
prevent duplicate side-effects.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Exactly-once is not provided — consumers must be idempotent.
|
||||||
|
- Operational complexity of a broker (mitigated by managed services in prod).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR-0004 — AI Gateway Abstraction Layer
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-09-07
|
||||||
|
- **Deciders:** ASF Architecture Team
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ASF requires LLM capabilities for:
|
||||||
|
- Code analysis
|
||||||
|
- Requirement decomposition
|
||||||
|
- Test generation
|
||||||
|
- Context-aware chat ("Ask ASF")
|
||||||
|
- Incident analysis
|
||||||
|
|
||||||
|
Target inference backends include on-prem DGX Spark, OpenAI-compatible
|
||||||
|
local APIs, commercial providers, and customer-hosted endpoints.
|
||||||
|
|
||||||
|
Tenants may have different AI policies (allowed models, budgets,
|
||||||
|
geographic restrictions).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce an **ASF AI Gateway** service (`services/asf-ai/`) with a
|
||||||
|
stable Python interface.
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
Business code requests **capabilities**, never models:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AIGateway(Protocol):
|
||||||
|
async def analyze_code(self, request: CodeAnalysisRequest) -> AnalysisResult: ...
|
||||||
|
async def generate_tests(self, request: TestGenerationRequest) -> GeneratedTests: ...
|
||||||
|
async def chat(self, request: ChatRequest, ctx: AIContext) -> ChatResponse: ...
|
||||||
|
async def decompose_requirement(self, req: RequirementDecomposeRequest) -> Decomposition: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider adapters implement the gateway. Model selection, routing,
|
||||||
|
priority queuing, usage metering, cost tracking and tenant AI policy
|
||||||
|
live inside the gateway — not in callers.
|
||||||
|
|
||||||
|
### Bootstrap
|
||||||
|
|
||||||
|
The first implementation ships with a `MockProvider` that returns
|
||||||
|
deterministic canned responses with realistic latencies. Real providers
|
||||||
|
(OpenAI-compatible, NVIDIA, etc.) are added as adapters later without
|
||||||
|
touching domain/business code.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- No LLM provider lock-in — swap / add adapters freely.
|
||||||
|
- Usage and cost tracking centralized.
|
||||||
|
- Tenant-level policy enforceable at one boundary.
|
||||||
|
- Tests for business logic run fast against the mock — no external API
|
||||||
|
calls, no flakiness.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- One more abstraction — the initial API surface must be designed
|
||||||
|
carefully so future providers truly fit.
|
||||||
|
|
||||||
|
### Mitigation
|
||||||
|
- Provider adapters are written early for at least two real providers,
|
||||||
|
to validate the abstraction before domain code depends heavily on it.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# ASF Architecture Overview
|
||||||
|
|
||||||
|
> Metafora "**Ufficio Postale di ASF**":
|
||||||
|
>
|
||||||
|
> Ogni informazione che entra o esce da ASF è una **busta (Artifact)**.
|
||||||
|
> Le **Relazioni** sono gli indirizzi che legano una busta all'altra.
|
||||||
|
> RabbitMQ è il **carteggio** (posta interna), MongoDB l'**archivio**,
|
||||||
|
> l'AI Gateway è l'**ufficio analisti** che legge le buste e risponde,
|
||||||
|
> il Context Engine è l'**impiegato che raccoglie il fascicolo completo**
|
||||||
|
> prima di passarlo a un analista.
|
||||||
|
|
||||||
|
## Logical Diagram (ASCII)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ asf-web (Next.js) │
|
||||||
|
│ Shell · Overview · Context Explorer · │
|
||||||
|
│ Traceability (React Flow) · Ask ASF chat │
|
||||||
|
└────────────────────┬────────────────────────┘
|
||||||
|
│ HTTPS /api/v1
|
||||||
|
┌────────────────────▼────────────────────────┐
|
||||||
|
│ asf-core (FastAPI REST API) │
|
||||||
|
│ Orgs · Projects · Artifacts · Relations · │
|
||||||
|
│ Jobs CRUD · Audit · Auth boundary │
|
||||||
|
└──────┬───────────────┬───────────┬───────────┘
|
||||||
|
│ │ │
|
||||||
|
(writes/reads)│ (events) │ (queries)│
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────┐ ┌────────────┐ ┌───────────────┐
|
||||||
|
│ MongoDB │ │ RabbitMQ │ │ asf-context │
|
||||||
|
│ (archivio) │ │ (carteggio)│ │ (fascicolatore)│
|
||||||
|
└─────────────┘ └──────┬─────┘ └───────┬───────┘
|
||||||
|
│ │
|
||||||
|
(jobs) │ │ assemble context
|
||||||
|
▼ │
|
||||||
|
┌───────────────────┐ │
|
||||||
|
│ asf-worker │ │
|
||||||
|
│ (addetti alle poste)│ │
|
||||||
|
│ consumers: │ │
|
||||||
|
│ ai.*, indexing, │ │
|
||||||
|
│ testing, devops │ │
|
||||||
|
└─────────┬─────────┘ │
|
||||||
|
│ │
|
||||||
|
(inference reqs) │
|
||||||
|
▼ │
|
||||||
|
┌───────────────────┐ │
|
||||||
|
│ asf-ai │◄──────┘
|
||||||
|
│ AI Gateway │
|
||||||
|
│ (ufficio analisti)│
|
||||||
|
│ ├─ MockProvider │
|
||||||
|
│ ├─ model routing │
|
||||||
|
│ └─ usage/cost │
|
||||||
|
└─────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ Provider adapters (future): │
|
||||||
|
│ · NVIDIA DGX / OpenAI compat │
|
||||||
|
│ · External providers │
|
||||||
|
└───────────────────────────────┘
|
||||||
|
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Shared packages (imported by all services): │
|
||||||
|
│ · asf-domain → Pydantic models (Artifact, Job, Org, ...) │
|
||||||
|
│ · asf-events → Event schemas (RabbitMQ payload contracts) │
|
||||||
|
│ · asf-connectors → Integration interfaces (GitHub, Jira, ...) │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Supporting infrastructure │
|
||||||
|
│ · Redis (cache / locks) │
|
||||||
|
│ · MinIO (large blobs) │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow — end-to-end (Example: "Crea un Requirement")
|
||||||
|
|
||||||
|
1. User POST `/api/v1/projects/{p_id}/artifacts` (type=`requirement`)
|
||||||
|
via **asf-web** → **asf-core**.
|
||||||
|
2. **asf-core** validates tenant context, writes to MongoDB `artifacts`,
|
||||||
|
emits `artifact.created` event to RabbitMQ, returns `201`.
|
||||||
|
3. **asf-worker** picks up `artifact.created` from the events exchange.
|
||||||
|
4. Worker optionally submits `ai.inference.requested` for "enrich
|
||||||
|
requirement" to `asf.ai.background` queue.
|
||||||
|
5. Another worker calls **asf-ai** gateway → provider → returns
|
||||||
|
enrichment. Updates `artifact.metadata.enriched = true`, writes an
|
||||||
|
`artifact.updated` event, records an `AIExecution` in MongoDB.
|
||||||
|
6. UI polls / SSE receives update; Context Explorer now shows both the
|
||||||
|
Artifact AND its enrichment trace.
|
||||||
|
|
||||||
|
## Tenancy Boundary
|
||||||
|
|
||||||
|
```
|
||||||
|
Organization (tenant_id == organization_id in bootstrap)
|
||||||
|
│
|
||||||
|
├── Team[]
|
||||||
|
└── Project[]
|
||||||
|
└── Artifact[] + ArtifactRelationship[] + Job[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule:** Ogni query su dati tenant-owned è filtrata da `tenant_id`.
|
||||||
|
Il `tenant_id` non viene mai accettato dal client direttamente ma
|
||||||
|
deriva dal contesto di autenticazione validato.
|
||||||
|
|
||||||
|
## Extensibility Points
|
||||||
|
|
||||||
|
| Concern | Where | Future Hook |
|
||||||
|
|-----------------------|----------------------------|--------------------------------|
|
||||||
|
| Semantic search / RAG | asf-context | Vector DB (pgvector / Qdrant) |
|
||||||
|
| Graph traversal | asf-context (Mongo $lookup)| Graph DB projection |
|
||||||
|
| Auth / SSO | asf-core middleware | OIDC / SAML adapters |
|
||||||
|
| Billing / metering | asf-core + asf-ai usage | Subscription service |
|
||||||
|
| External providers | asf-connectors + AI Gateway| Per-system adapter |
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# --- ASF Web (Next.js) ---
|
||||||
|
NEXT_PUBLIC_ASF_CORE_URL=http://localhost:8000
|
||||||
|
NEXT_PUBLIC_APP_NAME=Azurian Software Factory
|
||||||
|
NEXT_PUBLIC_APP_ENV=development
|
||||||
|
|
||||||
|
# --- Dev trust headers (bootstrap only) ---
|
||||||
|
# These are sent to asf-core so the dev-mode trust-header path injects tenant context.
|
||||||
|
# Remove in production and use real auth (JWT / session).
|
||||||
|
NEXT_PUBLIC_DEV_TENANT_ID=00000000-0000-0000-0000-000000000001
|
||||||
|
NEXT_PUBLIC_DEV_USER_ID=00000000-0000-0000-0000-000000000002
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Bot, Cpu, Zap, ShieldCheck } from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { MetricCard } from "@/components/ui/MetricCard";
|
||||||
|
|
||||||
|
export default function AiAgentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<Bot className="w-6 h-6 text-accent" />
|
||||||
|
AI Agents
|
||||||
|
</h1>
|
||||||
|
<Badge tone="violet">α · Alpha</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
ASF AI Gateway — provider-agnostic inference, policy routing and usage metering. Interfaces are ready, runtime behaviors are stabilizing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone="brand"><Cpu className="w-3 h-3" /> Gateway abstraction OK</Badge>
|
||||||
|
<Badge tone="green"><ShieldCheck className="w-3 h-3" /> No provider lock-in</Badge>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<MetricCard label="Models routable" value={6} tone="violet" icon={<Bot className="w-5 h-5" />} subtitle="NVIDIA DGX · OpenAI compat · local" />
|
||||||
|
<MetricCard label="Active AI jobs" value={5} tone="brand" icon={<Zap className="w-5 h-5" />} delta={2.1} />
|
||||||
|
<MetricCard label="Tenant policies" value={2} tone="green" icon={<ShieldCheck className="w-5 h-5" />} subtitle="Cost ceilings, model allowlists" />
|
||||||
|
<MetricCard label="Queued prompts" value={12} tone="amber" icon={<Cpu className="w-5 h-5" />} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Agent registry</CardTitle>
|
||||||
|
<Badge tone="neutral">0 active · 5 templates</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<EmptyState
|
||||||
|
title="No custom agents yet"
|
||||||
|
description="Create a reusable AI agent by selecting a model, system prompt, tools and a cost policy. Templates below are ready to enable."
|
||||||
|
icon={Bot}
|
||||||
|
actionLabel="New AI agent"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 divide-y divide-slate-100 border border-slate-100 rounded-lg overflow-hidden">
|
||||||
|
{[
|
||||||
|
{ name: "Traceability Analyst", desc: "Recommends missing links across the artifact graph.", model: "routing default", status: "queued" },
|
||||||
|
{ name: "Test Generator", desc: "Generates unit tests for a selected Task artifact.", model: "code-model", status: "running" },
|
||||||
|
{ name: "Incident Responder", desc: "Traces an incident backwards to its offending commit/PR.", model: "reasoning-model", status: "queued" },
|
||||||
|
{ name: "Requirement Cleric", desc: "Refines ambiguous requirements and proposes acceptance criteria.", model: "reasoning-model", status: "completed" },
|
||||||
|
{ name: "Impact Predictor", desc: "Estimates blast radius for a proposed requirement change.", model: "routing default", status: "queued" },
|
||||||
|
].map((a) => (
|
||||||
|
<div key={a.name} className="flex items-center gap-3 px-4 py-3 hover:bg-slate-50">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-violet-50 border border-violet-100 grid place-items-center text-violet-600 shrink-0">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-800">{a.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{a.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral" className="hidden sm:inline-flex asf-monospace">{a.model}</Badge>
|
||||||
|
<JobStatusBadge status={a.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI Gateway</CardTitle>
|
||||||
|
<Badge tone="violet">Interfaces</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-3 text-sm">
|
||||||
|
<div className="p-3 rounded-lg border border-violet-100 bg-violet-50/60">
|
||||||
|
<div className="font-semibold text-violet-900 flex items-center gap-1.5">
|
||||||
|
<Zap className="w-4 h-4" /> Provider adapter pattern
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-violet-800/80 mt-1 leading-relaxed">
|
||||||
|
Business logic depends on <code className="bg-white/70 px-1 rounded">asf-ai</code> interfaces only. Real providers plug in without touching domain code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-xs">
|
||||||
|
{[
|
||||||
|
"Model routing by task + tenant policy",
|
||||||
|
"Usage metering (tokens, GPU-s, $)",
|
||||||
|
"Priority queues + cost ceilings",
|
||||||
|
"Audit log of every inference",
|
||||||
|
].map((t) => (
|
||||||
|
<li key={t} className="flex items-start gap-2 text-slate-600">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-emerald-600 mt-0.5 shrink-0" />
|
||||||
|
{t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="pt-3 border-t border-slate-100 text-xs text-slate-500">
|
||||||
|
Hint: use <span className="asf-chip font-mono">⌘ K</span> → Ask ASF to try the interactive query interface.
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef, useState, useEffect, useMemo } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
ChevronRight,
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
GitBranch,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { askAsf, jobs, projects, artifacts as apiArtifacts } from "@/lib/api";
|
||||||
|
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
|
||||||
|
import type { Job, JobStatus, Artifact, Project } from "@/lib/types";
|
||||||
|
|
||||||
|
type MessageRole = "user" | "assistant" | "system";
|
||||||
|
type ChatStatus = "idle" | "queued" | "running" | "completed" | "failed" | "cancelled";
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: MessageRole;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
status?: ChatStatus;
|
||||||
|
jobId?: string;
|
||||||
|
job?: Job | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTIONS = [
|
||||||
|
"Which services implement REQ-103 Requisito RBAC?",
|
||||||
|
"Summarize the current deployment chain from PR to PROD",
|
||||||
|
"What is the traceability score of project ASF Bootstrap?",
|
||||||
|
"List all tests that verify PR#88 feat(rbac)",
|
||||||
|
"How healthy is my project last 24h?",
|
||||||
|
];
|
||||||
|
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
const MAX_POLLS = 120;
|
||||||
|
|
||||||
|
const statusTone: Record<Exclude<ChatStatus, "idle">, "brand" | "amber" | "green" | "red" | "neutral"> = {
|
||||||
|
queued: "neutral",
|
||||||
|
running: "brand",
|
||||||
|
completed: "green",
|
||||||
|
failed: "red",
|
||||||
|
cancelled: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AskAsfPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [projectId, setProjectId] = useState<string>("all");
|
||||||
|
const [ctxIds, setCtxIds] = useState<string[]>([]);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [pendingJob, setPendingJob] = useState<{ jobId: string; msgId: string; polls: number } | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string>("");
|
||||||
|
|
||||||
|
const projectsQ = useMemo(() => ({ isFetched: false, data: [] as Project[], error: null }), []);
|
||||||
|
const [projectsList, setProjectsList] = useState<Project[]>([]);
|
||||||
|
const [artifactsList, setArtifactsList] = useState<Artifact[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [ps, as] = await Promise.all([
|
||||||
|
projects.list({ limit: 50 }),
|
||||||
|
apiArtifacts.list({ limit: 100 }),
|
||||||
|
]);
|
||||||
|
setProjectsList(ps);
|
||||||
|
setArtifactsList(as);
|
||||||
|
if (ps.length === 1) setProjectId(String(ps[0].id));
|
||||||
|
} catch {
|
||||||
|
// ignore offline fallback: demo suggestions
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||||
|
}, [messages, pendingJob]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingJob) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let polls = pendingJob.polls;
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
polls += 1;
|
||||||
|
try {
|
||||||
|
const j = await jobs.get(pendingJob.jobId);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === pendingJob.msgId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
status: (j.status as ChatStatus) ?? m.status,
|
||||||
|
job: j,
|
||||||
|
content: pickAnswer(j, m.content),
|
||||||
|
error: j.status === "failed" ? j.error ?? "AI job failed" : undefined,
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
void qc.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined);
|
||||||
|
if (j.status === "completed" || j.status === "failed" || j.status === "cancelled" || polls > MAX_POLLS) {
|
||||||
|
if (polls > MAX_POLLS) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === pendingJob.msgId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
status: "failed",
|
||||||
|
error: "Timed out waiting for AI answer (max 240s).",
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPendingJob(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingJob({ ...pendingJob, polls });
|
||||||
|
} catch {
|
||||||
|
// keep retrying
|
||||||
|
}
|
||||||
|
if (!cancelled) setTimeout(tick, POLL_MS);
|
||||||
|
};
|
||||||
|
const t = setTimeout(tick, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [pendingJob, qc]);
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => SUGGESTIONS, []);
|
||||||
|
|
||||||
|
const toggleCtxArtifact = (id: string) => {
|
||||||
|
setCtxIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const q = input.trim();
|
||||||
|
if (!q || pendingJob) return;
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: cryptoId(),
|
||||||
|
role: "user",
|
||||||
|
content: q,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const assistantMsgId = cryptoId();
|
||||||
|
const assistantMsg: ChatMessage = {
|
||||||
|
id: assistantMsgId,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Thinking…",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: "queued",
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await askAsf.query({
|
||||||
|
question: q,
|
||||||
|
project_id: projectId !== "all" ? projectId : undefined,
|
||||||
|
context_artifact_ids: ctxIds.length ? ctxIds : undefined,
|
||||||
|
});
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId ? { ...m, jobId: res.job_id, status: "queued" } : m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setPendingJob({ jobId: res.job_id, msgId: assistantMsgId, polls: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
status: "failed",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to enquire ASF",
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (id: string, text: string) => {
|
||||||
|
void navigator.clipboard?.writeText(text).then(() => {
|
||||||
|
setCopied(id);
|
||||||
|
setTimeout(() => setCopied(""), 1800);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col min-h-0">
|
||||||
|
<header className="px-6 lg:px-8 pt-6 pb-4">
|
||||||
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-accent to-brand-600 shadow-soft grid place-items-center text-white shrink-0">
|
||||||
|
<Sparkles className="w-5.5 h-5.5 w-[22px] h-[22px]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
|
||||||
|
Ask ASF
|
||||||
|
<Badge tone="green" className="uppercase text-[10px] tracking-wide">
|
||||||
|
AI Live
|
||||||
|
</Badge>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 max-w-2xl">
|
||||||
|
Natural-language interface to the ASF graph. Questions are enqueued as{" "}
|
||||||
|
<span className="asf-monospace text-slate-700 bg-slate-100 rounded px-1">ai_interactive</span> jobs and
|
||||||
|
answered by the ASF Worker calling the asf-ai Gateway.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||||
|
<Card className="lg:col-span-4 xl:col-span-3 flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="!text-sm">Context & Scope</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="overflow-auto flex-1 min-h-0 space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Project scope</label>
|
||||||
|
<select
|
||||||
|
className="asf-input text-sm"
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
disabled={!!pendingJob}
|
||||||
|
>
|
||||||
|
<option value="all">All projects (tenant-wide)</option>
|
||||||
|
{projectsList.map((p) => (
|
||||||
|
<option key={p.id} value={String(p.id)}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="block text-xs font-medium text-slate-500">Anchor artifacts</label>
|
||||||
|
{ctxIds.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] text-slate-400 hover:text-slate-600"
|
||||||
|
onClick={() => setCtxIds([])}
|
||||||
|
>
|
||||||
|
Clear ({ctxIds.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-60 overflow-auto divide-y divide-slate-100 border border-slate-100 rounded-lg text-xs">
|
||||||
|
{artifactsList.length === 0 && (
|
||||||
|
<li className="p-3 text-slate-400">No artifacts loaded yet.</li>
|
||||||
|
)}
|
||||||
|
{artifactsList.map((a) => {
|
||||||
|
const on = ctxIds.includes(String(a.id));
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors",
|
||||||
|
on ? "bg-brand-50/60" : "hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleCtxArtifact(String(a.id))}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 rounded border grid place-items-center shrink-0",
|
||||||
|
on ? "bg-brand-500 border-brand-500 text-white" : "border-slate-300 bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{on && <Check className="w-2.5 h-2.5" />}
|
||||||
|
</span>
|
||||||
|
<ArtifactTypeBadge type={a.type} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-slate-700 font-medium">
|
||||||
|
{a.external_id ?? shortId(a.id)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-slate-500">{truncate(a.title, 44)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-100">
|
||||||
|
<p className="text-xs font-semibold text-slate-500 mb-2 uppercase tracking-wide">Try asking</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
disabled={!!pendingJob}
|
||||||
|
onClick={() => {
|
||||||
|
setInput(s);
|
||||||
|
setTimeout(() => handleSubmit(), 0);
|
||||||
|
}}
|
||||||
|
className="w-full text-left text-xs p-2 rounded-md border border-slate-100 hover:border-brand-200 hover:bg-brand-50/30 text-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-3 h-3 text-brand-500 shrink-0" />
|
||||||
|
<span className="truncate">{s}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-8 xl:col-span-9 flex flex-col min-h-0">
|
||||||
|
<CardHeader className="flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="!text-sm flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-4 h-4 text-slate-400" />
|
||||||
|
Conversation
|
||||||
|
{pendingJob && (
|
||||||
|
<Badge tone={statusTone[(jobsStatusByJobId(pendingJob.jobId, messages) as Exclude<ChatStatus, "idle">) ?? "running"]} className="animate-pulse">
|
||||||
|
<Loader2 className="w-3 h-3 inline mr-1 animate-spin" />
|
||||||
|
Processing…
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={messages.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setMessages([]);
|
||||||
|
setPendingJob(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 min-h-0 overflow-auto px-5 py-4 space-y-5 bg-slate-50/40 border-y border-slate-100"
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<EmptyState
|
||||||
|
title="Ask anything about your ASF graph"
|
||||||
|
description="The AI worker will reason over projects, artifacts, relationships and jobs. Start by typing a question above or pick a suggestion."
|
||||||
|
icon={Sparkles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((m) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
artifacts={artifactsList}
|
||||||
|
projects={projectsList}
|
||||||
|
onCopy={() => handleCopy(m.id, m.content)}
|
||||||
|
copied={copied === m.id}
|
||||||
|
onJumpArtifact={(id) => {
|
||||||
|
const u = `/context?artifact=${encodeURIComponent(id)}`;
|
||||||
|
window.open(u, "_blank", "noopener");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="p-3 border-t border-slate-100 bg-white rounded-b-xl flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Ask ASF: Which services implement REQ-103?… (Enter to send, Shift+Enter for newline)"
|
||||||
|
className="asf-input pr-24 resize-none min-h-[60px]"
|
||||||
|
disabled={!!pendingJob}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2.5 right-2.5 text-[11px] text-slate-400 pointer-events-none select-none">
|
||||||
|
{projectId !== "all" && (
|
||||||
|
<span className="asf-chip !py-0.5 !px-2 mr-1">Project: {projectsList.find((p) => String(p.id) === projectId)?.name ?? shortId(projectId)}</span>
|
||||||
|
)}
|
||||||
|
{ctxIds.length > 0 && (
|
||||||
|
<span className="asf-chip !py-0.5 !px-2">{ctxIds.length} anchors</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || !!pendingJob}
|
||||||
|
className="h-[60px] !px-4"
|
||||||
|
>
|
||||||
|
{pendingJob ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Working…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Send
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({
|
||||||
|
m,
|
||||||
|
artifacts,
|
||||||
|
projects,
|
||||||
|
onCopy,
|
||||||
|
copied,
|
||||||
|
onJumpArtifact,
|
||||||
|
}: {
|
||||||
|
m: ChatMessage;
|
||||||
|
artifacts: Artifact[];
|
||||||
|
projects: Project[];
|
||||||
|
onCopy: () => void;
|
||||||
|
copied: boolean;
|
||||||
|
onJumpArtifact: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const isUser = m.role === "user";
|
||||||
|
const projMap = useMemo(() => {
|
||||||
|
const mp = new Map<string, Project>();
|
||||||
|
for (const p of projects) mp.set(String(p.id), p);
|
||||||
|
return mp;
|
||||||
|
}, [projects]);
|
||||||
|
const artMap = useMemo(() => {
|
||||||
|
const mp = new Map<string, Artifact>();
|
||||||
|
for (const a of artifacts) mp.set(String(a.id), a);
|
||||||
|
return mp;
|
||||||
|
}, [artifacts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex gap-3", isUser ? "justify-end" : "justify-start")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-8 h-8 shrink-0 rounded-lg grid place-items-center border",
|
||||||
|
isUser
|
||||||
|
? "order-2 bg-slate-900 text-white border-slate-900"
|
||||||
|
: "bg-white border-slate-200 text-accent shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
<div className={cn("max-w-[82%] flex flex-col gap-1.5", isUser ? "items-end" : "items-start")}>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||||
|
<span className="font-semibold text-slate-500">{isUser ? "You" : "ASF AI"}</span>
|
||||||
|
<span>· {fmtDate(m.timestamp)}</span>
|
||||||
|
{m.jobId && (
|
||||||
|
<>
|
||||||
|
<GitBranch className="w-3 h-3 ml-1" />
|
||||||
|
<span className="asf-monospace">{shortId(m.jobId)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{m.status && m.status !== "idle" && (
|
||||||
|
<Badge tone={statusTone[m.status as Exclude<ChatStatus, "idle">] ?? "neutral"}>
|
||||||
|
{statusIcon(m.status)} {m.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-3 rounded-2xl border text-sm leading-relaxed",
|
||||||
|
isUser
|
||||||
|
? "bg-brand-600 text-white rounded-tr-sm border-brand-600"
|
||||||
|
: "bg-white text-slate-800 border-slate-200 rounded-tl-sm shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.status === "queued" || m.status === "running" ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-slate-600">
|
||||||
|
{m.status === "queued"
|
||||||
|
? "Query queued as ai_interactive job. Waiting for ASF Worker to claim it…"
|
||||||
|
: "Worker claimed the job. Reasoning over graph and calling asf-ai…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="whitespace-pre-wrap break-words">
|
||||||
|
{m.status === "failed" || m.error ? (
|
||||||
|
<div className="flex items-start gap-2 text-rose-700 bg-rose-50/60 -mx-1 px-3 py-2 rounded-lg">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold mb-0.5">AI execution failed</div>
|
||||||
|
<div className="text-[12px] text-rose-600">{m.error ?? "Unknown error"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContentAutoLink text={m.content} artMap={artMap} projMap={projMap} onJump={onJumpArtifact} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.job && (
|
||||||
|
<JobMeta job={m.job} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopy}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-600 px-2 py-0.5 rounded-md hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentAutoLink({
|
||||||
|
text,
|
||||||
|
artMap,
|
||||||
|
projMap,
|
||||||
|
onJump,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
artMap: Map<string, Artifact>;
|
||||||
|
projMap: Map<string, Project>;
|
||||||
|
onJump: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const pattern = /\b(REQ|TASK|PR|TEST|BUILD|DEP|DOC|SRC|SEC|INC|EPIC)[-#]?\d*[A-Za-z0-9]*\b/g;
|
||||||
|
const parts: Array<string | JSX.Element> = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
let key = 0;
|
||||||
|
while ((m = pattern.exec(text))) {
|
||||||
|
if (m.index > last) parts.push(text.slice(last, m.index));
|
||||||
|
const token = m[0];
|
||||||
|
const foundArt = Array.from(artMap.values()).find(
|
||||||
|
(a) => (a.external_id && a.external_id.toLowerCase() === token.toLowerCase()) || false,
|
||||||
|
);
|
||||||
|
if (foundArt) {
|
||||||
|
parts.push(
|
||||||
|
<button
|
||||||
|
key={`link-${key++}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJump(String(foundArt.id))}
|
||||||
|
className="asf-monospace inline-flex items-center gap-1 mx-0.5 -my-0.5 px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 border border-brand-200 hover:bg-brand-100 transition-colors align-baseline"
|
||||||
|
>
|
||||||
|
{token}
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(token);
|
||||||
|
}
|
||||||
|
last = m.index + token.length;
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push(text.slice(last));
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobMeta({ job }: { job: Job }) {
|
||||||
|
const usage = job.usage;
|
||||||
|
return (
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100 text-[11px] text-slate-500 grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<JobMetaPill label="Status" tone={statusTone[job.status as Exclude<ChatStatus, "idle">] ?? "neutral"} value={job.status} />
|
||||||
|
<JobMetaPill label="Started" value={job.started_at ? fmtDate(job.started_at) : "—"} />
|
||||||
|
<JobMetaPill label="Completed" value={job.completed_at ? fmtDate(job.completed_at) : "—"} />
|
||||||
|
<JobMetaPill label="Worker" value={job.worker ?? "asf-worker"} />
|
||||||
|
{usage?.input_tokens !== undefined && <JobMetaPill label="Input tok." value={String(usage.input_tokens)} />}
|
||||||
|
{usage?.output_tokens !== undefined && <JobMetaPill label="Output tok." value={String(usage.output_tokens)} />}
|
||||||
|
{usage?.cpu_seconds !== undefined && <JobMetaPill label="CPU s." value={usage.cpu_seconds.toFixed(2)} />}
|
||||||
|
{usage?.estimated_cost !== undefined && <JobMetaPill label="Cost" value={`$${usage.estimated_cost.toFixed(4)}`} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobMetaPill({ label, value, tone }: { label: string; value: string; tone?: "brand" | "green" | "red" | "neutral" | "amber" }) {
|
||||||
|
const toneCls = tone
|
||||||
|
? {
|
||||||
|
brand: "bg-brand-50 text-brand-700 border-brand-100",
|
||||||
|
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
|
||||||
|
red: "bg-rose-50 text-rose-700 border-rose-100",
|
||||||
|
neutral: "bg-slate-50 text-slate-600 border-slate-200",
|
||||||
|
amber: "bg-amber-50 text-amber-700 border-amber-100",
|
||||||
|
}[tone]
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200";
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-md border px-2 py-1", toneCls)}>
|
||||||
|
<div className="uppercase tracking-wide text-[9px] opacity-70">{label}</div>
|
||||||
|
<div className="asf-monospace font-semibold truncate">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIcon(s: ChatStatus) {
|
||||||
|
switch (s) {
|
||||||
|
case "queued":
|
||||||
|
return <Clock className="w-3 h-3 inline mr-1" />;
|
||||||
|
case "running":
|
||||||
|
return <Loader2 className="w-3 h-3 inline mr-1 animate-spin" />;
|
||||||
|
case "completed":
|
||||||
|
return <CheckCircle2 className="w-3 h-3 inline mr-1" />;
|
||||||
|
case "failed":
|
||||||
|
return <AlertTriangle className="w-3 h-3 inline mr-1" />;
|
||||||
|
case "cancelled":
|
||||||
|
return <X className="w-3 h-3 inline mr-1" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobsStatusByJobId(jid: string, msgs: ChatMessage[]): JobStatus | null {
|
||||||
|
const m = msgs.find((x) => x.jobId === jid);
|
||||||
|
return (m?.job?.status ?? m?.status) as JobStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAnswer(j: Job, fallback: string): string {
|
||||||
|
if (j.result) {
|
||||||
|
const r = j.result as Record<string, unknown>;
|
||||||
|
for (const k of ["message", "answer", "summary", "response", "text"]) {
|
||||||
|
if (typeof r[k] === "string" && (r[k] as string).length > 0) return r[k] as string;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(j.result, null, 2);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j.status === "failed") {
|
||||||
|
return j.error ?? "ASF AI could not answer this question.";
|
||||||
|
}
|
||||||
|
if (j.status === "completed") {
|
||||||
|
return "Done. (AI result was returned with no message field.)";
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cryptoId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||||
|
return "id-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
Search,
|
||||||
|
AlertTriangle,
|
||||||
|
FileCode2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertOctagon,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
GitBranch,
|
||||||
|
ArrowLeft,
|
||||||
|
Sparkles,
|
||||||
|
FileText,
|
||||||
|
Layers,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { artifacts as apiArtifacts, projects } from "@/lib/api";
|
||||||
|
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
|
||||||
|
import type { Artifact, ArtifactType, Project } from "@/lib/types";
|
||||||
|
|
||||||
|
type Scope = "all" | "source_file" | "smell";
|
||||||
|
type Severity = "all" | "low" | "medium" | "high" | "critical";
|
||||||
|
|
||||||
|
const SEVERITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||||
|
const SEVERITY_META: Record<Exclude<Severity, "all">, { icon: typeof AlertTriangle; tone: "red" | "amber" | "brand" | "neutral"; label: string }> = {
|
||||||
|
critical: { icon: AlertOctagon, tone: "red", label: "CRITICAL" },
|
||||||
|
high: { icon: AlertTriangle, tone: "amber", label: "HIGH" },
|
||||||
|
medium: { icon: AlertCircle, tone: "brand", label: "MEDIUM" },
|
||||||
|
low: { icon: Info, tone: "neutral", label: "LOW" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEMO_ROWS: Artifact[] = [
|
||||||
|
{
|
||||||
|
id: "src-1", tenant_id: "t1", project_id: "p1",
|
||||||
|
type: "source_file", external_id: "SRC-DEMO-1",
|
||||||
|
title: "app/middleware/auth.py",
|
||||||
|
status: "active", source: "git",
|
||||||
|
content: "def authorize(request):\n return check_jwt(request.headers.get('authorization'))\n",
|
||||||
|
metadata: { language: "python", path: "app/middleware/auth.py", loc: 134, repo: "asf", branch: "main" },
|
||||||
|
created_at: "2025-11-01T09:00:00Z", updated_at: "2025-11-05T11:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sm-1", tenant_id: "t1", project_id: "p1",
|
||||||
|
type: "ai_analysis", external_id: "CS-DEMO-1",
|
||||||
|
title: "SQL injection in user lookup (no ORM)",
|
||||||
|
status: "open", source: "bandit",
|
||||||
|
content: "Line 22 uses f-string on cursor.execute().",
|
||||||
|
metadata: { kind: "code_smell", language: "python", severity: "high", rule_id: "B608", engine: "bandit", lines: [20, 21, 22] },
|
||||||
|
created_at: "2025-11-08T09:00:00Z", updated_at: "2025-11-08T09:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function isSmell(a: Artifact): boolean {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
return typeof md === "object" && (md as Record<string, unknown>)["kind"] === "code_smell";
|
||||||
|
}
|
||||||
|
function isSourceFile(a: Artifact): boolean {
|
||||||
|
return a.type === "source_file";
|
||||||
|
}
|
||||||
|
function getLanguage(a: Artifact): string {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["language"];
|
||||||
|
return typeof v === "string" ? v : "—";
|
||||||
|
}
|
||||||
|
function getSeverity(a: Artifact): "low" | "medium" | "high" | "critical" | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["severity"];
|
||||||
|
if (typeof v === "string" && (v === "low" || v === "medium" || v === "high" || v === "critical")) return v;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function getRule(a: Artifact): string | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["rule_id"];
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
}
|
||||||
|
function getEngine(a: Artifact): string | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["engine"];
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
}
|
||||||
|
function getPath(a: Artifact): string | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["path"];
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
}
|
||||||
|
function getLines(a: Artifact): number[] | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["lines"];
|
||||||
|
return Array.isArray(v) && v.every((x) => typeof x === "number") ? (v as number[]) : null;
|
||||||
|
}
|
||||||
|
function getLOC(a: Artifact): number | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["loc"];
|
||||||
|
return typeof v === "number" ? v : null;
|
||||||
|
}
|
||||||
|
function getRepo(a: Artifact): string | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["repo"];
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
}
|
||||||
|
function getBranch(a: Artifact): string | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["branch"];
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
}
|
||||||
|
function getConfidence(a: Artifact): number | null {
|
||||||
|
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||||
|
const v = (md as Record<string, unknown>)["confidence_pct"];
|
||||||
|
return typeof v === "number" ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodePage() {
|
||||||
|
const [scope, setScope] = useState<Scope>("all");
|
||||||
|
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||||
|
const [languageFilter, setLanguageFilter] = useState<string>("all");
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<Severity>("all");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [q, setQ] = useState<string>("");
|
||||||
|
const [detailId, setDetailId] = useState<string>("");
|
||||||
|
|
||||||
|
const projectsQ = useQuery({
|
||||||
|
queryKey: ["code-projects"],
|
||||||
|
queryFn: () => projects.list({ limit: 50 }),
|
||||||
|
retry: false, refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const apiResolved = projectsQ.isFetched && projectsQ.data !== undefined;
|
||||||
|
const projectRows: Project[] = apiResolved ? projectsQ.data! : [];
|
||||||
|
const projectNameById = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const p of projectRows) m.set(String(p.id), p.name);
|
||||||
|
return m;
|
||||||
|
}, [projectRows]);
|
||||||
|
|
||||||
|
const sourceQ = useQuery({
|
||||||
|
queryKey: ["code-source", projectFilter, q.trim()],
|
||||||
|
queryFn: () =>
|
||||||
|
apiArtifacts.list({
|
||||||
|
type: "source_file" as ArtifactType,
|
||||||
|
project_id: projectFilter !== "all" ? projectFilter : undefined,
|
||||||
|
search: q.trim() || undefined,
|
||||||
|
limit: 200,
|
||||||
|
}),
|
||||||
|
retry: false, refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const smellsQ = useQuery({
|
||||||
|
queryKey: ["code-smells", projectFilter, q.trim()],
|
||||||
|
queryFn: () =>
|
||||||
|
apiArtifacts.list({
|
||||||
|
type: "ai_analysis" as ArtifactType,
|
||||||
|
project_id: projectFilter !== "all" ? projectFilter : undefined,
|
||||||
|
search: q.trim() || undefined,
|
||||||
|
limit: 200,
|
||||||
|
}),
|
||||||
|
retry: false, refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceResolved = sourceQ.isFetched && sourceQ.data !== undefined;
|
||||||
|
const smellsResolved = smellsQ.isFetched && smellsQ.data !== undefined;
|
||||||
|
const isMock = !sourceResolved || !smellsResolved;
|
||||||
|
|
||||||
|
const rawRows: Artifact[] = useMemo(() => {
|
||||||
|
const allSources = sourceResolved ? sourceQ.data! : DEMO_ROWS.filter(isSourceFile);
|
||||||
|
const allAnalyses = smellsResolved ? smellsQ.data! : DEMO_ROWS.filter(isSmell);
|
||||||
|
const smellsOnly = allAnalyses.filter(isSmell);
|
||||||
|
const combined = [...allSources, ...smellsOnly];
|
||||||
|
// apply language + severity + status filters (server can't filter metadata)
|
||||||
|
return combined.filter((a) => {
|
||||||
|
if (scope === "source_file" && !isSourceFile(a)) return false;
|
||||||
|
if (scope === "smell" && !isSmell(a)) return false;
|
||||||
|
if (languageFilter !== "all" && getLanguage(a) !== languageFilter) return false;
|
||||||
|
if (severityFilter !== "all") {
|
||||||
|
if (!isSmell(a)) return false;
|
||||||
|
if (getSeverity(a) !== severityFilter) return false;
|
||||||
|
}
|
||||||
|
if (statusFilter !== "all" && (a.status ?? "") !== statusFilter) return false;
|
||||||
|
return true;
|
||||||
|
}).sort((b, a) => {
|
||||||
|
// severity first (only if smell), then updated_at desc
|
||||||
|
const sa = isSmell(a) ? (SEVERITY_ORDER[getSeverity(a) ?? "low"] ?? 9) : 99;
|
||||||
|
const sb = isSmell(b) ? (SEVERITY_ORDER[getSeverity(b) ?? "low"] ?? 9) : 99;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
return (a.updated_at ?? "").localeCompare(b.updated_at ?? "");
|
||||||
|
});
|
||||||
|
}, [sourceResolved, smellsResolved, sourceQ.data, smellsQ.data, scope, languageFilter, severityFilter, statusFilter]);
|
||||||
|
|
||||||
|
const languages = useMemo(() => {
|
||||||
|
const s = new Set<string>();
|
||||||
|
for (const r of rawRows) {
|
||||||
|
const l = getLanguage(r);
|
||||||
|
if (l && l !== "—") s.add(l);
|
||||||
|
}
|
||||||
|
return Array.from(s).sort();
|
||||||
|
}, [rawRows]);
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const markFixedM = useMutation({
|
||||||
|
mutationFn: async ({ id, status }: { id: string; status: string }) =>
|
||||||
|
apiArtifacts.update(id, { status } as Partial<Artifact>),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["code-source"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["code-smells"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const src = rawRows.filter(isSourceFile).length;
|
||||||
|
const sm = rawRows.filter(isSmell);
|
||||||
|
const smBySev = { low: 0, medium: 0, high: 0, critical: 0 } as Record<"low" | "medium" | "high" | "critical", number>;
|
||||||
|
for (const s of sm) {
|
||||||
|
const sv = getSeverity(s);
|
||||||
|
if (sv) smBySev[sv] += 1;
|
||||||
|
}
|
||||||
|
return { src, smells: sm.length, smBySev };
|
||||||
|
}, [rawRows]);
|
||||||
|
|
||||||
|
const effectiveDetail = useMemo(() => {
|
||||||
|
if (!detailId) return null;
|
||||||
|
return rawRows.find((r) => String(r.id) === String(detailId)) ?? null;
|
||||||
|
}, [detailId, rawRows]);
|
||||||
|
|
||||||
|
if (effectiveDetail) {
|
||||||
|
return (
|
||||||
|
<CodeDetailView
|
||||||
|
artifact={effectiveDetail}
|
||||||
|
projectName={projectNameById.get(String(effectiveDetail.project_id)) ?? String(effectiveDetail.project_id)}
|
||||||
|
onBack={() => setDetailId("")}
|
||||||
|
onMarkFixed={(status) =>
|
||||||
|
markFixedM.mutate({ id: String(effectiveDetail.id), status })
|
||||||
|
}
|
||||||
|
markFixedPending={markFixedM.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col min-h-0">
|
||||||
|
<header className="px-6 lg:px-8 pt-6 pb-4">
|
||||||
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 shadow-soft grid place-items-center text-white shrink-0">
|
||||||
|
<Code2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
|
||||||
|
Code Analysis
|
||||||
|
{isMock && <Badge tone="amber">Demo data</Badge>}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 max-w-3xl">
|
||||||
|
Source files indexed from your repositories + automated code smells detected by ASF AI Analyzer
|
||||||
|
(Ruff / ESLint / Bandit …). Click any row to see the detail with highlighted lines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
<StatCard label="Source files" value={stats.src} tone="brand" icon={FileCode2} />
|
||||||
|
<StatCard label="Code smells" value={stats.smells} tone="amber" icon={AlertTriangle} />
|
||||||
|
<StatCard label="Critical" value={stats.smBySev.critical} tone="red" icon={AlertOctagon} />
|
||||||
|
<StatCard label="High" value={stats.smBySev.high} tone="amber" icon={AlertTriangle} />
|
||||||
|
<StatCard label="Med/Low" value={stats.smBySev.medium + stats.smBySev.low} tone="neutral" icon={Info} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<CardHeader className="flex-wrap items-end gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="!text-sm flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-slate-400" />
|
||||||
|
Inventory
|
||||||
|
<span className="asf-chip">{rawRows.length}</span>
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<div className="relative w-full md:w-64">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
className="asf-input pl-9"
|
||||||
|
placeholder="Search paths, titles, rules…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select className="asf-input !w-auto" value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
|
||||||
|
<option value="all">All code</option>
|
||||||
|
<option value="source_file">Source files only</option>
|
||||||
|
<option value="smell">Code smells only</option>
|
||||||
|
</select>
|
||||||
|
<select className="asf-input !w-auto" value={languageFilter} onChange={(e) => setLanguageFilter(e.target.value)}>
|
||||||
|
<option value="all">Any language</option>
|
||||||
|
{languages.map((l) => (
|
||||||
|
<option key={l} value={l}>{l.toUpperCase()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select className="asf-input !w-auto" value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as Severity)}>
|
||||||
|
<option value="all">Any severity</option>
|
||||||
|
<option value="critical">CRITICAL</option>
|
||||||
|
<option value="high">HIGH</option>
|
||||||
|
<option value="medium">MEDIUM</option>
|
||||||
|
<option value="low">LOW</option>
|
||||||
|
</select>
|
||||||
|
<select className="asf-input !w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||||
|
<option value="all">Any status</option>
|
||||||
|
{Array.from(new Set(rawRows.map((r) => r.status ?? "unknown").filter(Boolean))).sort().map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select className="asf-input !w-auto" value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}>
|
||||||
|
<option value="all">All projects</option>
|
||||||
|
{projectRows.map((p) => (
|
||||||
|
<option key={p.id} value={String(p.id)}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
{rawRows.length === 0 ? (
|
||||||
|
<div className="flex-1 grid place-items-center p-10">
|
||||||
|
<EmptyState title="No matches" description="Try a different combination of filters, or index a repository to seed source files." icon={FileCode2} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-auto flex-1 min-h-0">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 bg-slate-50/95 backdrop-blur-sm border-b border-slate-200 text-[11px] uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5 text-left w-16">Kind</th>
|
||||||
|
<th className="px-4 py-2.5 text-left">Artifact</th>
|
||||||
|
<th className="px-4 py-2.5 text-left w-28">Lang</th>
|
||||||
|
<th className="px-4 py-2.5 text-left w-28">Severity</th>
|
||||||
|
<th className="px-4 py-2.5 text-left w-28">Status</th>
|
||||||
|
<th className="px-4 py-2.5 text-left w-48">Project</th>
|
||||||
|
<th className="px-4 py-2.5 text-right w-28">Updated</th>
|
||||||
|
<th className="px-4 py-2.5 w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{rawRows.map((a) => (
|
||||||
|
<tr
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => setDetailId(String(a.id))}
|
||||||
|
className="group cursor-pointer hover:bg-slate-50/80 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
{isSourceFile(a) ? (
|
||||||
|
<Badge tone="blue" className="flex items-center gap-1 w-fit">
|
||||||
|
<FileCode2 className="w-3 h-3" />
|
||||||
|
<span>file</span>
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="amber" className="flex items-center gap-1 w-fit">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
<span>smell</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="asf-monospace text-[11px] text-slate-500">
|
||||||
|
{a.external_id ?? shortId(a.id)}
|
||||||
|
</span>
|
||||||
|
{isSmell(a) && getRule(a) && (
|
||||||
|
<Badge tone="neutral">{getRule(a)}</Badge>
|
||||||
|
)}
|
||||||
|
{isSourceFile(a) && getLOC(a) != null && (
|
||||||
|
<span className="asf-chip">{getLOC(a)} LOC</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-medium text-slate-800 truncate max-w-[520px]">
|
||||||
|
{a.title}
|
||||||
|
</div>
|
||||||
|
{getPath(a) && (
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500 truncate font-mono max-w-[520px]">
|
||||||
|
{getPath(a)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSmell(a) && getEngine(a) && (
|
||||||
|
<div className="mt-1 text-[11px] text-slate-400">
|
||||||
|
scanner: <span className="text-slate-600 font-medium">{getEngine(a)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<Badge tone="brand" className="uppercase">{getLanguage(a)}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
{isSmell(a) && getSeverity(a) ? (
|
||||||
|
<SeverityBadge s={getSeverity(a)!} />
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
{a.status ? <Badge tone={a.status === "open" ? "amber" : a.status === "fixed" ? "green" : a.status === "active" ? "blue" : "brand"}>{a.status}</Badge> : <span className="text-slate-300 text-xs">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top text-slate-600 text-xs truncate max-w-[200px]">
|
||||||
|
{projectNameById.get(String(a.project_id)) ?? shortId(a.project_id)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top text-right text-xs text-slate-500 whitespace-nowrap">
|
||||||
|
{fmtDate(a.updated_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top text-right">
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-brand-500 inline-block" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ s }: { s: "low" | "medium" | "high" | "critical" }) {
|
||||||
|
const m = SEVERITY_META[s];
|
||||||
|
const Icon = m.icon;
|
||||||
|
return (
|
||||||
|
<Badge tone={m.tone} className="inline-flex items-center gap-1">
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{m.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label, value, tone, icon: Icon,
|
||||||
|
}: {
|
||||||
|
label: string; value: number; tone: "brand" | "amber" | "red" | "neutral" | "green" | "blue";
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}) {
|
||||||
|
const toneBg: Record<string, string> = {
|
||||||
|
brand: "from-brand-500 to-brand-600",
|
||||||
|
amber: "from-amber-400 to-amber-500",
|
||||||
|
red: "from-rose-500 to-red-600",
|
||||||
|
neutral: "from-slate-400 to-slate-500",
|
||||||
|
green: "from-emerald-400 to-emerald-600",
|
||||||
|
blue: "from-sky-400 to-blue-600",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card className="!shadow-none overflow-hidden">
|
||||||
|
<CardBody className="p-4 flex items-center gap-3">
|
||||||
|
<div className={cn("w-11 h-11 rounded-xl bg-gradient-to-br shadow-sm grid place-items-center text-white shrink-0", toneBg[tone])}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</div>
|
||||||
|
<div className="text-2xl font-semibold text-slate-900 leading-tight mt-0.5">{value}</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeDetailView({
|
||||||
|
artifact, projectName, onBack, onMarkFixed, markFixedPending,
|
||||||
|
}: {
|
||||||
|
artifact: Artifact;
|
||||||
|
projectName: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onMarkFixed?: (status: string) => void;
|
||||||
|
markFixedPending?: boolean;
|
||||||
|
}) {
|
||||||
|
const lang = getLanguage(artifact);
|
||||||
|
const sev = getSeverity(artifact);
|
||||||
|
const path = getPath(artifact);
|
||||||
|
const rule = getRule(artifact);
|
||||||
|
const engine = getEngine(artifact);
|
||||||
|
const repo = getRepo(artifact);
|
||||||
|
const branch = getBranch(artifact);
|
||||||
|
const lines = getLines(artifact);
|
||||||
|
const confidence = getConfidence(artifact);
|
||||||
|
const smell = isSmell(artifact);
|
||||||
|
const source = isSourceFile(artifact);
|
||||||
|
|
||||||
|
const linesToHighlight = useMemo(() => {
|
||||||
|
const set = new Set<number>();
|
||||||
|
if (lines) {
|
||||||
|
for (const n of lines) set.add(n);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [lines]);
|
||||||
|
|
||||||
|
const contentLines = useMemo(() => {
|
||||||
|
const raw = artifact.content ?? "";
|
||||||
|
return raw.split("\n");
|
||||||
|
}, [artifact.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col min-h-0">
|
||||||
|
<header className="px-6 lg:px-8 pt-6 pb-4">
|
||||||
|
<Button variant="ghost" size="sm" className="mb-3 -ml-2" onClick={onBack}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-1" /> Back to inventory
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
|
<div className={cn(
|
||||||
|
"w-11 h-11 rounded-xl bg-gradient-to-br shadow-soft grid place-items-center text-white shrink-0",
|
||||||
|
smell
|
||||||
|
? "from-amber-400 to-rose-500"
|
||||||
|
: "from-brand-500 to-violet-600",
|
||||||
|
)}>
|
||||||
|
{smell ? <AlertTriangle className="w-5 h-5" /> : <FileCode2 className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<ArtifactTypeBadge type={artifact.type as ArtifactType} />
|
||||||
|
{smell ? (
|
||||||
|
<Badge tone="amber">code_smell</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="blue">source_file</Badge>
|
||||||
|
)}
|
||||||
|
{sev && <SeverityBadge s={sev} />}
|
||||||
|
{rule && <Badge tone="neutral">rule {rule}</Badge>}
|
||||||
|
{lang && <Badge tone="brand" className="uppercase">{lang}</Badge>}
|
||||||
|
{artifact.status && (
|
||||||
|
<Badge tone={artifact.status === "open" ? "amber" : artifact.status === "fixed" ? "green" : artifact.status === "active" ? "blue" : "brand"}>
|
||||||
|
{artifact.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="asf-monospace text-xs text-slate-500">
|
||||||
|
{artifact.external_id ?? shortId(artifact.id)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-2 text-xl font-semibold text-slate-900 break-words">{artifact.title}</h1>
|
||||||
|
{path && (
|
||||||
|
<div className="mt-1 text-xs text-slate-500 font-mono break-all">{path}</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 text-xs text-slate-500 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span>Project: <span className="text-slate-700 font-medium">{projectName}</span></span>
|
||||||
|
{engine && <span>Scanner: <span className="text-slate-700 font-medium">{engine}</span></span>}
|
||||||
|
{repo && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<GitBranch className="w-3 h-3 text-slate-400" />
|
||||||
|
<span className="text-slate-700 font-medium">{truncate(repo, 40)}{branch ? ` @ ${branch}` : ""}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{confidence != null && <span>Confidence: <span className="text-slate-700 font-medium">{confidence}%</span></span>}
|
||||||
|
<span>Updated: <span className="text-slate-700 font-medium">{fmtDate(artifact.updated_at)}</span></span>
|
||||||
|
{lines && <span>Affected lines: <span className="text-slate-700 font-medium">{lines.join(", ")}</span></span>}
|
||||||
|
{source && getLOC(artifact) != null && <span>Lines of code: <span className="text-slate-700 font-medium">{getLOC(artifact)!}</span></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{smell && onMarkFixed && (
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-auto">
|
||||||
|
{artifact.status === "fixed" ? (
|
||||||
|
<Badge tone="green" className="inline-flex items-center gap-1 !px-3 !py-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" /> Resolved
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
disabled={markFixedPending}
|
||||||
|
onClick={() => onMarkFixed("fixed")}
|
||||||
|
className="!bg-emerald-500 hover:!bg-emerald-600"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{markFixedPending ? "Saving..." : "Mark as fixed"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||||
|
<Card className="lg:col-span-7 flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="!text-sm flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-400" />
|
||||||
|
{smell ? "Finding details" : "Source preview"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
{contentLines.length === 1 && contentLines[0] === "" ? (
|
||||||
|
<div className="p-10">
|
||||||
|
<EmptyState title="No content body available" description="This artifact is metadata-only. The blob content may still be fetchable from the Git integration." icon={FileCode2} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="flex-1 min-h-0 overflow-auto text-[12.5px] leading-6 bg-slate-900 text-slate-100 p-4 font-mono rounded-b-xl">
|
||||||
|
{contentLines.map((ln, i) => {
|
||||||
|
const n = i + 1;
|
||||||
|
const hi = linesToHighlight.has(n);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n}
|
||||||
|
className={cn(
|
||||||
|
"flex -mx-4 px-4",
|
||||||
|
hi && "bg-rose-500/15 border-l-2 border-rose-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"w-10 shrink-0 text-right pr-3 select-none",
|
||||||
|
hi ? "text-rose-200" : "text-slate-600",
|
||||||
|
)}>{n}</span>
|
||||||
|
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap">{ln || " "}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-5 flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="!text-sm flex items-center gap-2">
|
||||||
|
{smell ? <AlertTriangle className="w-4 h-4 text-amber-500" /> : <CheckCircle2 className="w-4 h-4 text-emerald-500" />}
|
||||||
|
{smell ? "Remediation guidance" : "File index info"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-4 text-sm">
|
||||||
|
{smell ? (
|
||||||
|
<>
|
||||||
|
<SuggestionBlock
|
||||||
|
title="Root cause"
|
||||||
|
body={
|
||||||
|
`Rule ${rule ?? "unknown"} reported by ${engine ?? "scanner"}. ` +
|
||||||
|
(lines?.length ? `Focus attention on ${lines.length} affected line${lines.length > 1 ? "s" : ""}.` : "Inspect the highlighted rows in the source pane.")
|
||||||
|
}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
<SuggestionBlock
|
||||||
|
title="Suggested fix"
|
||||||
|
body={
|
||||||
|
sev === "critical" || sev === "high"
|
||||||
|
? "Open a TASK linked to this finding, assign an owner, and block the PR/deployment pipeline until merged. Prefer writing a regression test that proves the issue."
|
||||||
|
: "Fix in the next planned sprint; add a suppression comment only if you've filed a follow-up TASK with justification."
|
||||||
|
}
|
||||||
|
tone={sev === "critical" || sev === "high" ? "red" : "brand"}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<InfoCell label="Version" value={artifact.version != null ? `v${artifact.version}` : "—"} />
|
||||||
|
<InfoCell label="Source" value={artifact.source ?? "—"} />
|
||||||
|
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
|
||||||
|
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SuggestionBlock
|
||||||
|
title="Traceability"
|
||||||
|
body="This file is linked to the ASF artifact graph. Open Context Explorer to inspect which PRs, commits, tasks, and deployments reference it."
|
||||||
|
tone="brand"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<InfoCell label="LOC" value={getLOC(artifact) != null ? String(getLOC(artifact)!) : "—"} />
|
||||||
|
<InfoCell label="Language" value={lang ? lang.toUpperCase() : "—"} />
|
||||||
|
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
|
||||||
|
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestionBlock({
|
||||||
|
title, body, tone,
|
||||||
|
}: { title: string; body: string; tone: "brand" | "amber" | "red" | "green" | "blue" }) {
|
||||||
|
const toneCls: Record<string, string> = {
|
||||||
|
brand: "bg-brand-50 border-brand-100 text-brand-900",
|
||||||
|
amber: "bg-amber-50 border-amber-100 text-amber-900",
|
||||||
|
red: "bg-rose-50 border-rose-100 text-rose-900",
|
||||||
|
green: "bg-emerald-50 border-emerald-100 text-emerald-900",
|
||||||
|
blue: "bg-sky-50 border-sky-100 text-sky-900",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={cn("p-3 rounded-lg border", toneCls[tone])}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide mb-1 opacity-80">{title}</div>
|
||||||
|
<p className="text-sm leading-snug">{body}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCell({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5">
|
||||||
|
<div className="uppercase tracking-wide text-[9px] text-slate-400">{label}</div>
|
||||||
|
<div className="asf-monospace font-semibold text-slate-700 truncate">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Rocket, Cloud, CheckCircle2, AlertTriangle, Clock } from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { MetricCard } from "@/components/ui/MetricCard";
|
||||||
|
import { fmtDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DEPLOYS = [
|
||||||
|
{ id: "DEP-44", env: "prod-west-1", service: "asf-core", version: "v0.4.0", status: "done", at: "2025-11-14T14:12:00Z", by: "ci-bot" },
|
||||||
|
{ id: "DEP-43", env: "staging", service: "asf-core", version: "v0.4.0-rc3", status: "done", at: "2025-11-13T17:00:00Z", by: "ci-bot" },
|
||||||
|
{ id: "DEP-42", env: "prod-west-1", service: "asf-web", version: "v0.3.2", status: "done", at: "2025-11-11T09:30:00Z", by: "m.rossi" },
|
||||||
|
{ id: "DEP-41", env: "prod-west-1", service: "asf-worker", version: "v0.3.1", status: "failed", at: "2025-11-09T20:45:00Z", by: "ci-bot" },
|
||||||
|
{ id: "DEP-40", env: "staging", service: "asf-ai", version: "v0.3.0", status: "running", at: new Date().toISOString(), by: "ci-bot" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DeploymentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<Rocket className="w-6 h-6 text-emerald-600" />
|
||||||
|
Deployments
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Release history per environment, linked back to Build, Pull Request and Requirement artifacts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">Argo CD · readiness probe</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<MetricCard label="Deployed (7d)" value={14} tone="green" icon={<Rocket className="w-5 h-5" />} delta={2.0} />
|
||||||
|
<MetricCard label="Success rate" value={92.6} isPercentage tone="brand" icon={<CheckCircle2 className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="In progress" value={1} tone="blue" icon={<Clock className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Failed (7d)" value={1} tone="red" icon={<AlertTriangle className="w-5 h-5" />} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent deployments</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Badge tone="green"><Cloud className="w-3 h-3" /> prod-west-1</Badge>
|
||||||
|
<Badge tone="blue"><Cloud className="w-3 h-3" /> staging</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{DEPLOYS.length === 0 ? (
|
||||||
|
<div className="p-6"><EmptyState title="No deployments" description="Connect a CI/CD integration or create a Deployment artifact." /></div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">ID</th>
|
||||||
|
<th className="text-left px-4 py-2">Env</th>
|
||||||
|
<th className="text-left px-4 py-2">Service</th>
|
||||||
|
<th className="text-left px-4 py-2">Version</th>
|
||||||
|
<th className="text-left px-4 py-2">Status</th>
|
||||||
|
<th className="text-left px-4 py-2">At</th>
|
||||||
|
<th className="text-left px-4 py-2">By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{DEPLOYS.map((d) => (
|
||||||
|
<tr key={d.id} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500">{d.id}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge tone={d.env.startsWith("prod") ? "green" : "blue"}>{d.env}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5"><ArtifactTypeBadge type="deployment" /> <span className="ml-2 font-medium text-slate-800">{d.service}</span></td>
|
||||||
|
<td className="px-4 py-2.5 asf-monospace text-xs">{d.version}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge tone={d.status === "done" ? "green" : d.status === "running" ? "blue" : "red"}>{d.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-slate-500 whitespace-nowrap">{fmtDate(d.at)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-slate-600">{d.by}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #__next {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 0% -10%, rgba(11, 140, 255, 0.08), transparent 60%),
|
||||||
|
radial-gradient(800px 500px at 100% 0%, rgba(124, 92, 255, 0.08), transparent 55%),
|
||||||
|
#f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.asf-card {
|
||||||
|
@apply bg-white/90 backdrop-blur border border-slate-200/80 rounded-xl shadow-card;
|
||||||
|
}
|
||||||
|
.asf-card-header {
|
||||||
|
@apply flex items-center justify-between px-5 py-4 border-b border-slate-100;
|
||||||
|
}
|
||||||
|
.asf-card-title {
|
||||||
|
@apply font-semibold text-slate-800 text-sm tracking-tight;
|
||||||
|
}
|
||||||
|
.asf-card-body {
|
||||||
|
@apply p-5;
|
||||||
|
}
|
||||||
|
.asf-btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-3.5 py-2 rounded-lg text-sm font-medium
|
||||||
|
transition-colors border border-transparent;
|
||||||
|
}
|
||||||
|
.asf-btn-primary {
|
||||||
|
@apply asf-btn bg-brand-600 text-white hover:bg-brand-700 focus:ring-2 focus:ring-brand-300;
|
||||||
|
}
|
||||||
|
.asf-btn-secondary {
|
||||||
|
@apply asf-btn bg-white text-slate-700 border-slate-200 hover:bg-slate-50;
|
||||||
|
}
|
||||||
|
.asf-btn-ghost {
|
||||||
|
@apply asf-btn text-slate-600 hover:bg-slate-100;
|
||||||
|
}
|
||||||
|
.asf-input {
|
||||||
|
@apply w-full px-3 py-2 rounded-lg text-sm bg-white border border-slate-200
|
||||||
|
focus:border-brand-400 focus:ring-2 focus:ring-brand-200 outline-none;
|
||||||
|
}
|
||||||
|
.asf-nav-item {
|
||||||
|
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
|
||||||
|
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors;
|
||||||
|
}
|
||||||
|
.asf-nav-item-active {
|
||||||
|
@apply bg-brand-50 text-brand-700 hover:bg-brand-50 hover:text-brand-800
|
||||||
|
ring-1 ring-inset ring-brand-100;
|
||||||
|
}
|
||||||
|
.asf-chip {
|
||||||
|
@apply inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5
|
||||||
|
rounded-full border border-slate-200 bg-slate-50 text-slate-600;
|
||||||
|
}
|
||||||
|
.asf-monospace {
|
||||||
|
font-family: "JetBrains Mono", Menlo, ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Flow theme tweaks */
|
||||||
|
.react-flow__attribution {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.react-flow__node {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.react-flow__node-default,
|
||||||
|
.react-flow__node-input,
|
||||||
|
.react-flow__node-output {
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
box-shadow: 0 1px 2px rgba(15,23,42,0.04), 0 4px 20px -8px rgba(15,23,42,0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 999px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Plug, Github, GitBranch, CircleDot, BookMarked, Activity, CheckCircle2, AlertTriangle, Plus } from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
|
||||||
|
const CATALOG = [
|
||||||
|
{ key: "github", name: "GitHub", icon: Github, kind: "git", status: "available", desc: "Import PRs, commits, branches and release events." },
|
||||||
|
{ key: "gitlab", name: "GitLab", icon: GitBranch, kind: "git", status: "planned", desc: "Planned connector — same primitives as GitHub." },
|
||||||
|
{ key: "jira", name: "Jira / Linear", icon: CircleDot, kind: "requirements", status: "planned", desc: "Sync epics, stories and tasks as ASF Artifacts." },
|
||||||
|
{ key: "jenkins", name: "Jenkins CI", icon: GitBranch, kind: "ci", status: "planned", desc: "Map build results to Build & Test artifacts." },
|
||||||
|
{ key: "notion", name: "Notion / Confluence", icon: BookMarked, kind: "docs", status: "planned", desc: "ADRs and docs synchronized as Document artifacts." },
|
||||||
|
{ key: "datadog", name: "Datadog / Prometheus",icon: Activity, kind: "observability", status: "planned", desc: "Incidents and runtime events → incident artifacts." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONNECTED = [
|
||||||
|
{ key: "github", name: "GitHub (azurian-org)", tenant: "default", status: "ok", since: "2025-11-01T00:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function IntegrationsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<Plug className="w-6 h-6 text-brand-600" />
|
||||||
|
Integrations
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
ASF connectors — plug in Git providers, requirements tools, CI/CD, docs and observability platforms. Core stays decoupled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" size="sm"><Plus className="w-4 h-4" /> Add integration</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mb-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Connected</CardTitle>
|
||||||
|
<Badge tone="green">{CONNECTED.length} active</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{CONNECTED.length === 0 ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState title="Nothing connected yet" description="Choose a provider from the catalog to start ingesting artifacts." icon={Plug} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{CONNECTED.map((c) => (
|
||||||
|
<li key={c.key} className="p-4 flex items-center gap-4 hover:bg-slate-50">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-50 border border-slate-200 grid place-items-center shrink-0">
|
||||||
|
<Github className="w-5 h-5 text-slate-700" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-slate-800">{c.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">tenant: <span className="asf-monospace">{c.tenant}</span></div>
|
||||||
|
</div>
|
||||||
|
<Badge tone={c.status === "ok" ? "green" : "amber"} className="inline-flex items-center gap-1">
|
||||||
|
{c.status === "ok" ? <CheckCircle2 className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
|
||||||
|
{c.status}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Catalog</CardTitle>
|
||||||
|
<Badge tone="brand">asf-connectors package</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
|
{CATALOG.map((c) => {
|
||||||
|
const Icon = c.icon;
|
||||||
|
return (
|
||||||
|
<div key={c.key} className="p-4 rounded-xl border border-slate-200 bg-white hover:border-brand-200 hover:shadow-card transition-all">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-brand-50 border border-brand-100 grid place-items-center text-brand-600 shrink-0">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
tone={c.status === "available" ? "green" : c.status === "planned" ? "neutral" : "amber"}
|
||||||
|
>
|
||||||
|
{c.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="font-semibold text-slate-800">{c.name}</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-1 capitalize">{c.kind}</div>
|
||||||
|
<p className="text-xs text-slate-600 mt-2 leading-relaxed">{c.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button variant="secondary" size="sm" disabled={c.status !== "available"}>
|
||||||
|
{c.status === "available" ? "Connect" : "Coming soon"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Workflow,
|
||||||
|
Search,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
CirclePause,
|
||||||
|
Circle,
|
||||||
|
PlayCircle,
|
||||||
|
Activity,
|
||||||
|
ChevronRight,
|
||||||
|
Send,
|
||||||
|
Tag,
|
||||||
|
Hash,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { jobs, projects } from "@/lib/api";
|
||||||
|
import { cn, fmtDate, shortId } from "@/lib/utils";
|
||||||
|
import type { Job, JobCreate, JobStatus, JobType } from "@/lib/types";
|
||||||
|
import { JOB_STATUSES, JOB_TYPES } from "@/lib/types";
|
||||||
|
|
||||||
|
const DEMO_JOBS: Job[] = [
|
||||||
|
{
|
||||||
|
id: "j-demo-1",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p1",
|
||||||
|
type: "ai_interactive",
|
||||||
|
priority: "normal",
|
||||||
|
status: "completed",
|
||||||
|
payload: { prompt: "Summarize the spec.", temperature: 0.2 },
|
||||||
|
correlation_id: "cid-summ-001",
|
||||||
|
requested_by: "u1",
|
||||||
|
worker: "asf-worker-3",
|
||||||
|
model: "asf-mock-1",
|
||||||
|
result: { summary: "Mock AI summary for demo purposes." },
|
||||||
|
usage: { input_tokens: 112, output_tokens: 48, cpu_seconds: 0.02 },
|
||||||
|
progress_percent: 100,
|
||||||
|
created_at: "2025-11-15T09:00:00Z",
|
||||||
|
started_at: "2025-11-15T09:00:01Z",
|
||||||
|
completed_at: "2025-11-15T09:00:06Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "j-demo-2",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p1",
|
||||||
|
type: "testing",
|
||||||
|
priority: "high",
|
||||||
|
status: "running",
|
||||||
|
payload: { suite: "smoke", pattern: "**/test_*.py" },
|
||||||
|
requested_by: "u2",
|
||||||
|
worker: "asf-worker-1",
|
||||||
|
progress_percent: 42,
|
||||||
|
created_at: "2025-11-15T09:18:00Z",
|
||||||
|
started_at: "2025-11-15T09:18:10Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "j-demo-3",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p2",
|
||||||
|
type: "code_analysis",
|
||||||
|
priority: "normal",
|
||||||
|
status: "failed",
|
||||||
|
payload: { repository: "asf/core", branch: "main" },
|
||||||
|
requested_by: "u1",
|
||||||
|
worker: "asf-worker-2",
|
||||||
|
error: "Repository clone failed",
|
||||||
|
error_detail: { exit_code: 128, stderr: "fatal: repository not found" },
|
||||||
|
created_at: "2025-11-15T08:55:00Z",
|
||||||
|
started_at: "2025-11-15T08:55:15Z",
|
||||||
|
completed_at: "2025-11-15T08:56:30Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<JobStatus, React.ComponentType<{ className?: string }>> = {
|
||||||
|
queued: Clock,
|
||||||
|
running: PlayCircle,
|
||||||
|
completed: CheckCircle2,
|
||||||
|
failed: XCircle,
|
||||||
|
cancelled: CirclePause,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<JobStatus, "blue" | "amber" | "green" | "red" | "neutral" | "brand" | "violet"> = {
|
||||||
|
queued: "neutral",
|
||||||
|
running: "blue",
|
||||||
|
completed: "green",
|
||||||
|
failed: "red",
|
||||||
|
cancelled: "amber",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_TONE: Record<string, "brand" | "violet" | "blue" | "green" | "amber" | "neutral" | "red"> = {
|
||||||
|
ai_interactive: "violet",
|
||||||
|
ai_background: "violet",
|
||||||
|
indexing: "blue",
|
||||||
|
code_analysis: "brand",
|
||||||
|
testing: "green",
|
||||||
|
devops: "amber",
|
||||||
|
notification: "neutral",
|
||||||
|
custom: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
function queueForJobType(t: JobType | string): string {
|
||||||
|
// 1-to-1 mapping with asf.jobs DIRECT exchange queues
|
||||||
|
switch (t) {
|
||||||
|
case "ai_interactive": return "asf.ai.interactive";
|
||||||
|
case "ai_background": return "asf.ai.background";
|
||||||
|
case "indexing": return "asf.indexing";
|
||||||
|
case "code_analysis": return "asf.code_analysis";
|
||||||
|
case "testing": return "asf.testing";
|
||||||
|
case "devops": return "asf.devops";
|
||||||
|
case "notification": return "asf.notifications";
|
||||||
|
default: return `asf.${t ?? "custom"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Create modal state
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [newType, setNewType] = useState<JobType>("testing");
|
||||||
|
const [newProjectId, setNewProjectId] = useState<string>("");
|
||||||
|
const [newPriority, setNewPriority] = useState<"low" | "normal" | "high" | "urgent">("normal");
|
||||||
|
const [newSequence, setNewSequence] = useState<string>("hello-asf");
|
||||||
|
|
||||||
|
const projectsQ = useQuery({
|
||||||
|
queryKey: ["jobs-projects"],
|
||||||
|
queryFn: () => projects.list({ limit: 100 }),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQ = useQuery({
|
||||||
|
queryKey: ["jobs-list", { status: statusFilter, type: typeFilter, q: q.trim() }],
|
||||||
|
queryFn: () =>
|
||||||
|
jobs.list({
|
||||||
|
status: statusFilter !== "all" ? (statusFilter as JobStatus) : undefined,
|
||||||
|
type: typeFilter !== "all" ? (typeFilter as JobType) : undefined,
|
||||||
|
limit: 200,
|
||||||
|
}),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: 3000, // auto-refresh each 3s so running → completed appears without manual reload
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitM = useMutation({
|
||||||
|
mutationFn: async (body: JobCreate) => jobs.submit(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined);
|
||||||
|
setShowNew(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiResolved = listQ.isFetched && listQ.data !== undefined;
|
||||||
|
const rows: Job[] = apiResolved ? listQ.data! : DEMO_JOBS;
|
||||||
|
const isMock = !apiResolved;
|
||||||
|
|
||||||
|
const projectNameById = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const p of projectsQ.data ?? []) {
|
||||||
|
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [projectsQ.data]);
|
||||||
|
|
||||||
|
// Default first project for new job dropdown (if any)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!newProjectId && projectsQ.data?.length) setNewProjectId(String(projectsQ.data[0].id));
|
||||||
|
}, [newProjectId, projectsQ.data]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const ql = q.trim().toLowerCase();
|
||||||
|
return rows.filter((j) => {
|
||||||
|
if (statusFilter !== "all" && j.status !== statusFilter) return false;
|
||||||
|
if (typeFilter !== "all" && j.type !== typeFilter) return false;
|
||||||
|
if (ql) {
|
||||||
|
const hay = [
|
||||||
|
String(j.id),
|
||||||
|
String(j.correlation_id ?? ""),
|
||||||
|
String(j.worker ?? ""),
|
||||||
|
String(j.model ?? ""),
|
||||||
|
String(j.error ?? ""),
|
||||||
|
JSON.stringify(j.payload ?? {}),
|
||||||
|
JSON.stringify(j.result ?? {}),
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
if (!hay.includes(ql)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [rows, statusFilter, typeFilter, q]);
|
||||||
|
|
||||||
|
const selected =
|
||||||
|
(selectedId !== null ? filtered.find((j) => String(j.id) === String(selectedId)) : null) ??
|
||||||
|
filtered[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const statuses = Array.from(new Set(rows.map((j) => j.status)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col min-h-0">
|
||||||
|
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<Workflow className="w-6 h-6 text-brand-600" /> Jobs
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Asynchronous ASF work: queued → running → completed / failed. Each job is routed to its dedicated RabbitMQ queue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMock && <Badge tone="amber">Demo data</Badge>}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
|
||||||
|
>
|
||||||
|
<Activity className="w-4 h-4" /> Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
|
||||||
|
<Send className="w-4 h-4" /> Enqueue job
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<section className="px-6 lg:px-8 pb-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2 text-sm">
|
||||||
|
{(["queued", "running", "completed", "failed", "cancelled"] as JobStatus[]).map((s) => {
|
||||||
|
const count = rows.filter((r) => r.status === s).length;
|
||||||
|
const Icon = STATUS_ICON[s];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStatusFilter(statusFilter === s ? "all" : s)}
|
||||||
|
className={cn(
|
||||||
|
"text-left p-2 rounded-lg border transition-colors",
|
||||||
|
statusFilter === s
|
||||||
|
? "border-brand-300 bg-brand-50"
|
||||||
|
: "border-slate-200 bg-white hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-slate-500 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1"><Icon className="w-3.5 h-3.5" />{s}</span>
|
||||||
|
<Badge tone={STATUS_TONE[s]}><span className="tabular-nums">{count}</span></Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="p-2 rounded-lg border border-slate-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between text-slate-500 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1"><Hash className="w-3.5 h-3.5" />Total</span>
|
||||||
|
<Badge tone="brand"><span className="tabular-nums">{rows.length}</span></Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
|
||||||
|
<div className="relative flex-1 min-w-[220px]">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
className="asf-input pl-9"
|
||||||
|
placeholder="Search id, correlation_id, worker, payload json, error…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<select
|
||||||
|
className="asf-input !py-1.5 !text-xs !w-auto"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
{JOB_STATUSES.concat(statuses).map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="asf-input !py-1.5 !text-xs !w-auto"
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
{JOB_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
|
||||||
|
<Card className="xl:col-span-3 flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Job list</CardTitle>
|
||||||
|
<span className="text-xs text-slate-500">Auto-refresh each 3s · click a row →</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
title="No jobs match"
|
||||||
|
description="Try adjusting filters, or click Enqueue job to create a new echo testing job."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">ID</th>
|
||||||
|
<th className="text-left px-4 py-2">Type</th>
|
||||||
|
<th className="text-left px-4 py-2">Status</th>
|
||||||
|
<th className="text-left px-4 py-2">Progress</th>
|
||||||
|
<th className="text-left px-4 py-2">Queue</th>
|
||||||
|
<th className="text-left px-4 py-2">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filtered.map((j) => {
|
||||||
|
const active = selected && String(selected.id) === String(j.id);
|
||||||
|
const StatusIcon = STATUS_ICON[j.status] ?? Circle;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={String(j.id)}
|
||||||
|
onClick={() => setSelectedId(String(j.id))}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer hover:bg-brand-50/40 transition-colors",
|
||||||
|
active && "bg-brand-50/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500 align-top">
|
||||||
|
{shortId(String(j.id))}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 align-top">
|
||||||
|
<Badge tone={TYPE_TONE[j.type] ?? "neutral"}>{j.type}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 align-top">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<StatusIcon className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
j.status === "running" && "animate-spin text-brand-600",
|
||||||
|
j.status === "completed" && "text-emerald-600",
|
||||||
|
j.status === "failed" && "text-rose-600",
|
||||||
|
j.status === "queued" && "text-slate-500",
|
||||||
|
)} />
|
||||||
|
<Badge tone={STATUS_TONE[j.status]}>{j.status}</Badge>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 align-top">
|
||||||
|
<div className="flex items-center gap-2 min-w-[100px]">
|
||||||
|
<div className="h-1.5 flex-1 rounded-full bg-slate-100 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all",
|
||||||
|
j.status === "failed" ? "bg-rose-500" : j.status === "completed" ? "bg-emerald-500" : "bg-brand-500",
|
||||||
|
)}
|
||||||
|
style={{ width: `${j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "running" ? 35 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-slate-500 w-9 text-right">
|
||||||
|
{j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "queued" ? 0 : "—")}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 align-top text-xs text-slate-500 asf-monospace whitespace-nowrap">
|
||||||
|
{queueForJobType(j.type)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 align-top text-xs text-slate-500 whitespace-nowrap">
|
||||||
|
{fmtDate(j.completed_at ?? j.started_at ?? j.created_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="xl:col-span-2 flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Detail</CardTitle>
|
||||||
|
<Link href="/ai-agents" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
|
||||||
|
AI Agents <ChevronRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="overflow-auto flex-1 min-h-0">
|
||||||
|
{selected ? (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1.5">
|
||||||
|
<span className="asf-monospace text-xs text-slate-500">{shortId(String(selected.id))}</span>
|
||||||
|
<Badge tone={TYPE_TONE[selected.type] ?? "neutral"}>{selected.type}</Badge>
|
||||||
|
<Badge tone={STATUS_TONE[selected.status]}>{selected.status}</Badge>
|
||||||
|
{selected.priority && <Badge tone="neutral">{selected.priority}</Badge>}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
|
||||||
|
{selected.type} job · {queueForJobType(selected.type)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Project</dt>
|
||||||
|
<dd className="font-medium text-slate-800">
|
||||||
|
{selected.project_id
|
||||||
|
? (projectNameById.get(String(selected.project_id)) ?? shortId(String(selected.project_id)))
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Queue</dt>
|
||||||
|
<dd className="asf-monospace text-slate-700">{queueForJobType(selected.type)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Worker</dt>
|
||||||
|
<dd className="text-slate-700">{selected.worker ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Model</dt>
|
||||||
|
<dd className="text-slate-700">{selected.model ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Requested by</dt>
|
||||||
|
<dd className="asf-monospace text-slate-700">
|
||||||
|
{selected.requested_by ? shortId(String(selected.requested_by)) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Correlation</dt>
|
||||||
|
<dd className="asf-monospace text-slate-700">{selected.correlation_id ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Created</dt>
|
||||||
|
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Completed</dt>
|
||||||
|
<dd className="text-slate-700">{selected.completed_at ? fmtDate(selected.completed_at) : "—"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{selected.payload !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><Tag className="w-3 h-3" /> payload</div>
|
||||||
|
<pre className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-xs overflow-auto max-h-40">
|
||||||
|
{JSON.stringify(selected.payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.result !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-emerald-600" /> result</div>
|
||||||
|
<pre className="p-3 rounded-lg bg-emerald-50 border border-emerald-100 text-xs overflow-auto max-h-48">
|
||||||
|
{JSON.stringify(selected.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.error && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><XCircle className="w-3 h-3 text-rose-600" /> error</div>
|
||||||
|
<div className="p-3 rounded-lg bg-rose-50 border border-rose-100 text-rose-700 text-xs overflow-auto max-h-40 space-y-2">
|
||||||
|
<div className="font-semibold">{selected.error}</div>
|
||||||
|
{selected.error_detail && (
|
||||||
|
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(selected.error_detail, null, 2)}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.usage && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-1.5">usage</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{Object.entries(selected.usage).map(([k, v]) =>
|
||||||
|
v === undefined ? null : (
|
||||||
|
<div key={k} className="flex justify-between p-2 rounded bg-slate-50 border border-slate-100">
|
||||||
|
<span className="text-slate-500">{k}</span>
|
||||||
|
<span className="font-medium text-slate-800 tabular-nums">{JSON.stringify(v)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
|
||||||
|
>
|
||||||
|
<Activity className="w-4 h-4" /> Refresh status
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
|
||||||
|
<Send className="w-4 h-4" /> Enqueue similar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="Select a job"
|
||||||
|
description="Pick any row from the list to inspect payload, result, error and usage counters."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New job modal */}
|
||||||
|
{showNew && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => setShowNew(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-xl bg-white border border-slate-200 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<Send className="w-4 h-4 text-brand-600" /> Enqueue new job
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setShowNew(false)} className="text-slate-400 hover:text-slate-600 text-xl leading-none" aria-label="close">×</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="p-5 space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submitM.mutate({
|
||||||
|
project_id: newProjectId || undefined,
|
||||||
|
type: newType,
|
||||||
|
priority: newPriority,
|
||||||
|
payload: { sequence: newSequence, message: "hello-asf", ts: Date.now() },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Type (direct queue)</label>
|
||||||
|
<select
|
||||||
|
className="asf-input mt-1"
|
||||||
|
value={newType}
|
||||||
|
onChange={(e) => setNewType(e.target.value as JobType)}
|
||||||
|
>
|
||||||
|
{JOB_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t} · {queueForJobType(t)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Project</label>
|
||||||
|
<select
|
||||||
|
className="asf-input mt-1"
|
||||||
|
value={newProjectId}
|
||||||
|
onChange={(e) => setNewProjectId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">(no project)</option>
|
||||||
|
{(projectsQ.data ?? []).map((p) => (
|
||||||
|
<option key={String(p.id)} value={String(p.id)}>{p.name ?? shortId(String(p.id))}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Priority</label>
|
||||||
|
<select
|
||||||
|
className="asf-input mt-1"
|
||||||
|
value={newPriority}
|
||||||
|
onChange={(e) => setNewPriority(e.target.value as typeof newPriority)}
|
||||||
|
>
|
||||||
|
{(["low", "normal", "high", "urgent"] as const).map((p) => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Echo sequence (payload.sequence)</label>
|
||||||
|
<input
|
||||||
|
className="asf-input mt-1"
|
||||||
|
value={newSequence}
|
||||||
|
onChange={(e) => setNewSequence(e.target.value)}
|
||||||
|
placeholder="job-e2e-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={() => setShowNew(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={submitM.isPending || !newType}
|
||||||
|
>
|
||||||
|
{submitM.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Enqueuing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-4 h-4" /> Enqueue
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Sidebar } from "@/components/shell/Sidebar";
|
||||||
|
import { Topbar } from "@/components/shell/Topbar";
|
||||||
|
import { AskAsfCommandBar } from "@/components/shell/AskAsfCommandBar";
|
||||||
|
import { AppProviders } from "@/components/shell/AppProviders";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "ASF · Azurian Software Factory",
|
||||||
|
description:
|
||||||
|
"From Requirement to Production, everything is contextualized, traceable and AI-accessible.",
|
||||||
|
icons: { icon: "/favicon.svg" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="h-full">
|
||||||
|
<body className="h-full">
|
||||||
|
<AppProviders>
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col">
|
||||||
|
<Topbar />
|
||||||
|
<main className="flex-1 min-w-0 overflow-x-hidden animate-fade-in">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AskAsfCommandBar />
|
||||||
|
</AppProviders>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Activity, Server, AlertOctagon, Clock } from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { MetricCard } from "@/components/ui/MetricCard";
|
||||||
|
import { fmtDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ name: "asf-core", status: "healthy", latency_ms: 42, error_rate: 0.1, rps: 184 },
|
||||||
|
{ name: "asf-worker", status: "healthy", latency_ms: 0, error_rate: 0.0, rps: 0, jobs: 6 },
|
||||||
|
{ name: "asf-context", status: "degraded", latency_ms: 310, error_rate: 2.3, rps: 42 },
|
||||||
|
{ name: "asf-ai", status: "healthy", latency_ms: 620, error_rate: 0.5, rps: 12 },
|
||||||
|
{ name: "mongodb", status: "healthy", latency_ms: 3, error_rate: 0.0, rps: 0 },
|
||||||
|
{ name: "rabbitmq", status: "healthy", latency_ms: 2, error_rate: 0.0, queue_depth: 14 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INCIDENTS = [
|
||||||
|
{ id: "INC-233", sev: "medium", title: "asf-context p99 latency spike", started: "2025-11-22T08:10:00Z", state: "open" },
|
||||||
|
{ id: "INC-232", sev: "low", title: "MinIO console certificate warn", started: "2025-11-21T14:40:00Z", state: "resolved" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ObservabilityPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<Activity className="w-6 h-6 text-brand-600" />
|
||||||
|
Observability
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Service health, latency, error rates and active incidents. Structured JSON logs + correlation_id propagate across every ASF hop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">OpenTelemetry · prepared</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<MetricCard label="Services healthy" value={5} tone="green" icon={<Server className="w-5 h-5" />} subtitle="of 6 total" />
|
||||||
|
<MetricCard label="Requests / sec" value={238} tone="brand" icon={<Activity className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Error rate" value={0.6} isPercentage tone="amber" icon={<AlertOctagon className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Open incidents" value={1} tone="red" icon={<Clock className="w-5 h-5" />} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Service inventory</CardTitle>
|
||||||
|
<Badge tone="brand">p50 latency</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">Service</th>
|
||||||
|
<th className="text-left px-4 py-2">Status</th>
|
||||||
|
<th className="text-right px-4 py-2">Latency</th>
|
||||||
|
<th className="text-right px-4 py-2">Error %</th>
|
||||||
|
<th className="text-right px-4 py-2">Load</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{SERVICES.map((s) => (
|
||||||
|
<tr key={s.name} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs">{s.name}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge tone={s.status === "healthy" ? "green" : s.status === "degraded" ? "amber" : "red"}>{s.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{s.latency_ms} ms</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{s.error_rate.toFixed(2)}%</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs text-slate-600 tabular-nums">
|
||||||
|
{s.rps ? `${s.rps} rps` : s.queue_depth !== undefined ? `q=${s.queue_depth}` : `${s.jobs} jobs`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Incidents</CardTitle>
|
||||||
|
<Badge tone="amber">correlation_id · audit</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{INCIDENTS.length === 0 ? (
|
||||||
|
<EmptyState title="All quiet" description="No incidents in the last 24 hours." icon={Activity} />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{INCIDENTS.map((i) => (
|
||||||
|
<li key={i.id} className="p-3 rounded-lg border border-slate-100 hover:border-slate-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={i.sev === "high" || i.sev === "critical" ? "red" : i.sev === "medium" ? "amber" : "neutral"} className="uppercase">{i.sev}</Badge>
|
||||||
|
<span className="font-mono text-xs text-slate-500">{i.id}</span>
|
||||||
|
<Badge tone={i.state === "open" ? "amber" : "green"}>{i.state}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 font-medium text-slate-800">{i.title}</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">Started {fmtDate(i.started)}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
FolderKanban,
|
||||||
|
TestTube2,
|
||||||
|
Rocket,
|
||||||
|
GitCommit,
|
||||||
|
AlertTriangle,
|
||||||
|
Bot,
|
||||||
|
ShieldAlert,
|
||||||
|
RefreshCw,
|
||||||
|
PlayCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle, CardFooter } from "@/components/ui/Card";
|
||||||
|
import { MetricCard } from "@/components/ui/MetricCard";
|
||||||
|
import { ProgressRing } from "@/components/ui/ProgressRing";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { overview, health } from "@/lib/api";
|
||||||
|
import type { OverviewMetrics } from "@/lib/types";
|
||||||
|
|
||||||
|
const DEMO_METRICS: OverviewMetrics = {
|
||||||
|
tenant_id: "demo-tenant",
|
||||||
|
total_projects: 6,
|
||||||
|
total_requirements: 142,
|
||||||
|
total_tasks: 318,
|
||||||
|
total_tests: 512,
|
||||||
|
total_deployments: 27,
|
||||||
|
total_commits: 1204,
|
||||||
|
jobs_running: 3,
|
||||||
|
jobs_queued: 8,
|
||||||
|
jobs_failed_24h: 2,
|
||||||
|
implementation_coverage_pct: 74.3,
|
||||||
|
test_coverage_pct: 68.9,
|
||||||
|
traceability_score_pct: 81.5,
|
||||||
|
project_health_pct: 77.2,
|
||||||
|
security_findings: 14,
|
||||||
|
failed_builds: 3,
|
||||||
|
active_ai_jobs: 5,
|
||||||
|
featured_projects: [
|
||||||
|
{ key: "ASF", name: "Azurian Software Factory" },
|
||||||
|
{ key: "NOVA", name: "Nova Customer Portal" },
|
||||||
|
{ key: "ORB", name: "Orbital Billing" },
|
||||||
|
],
|
||||||
|
mock_fields: ["*"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OverviewPage() {
|
||||||
|
const metricsQ = useQuery({
|
||||||
|
queryKey: ["overview"],
|
||||||
|
queryFn: () => overview.get(),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const healthQ = useQuery({
|
||||||
|
queryKey: ["health"],
|
||||||
|
queryFn: () => health(),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const m: OverviewMetrics = metricsQ.data ?? DEMO_METRICS;
|
||||||
|
const isMock = !metricsQ.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
|
||||||
|
Overview
|
||||||
|
</h1>
|
||||||
|
{isMock && (
|
||||||
|
<Badge tone="amber" className="shrink-0">
|
||||||
|
Demo data
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{healthQ.data && (
|
||||||
|
<Badge tone={healthQ.data.status === "ok" ? "green" : "red"}>
|
||||||
|
{healthQ.data.service} v{healthQ.data.version} · {healthQ.data.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
From Requirement to Production — everything contextualized, traceable and AI-accessible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void metricsQ.refetch();
|
||||||
|
void healthQ.refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm">
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
New AI Job
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Projects"
|
||||||
|
value={m.total_projects}
|
||||||
|
tone="brand"
|
||||||
|
icon={<FolderKanban className="w-5 h-5" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Requirements"
|
||||||
|
value={m.total_requirements}
|
||||||
|
tone="violet"
|
||||||
|
delta={3.4}
|
||||||
|
icon={<ClipboardList className="w-5 h-5" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Tasks"
|
||||||
|
value={m.total_tasks}
|
||||||
|
tone="blue"
|
||||||
|
icon={<ClipboardList className="w-5 h-5" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Tests"
|
||||||
|
value={m.total_tests}
|
||||||
|
tone="green"
|
||||||
|
delta={1.1}
|
||||||
|
icon={<TestTube2 className="w-5 h-5" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Deployments"
|
||||||
|
value={m.total_deployments}
|
||||||
|
tone="brand"
|
||||||
|
icon={<Rocket className="w-5 h-5" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Commits"
|
||||||
|
value={m.total_commits}
|
||||||
|
tone="neutral"
|
||||||
|
icon={<GitCommit className="w-5 h-5" />}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Health & Coverage</CardTitle>
|
||||||
|
<Badge tone="neutral">Across all projects</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<ProgressRing
|
||||||
|
value={m.project_health_pct}
|
||||||
|
tone="green"
|
||||||
|
label="Project Health"
|
||||||
|
sublabel="Weighted stability score"
|
||||||
|
/>
|
||||||
|
<ProgressRing
|
||||||
|
value={m.implementation_coverage_pct}
|
||||||
|
tone="brand"
|
||||||
|
label="Implementation Coverage"
|
||||||
|
sublabel="Requirements → Tasks → Code"
|
||||||
|
/>
|
||||||
|
<ProgressRing
|
||||||
|
value={m.test_coverage_pct}
|
||||||
|
tone="amber"
|
||||||
|
label="Test Coverage"
|
||||||
|
sublabel="Automated vs total tests"
|
||||||
|
/>
|
||||||
|
<ProgressRing
|
||||||
|
value={m.traceability_score_pct}
|
||||||
|
tone="violet"
|
||||||
|
label="Traceability Score"
|
||||||
|
sublabel="Graph connectivity"
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Jobs & AI</CardTitle>
|
||||||
|
<Badge tone="brand">
|
||||||
|
<Bot className="w-3 h-3" /> AI
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-3">
|
||||||
|
<MetricCard
|
||||||
|
label="Jobs running"
|
||||||
|
value={m.jobs_running}
|
||||||
|
tone="blue"
|
||||||
|
icon={<PlayCircle className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Jobs queued"
|
||||||
|
value={m.jobs_queued}
|
||||||
|
tone="neutral"
|
||||||
|
icon={<RefreshCw className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Jobs failed (24h)"
|
||||||
|
value={m.jobs_failed_24h}
|
||||||
|
tone="red"
|
||||||
|
icon={<AlertTriangle className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Active AI jobs"
|
||||||
|
value={m.active_ai_jobs}
|
||||||
|
tone="violet"
|
||||||
|
icon={<Bot className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter>
|
||||||
|
<span className="text-slate-500">Priority queue: high → low</span>
|
||||||
|
<a className="text-brand-600 font-medium hover:underline cursor-pointer" href="/pipelines">
|
||||||
|
View pipelines →
|
||||||
|
</a>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Alerts</CardTitle>
|
||||||
|
<Badge tone="red">{m.security_findings + m.failed_builds}</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border border-red-100 bg-red-50/60">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-red-600 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-red-900">
|
||||||
|
{m.security_findings} Security findings
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-red-700/80 mt-0.5">
|
||||||
|
Review findings in Quality → Security
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border border-amber-100 bg-amber-50/60">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-amber-900">
|
||||||
|
{m.failed_builds} Failed builds
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-800/80 mt-0.5">
|
||||||
|
Last 24 hours — inspect for root causes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{m.featured_projects && m.featured_projects.length > 0 ? (
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">
|
||||||
|
Featured projects
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-slate-100 border border-slate-100 rounded-lg overflow-hidden">
|
||||||
|
{m.featured_projects.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.key}
|
||||||
|
className="flex items-center justify-between px-3 py-2.5 text-sm hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-slate-800 truncate">{p.name}</div>
|
||||||
|
<div className="text-xs text-slate-500 asf-monospace">{p.key}</div>
|
||||||
|
</div>
|
||||||
|
<Badge tone="brand">Open</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No projects yet"
|
||||||
|
description="Create a project to start tracking your software factory."
|
||||||
|
actionLabel="Create project"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { GitFork, PlayCircle, Clock, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { MetricCard } from "@/components/ui/MetricCard";
|
||||||
|
import { fmtDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
const PIPELINES = [
|
||||||
|
{ id: "pl-indexing", name: "Repository Indexing", stage: "connectors → asf-worker → MongoDB", last: "2025-11-22T09:10:00Z", status: "running", runs: 184 },
|
||||||
|
{ id: "pl-code-analysis", name: "Code Analysis", stage: "PR event → asf-ai → artifact + relationships", last: "2025-11-22T08:40:00Z", status: "queued", runs: 52 },
|
||||||
|
{ id: "pl-test-gen", name: "AI Test Generation", stage: "Task → Prompt → LLM → PR", last: "2025-11-21T18:02:00Z", status: "completed", runs: 13 },
|
||||||
|
{ id: "pl-deploy", name: "Deployment Rollout", stage: "Build OK → Argo → runtime_events", last: "2025-11-20T14:11:00Z", status: "completed", runs: 27 },
|
||||||
|
{ id: "pl-incident", name: "Incident Traceback", stage: "Incident → asf-context → root-cause", last: "2025-11-19T10:55:00Z", status: "failed", runs: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PipelinesPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<GitFork className="w-6 h-6 text-brand-600" />
|
||||||
|
Pipelines
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
RabbitMQ-backed flows: domain events → workers → downstream artifacts. This page lists currently registered pipeline topologies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">Event backbone · RabbitMQ</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<MetricCard label="Running" value={1} tone="blue" icon={<PlayCircle className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Queued" value={1} tone="neutral" icon={<Clock className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Completed (7d)" value={112} tone="green" icon={<CheckCircle2 className="w-5 h-5" />} delta={4.2} />
|
||||||
|
<MetricCard label="Failed (24h)" value={1} tone="red" icon={<AlertTriangle className="w-5 h-5" />} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Registered pipelines</CardTitle>
|
||||||
|
<Badge tone="brand">{PIPELINES.length} topologies</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{PIPELINES.length === 0 ? (
|
||||||
|
<li className="p-6"><EmptyState title="No pipelines yet" description="Register an async topology in asf-worker to surface it here." /></li>
|
||||||
|
) : PIPELINES.map((p) => (
|
||||||
|
<li key={p.id} className="p-4 hover:bg-slate-50 transition-colors flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 mt-0.5 rounded-lg bg-brand-50 border border-brand-100 grid place-items-center text-brand-600 shrink-0">
|
||||||
|
<GitFork className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="font-semibold text-slate-800">{p.name}</div>
|
||||||
|
<Badge tone="neutral" className="asf-monospace">{p.id}</Badge>
|
||||||
|
<JobStatusBadge status={p.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-600 font-mono bg-slate-50 inline-block px-2 py-1 rounded-md">
|
||||||
|
{p.stage}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-slate-500">
|
||||||
|
<span>Last run: <span className="text-slate-700">{fmtDate(p.last)}</span></span>
|
||||||
|
<span>Total runs: <span className="tabular-nums text-slate-700 font-semibold">{p.runs}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ShieldCheck, ShieldAlert, Bug, TestTube2, Lock } from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { MetricCard } from "@/components/ui/MetricCard";
|
||||||
|
import { ProgressRing } from "@/components/ui/ProgressRing";
|
||||||
|
|
||||||
|
const FINDINGS = [
|
||||||
|
{ sev: "critical", title: "Hardcoded secret in asf-core/config.py#L18", rule: "SECRET-LEAK-001", file: "services/asf-core/app/config.py" },
|
||||||
|
{ sev: "high", title: "SQL injection vector in raw query", rule: "SQLI-014", file: "services/asf-core/app/repositories/artifact_repo.py" },
|
||||||
|
{ sev: "medium", title: "CORS permissive in dev-trust mode", rule: "CORS-002", file: "services/asf-core/app/main.py" },
|
||||||
|
{ sev: "medium", title: "Missing rate limit on /ask-asf", rule: "RATE-010", file: "services/asf-core/app/api/v1/ask_asf.py" },
|
||||||
|
{ sev: "low", title: "Insecure cookie flags hint", rule: "COOKIE-004", file: "services/asf-core/app/dependencies.py" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEV_TONES: Record<string, "red" | "amber" | "neutral"> = {
|
||||||
|
critical: "red", high: "red", medium: "amber", low: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QualityPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-6 h-6 text-emerald-600" />
|
||||||
|
Quality & Security
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Static analysis, test coverage, security findings, build health — consolidated from every ASF connector.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">SonarQube · Semgrep connectors</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<MetricCard label="Critical findings" value={1} tone="red" icon={<ShieldAlert className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Tests passing" value={498} tone="green" icon={<TestTube2 className="w-5 h-5" />} subtitle="of 512 total" delta={0.6} />
|
||||||
|
<MetricCard label="Coverage" value={68.9} isPercentage tone="brand" icon={<Bug className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Reliability score" value="A-" tone="green" icon={<Lock className="w-5 h-5" />} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Scores</CardTitle></CardHeader>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<ProgressRing value={72} tone="green" label="Security" sublabel="Findings + hardening" />
|
||||||
|
<ProgressRing value={68.9} tone="brand" label="Coverage" sublabel="Statement coverage" />
|
||||||
|
<ProgressRing value={81} tone="amber" label="Maintainability" sublabel="Duplication + complexity" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security findings</CardTitle>
|
||||||
|
<Badge tone="red">{FINDINGS.filter((f) => f.sev === "critical" || f.sev === "high").length} critical/high</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{FINDINGS.length === 0 ? (
|
||||||
|
<div className="p-6"><EmptyState title="No findings" description="Your first scan will populate this panel." icon={ShieldCheck} /></div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{FINDINGS.map((f, i) => (
|
||||||
|
<li key={i} className="p-3 hover:bg-slate-50 flex items-start gap-3">
|
||||||
|
<Badge tone={SEV_TONES[f.sev]} className="uppercase mt-0.5">{f.sev}</Badge>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-800">{f.title}</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 flex flex-wrap items-center gap-3">
|
||||||
|
<span className="asf-monospace">{f.rule}</span>
|
||||||
|
<span className="asf-monospace truncate">{f.file}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
Tag,
|
||||||
|
Calendar,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { artifacts, projects } from "@/lib/api";
|
||||||
|
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
|
||||||
|
import type { Artifact } from "@/lib/types";
|
||||||
|
|
||||||
|
const DEMO_REQS: Artifact[] = [
|
||||||
|
{
|
||||||
|
id: "req-001-aaaaaaaa",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p1",
|
||||||
|
type: "requirement",
|
||||||
|
external_id: "REQ-441",
|
||||||
|
title: "Multi-tenant role-based access control",
|
||||||
|
content:
|
||||||
|
"As a platform administrator I want to define organization roles (owner, admin, member, viewer) so that I can enforce least-privilege access on every ASF module.",
|
||||||
|
status: "in_progress",
|
||||||
|
version: 3,
|
||||||
|
source: "jira",
|
||||||
|
created_by: "u1",
|
||||||
|
created_at: "2025-10-02T09:12:00Z",
|
||||||
|
updated_at: "2025-11-15T14:22:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "req-002-bbbbbbbb",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p1",
|
||||||
|
type: "requirement",
|
||||||
|
external_id: "REQ-442",
|
||||||
|
title: "Audit log immutability & retention",
|
||||||
|
content:
|
||||||
|
"Audit events must be append-only and retained for 365 days with tamper-evident hashing. Export to JSON/CSV must be signed.",
|
||||||
|
status: "backlog",
|
||||||
|
version: 1,
|
||||||
|
source: "manual",
|
||||||
|
created_by: "u2",
|
||||||
|
created_at: "2025-10-18T11:40:00Z",
|
||||||
|
updated_at: "2025-10-18T11:40:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "req-003-cccccccc",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p2",
|
||||||
|
type: "epic",
|
||||||
|
external_id: "EPIC-12",
|
||||||
|
title: "Traceability graph v1",
|
||||||
|
content:
|
||||||
|
"End-to-end artifact visualization: requirement → tasks → PR → builds → tests → deployments, with impact analysis sidebar.",
|
||||||
|
status: "in_progress",
|
||||||
|
version: 5,
|
||||||
|
source: "linear",
|
||||||
|
created_by: "u1",
|
||||||
|
created_at: "2025-09-10T08:00:00Z",
|
||||||
|
updated_at: "2025-11-20T09:02:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "req-004-dddddddd",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p2",
|
||||||
|
type: "requirement",
|
||||||
|
external_id: "REQ-501",
|
||||||
|
title: "AI Gateway provider abstraction",
|
||||||
|
content:
|
||||||
|
"Business logic must not depend on a specific LLM provider. Introduce ASF AI Gateway with adapter pattern, policy routing, usage metering.",
|
||||||
|
status: "done",
|
||||||
|
version: 2,
|
||||||
|
source: "manual",
|
||||||
|
created_at: "2025-08-22T16:15:00Z",
|
||||||
|
updated_at: "2025-11-05T12:30:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "req-005-eeeeeeee",
|
||||||
|
tenant_id: "t1",
|
||||||
|
project_id: "p1",
|
||||||
|
type: "requirement",
|
||||||
|
external_id: "REQ-518",
|
||||||
|
title: "SSO with SAML 2.0 & OIDC",
|
||||||
|
content:
|
||||||
|
"Support Enterprise SSO: Okta, Azure AD, Google Workspace. SCIM provisioning for automated user/group lifecycle.",
|
||||||
|
status: "blocked",
|
||||||
|
version: 1,
|
||||||
|
source: "jira",
|
||||||
|
created_at: "2025-11-01T10:10:00Z",
|
||||||
|
updated_at: "2025-11-22T08:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_TONES: Record<string, "green" | "amber" | "red" | "brand" | "neutral" | "violet" | "blue"> = {
|
||||||
|
backlog: "neutral",
|
||||||
|
todo: "neutral",
|
||||||
|
in_progress: "blue",
|
||||||
|
review: "violet",
|
||||||
|
done: "green",
|
||||||
|
blocked: "red",
|
||||||
|
cancelled: "amber",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RequirementsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/* ---------- New requirement modal ---------- */
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
const [newExternalId, setNewExternalId] = useState("");
|
||||||
|
const [newStatus, setNewStatus] = useState<string>("backlog");
|
||||||
|
const [newProjectId, setNewProjectId] = useState<string>("");
|
||||||
|
const [newContent, setNewContent] = useState("");
|
||||||
|
const [newSource, setNewSource] = useState("manual");
|
||||||
|
const [newMetaText, setNewMetaText] = useState<string>("{}\n");
|
||||||
|
const [newMetaError, setNewMetaError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const parsedMeta = useMemo<Record<string, unknown> | null>(() => {
|
||||||
|
const s = newMetaText.trim();
|
||||||
|
if (!s) return {};
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(s);
|
||||||
|
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
||||||
|
setNewMetaError("metadata must be a JSON object (not null/array/primitive).");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setNewMetaError(null);
|
||||||
|
return v as Record<string, unknown>;
|
||||||
|
} catch (e) {
|
||||||
|
setNewMetaError("Invalid JSON in metadata");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [newMetaText]);
|
||||||
|
|
||||||
|
const createReqM = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!newTitle.trim()) throw new Error("title required");
|
||||||
|
if (!newProjectId) throw new Error("project required");
|
||||||
|
if (parsedMeta === null) throw new Error("metadata JSON invalid");
|
||||||
|
return artifacts.create({
|
||||||
|
project_id: newProjectId,
|
||||||
|
type: "requirement",
|
||||||
|
title: newTitle.trim(),
|
||||||
|
external_id: newExternalId.trim() || undefined,
|
||||||
|
status: newStatus || undefined,
|
||||||
|
content: newContent.trim() || undefined,
|
||||||
|
source: newSource.trim() || undefined,
|
||||||
|
metadata: parsedMeta,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setShowNew(false);
|
||||||
|
setNewTitle(""); setNewExternalId(""); setNewStatus("backlog");
|
||||||
|
setNewProjectId(""); setNewContent(""); setNewSource("manual"); setNewMetaText("{}\n");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["artifacts", "list"] }).catch(() => undefined);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
|
||||||
|
setSelectedId(String(data.id));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectsQ = useQuery({
|
||||||
|
queryKey: ["projects", "list"],
|
||||||
|
queryFn: () => projects.list({ limit: 200 }),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQ = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"artifacts",
|
||||||
|
"list",
|
||||||
|
{ status: statusFilter, type: typeFilter, project: projectFilter, q: q.trim() },
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
artifacts.list({
|
||||||
|
type: typeFilter !== "all" ? (typeFilter as any) : undefined,
|
||||||
|
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||||
|
project_id: projectFilter !== "all" ? projectFilter : undefined,
|
||||||
|
search: q.trim() || undefined,
|
||||||
|
limit: 200,
|
||||||
|
}),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiResolved = listQ.isFetched && listQ.data !== undefined;
|
||||||
|
const rows: Artifact[] = apiResolved ? listQ.data! : DEMO_REQS;
|
||||||
|
const isMock = !apiResolved;
|
||||||
|
|
||||||
|
const projectNameById = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const p of projectsQ.data ?? []) {
|
||||||
|
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [projectsQ.data]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (projectFilter !== "all" && String(r.project_id) !== projectFilter) return false;
|
||||||
|
if (typeFilter !== "all" && r.type !== typeFilter) return false;
|
||||||
|
if (statusFilter !== "all" && (r.status ?? "backlog") !== statusFilter) return false;
|
||||||
|
const ql = q.trim().toLowerCase();
|
||||||
|
if (ql) {
|
||||||
|
const hay = `${r.title} ${r.external_id ?? ""} ${r.content ?? ""}`.toLowerCase();
|
||||||
|
if (!hay.includes(ql)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [rows, projectFilter, typeFilter, statusFilter, q]);
|
||||||
|
|
||||||
|
const selected = filtered.find((r) => r.id === selectedId) ?? filtered[0] ?? null;
|
||||||
|
|
||||||
|
const statuses = Array.from(new Set(rows.map((r) => r.status ?? "backlog")));
|
||||||
|
const types = Array.from(new Set(rows.map((r) => r.type)));
|
||||||
|
const projectOptions = useMemo(() => {
|
||||||
|
const setRows = new Map<string, string>();
|
||||||
|
for (const r of rows) setRows.set(String(r.project_id), projectNameById.get(String(r.project_id)) ?? shortId(String(r.project_id)));
|
||||||
|
for (const p of projectsQ.data ?? []) {
|
||||||
|
if (p?.id) setRows.set(String(p.id), p.name ?? shortId(String(p.id)));
|
||||||
|
}
|
||||||
|
return Array.from(setRows.entries()).map(([id, name]) => ({ id, name }));
|
||||||
|
}, [rows, projectsQ.data, projectNameById]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col min-h-0">
|
||||||
|
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<ClipboardList className="w-6 h-6 text-brand-600" />
|
||||||
|
Requirements
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Every lifecycle item in ASF is an Artifact. Requirements, epics, stories, tasks — searchable, linkable, traceable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMock && <Badge tone="amber">Demo data</Badge>}
|
||||||
|
<Button variant="primary" size="sm" onClick={() => {
|
||||||
|
setShowNew(true);
|
||||||
|
if ((projectsQ.data ?? []).length > 0 && !newProjectId) {
|
||||||
|
setNewProjectId(String(projectsQ.data![0].id));
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Plus className="w-4 h-4" /> New Requirement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
|
||||||
|
<div className="relative flex-1 min-w-[220px]">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
className="asf-input pl-9"
|
||||||
|
placeholder="Search by id, title, content…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<Filter className="w-3.5 h-3.5" />
|
||||||
|
<select
|
||||||
|
className="asf-input !py-1.5 !text-xs !w-auto"
|
||||||
|
value={projectFilter}
|
||||||
|
onChange={(e) => setProjectFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All projects</option>
|
||||||
|
{projectOptions.map(({ id, name }) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="asf-input !py-1.5 !text-xs !w-auto"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="asf-input !py-1.5 !text-xs !w-auto"
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
{types.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Badge tone="brand">
|
||||||
|
<span className="tabular-nums font-semibold">{filtered.length}</span> / {rows.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
|
||||||
|
<Card className="xl:col-span-3 flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Artifact list</CardTitle>
|
||||||
|
<span className="text-xs text-slate-500">Click a row →</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
title="No artifacts match"
|
||||||
|
description="Try adjusting filters or the search term."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">ID</th>
|
||||||
|
<th className="text-left px-4 py-2">Type</th>
|
||||||
|
<th className="text-left px-4 py-2">Title</th>
|
||||||
|
<th className="text-left px-4 py-2">Status</th>
|
||||||
|
<th className="text-left px-4 py-2">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filtered.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => setSelectedId(r.id)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer hover:bg-brand-50/40 transition-colors",
|
||||||
|
selected?.id === r.id && "bg-brand-50/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500">
|
||||||
|
{r.external_id ?? shortId(r.id)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<ArtifactTypeBadge type={r.type} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 font-medium text-slate-800 max-w-md truncate">
|
||||||
|
{r.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge tone={STATUS_TONES[r.status ?? "backlog"] ?? "neutral"}>
|
||||||
|
{r.status ?? "backlog"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-slate-500 whitespace-nowrap">
|
||||||
|
{fmtDate(r.updated_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="xl:col-span-2 flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Detail</CardTitle>
|
||||||
|
<Link href="/traceability" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
|
||||||
|
Traceability <ChevronRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="overflow-auto flex-1 min-h-0">
|
||||||
|
{selected ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="asf-monospace text-xs text-slate-500">
|
||||||
|
{selected.external_id ?? shortId(selected.id)}
|
||||||
|
</span>
|
||||||
|
<ArtifactTypeBadge type={selected.type} />
|
||||||
|
<Badge tone={STATUS_TONES[selected.status ?? "backlog"] ?? "neutral"}>
|
||||||
|
{selected.status ?? "backlog"}
|
||||||
|
</Badge>
|
||||||
|
{selected.version !== undefined && (
|
||||||
|
<Badge tone="neutral">v{selected.version}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
|
||||||
|
{selected.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{selected.content && (
|
||||||
|
<div className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{selected.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Project</dt>
|
||||||
|
<dd className="font-medium text-slate-800">
|
||||||
|
{projectNameById.get(String(selected.project_id)) ?? (
|
||||||
|
<span className="asf-monospace">{shortId(selected.project_id)}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Source</dt>
|
||||||
|
<dd className="font-medium text-slate-800 inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="w-3 h-3" /> {selected.source ?? "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" /> Created
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" /> Updated
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-700">{fmtDate(selected.updated_at)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" size="sm">View relationships</Button>
|
||||||
|
<Button variant="secondary" size="sm">History</Button>
|
||||||
|
<Button variant="primary" size="sm">Ask ASF about this…</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 pt-1">
|
||||||
|
Description preview: {truncate(selected.content ?? "", 160).length > 0 ? truncate(selected.content ?? "", 160) : "No description."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="Select an artifact"
|
||||||
|
description="Pick any row on the left to inspect its metadata and content."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New requirement modal */}
|
||||||
|
{showNew && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createReqM.isPending && setShowNew(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<Tag className="w-4 h-4 text-brand-600" /> New Requirement
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => !createReqM.isPending && setShowNew(false)}
|
||||||
|
disabled={createReqM.isPending}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="p-5 space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
createReqM.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="text-xs text-slate-500">Title *</label>
|
||||||
|
<input
|
||||||
|
className="asf-input mt-1"
|
||||||
|
placeholder="As a user I want …"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">External ID</label>
|
||||||
|
<input
|
||||||
|
className="asf-input mt-1"
|
||||||
|
placeholder="REQ-104"
|
||||||
|
value={newExternalId}
|
||||||
|
onChange={(e) => setNewExternalId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Project *</label>
|
||||||
|
<select
|
||||||
|
className="asf-input mt-1"
|
||||||
|
value={newProjectId}
|
||||||
|
onChange={(e) => setNewProjectId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select project…</option>
|
||||||
|
{(projectsQ.data ?? []).map((p) => (
|
||||||
|
<option key={String(p.id)} value={String(p.id)}>
|
||||||
|
{p.name ?? shortId(String(p.id))}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Status</label>
|
||||||
|
<select
|
||||||
|
className="asf-input mt-1"
|
||||||
|
value={newStatus}
|
||||||
|
onChange={(e) => setNewStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
{["backlog","todo","in_progress","review","done","blocked","cancelled"].map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Source</label>
|
||||||
|
<input
|
||||||
|
className="asf-input mt-1"
|
||||||
|
placeholder="manual"
|
||||||
|
value={newSource}
|
||||||
|
onChange={(e) => setNewSource(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Content / Description</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
className="asf-input mt-1"
|
||||||
|
placeholder="Acceptance criteria, non-goals, links…"
|
||||||
|
value={newContent}
|
||||||
|
onChange={(e) => setNewContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 flex items-center justify-between mb-1">
|
||||||
|
<span>Metadata (JSON)</span>
|
||||||
|
{newMetaError && <span className="text-red-600 font-medium">{newMetaError}</span>}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
className={cn("asf-input mt-0", newMetaError && "border-red-300 focus:ring-red-200")}
|
||||||
|
value={newMetaText}
|
||||||
|
onChange={(e) => setNewMetaText(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1">
|
||||||
|
Example: <code className="bg-slate-100 rounded px-1">{"{\"priority\": \"P0\", \"points\": 8}"} </code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{createReqM.isError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||||||
|
{createReqM.error instanceof Error ? createReqM.error.message : "Failed to create requirement."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowNew(false)}
|
||||||
|
disabled={createReqM.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={createReqM.isPending || !newTitle.trim() || !newProjectId || parsedMeta === null}
|
||||||
|
>
|
||||||
|
{createReqM.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Creating…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4" /> Create requirement
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Settings2,
|
||||||
|
User,
|
||||||
|
Building2,
|
||||||
|
ShieldCheck,
|
||||||
|
Bell,
|
||||||
|
Palette,
|
||||||
|
KeyRound,
|
||||||
|
Save,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
FolderKanban,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Calendar,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { orgs, projects as projectsApi } from "@/lib/api";
|
||||||
|
import type { Organization, Project } from "@/lib/types";
|
||||||
|
import { cn, fmtDate, shortId } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ key: "profile", label: "Profile", icon: User },
|
||||||
|
{ key: "org", label: "Organization", icon: Building2 },
|
||||||
|
{ key: "security", label: "Security", icon: ShieldCheck },
|
||||||
|
{ key: "ai", label: "AI Policy", icon: KeyRound },
|
||||||
|
{ key: "notifs", label: "Notifications", icon: Bell },
|
||||||
|
{ key: "theme", label: "Appearance", icon: Palette },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SecKey = (typeof SECTIONS)[number]["key"];
|
||||||
|
|
||||||
|
const PLAN_TONES: Record<string, "brand" | "violet" | "green" | "amber" | "neutral" | "blue" | "red"> = {
|
||||||
|
Free: "neutral", Team: "blue", Business: "brand", Enterprise: "violet",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TONES: Record<string, "green" | "red" | "neutral" | "brand" | "violet" | "amber" | "blue"> = {
|
||||||
|
true: "green", false: "red", active: "green", inactive: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [sec, setSec] = useState<SecKey>("org");
|
||||||
|
const [name, setName] = useState("Mario Rossi");
|
||||||
|
const [email, setEmail] = useState("mario.rossi@azurian.example");
|
||||||
|
const [role, setRole] = useState("Owner");
|
||||||
|
const [mfa, setMfa] = useState(true);
|
||||||
|
const [ceiling, setCeiling] = useState("50");
|
||||||
|
|
||||||
|
/* ---------------- Organizations ---------------- */
|
||||||
|
const orgsQ = useQuery({
|
||||||
|
queryKey: ["orgs", "list"],
|
||||||
|
queryFn: () => orgs.list({ limit: 200 }),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const [showNewOrg, setShowNewOrg] = useState(false);
|
||||||
|
const [newOrgName, setNewOrgName] = useState("");
|
||||||
|
const [newOrgSlug, setNewOrgSlug] = useState("");
|
||||||
|
const [newOrgPlan, setNewOrgPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
|
||||||
|
const [newOrgActive, setNewOrgActive] = useState(true);
|
||||||
|
const createOrgM = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
|
||||||
|
if (!newOrgName.trim()) throw new Error("Organization name required");
|
||||||
|
if (!slugRgx.test(newOrgSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
|
||||||
|
return orgs.create({
|
||||||
|
name: newOrgName.trim(),
|
||||||
|
slug: newOrgSlug.trim().toLowerCase(),
|
||||||
|
plan: newOrgPlan,
|
||||||
|
is_active: newOrgActive,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowNewOrg(false);
|
||||||
|
setNewOrgName(""); setNewOrgSlug(""); setNewOrgPlan("Team"); setNewOrgActive(true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const slugHint = useMemo(() => {
|
||||||
|
if (!newOrgSlug) return null;
|
||||||
|
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug);
|
||||||
|
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
|
||||||
|
}, [newOrgSlug]);
|
||||||
|
|
||||||
|
/* ---------------- Projects ---------------- */
|
||||||
|
const projectsQ = useQuery({
|
||||||
|
queryKey: ["projects", "list"],
|
||||||
|
queryFn: () => projectsApi.list({ limit: 500 }),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const [showNewProj, setShowNewProj] = useState(false);
|
||||||
|
const [newProjName, setNewProjName] = useState("");
|
||||||
|
const [newProjKey, setNewProjKey] = useState("");
|
||||||
|
const [newProjDesc, setNewProjDesc] = useState("");
|
||||||
|
const [newProjBranch, setNewProjBranch] = useState("main");
|
||||||
|
const [newProjActive, setNewProjActive] = useState(true);
|
||||||
|
const createProjM = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
|
||||||
|
if (!newProjName.trim()) throw new Error("Project name required");
|
||||||
|
if (!keyRgx.test(newProjKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
|
||||||
|
return projectsApi.create({
|
||||||
|
name: newProjName.trim(),
|
||||||
|
key: newProjKey.trim().toUpperCase(),
|
||||||
|
description: newProjDesc.trim() || undefined,
|
||||||
|
default_branch: newProjBranch.trim() || undefined,
|
||||||
|
is_active: newProjActive,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowNewProj(false);
|
||||||
|
setNewProjName(""); setNewProjKey(""); setNewProjDesc(""); setNewProjBranch("main"); setNewProjActive(true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const keyHint = useMemo(() => {
|
||||||
|
if (!newProjKey) return null;
|
||||||
|
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey);
|
||||||
|
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
|
||||||
|
}, [newProjKey]);
|
||||||
|
|
||||||
|
/* ---------------- Edit Organization ---------------- */
|
||||||
|
const [showEditOrg, setShowEditOrg] = useState(false);
|
||||||
|
const [editOrg, setEditOrg] = useState<Organization | null>(null);
|
||||||
|
const [eoName, setEoName] = useState("");
|
||||||
|
const [eoSlug, setEoSlug] = useState("");
|
||||||
|
const [eoPlan, setEoPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
|
||||||
|
const [eoActive, setEoActive] = useState(true);
|
||||||
|
|
||||||
|
const openEditOrg = (o: Organization) => {
|
||||||
|
setEditOrg(o);
|
||||||
|
setEoName(o.name ?? "");
|
||||||
|
setEoSlug(o.slug ?? "");
|
||||||
|
setEoPlan((o.plan as typeof eoPlan) ?? "Team");
|
||||||
|
setEoActive(Boolean(o.is_active ?? true));
|
||||||
|
setShowEditOrg(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editOrgM = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!editOrg) throw new Error("no organization");
|
||||||
|
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
|
||||||
|
if (!eoName.trim()) throw new Error("Organization name required");
|
||||||
|
if (!slugRgx.test(eoSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
|
||||||
|
return orgs.update(String(editOrg.id), {
|
||||||
|
name: eoName.trim(),
|
||||||
|
slug: eoSlug.trim().toLowerCase(),
|
||||||
|
plan: eoPlan,
|
||||||
|
is_active: eoActive,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowEditOrg(false);
|
||||||
|
setEditOrg(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eoSlugHint = useMemo(() => {
|
||||||
|
if (!eoSlug) return null;
|
||||||
|
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug);
|
||||||
|
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
|
||||||
|
}, [eoSlug]);
|
||||||
|
|
||||||
|
/* ---------------- Delete Organization ---------------- */
|
||||||
|
const [delOrg, setDelOrg] = useState<Organization | null>(null);
|
||||||
|
|
||||||
|
const deleteOrgM = useMutation({
|
||||||
|
mutationFn: async (id: string) => orgs.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDelOrg(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Edit Project ---------------- */
|
||||||
|
const [showEditProj, setShowEditProj] = useState(false);
|
||||||
|
const [editProj, setEditProj] = useState<Project | null>(null);
|
||||||
|
const [epName, setEpName] = useState("");
|
||||||
|
const [epKey, setEpKey] = useState("");
|
||||||
|
const [epDesc, setEpDesc] = useState("");
|
||||||
|
const [epBranch, setEpBranch] = useState("main");
|
||||||
|
const [epActive, setEpActive] = useState(true);
|
||||||
|
|
||||||
|
const openEditProj = (p: Project) => {
|
||||||
|
setEditProj(p);
|
||||||
|
setEpName(p.name ?? "");
|
||||||
|
setEpKey(p.key ?? "");
|
||||||
|
setEpDesc(p.description ?? "");
|
||||||
|
setEpBranch(p.default_branch ?? "main");
|
||||||
|
setEpActive(Boolean(p.is_active ?? true));
|
||||||
|
setShowEditProj(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editProjM = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!editProj) throw new Error("no project");
|
||||||
|
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
|
||||||
|
if (!epName.trim()) throw new Error("Project name required");
|
||||||
|
if (!keyRgx.test(epKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
|
||||||
|
return projectsApi.update(String(editProj.id), {
|
||||||
|
name: epName.trim(),
|
||||||
|
key: epKey.trim().toUpperCase(),
|
||||||
|
description: epDesc.trim() || undefined,
|
||||||
|
default_branch: epBranch.trim() || undefined,
|
||||||
|
is_active: epActive,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowEditProj(false);
|
||||||
|
setEditProj(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const epKeyHint = useMemo(() => {
|
||||||
|
if (!epKey) return null;
|
||||||
|
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(epKey);
|
||||||
|
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
|
||||||
|
}, [epKey]);
|
||||||
|
|
||||||
|
/* ---------------- Delete Project ---------------- */
|
||||||
|
const [delProj, setDelProj] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
const deleteProjM = useMutation({
|
||||||
|
mutationFn: async (id: string) => projectsApi.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDelProj(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<Settings2 className="w-6 h-6 text-brand-600" />
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
User profile, organization, security, AI policy, notifications and appearance.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardBody className="p-2">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{SECTIONS.map(({ key, label, icon: Icon }) => (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSec(key)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||||
|
sec === key
|
||||||
|
? "bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-100"
|
||||||
|
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="capitalize flex items-center gap-2">
|
||||||
|
{sec === "profile" && <>User Profile <Badge tone="brand">You</Badge></>}
|
||||||
|
{sec === "org" && "Organization & Projects"}
|
||||||
|
{sec === "security" && "Security"}
|
||||||
|
{sec === "ai" && "AI Policy"}
|
||||||
|
{sec === "notifs" && "Notifications"}
|
||||||
|
{sec === "theme" && "Appearance"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{sec === "profile" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl">
|
||||||
|
<Field label="Full name">
|
||||||
|
<input className="asf-input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Email">
|
||||||
|
<input className="asf-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Role">
|
||||||
|
<select className="asf-input" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||||
|
<option>Owner</option><option>Admin</option><option>Member</option><option>Viewer</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div />
|
||||||
|
<div className="md:col-span-2 flex justify-end">
|
||||||
|
<Button variant="primary"><Save className="w-4 h-4" /> Save changes</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sec === "org" && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Organizations Card */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5 text-slate-500" />
|
||||||
|
<h4 className="font-medium text-slate-800">Organizations</h4>
|
||||||
|
<Badge tone="brand">{orgsQ.data?.length ?? 0}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => setShowNewOrg(true)}>
|
||||||
|
<Plus className="w-4 h-4" /> New organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{orgsQ.isFetching && !orgsQ.data ? (
|
||||||
|
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading organizations…
|
||||||
|
</div>
|
||||||
|
) : (orgsQ.data ?? []).length === 0 ? (
|
||||||
|
<EmptyState title="No organizations yet" description="Click 'New organization' above to create your first tenant." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Name</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Slug</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Plan</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Status</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Created</th>
|
||||||
|
<th className="text-right font-medium px-4 py-2.5">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{(orgsQ.data ?? []).map((o: Organization) => (
|
||||||
|
<tr key={String(o.id)} className="hover:bg-slate-50/50">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-800">{o.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(o.id))}</span></td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-slate-600">{o.slug}</td>
|
||||||
|
<td className="px-4 py-3"><Badge tone={PLAN_TONES[String(o.plan ?? "Free")]}>{o.plan ?? "Free"}</Badge></td>
|
||||||
|
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(o.is_active ?? true)]}>{o.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Inactive</>}</Badge></td>
|
||||||
|
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{o.created_at ? fmtDate(o.created_at) : "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="xs" onClick={() => openEditOrg(o)} title="Edit organization">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className="!text-red-600 hover:!bg-red-50"
|
||||||
|
onClick={() => setDelOrg(o)}
|
||||||
|
title="Delete organization"
|
||||||
|
disabled={delOrg?.id === o.id && deleteOrgM.isPending}
|
||||||
|
>
|
||||||
|
{delOrg?.id === o.id && deleteOrgM.isPending
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects Card */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderKanban className="w-5 h-5 text-slate-500" />
|
||||||
|
<h4 className="font-medium text-slate-800">Projects</h4>
|
||||||
|
<Badge tone="brand">{projectsQ.data?.length ?? 0}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => setShowNewProj(true)}>
|
||||||
|
<Plus className="w-4 h-4" /> New project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{projectsQ.isFetching && !projectsQ.data ? (
|
||||||
|
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading projects…
|
||||||
|
</div>
|
||||||
|
) : (projectsQ.data ?? []).length === 0 ? (
|
||||||
|
<EmptyState title="No projects yet" description="Create a project to start tracking requirements, code and deployments." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Key</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Name</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Default branch</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Status</th>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Updated</th>
|
||||||
|
<th className="text-right font-medium px-4 py-2.5">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{(projectsQ.data ?? []).map((p: Project) => (
|
||||||
|
<tr key={String(p.id)} className="hover:bg-slate-50/50">
|
||||||
|
<td className="px-4 py-3"><Badge tone="violet" className="font-mono">{p.key}</Badge></td>
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-800">{p.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(p.id))}</span></td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-slate-600">{p.default_branch ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(p.is_active ?? true)]}>{p.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Archived</>}</Badge></td>
|
||||||
|
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{p.updated_at ? fmtDate(p.updated_at) : "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="xs" onClick={() => openEditProj(p)} title="Edit project">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className="!text-red-600 hover:!bg-red-50"
|
||||||
|
onClick={() => setDelProj(p)}
|
||||||
|
title="Delete project"
|
||||||
|
disabled={delProj?.id === p.id && deleteProjM.isPending}
|
||||||
|
>
|
||||||
|
{delProj?.id === p.id && deleteProjM.isPending
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sec === "security" && (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<Toggle label="Multi-factor authentication" hint="Require a second factor at every login." checked={mfa} onChange={setMfa} />
|
||||||
|
<Toggle label="Session timeout 15m" hint="Force re-authentication after idle period." checked={true} onChange={() => {}} />
|
||||||
|
<Toggle label="Audit log retention 365 days" hint="Append-only, tamper-evident export." checked={true} onChange={() => {}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec === "ai" && (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<Field label="Monthly $ ceiling (USD)">
|
||||||
|
<input className="asf-input" type="number" value={ceiling} onChange={(e) => setCeiling(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Toggle label="Allow local models only" hint="Never route prompts to external providers." checked={false} onChange={() => {}} />
|
||||||
|
<Toggle label="Audit every inference" hint="Append every prompt + response to audit log." checked={true} onChange={() => {}} />
|
||||||
|
<p className="text-xs text-slate-500">AI Policy can be further refined per-project and per-team inside asf-ai.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec === "notifs" && (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
{[
|
||||||
|
{ l: "Job failures", h: "Email + in-app when any job fails." },
|
||||||
|
{ l: "Security findings critical/high", h: "Instant via configured channel." },
|
||||||
|
{ l: "Deployment completed", h: "Production deployments only." },
|
||||||
|
{ l: "Weekly digest", h: "Health, coverage and cost snapshot." },
|
||||||
|
].map((t) => (
|
||||||
|
<Toggle key={t.l} label={t.l} hint={t.h} defaultChecked onChange={() => {}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec === "theme" && (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ k: "light", label: "Light", sw: "bg-white border-slate-300" },
|
||||||
|
{ k: "system", label: "System", sw: "bg-gradient-to-r from-white to-slate-900 border-slate-400" },
|
||||||
|
{ k: "dark", label: "Dark", sw: "bg-slate-900 border-slate-700" },
|
||||||
|
].map((t) => (
|
||||||
|
<button key={t.k} type="button" className={cn("rounded-xl border p-3 text-left hover:border-brand-400 transition-colors", t.k === "light" ? "border-brand-400 ring-2 ring-brand-100" : "border-slate-200")}>
|
||||||
|
<div className={cn("w-full h-16 rounded-lg border mb-2", t.sw)} />
|
||||||
|
<div className="text-sm font-medium text-slate-800">{t.label}</div>
|
||||||
|
<div className="text-xs text-slate-500">Default</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Toggle label="Density · Comfortable" hint="Looser paddings throughout the shell." checked={true} onChange={() => {}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- New Organization modal ---------- */}
|
||||||
|
{showNewOrg && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createOrgM.isPending && setShowNewOrg(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-brand-600" /> New organization
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => !createOrgM.isPending && setShowNewOrg(false)}
|
||||||
|
disabled={createOrgM.isPending}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||||||
|
aria-label="close"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="p-5 space-y-3"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); createOrgM.mutate(); }}
|
||||||
|
>
|
||||||
|
<Field label="Name *">
|
||||||
|
<input className="asf-input" autoFocus required
|
||||||
|
placeholder="Acme Inc." value={newOrgName}
|
||||||
|
onChange={(e) => setNewOrgName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
|
||||||
|
<input className="asf-input font-mono text-sm"
|
||||||
|
placeholder="acme-inc"
|
||||||
|
value={newOrgSlug}
|
||||||
|
onChange={(e) => setNewOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
|
||||||
|
<div className={cn("text-[11px] mt-1", slugHint && slugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{slugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Plan">
|
||||||
|
<select className="asf-input" value={newOrgPlan}
|
||||||
|
onChange={(e) => setNewOrgPlan(e.target.value as typeof newOrgPlan)}>
|
||||||
|
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<Toggle label="Active" hint="Organization visible and usable." checked={newOrgActive} onChange={setNewOrgActive} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{createOrgM.isError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||||||
|
{createOrgM.error instanceof Error ? createOrgM.error.message : "Failed to create organization."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={createOrgM.isPending} onClick={() => setShowNewOrg(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="primary" size="sm" disabled={createOrgM.isPending || !newOrgName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug)}>
|
||||||
|
{createOrgM.isPending ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
|
||||||
|
) : (<><Plus className="w-4 h-4" /> Create organization</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------- New Project modal ---------- */}
|
||||||
|
{showNewProj && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createProjM.isPending && setShowNewProj(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<FolderKanban className="w-4 h-4 text-brand-600" /> New project
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => !createProjM.isPending && setShowNewProj(false)}
|
||||||
|
disabled={createProjM.isPending}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||||||
|
aria-label="close"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="p-5 space-y-3"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); createProjM.mutate(); }}
|
||||||
|
>
|
||||||
|
<Field label="Name *">
|
||||||
|
<input className="asf-input" required autoFocus
|
||||||
|
placeholder="ASF Platform MVP"
|
||||||
|
value={newProjName}
|
||||||
|
onChange={(e) => setNewProjName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
|
||||||
|
<input className="asf-input font-mono text-sm uppercase tracking-wide"
|
||||||
|
placeholder="ASF"
|
||||||
|
maxLength={20}
|
||||||
|
value={newProjKey}
|
||||||
|
onChange={(e) => setNewProjKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
|
||||||
|
<div className={cn("text-[11px] mt-1", keyHint && keyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{keyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Default branch">
|
||||||
|
<input className="asf-input font-mono text-xs"
|
||||||
|
placeholder="main"
|
||||||
|
value={newProjBranch}
|
||||||
|
onChange={(e) => setNewProjBranch(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Description">
|
||||||
|
<textarea className="asf-input" rows={3}
|
||||||
|
placeholder="Scope, goals and non-goals of this project."
|
||||||
|
value={newProjDesc}
|
||||||
|
onChange={(e) => setNewProjDesc(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<Toggle label="Active" hint="Project appears in pickers and filters." checked={newProjActive} onChange={setNewProjActive} />
|
||||||
|
</Field>
|
||||||
|
{createProjM.isError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||||||
|
{createProjM.error instanceof Error ? createProjM.error.message : "Failed to create project."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={createProjM.isPending} onClick={() => setShowNewProj(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="primary" size="sm"
|
||||||
|
disabled={createProjM.isPending || !newProjName || !/^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey)}>
|
||||||
|
{createProjM.isPending ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
|
||||||
|
) : (<><Plus className="w-4 h-4" /> Create project</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------- Edit Organization modal ---------- */}
|
||||||
|
{showEditOrg && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editOrgM.isPending && setShowEditOrg(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<Pencil className="w-4 h-4 text-brand-600" /> Edit organization
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => !editOrgM.isPending && setShowEditOrg(false)}
|
||||||
|
disabled={editOrgM.isPending}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||||||
|
aria-label="close"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="p-5 space-y-3"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); void editOrgM.mutateAsync(); }}
|
||||||
|
>
|
||||||
|
<Field label="Name *">
|
||||||
|
<input className="asf-input" required autoFocus
|
||||||
|
placeholder="Acme Inc." value={eoName}
|
||||||
|
onChange={(e) => setEoName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
|
||||||
|
<input className="asf-input font-mono text-sm"
|
||||||
|
placeholder="acme-inc"
|
||||||
|
value={eoSlug}
|
||||||
|
onChange={(e) => setEoSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
|
||||||
|
<div className={cn("text-[11px] mt-1", eoSlugHint && eoSlugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{eoSlugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Plan">
|
||||||
|
<select className="asf-input" value={eoPlan}
|
||||||
|
onChange={(e) => setEoPlan(e.target.value as typeof eoPlan)}>
|
||||||
|
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<Toggle label="Active" hint="Organization visible and usable." checked={eoActive} onChange={setEoActive} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{editOrgM.isError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||||||
|
{editOrgM.error instanceof Error ? editOrgM.error.message : "Failed to update organization."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={editOrgM.isPending} onClick={() => setShowEditOrg(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="primary" size="sm" disabled={editOrgM.isPending || !eoName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug)}>
|
||||||
|
{editOrgM.isPending ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
|
||||||
|
) : (<><Save className="w-4 h-4" /> Save changes</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------- Delete Organization confirm ---------- */}
|
||||||
|
{delOrg && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteOrgM.isPending && setDelOrg(null)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900">Delete organization?</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Permanently delete <span className="font-semibold text-slate-700">{delOrg.name}</span>{" "}
|
||||||
|
<span className="font-mono text-xs">({delOrg.slug})</span>. All projects, artifacts and relationships under this tenant will be removed. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{deleteOrgM.isError && (
|
||||||
|
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||||||
|
{deleteOrgM.error instanceof Error ? deleteOrgM.error.message : "Failed to delete organization."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={deleteOrgM.isPending} onClick={() => setDelOrg(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="!bg-red-600 hover:!bg-red-700 !text-white"
|
||||||
|
disabled={deleteOrgM.isPending}
|
||||||
|
onClick={() => void deleteOrgM.mutateAsync(String(delOrg.id))}
|
||||||
|
>
|
||||||
|
{deleteOrgM.isPending ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
|
||||||
|
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------- Edit Project modal ---------- */}
|
||||||
|
{showEditProj && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editProjM.isPending && setShowEditProj(false)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<Pencil className="w-4 h-4 text-brand-600" /> Edit project
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => !editProjM.isPending && setShowEditProj(false)}
|
||||||
|
disabled={editProjM.isPending}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||||||
|
aria-label="close"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="p-5 space-y-3"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); void editProjM.mutateAsync(); }}
|
||||||
|
>
|
||||||
|
<Field label="Name *">
|
||||||
|
<input className="asf-input" required autoFocus
|
||||||
|
placeholder="ASF Platform MVP"
|
||||||
|
value={epName}
|
||||||
|
onChange={(e) => setEpName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
|
||||||
|
<input className="asf-input font-mono text-sm uppercase tracking-wide"
|
||||||
|
placeholder="ASF"
|
||||||
|
maxLength={20}
|
||||||
|
value={epKey}
|
||||||
|
onChange={(e) => setEpKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
|
||||||
|
<div className={cn("text-[11px] mt-1", epKeyHint && epKeyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{epKeyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Default branch">
|
||||||
|
<input className="asf-input font-mono text-xs"
|
||||||
|
placeholder="main"
|
||||||
|
value={epBranch}
|
||||||
|
onChange={(e) => setEpBranch(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Description">
|
||||||
|
<textarea className="asf-input" rows={3}
|
||||||
|
placeholder="Scope, goals and non-goals of this project."
|
||||||
|
value={epDesc}
|
||||||
|
onChange={(e) => setEpDesc(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<Toggle label="Active" hint="Project appears in pickers and filters." checked={epActive} onChange={setEpActive} />
|
||||||
|
</Field>
|
||||||
|
{editProjM.isError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||||||
|
{editProjM.error instanceof Error ? editProjM.error.message : "Failed to update project."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={editProjM.isPending} onClick={() => setShowEditProj(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="primary" size="sm"
|
||||||
|
disabled={editProjM.isPending || !epName || !/^[A-Z][A-Z0-9]{1,19}$/.test(epKey)}>
|
||||||
|
{editProjM.isPending ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
|
||||||
|
) : (<><Save className="w-4 h-4" /> Save changes</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------- Delete Project confirm ---------- */}
|
||||||
|
{delProj && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteProjM.isPending && setDelProj(null)}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900">Delete project?</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Permanently delete <span className="font-semibold text-slate-700">{delProj.name}</span>{" "}
|
||||||
|
{delProj.key && <span className="font-mono text-xs">([{delProj.key}])</span>}. All artifacts in this project will be removed. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{deleteProjM.isError && (
|
||||||
|
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||||||
|
{deleteProjM.error instanceof Error ? deleteProjM.error.message : "Failed to delete project."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={deleteProjM.isPending} onClick={() => setDelProj(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="!bg-red-600 hover:!bg-red-700 !text-white"
|
||||||
|
disabled={deleteProjM.isPending}
|
||||||
|
onClick={() => void deleteProjM.mutateAsync(String(delProj.id))}
|
||||||
|
>
|
||||||
|
{deleteProjM.isPending ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
|
||||||
|
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<div className="text-xs font-medium text-slate-500 mb-1.5">{label}</div>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
label, hint, checked, defaultChecked, onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
defaultChecked?: boolean;
|
||||||
|
onChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [internal, setInternal] = useState<boolean>(Boolean(defaultChecked));
|
||||||
|
const value = checked !== undefined ? checked : internal;
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !value;
|
||||||
|
if (checked === undefined) setInternal(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 p-3 rounded-lg border border-slate-100 hover:border-slate-200">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-slate-800">{label}</div>
|
||||||
|
{hint && <div className="text-xs text-slate-500 mt-0.5">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-300",
|
||||||
|
value ? "bg-brand-600" : "bg-slate-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition-transform",
|
||||||
|
value ? "translate-x-5" : "translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import ReactFlow, {
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
MarkerType,
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
type NodeProps,
|
||||||
|
} from "reactflow";
|
||||||
|
import "reactflow/dist/style.css";
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Maximize2,
|
||||||
|
RefreshCw,
|
||||||
|
Layers,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge, ArtifactTypeBadge, JobStatusBadge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { traceability, artifacts, projects } from "@/lib/api";
|
||||||
|
import { cn, shortId } from "@/lib/utils";
|
||||||
|
import type { TraceGraph, ArtifactType } from "@/lib/types";
|
||||||
|
|
||||||
|
const DEMO_GRAPH: TraceGraph = {
|
||||||
|
nodes: [
|
||||||
|
{ id: "REQ-441", type: "requirement", label: "Multi-tenant RBAC", project_id: "p1", status: "in_progress", version: 3 },
|
||||||
|
{ id: "TASK-921", type: "task", label: "Tenant middleware", project_id: "p1", status: "done" },
|
||||||
|
{ id: "TASK-922", type: "task", label: "Policy evaluator", project_id: "p1", status: "done" },
|
||||||
|
{ id: "PR-214", type: "pull_request", label: "feat(rbac) #214", project_id: "p1", status: "done" },
|
||||||
|
{ id: "TEST-88", type: "test", label: "tenant_isolation.py", project_id: "p1", status: "done" },
|
||||||
|
{ id: "BUILD-4412", type: "build", label: "build #4412", project_id: "p1", status: "done" },
|
||||||
|
{ id: "DEP-44", type: "deployment", label: "asf-core v0.4.0 → prod", project_id: "p1", status: "done" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: "e1", source: "TASK-921", target: "REQ-441", label: "implements" },
|
||||||
|
{ id: "e2", source: "TASK-922", target: "REQ-441", label: "implements" },
|
||||||
|
{ id: "e3", source: "PR-214", target: "TASK-921", label: "generated_from" },
|
||||||
|
{ id: "e4", source: "PR-214", target: "TASK-922", label: "generated_from" },
|
||||||
|
{ id: "e5", source: "TEST-88", target: "PR-214", label: "verifies" },
|
||||||
|
{ id: "e6", source: "BUILD-4412", target: "PR-214", label: "generated_from" },
|
||||||
|
{ id: "e7", source: "DEP-44", target: "BUILD-4412", label: "deployed_as" },
|
||||||
|
],
|
||||||
|
mock_fields: ["*"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<ArtifactType | string, { node: string; ring: string; text: string }> = {
|
||||||
|
requirement: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
|
||||||
|
epic: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
|
||||||
|
story: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
|
||||||
|
task: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
|
||||||
|
architecture_decision: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
|
||||||
|
document: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
|
||||||
|
source_file: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
|
||||||
|
commit: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
|
||||||
|
pull_request: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
|
||||||
|
build: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
|
||||||
|
test: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
|
||||||
|
security_finding: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
|
||||||
|
deployment: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
|
||||||
|
release: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
|
||||||
|
runtime_event: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
|
||||||
|
incident: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
|
||||||
|
ai_analysis: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ArtifactNode({ data, selected }: NodeProps<{ label: string; type: ArtifactType | string; status?: string; version?: number }>) {
|
||||||
|
const c = TYPE_COLORS[data.type] ?? TYPE_COLORS.document;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 rounded-lg shadow-card border backdrop-blur min-w-[160px] transition-shadow",
|
||||||
|
selected && "ring-2 ring-offset-2 ring-brand-400",
|
||||||
|
)}
|
||||||
|
style={{ background: c.node, borderColor: c.ring + "44" }}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-6 rounded-full" style={{ background: c.ring }} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<ArtifactTypeBadge type={data.type} />
|
||||||
|
{data.version !== undefined && <Badge tone="neutral">v{data.version}</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs font-semibold leading-tight" style={{ color: c.text }}>
|
||||||
|
{data.label}
|
||||||
|
</div>
|
||||||
|
{data.status && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<JobStatusBadge status={data.status} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypes = { artifact: ArtifactNode as unknown as React.ComponentType<NodeProps> };
|
||||||
|
|
||||||
|
function layoutLayered(graph: TraceGraph): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||||
|
const outgoing = new Map<string, string[]>();
|
||||||
|
const incoming = new Map<string, string[]>();
|
||||||
|
graph.nodes.forEach((n) => {
|
||||||
|
outgoing.set(n.id, []);
|
||||||
|
incoming.set(n.id, []);
|
||||||
|
});
|
||||||
|
graph.edges.forEach((e) => {
|
||||||
|
outgoing.get(e.source)?.push(e.target);
|
||||||
|
incoming.get(e.target)?.push(e.source);
|
||||||
|
});
|
||||||
|
|
||||||
|
const depth = new Map<string, number>();
|
||||||
|
const visit = (id: string, d: number, seen: Set<string>) => {
|
||||||
|
if (seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
depth.set(id, Math.max(depth.get(id) ?? 0, d));
|
||||||
|
outgoing.get(id)?.forEach((next) => visit(next, d + 1, seen));
|
||||||
|
};
|
||||||
|
graph.nodes.forEach((n) => {
|
||||||
|
if ((incoming.get(n.id)?.length ?? 0) === 0) visit(n.id, 0, new Set());
|
||||||
|
});
|
||||||
|
graph.nodes.forEach((n) => {
|
||||||
|
if (!depth.has(n.id)) depth.set(n.id, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byDepth = new Map<number, string[]>();
|
||||||
|
depth.forEach((d, id) => {
|
||||||
|
if (!byDepth.has(d)) byDepth.set(d, []);
|
||||||
|
byDepth.get(d)!.push(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
const colW = 240;
|
||||||
|
const rowH = 110;
|
||||||
|
byDepth.forEach((ids, d) => {
|
||||||
|
ids.forEach((id, i) => {
|
||||||
|
const raw = nodeMap.get(id)!;
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
type: "artifact",
|
||||||
|
position: { x: d * colW, y: i * rowH },
|
||||||
|
data: { label: raw.label, type: raw.type, status: raw.status, version: raw.version },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges: Edge[] = graph.edges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
label: e.label,
|
||||||
|
type: "smoothstep",
|
||||||
|
animated: false,
|
||||||
|
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
|
||||||
|
labelStyle: { fontSize: 10, fill: "#64748b" },
|
||||||
|
labelBgStyle: { fill: "#ffffffcc" },
|
||||||
|
labelBgPadding: [4, 2],
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TraceabilityPage() {
|
||||||
|
const [rootId, setRootId] = useState<string>("");
|
||||||
|
const graphQ = useQuery({
|
||||||
|
queryKey: ["traceability", rootId],
|
||||||
|
queryFn: () => traceability.graph(rootId ? { artifact_id: rootId, max_depth: 4 } : { limit: 200 }),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const artQ = useQuery({
|
||||||
|
queryKey: ["traceability-artifacts"],
|
||||||
|
queryFn: () => artifacts.list({ limit: 30 }),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const projectsQ = useQuery({
|
||||||
|
queryKey: ["traceability-projects"],
|
||||||
|
queryFn: () => projects.list({ limit: 50 }),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiResolved = graphQ.isFetched && graphQ.data !== undefined;
|
||||||
|
const rawGraph: TraceGraph = apiResolved ? graphQ.data! : DEMO_GRAPH;
|
||||||
|
const isMock = !apiResolved;
|
||||||
|
const { nodes, edges } = useMemo(() => layoutLayered(rawGraph), [rawGraph]);
|
||||||
|
const artifactsList = (artQ.data ?? []).length ? artQ.data! : [];
|
||||||
|
|
||||||
|
// Map project_id → pretty project name for the Inspector panel
|
||||||
|
const projectNameById = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const p of projectsQ.data ?? []) {
|
||||||
|
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [projectsQ.data]);
|
||||||
|
|
||||||
|
const [selId, setSelId] = useState<string | null>(null);
|
||||||
|
const selectedNode = rawGraph.nodes.find((n) => n.id === selId);
|
||||||
|
|
||||||
|
const onPaneReady = useCallback(() => {}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col min-h-0">
|
||||||
|
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<GitBranch className="w-6 h-6 text-brand-600" />
|
||||||
|
Traceability
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
End-to-end artifact graph: Requirement → Task → Pull Request → Build → Test → Deployment. Click a node to inspect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMock && <Badge tone="amber">Demo graph</Badge>}
|
||||||
|
<select
|
||||||
|
className="asf-input !py-1.5 !text-sm !w-auto"
|
||||||
|
value={rootId}
|
||||||
|
onChange={(e) => setRootId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All projects</option>
|
||||||
|
{artifactsList.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.external_id ?? shortId(a.id)} · {a.title.slice(0, 40)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => void graphQ.refetch()}>
|
||||||
|
<RefreshCw className="w-4 h-4" /> Reload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-4 gap-4">
|
||||||
|
<Card className="xl:col-span-3 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
Graph view
|
||||||
|
<Badge tone="neutral" className="ml-auto">
|
||||||
|
{rawGraph.nodes.length} nodes · {rawGraph.edges.length} edges
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0 flex-1 min-h-0 relative">
|
||||||
|
{nodes.length === 0 ? (
|
||||||
|
<div className="absolute inset-0 p-8">
|
||||||
|
<EmptyState title="Graph is empty" description="Create artifacts and relationships to populate the traceability view." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onInit={onPaneReady}
|
||||||
|
onNodeClick={(_, n) => setSelId(n.id)}
|
||||||
|
onPaneClick={() => setSelId(null)}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.3 }}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background gap={16} color="#e2e8f0" />
|
||||||
|
<Controls
|
||||||
|
className="!rounded-lg !border !border-slate-200 !shadow-card overflow-hidden"
|
||||||
|
showInteractive={false}
|
||||||
|
>
|
||||||
|
<button className="asf-btn-ghost !p-2" title="Zoom in"><ZoomIn className="w-4 h-4" /></button>
|
||||||
|
<button className="asf-btn-ghost !p-2" title="Zoom out"><ZoomOut className="w-4 h-4" /></button>
|
||||||
|
<button className="asf-btn-ghost !p-2" title="Fit"><Maximize2 className="w-4 h-4" /></button>
|
||||||
|
</Controls>
|
||||||
|
<MiniMap
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
className="!rounded-lg !border !border-slate-200 !shadow-card"
|
||||||
|
nodeColor={(n) => (TYPE_COLORS[(n.data as { type?: string })?.type ?? "document"] ?? TYPE_COLORS.document).ring}
|
||||||
|
maskColor="rgba(15, 23, 42, 0.06)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col min-h-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Inspector</CardTitle>
|
||||||
|
<Badge tone="neutral">{rawGraph.nodes.length} types</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="overflow-auto flex-1 min-h-0">
|
||||||
|
{selectedNode ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
|
<span className="asf-monospace text-xs text-slate-500">{selectedNode.id}</span>
|
||||||
|
<ArtifactTypeBadge type={selectedNode.type} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-semibold text-slate-800 leading-tight">
|
||||||
|
{selectedNode.label}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<dl className="text-sm space-y-1.5">
|
||||||
|
{selectedNode.status && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-slate-500">Status</dt>
|
||||||
|
<dd><JobStatusBadge status={selectedNode.status} /></dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedNode.version !== undefined && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-slate-500">Version</dt>
|
||||||
|
<dd className="asf-monospace">v{selectedNode.version}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedNode.project_id && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-slate-500">Project</dt>
|
||||||
|
<dd className="font-medium text-slate-800">
|
||||||
|
{projectNameById.get(String(selectedNode.project_id)) ?? (
|
||||||
|
<span className="asf-monospace text-xs">{shortId(selectedNode.project_id)}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedNode.source && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-slate-500">Source</dt>
|
||||||
|
<dd>{selectedNode.source}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
<div className="pt-2 flex flex-wrap gap-2">
|
||||||
|
<a href="/context" className="asf-btn-secondary inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
|
||||||
|
Open in Context <ChevronRight className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
<a href="/requirements" className="asf-btn-ghost inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
|
||||||
|
Requirements
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-slate-100">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Connected edges</div>
|
||||||
|
<ul className="space-y-1 text-xs">
|
||||||
|
{rawGraph.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((e) => (
|
||||||
|
<li key={e.id} className="flex items-center gap-2 p-1.5 rounded hover:bg-slate-50">
|
||||||
|
<span className="asf-monospace text-slate-500">
|
||||||
|
{e.source === selectedNode.id ? "→" : "←"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-slate-700">{e.source === selectedNode.id ? e.target : e.source}</span>
|
||||||
|
<Badge tone="brand" className="ml-auto">{e.label}</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="Click a node"
|
||||||
|
description="Select any node on the graph to inspect its details and connections."
|
||||||
|
icon={GitBranch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Legend</div>
|
||||||
|
<ul className="grid grid-cols-1 gap-1.5 text-xs">
|
||||||
|
{Object.entries(TYPE_COLORS).slice(0, 10).map(([k, v]) => (
|
||||||
|
<li key={k} className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full border" style={{ background: v.node, borderColor: v.ring }} />
|
||||||
|
<span className="text-slate-700 capitalize">{k.replace("_", " ")}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { BarChart3, Cpu, Clock, DollarSign, Users, Zap } from "lucide-react";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { MetricCard } from "@/components/ui/MetricCard";
|
||||||
|
|
||||||
|
const MONTHS = ["Jul", "Aug", "Sep", "Oct", "Nov"];
|
||||||
|
const SERIES = [
|
||||||
|
{ label: "AI tokens (M)", data: [0.1, 0.8, 1.4, 2.9, 4.2], tone: "bg-violet-400" },
|
||||||
|
{ label: "Jobs executed", data: [120, 280, 412, 688, 904], tone: "bg-brand-400" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Bar({ label, values, tone }: { label: string; values: number[]; tone: string }) {
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-2 font-medium">{label}</div>
|
||||||
|
<div className="flex items-end gap-2 h-32">
|
||||||
|
{values.map((v, i) => (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={["w-full rounded-t-md transition-all", tone].join(" ")}
|
||||||
|
style={{ height: `${(v / max) * 100}%`, minHeight: "4px" }}
|
||||||
|
title={`${v}`}
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-slate-500">{MONTHS[i]}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
{ name: "Tenant default", projects: 6, users: 14, jobs_7d: 412, tokens_7d: 2.1, cost_7d: 128.4 },
|
||||||
|
{ name: "Tenant acme-corp", projects: 2, users: 6, jobs_7d: 88, tokens_7d: 0.6, cost_7d: 24.1 },
|
||||||
|
{ name: "Tenant contoso-labs", projects: 3, users: 9, jobs_7d: 180, tokens_7d: 1.1, cost_7d: 52.7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function UsagePage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||||||
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-6 h-6 text-brand-600" />
|
||||||
|
Usage
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Tenant-level metering: AI tokens, background jobs, projects, seats and estimated cost.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">Monthly view</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||||
|
<MetricCard label="Projects" value={11} tone="brand" icon={<BarChart3 className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Active users" value={29} tone="blue" icon={<Users className="w-5 h-5" />} />
|
||||||
|
<MetricCard label="Jobs (7d)" value={680} tone="green" icon={<Clock className="w-5 h-5" />} delta={12.4} />
|
||||||
|
<MetricCard label="AI tokens (7d, M)" value={3.8} tone="violet" icon={<Zap className="w-5 h-5" />} delta={18.7} />
|
||||||
|
<MetricCard label="Est. cost (7d, $)" value={205.2} tone="amber" icon={<DollarSign className="w-5 h-5" />} delta={9.2} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Trend · AI tokens</CardTitle><Badge tone="violet">millions</Badge></CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Bar label="" values={SERIES[0].data} tone={SERIES[0].tone} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Trend · Jobs executed</CardTitle><Badge tone="brand">count</Badge></CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Bar label="" values={SERIES[1].data} tone={SERIES[1].tone} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Per-tenant breakdown</CardTitle>
|
||||||
|
<Badge tone="neutral">{ROWS.length} tenants</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">Tenant</th>
|
||||||
|
<th className="text-right px-4 py-2">Projects</th>
|
||||||
|
<th className="text-right px-4 py-2">Users</th>
|
||||||
|
<th className="text-right px-4 py-2">Jobs (7d)</th>
|
||||||
|
<th className="text-right px-4 py-2">Tokens (7d, M)</th>
|
||||||
|
<th className="text-right px-4 py-2">Cost (7d)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{ROWS.map((r) => (
|
||||||
|
<tr key={r.name} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-2.5 font-medium text-slate-800">{r.name}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{r.projects}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{r.users}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{r.jobs_7d}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{r.tokens_7d.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">${r.cost_7d.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-3 text-xs text-slate-500">
|
||||||
|
<Badge tone="neutral"><Cpu className="w-3 h-3" /> GPU-s metered</Badge>
|
||||||
|
<Badge tone="neutral"><DollarSign className="w-3 h-3" /> Cost tables configurable per-tenant</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/lib/queryClient";
|
||||||
|
|
||||||
|
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Send,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
MessageCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { askAsf, jobs } from "@/lib/api";
|
||||||
|
import { useAppStore } from "@/stores/useAppStore";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
|
||||||
|
import { cn, fmtDate, truncate } from "@/lib/utils";
|
||||||
|
import type { Job } from "@/lib/types";
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "system" | "assistant";
|
||||||
|
kind?: "question" | "info" | "success" | "error";
|
||||||
|
content: string;
|
||||||
|
ts: Date;
|
||||||
|
jobId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AskAsfCommandBar() {
|
||||||
|
const open = useAppStore((s) => s.asfOpen);
|
||||||
|
const closeAsf = useAppStore((s) => s.closeAsf);
|
||||||
|
const openAsf = useAppStore((s) => s.openAsf);
|
||||||
|
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
role: "system",
|
||||||
|
kind: "info",
|
||||||
|
content:
|
||||||
|
"Ask anything about your tenant data: requirements without tests, why a build failed, which services implement REQ-441, … This UI wires to the AI Gateway through asf-worker — in bootstrap you'll see a transparent MockProvider response.",
|
||||||
|
ts: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [pollingJobId, setPollingJobId] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// open via global event + ⌘K
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
const meta = e.metaKey || e.ctrlKey;
|
||||||
|
if (meta && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (open) closeAsf(); else openAsf();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && open) closeAsf();
|
||||||
|
}
|
||||||
|
function onOpenEv() { openAsf(); }
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("asf:open-command", onOpenEv);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("asf:open-command", onOpenEv);
|
||||||
|
};
|
||||||
|
}, [open, openAsf, closeAsf]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setTimeout(() => textareaRef.current?.focus(), 50);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: (question: string) =>
|
||||||
|
askAsf.query({ question }),
|
||||||
|
onMutate: (question) => {
|
||||||
|
setMessages((m) => [
|
||||||
|
...m,
|
||||||
|
{ id: "u" + Date.now(), role: "user", kind: "question", content: question, ts: new Date() },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onSuccess: (resp) => {
|
||||||
|
setMessages((m) => [
|
||||||
|
...m,
|
||||||
|
{
|
||||||
|
id: "subm" + Date.now(),
|
||||||
|
role: "system",
|
||||||
|
kind: "info",
|
||||||
|
content: `Query accepted. Polling job ${resp.job_id.slice(0, 8)}… — status: queued`,
|
||||||
|
ts: new Date(),
|
||||||
|
jobId: resp.job_id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setPollingJobId(resp.job_id);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setMessages((m) => [
|
||||||
|
...m,
|
||||||
|
{ id: "e" + Date.now(), role: "system", kind: "error", content: err.message, ts: new Date() },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// long-poll the accepted job until terminal
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["ask-job", pollingJobId],
|
||||||
|
enabled: !!pollingJobId,
|
||||||
|
refetchInterval: (q) => (q.state.data?.status === "completed" || q.state.data?.status === "failed" || q.state.data?.status === "cancelled" ? false : 1500),
|
||||||
|
queryFn: async (): Promise<Job | undefined> => {
|
||||||
|
if (!pollingJobId) return undefined;
|
||||||
|
const job = await jobs.get(pollingJobId);
|
||||||
|
if (job && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) {
|
||||||
|
setPollingJobId(null);
|
||||||
|
setMessages((m) => {
|
||||||
|
// avoid adding duplicate result messages
|
||||||
|
if (m.some(x => x.id === `res-${job.id}`)) return m;
|
||||||
|
if (job.status === "completed" && job.result) {
|
||||||
|
const answer =
|
||||||
|
(job.result as Record<string, unknown>).answer as string ||
|
||||||
|
JSON.stringify(job.result, null, 2);
|
||||||
|
return [
|
||||||
|
...m,
|
||||||
|
{
|
||||||
|
id: `res-${job.id}`,
|
||||||
|
role: "assistant",
|
||||||
|
kind: "success",
|
||||||
|
content: answer,
|
||||||
|
ts: new Date(),
|
||||||
|
jobId: job.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (job.status === "failed") {
|
||||||
|
return [
|
||||||
|
...m,
|
||||||
|
{
|
||||||
|
id: `fail-${job.id}`,
|
||||||
|
role: "system",
|
||||||
|
kind: "error",
|
||||||
|
content: job.error || "Unknown AI job failure.",
|
||||||
|
ts: new Date(),
|
||||||
|
jobId: job.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!q.trim()) return;
|
||||||
|
submitMutation.mutate(q.trim());
|
||||||
|
setQ("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-slate-950/40 backdrop-blur-[2px] animate-fade-in"
|
||||||
|
onClick={closeAsf}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-slate-200 bg-gradient-to-r from-brand-50/60 to-transparent">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-accent grid place-items-center text-white shadow-sm">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-slate-800 leading-tight">Ask ASF</div>
|
||||||
|
<div className="text-[11px] text-slate-500 leading-tight">
|
||||||
|
Context-aware queries backed by the ASF AI Gateway
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge tone="violet">
|
||||||
|
<MessageCircle className="w-3 h-3 mr-1" />
|
||||||
|
mock provider
|
||||||
|
</Badge>
|
||||||
|
<button className="asf-btn-ghost !p-1.5" onClick={closeAsf} aria-label="Close">
|
||||||
|
<X className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* messages */}
|
||||||
|
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<MessageBubble key={m.id} m={m} />
|
||||||
|
))}
|
||||||
|
{submitMutation.isPending && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Submitting query to ASF AI job queue…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pollingJobId && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Polling job <code className="asf-monospace text-slate-700 bg-slate-50 rounded px-1.5 py-0.5 border border-slate-200">{pollingJobId.slice(0,12)}</code> until terminal state…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* toggle for expanded mini-chat */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="hidden w-full items-center justify-center gap-1 py-2 text-xs text-slate-500 border-t border-slate-100 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{expanded ? <>Collapse <ChevronDown className="w-3 h-3" /></> : <>Expand <ChevronUp className="w-3 h-3" /></>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* input */}
|
||||||
|
<form onSubmit={onSubmit} className="px-5 py-3.5 border-t border-slate-200 bg-slate-50/50 flex gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
rows={2}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(e);
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
expanded
|
||||||
|
? 'e.g. "Show requirements without tests", "Which services implement REQ-441?"'
|
||||||
|
: "Ask ASF… (⌘↩ to send)"
|
||||||
|
}
|
||||||
|
className="asf-input resize-none"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="self-end shrink-0"
|
||||||
|
disabled={!q.trim() || submitMutation.isPending}
|
||||||
|
>
|
||||||
|
{submitMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="hidden md:inline">Send</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="px-5 pb-4 flex flex-wrap gap-2 bg-slate-50/50">
|
||||||
|
{[
|
||||||
|
"Show requirements without tests.",
|
||||||
|
"Why did yesterday's build fail?",
|
||||||
|
"Which services implement REQ-441?",
|
||||||
|
"Generate tests for TASK-921.",
|
||||||
|
].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQ(s)}
|
||||||
|
className="asf-chip hover:bg-brand-50 hover:text-brand-700 hover:border-brand-100 transition-colors"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ m }: { m: ChatMessage }) {
|
||||||
|
const Icon =
|
||||||
|
m.kind === "success" ? CheckCircle2 :
|
||||||
|
m.kind === "error" ? AlertCircle :
|
||||||
|
m.kind === "question" ? MessageCircle : MessageCircle;
|
||||||
|
const tone =
|
||||||
|
m.role === "assistant" ? "bg-brand-50 border-brand-100" :
|
||||||
|
m.role === "user" ? "bg-slate-50 border-slate-200" :
|
||||||
|
m.kind === "error" ? "bg-red-50 border-red-100" :
|
||||||
|
"bg-white border-slate-200";
|
||||||
|
const toneIcon =
|
||||||
|
m.kind === "success" ? "text-emerald-600" :
|
||||||
|
m.kind === "error" ? "text-red-600" :
|
||||||
|
m.role === "user" ? "text-slate-600" :
|
||||||
|
"text-brand-600";
|
||||||
|
return (
|
||||||
|
<div className={cn("flex gap-3 animate-fade-in")}>
|
||||||
|
<div className={cn("w-7 h-7 rounded-lg grid place-items-center shrink-0 border", tone)}>
|
||||||
|
<Icon className={cn("w-4 h-4", toneIcon)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||||
|
<span className="font-medium capitalize text-slate-600">{m.role}</span>
|
||||||
|
<span>· {fmtDate(m.ts)}</span>
|
||||||
|
{m.jobId && <JobStatusBadge status={"queued"} />}
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"mt-1 rounded-lg border text-sm px-3 py-2 whitespace-pre-wrap break-words",
|
||||||
|
tone,
|
||||||
|
m.role === "assistant" ? "asf-monospace !font-sans leading-relaxed" : "",
|
||||||
|
)}>
|
||||||
|
{truncate(m.content, 1200)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
ClipboardList,
|
||||||
|
Search,
|
||||||
|
Code2,
|
||||||
|
GitBranch,
|
||||||
|
Bot,
|
||||||
|
GitFork,
|
||||||
|
Rocket,
|
||||||
|
ShieldCheck,
|
||||||
|
Activity,
|
||||||
|
Plug,
|
||||||
|
BarChart3,
|
||||||
|
Settings2,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV: NavItem[] = [
|
||||||
|
{ href: "/", label: "Overview", icon: LayoutDashboard },
|
||||||
|
{ href: "/requirements", label: "Requirements", icon: ClipboardList },
|
||||||
|
{ href: "/context", label: "Context Explorer", icon: Search },
|
||||||
|
{ href: "/code", label: "Code", icon: Code2 },
|
||||||
|
{ href: "/traceability", label: "Traceability", icon: GitBranch },
|
||||||
|
{ href: "/ask-asf", label: "Ask ASF", icon: Sparkles, badge: "AI" },
|
||||||
|
{ href: "/jobs", label: "Jobs", icon: Activity },
|
||||||
|
{ href: "/ai-agents", label: "AI Agents", icon: Bot, badge: "α" },
|
||||||
|
{ href: "/pipelines", label: "Pipelines", icon: GitFork },
|
||||||
|
{ href: "/deployments", label: "Deployments", icon: Rocket },
|
||||||
|
{ href: "/quality", label: "Quality", icon: ShieldCheck },
|
||||||
|
{ href: "/observability", label: "Observability", icon: Activity },
|
||||||
|
{ href: "/integrations", label: "Integrations", icon: Plug },
|
||||||
|
{ href: "/usage", label: "Usage", icon: BarChart3 },
|
||||||
|
{ href: "/settings", label: "Settings", icon: Settings2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const path = usePathname();
|
||||||
|
return (
|
||||||
|
<aside className="hidden lg:flex w-64 shrink-0 border-r border-slate-200/80 bg-white/60 backdrop-blur-sm flex-col">
|
||||||
|
<div className="h-16 flex items-center gap-3 px-5 border-b border-slate-200/80">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-accent shadow-soft grid place-items-center text-white font-bold">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-slate-800 leading-tight">
|
||||||
|
{process.env.NEXT_PUBLIC_APP_NAME || "ASF"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-500 leading-tight">
|
||||||
|
Software Factory
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto p-3 space-y-0.5">
|
||||||
|
{NAV.map(({ href, label, icon: Icon, badge }) => {
|
||||||
|
const active = href === "/" ? path === "/" : path.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn("asf-nav-item", active && "asf-nav-item-active")}
|
||||||
|
>
|
||||||
|
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px] shrink-0" />
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="asf-chip border-accent/20 bg-accent/10 text-accent-muted !px-1.5">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-slate-200/80">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// keyboard shortcut is handled in layout; this button also triggers.
|
||||||
|
const ev = new CustomEvent("asf:open-command");
|
||||||
|
window.dispatchEvent(ev);
|
||||||
|
}}
|
||||||
|
className="w-full asf-btn-secondary justify-between !px-3"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-slate-700">Ask ASF…</span>
|
||||||
|
</span>
|
||||||
|
<kbd className="asf-chip !text-[10px]">⌘ K</kbd>
|
||||||
|
</button>
|
||||||
|
<p className="mt-3 text-[11px] text-slate-400 leading-relaxed">
|
||||||
|
Tip: every lifecycle element in ASF is an <span className="font-mono text-slate-600 bg-slate-100 rounded px-1">Artifact</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
FolderKanban,
|
||||||
|
Bell,
|
||||||
|
Search,
|
||||||
|
ChevronDown,
|
||||||
|
UserCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { orgs, projects } from "@/lib/api";
|
||||||
|
import { Dropdown, type Option } from "@/components/ui/Dropdown";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { useAppStore } from "@/stores/useAppStore";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Topbar({ className }: { className?: string }) {
|
||||||
|
const setOrg = useAppStore((s) => s.setOrg);
|
||||||
|
const setProject = useAppStore((s) => s.setProject);
|
||||||
|
const currentOrg = useAppStore((s) => s.org_id);
|
||||||
|
const currentProject = useAppStore((s) => s.project_id);
|
||||||
|
const openAsf = useAppStore((s) => s.openAsf);
|
||||||
|
|
||||||
|
const { data: orgList = [] } = useQuery({
|
||||||
|
queryKey: ["orgs"],
|
||||||
|
queryFn: () => orgs.list({ limit: 50 }),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projectList = [] } = useQuery({
|
||||||
|
queryKey: ["projects"],
|
||||||
|
queryFn: () => projects.list({ limit: 100 }),
|
||||||
|
initialData: [],
|
||||||
|
enabled: !!currentOrg,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgOptions: Option<string>[] = orgList.map((o) => ({
|
||||||
|
value: o.id,
|
||||||
|
label: o.name,
|
||||||
|
description: o.slug,
|
||||||
|
}));
|
||||||
|
if (orgOptions.length === 0) {
|
||||||
|
// Dev-mode synthetic org (matches NEXT_PUBLIC_DEV_TENANT_ID)
|
||||||
|
orgOptions.push({
|
||||||
|
value: currentOrg || "00000000-0000-0000-0000-000000000001",
|
||||||
|
label: "Azurian Labs",
|
||||||
|
description: "azurian-labs (bootstrap demo)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectOptions: Option<string>[] = projectList.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.key} · ${p.name}`,
|
||||||
|
description: p.description || undefined,
|
||||||
|
}));
|
||||||
|
// Add a synthetic "All projects" option
|
||||||
|
projectOptions.unshift({
|
||||||
|
value: "__all__",
|
||||||
|
label: "All projects",
|
||||||
|
description: "Tenant-wide view",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject && projectList.length > 0) {
|
||||||
|
setProject(projectList[0].id);
|
||||||
|
}
|
||||||
|
}, [currentProject, projectList, setProject]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={cn(
|
||||||
|
"h-16 shrink-0 border-b border-slate-200/80 bg-white/80 backdrop-blur",
|
||||||
|
"flex items-center gap-4 px-4 md:px-6",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{/* Mobile brand */}
|
||||||
|
<div className="lg:hidden flex items-center gap-2 font-semibold text-slate-800">
|
||||||
|
<div className="w-8 h-8 rounded-md bg-gradient-to-br from-brand-500 to-accent text-white grid place-items-center text-sm font-bold">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
ASF
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 max-w-xl hidden md:block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openAsf}
|
||||||
|
className="w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-slate-500
|
||||||
|
bg-slate-50 hover:bg-slate-100 border border-slate-200 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 text-slate-400 group-hover:text-slate-600" />
|
||||||
|
<span className="flex-1">Search artifacts, projects, requirements… or press <span className="font-medium text-slate-600">⌘ K</span></span>
|
||||||
|
<kbd className="asf-chip !text-[10px]">Search</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2 md:gap-3">
|
||||||
|
<Dropdown
|
||||||
|
label="Organization"
|
||||||
|
size="sm"
|
||||||
|
icon={<Building2 className="w-4 h-4" />}
|
||||||
|
options={orgOptions}
|
||||||
|
value={currentOrg}
|
||||||
|
onChange={(v) => {
|
||||||
|
setOrg(v);
|
||||||
|
setProject(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
label="Project"
|
||||||
|
size="sm"
|
||||||
|
icon={<FolderKanban className="w-4 h-4" />}
|
||||||
|
options={projectOptions}
|
||||||
|
value={currentProject === undefined ? "__all__" : currentProject}
|
||||||
|
onChange={(v) => setProject(v === "__all__" ? undefined : v)}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="md" aria-label="Notifications">
|
||||||
|
<Bell className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||||
|
</Button>
|
||||||
|
<div className="h-9 w-9 rounded-full bg-slate-100 border border-slate-200 grid place-items-center text-slate-600">
|
||||||
|
<UserCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type BadgeTone =
|
||||||
|
| "neutral"
|
||||||
|
| "brand"
|
||||||
|
| "green"
|
||||||
|
| "amber"
|
||||||
|
| "red"
|
||||||
|
| "violet"
|
||||||
|
| "blue";
|
||||||
|
|
||||||
|
const TONES: Record<BadgeTone, string> = {
|
||||||
|
neutral: "bg-slate-100 text-slate-700 border-slate-200",
|
||||||
|
brand: "bg-brand-50 text-brand-700 border-brand-100",
|
||||||
|
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
|
||||||
|
amber: "bg-amber-50 text-amber-700 border-amber-100",
|
||||||
|
red: "bg-red-50 text-red-700 border-red-100",
|
||||||
|
violet: "bg-violet-50 text-violet-700 border-violet-100",
|
||||||
|
blue: "bg-sky-50 text-sky-700 border-sky-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
tone = "neutral",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
|
||||||
|
TONES[tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobStatusBadge({ status }: { status: string }) {
|
||||||
|
const map: Record<string, BadgeTone> = {
|
||||||
|
queued: "neutral",
|
||||||
|
running: "blue",
|
||||||
|
completed: "green",
|
||||||
|
failed: "red",
|
||||||
|
cancelled: "amber",
|
||||||
|
};
|
||||||
|
return <Badge tone={map[status] ?? "neutral"}>{status}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactTypeBadge({ type }: { type: string }) {
|
||||||
|
const map: Record<string, BadgeTone> = {
|
||||||
|
requirement: "brand",
|
||||||
|
epic: "violet",
|
||||||
|
story: "brand",
|
||||||
|
task: "blue",
|
||||||
|
architecture_decision: "violet",
|
||||||
|
document: "neutral",
|
||||||
|
source_file: "neutral",
|
||||||
|
commit: "green",
|
||||||
|
pull_request: "blue",
|
||||||
|
build: "amber",
|
||||||
|
test: "green",
|
||||||
|
security_finding: "red",
|
||||||
|
deployment: "green",
|
||||||
|
release: "green",
|
||||||
|
runtime_event: "amber",
|
||||||
|
incident: "red",
|
||||||
|
ai_analysis: "violet",
|
||||||
|
};
|
||||||
|
return <Badge tone={map[type] ?? "neutral"}>{type}</Badge>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
type Size = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
as?: "button" | "a";
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClass: Record<Variant, string> = {
|
||||||
|
primary: "asf-btn-primary",
|
||||||
|
secondary: "asf-btn-secondary",
|
||||||
|
ghost: "asf-btn-ghost",
|
||||||
|
danger: "asf-btn bg-red-50 text-red-700 hover:bg-red-100 border-red-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClass: Record<Size, string> = {
|
||||||
|
sm: "text-xs px-2.5 py-1.5",
|
||||||
|
md: "text-sm px-3.5 py-2",
|
||||||
|
lg: "text-base px-5 py-2.5",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
as = "button",
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const cls = cn(variantClass[variant], sizeClass[size], className);
|
||||||
|
if (as === "a" && href) {
|
||||||
|
return (
|
||||||
|
<a href={href} className={cls} {...(rest as unknown as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className={cls} {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("asf-card", className)} {...rest}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("asf-card-header", className)} {...rest}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("asf-card-title", className)} {...rest}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardBody({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("asf-card-body", className)} {...rest}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("px-5 py-3 border-t border-slate-100 text-xs text-slate-500 flex items-center justify-between", className)} {...rest}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface Option<T = string> {
|
||||||
|
value: T;
|
||||||
|
label: React.ReactNode;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dropdown<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select...",
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
size = "md",
|
||||||
|
tone = "secondary",
|
||||||
|
icon,
|
||||||
|
empty = "No options",
|
||||||
|
}: {
|
||||||
|
options: Option<T>[];
|
||||||
|
value?: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
label?: string;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
tone?: "secondary" | "ghost";
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
empty?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function h(e: MouseEvent) {
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (!ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", h);
|
||||||
|
return () => document.removeEventListener("mousedown", h);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const current = options.find((o) => o.value === value);
|
||||||
|
const btnClass = cn(
|
||||||
|
"inline-flex items-center gap-2 border rounded-lg transition-colors",
|
||||||
|
size === "sm" ? "text-xs px-2.5 py-1.5" : "text-sm px-3 py-2",
|
||||||
|
tone === "secondary"
|
||||||
|
? "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
|
||||||
|
: "bg-transparent border-transparent text-slate-700 hover:bg-slate-100",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative inline-block text-left", className)} ref={ref}>
|
||||||
|
{label && <div className="text-[11px] uppercase tracking-wide text-slate-500 mb-1">{label}</div>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btnClass}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{icon && <span className="text-slate-500">{icon}</span>}
|
||||||
|
<span className={cn("truncate", !current && "text-slate-400")}>
|
||||||
|
{current?.label ?? placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={cn("w-4 h-4 text-slate-400 transition-transform", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-30 mt-1 right-0 min-w-[240px] rounded-xl bg-white border border-slate-200 shadow-soft overflow-hidden animate-fade-in">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-slate-500 text-center">{empty}</div>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-80 overflow-auto py-1">
|
||||||
|
{options.map((o) => (
|
||||||
|
<li key={String(o.value)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 text-sm transition-colors",
|
||||||
|
value === o.value
|
||||||
|
? "bg-brand-50 text-brand-700 font-medium"
|
||||||
|
: "text-slate-700 hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(o.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="truncate">{o.label}</div>
|
||||||
|
{o.description && (
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5 truncate">{o.description}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Inbox, ShieldCheck } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title = "Nothing here yet",
|
||||||
|
description = "Create some data in ASF or wait a moment for it to load.",
|
||||||
|
icon: Icon = Inbox,
|
||||||
|
action,
|
||||||
|
actionLabel,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
action?: () => void;
|
||||||
|
actionLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center py-14 px-6 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/60 animate-fade-in",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-full bg-white border border-slate-200 grid place-items-center text-slate-500 shadow-sm mb-4">
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold text-slate-800 mb-1">{title}</h3>
|
||||||
|
<p className="text-sm text-slate-500 max-w-md mb-5">{description}</p>
|
||||||
|
{action && actionLabel && (
|
||||||
|
<Button variant="primary" size="sm" onClick={action}>
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Card, CardBody } from "./Card";
|
||||||
|
import { Badge } from "./Badge";
|
||||||
|
|
||||||
|
export function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
delta,
|
||||||
|
tone = "neutral",
|
||||||
|
icon,
|
||||||
|
subtitle,
|
||||||
|
isPercentage,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
delta?: number; // percentage change, e.g. +5.2
|
||||||
|
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "violet" | "blue";
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
isPercentage?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const showValue = isPercentage
|
||||||
|
? `${Number(value).toFixed(1)}%`
|
||||||
|
: typeof value === "number"
|
||||||
|
? value.toLocaleString()
|
||||||
|
: value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn("relative overflow-hidden", className)}>
|
||||||
|
<CardBody className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</div>
|
||||||
|
<div className="mt-1.5 text-2xl font-semibold text-slate-900 tabular-nums">
|
||||||
|
{showValue}
|
||||||
|
</div>
|
||||||
|
{subtitle !== undefined && (
|
||||||
|
<div className="mt-1 text-xs text-slate-500">{subtitle}</div>
|
||||||
|
)}
|
||||||
|
{delta !== undefined && (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-1 text-xs">
|
||||||
|
{delta >= 0 ? (
|
||||||
|
<>
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
|
<span className="text-emerald-700 font-medium">+{delta.toFixed(1)}%</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TrendingDown className="w-3.5 h-3.5 text-red-600" />
|
||||||
|
<span className="text-red-700 font-medium">{delta.toFixed(1)}%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-slate-400 ml-1">vs last period</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 rounded-lg grid place-items-center shrink-0 border",
|
||||||
|
tone === "brand" && "bg-brand-50 border-brand-100 text-brand-600",
|
||||||
|
tone === "green" && "bg-emerald-50 border-emerald-100 text-emerald-600",
|
||||||
|
tone === "red" && "bg-red-50 border-red-100 text-red-600",
|
||||||
|
tone === "amber" && "bg-amber-50 border-amber-100 text-amber-600",
|
||||||
|
tone === "violet" && "bg-violet-50 border-violet-100 text-violet-600",
|
||||||
|
tone === "blue" && "bg-sky-50 border-sky-100 text-sky-600",
|
||||||
|
tone === "neutral" && "bg-slate-50 border-slate-200 text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricPill({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone = "neutral",
|
||||||
|
isPercentage,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "blue";
|
||||||
|
isPercentage?: boolean;
|
||||||
|
}) {
|
||||||
|
const shown = isPercentage ? `${Number(value).toFixed(1)}%` : value;
|
||||||
|
const toneMap: Record<typeof tone, string> = {
|
||||||
|
neutral: "bg-slate-100 text-slate-700",
|
||||||
|
brand: "bg-brand-50 text-brand-700",
|
||||||
|
blue: "bg-sky-50 text-sky-700",
|
||||||
|
green: "bg-emerald-50 text-emerald-700",
|
||||||
|
red: "bg-red-50 text-red-700",
|
||||||
|
amber: "bg-amber-50 text-amber-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">{label}</span>
|
||||||
|
<Badge tone={tone === "neutral" ? "neutral" : tone === "brand" ? "brand" : tone === "green" ? "green" : tone === "red" ? "red" : "amber"}>
|
||||||
|
<span className="tabular-nums font-semibold">{shown}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Donut-style progress ring.
|
||||||
|
*
|
||||||
|
* Used in the Overview dashboard for things like implementation coverage,
|
||||||
|
* test coverage, project health score, traceability score.
|
||||||
|
*/
|
||||||
|
export function ProgressRing({
|
||||||
|
value,
|
||||||
|
size = 80,
|
||||||
|
stroke = 8,
|
||||||
|
tone = "brand",
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: number; // 0..100
|
||||||
|
size?: number;
|
||||||
|
stroke?: number;
|
||||||
|
tone?: "brand" | "green" | "amber" | "red" | "violet";
|
||||||
|
label?: string;
|
||||||
|
sublabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const r = (size - stroke) / 2;
|
||||||
|
const c = 2 * Math.PI * r;
|
||||||
|
const clamped = Math.max(0, Math.min(100, Number(value) || 0));
|
||||||
|
const offset = c - (clamped / 100) * c;
|
||||||
|
|
||||||
|
const toneMap: Record<string, string> = {
|
||||||
|
brand: "text-brand-600",
|
||||||
|
green: "text-emerald-600",
|
||||||
|
amber: "text-amber-600",
|
||||||
|
red: "text-red-600",
|
||||||
|
violet: "text-violet-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-4", className)}>
|
||||||
|
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className={cn("-rotate-90", toneMap[tone])}>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity={0.12}
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
className="transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 grid place-items-center">
|
||||||
|
<div className="text-sm font-semibold tabular-nums text-slate-900">
|
||||||
|
{clamped.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(label || sublabel) && (
|
||||||
|
<div className="min-w-0">
|
||||||
|
{label && <div className="text-sm font-medium text-slate-800">{label}</div>}
|
||||||
|
{sublabel && <div className="text-xs text-slate-500 mt-0.5">{sublabel}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
transpilePackages: ["lucide-react"],
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ["lucide-react"],
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/v1/:path*",
|
||||||
|
destination: `${process.env.NEXT_PUBLIC_ASF_CORE_URL || "http://localhost:8000"}/api/v1/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
Generated
+6909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "asf-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Azurian Software Factory SaaS UI shell",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --hostname 0.0.0.0 -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start --hostname 0.0.0.0 -p 3000",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.56.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.445.0",
|
||||||
|
"next": "14.2.15",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.15",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
org_id?: string;
|
||||||
|
project_id?: string;
|
||||||
|
asfOpen: boolean; // Ask ASF command / chat bar
|
||||||
|
|
||||||
|
setOrg: (org_id?: string) => void;
|
||||||
|
setProject: (project_id?: string) => void;
|
||||||
|
openAsf: () => void;
|
||||||
|
closeAsf: () => void;
|
||||||
|
toggleAsf: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
org_id: process.env.NEXT_PUBLIC_DEV_TENANT_ID,
|
||||||
|
project_id: undefined,
|
||||||
|
asfOpen: false,
|
||||||
|
setOrg: (org_id) => set({ org_id }),
|
||||||
|
setProject: (project_id) => set({ project_id }),
|
||||||
|
openAsf: () => set({ asfOpen: true }),
|
||||||
|
closeAsf: () => set({ asfOpen: false }),
|
||||||
|
toggleAsf: () => set((s) => ({ asfOpen: !s.asfOpen })),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#f0f7ff",
|
||||||
|
100: "#e0efff",
|
||||||
|
200: "#bae0ff",
|
||||||
|
300: "#7cc7ff",
|
||||||
|
400: "#35a9ff",
|
||||||
|
500: "#0b8cff",
|
||||||
|
600: "#006fe0",
|
||||||
|
700: "#0057b4",
|
||||||
|
800: "#084a8f",
|
||||||
|
900: "#0d3f75",
|
||||||
|
950: "#08264c",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "#7c5cff",
|
||||||
|
muted: "#5b44c0",
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
DEFAULT: "#ffffff",
|
||||||
|
dark: "#0b1220",
|
||||||
|
},
|
||||||
|
ink: {
|
||||||
|
DEFAULT: "#0f172a",
|
||||||
|
muted: "#64748b",
|
||||||
|
},
|
||||||
|
border: "#e2e8f0",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
"Inter",
|
||||||
|
"ui-sans-serif",
|
||||||
|
"system-ui",
|
||||||
|
"-apple-system",
|
||||||
|
"Segoe UI",
|
||||||
|
"Roboto",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
mono: ["JetBrains Mono", "Menlo", "ui-monospace", "monospace"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
soft: "0 2px 10px -2px rgba(15,23,42,0.06), 0 1px 3px rgba(15,23,42,0.04)",
|
||||||
|
card: "0 1px 2px rgba(15,23,42,0.04), 0 4px 20px -8px rgba(15,23,42,0.08)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"fade-in": {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(4px)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"fade-in": "fade-in 180ms ease-out both",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"],
|
||||||
|
"@/app/*": ["./app/*"],
|
||||||
|
"@/components/*": ["./components/*"],
|
||||||
|
"@/lib/*": ["./lib/*"],
|
||||||
|
"@/stores/*": ["./stores/*"],
|
||||||
|
"@/types/*": ["./types/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
|||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
POETRY_VIRTUALENVS_CREATE=false
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY packages/asf-domain /packages/asf-domain
|
||||||
|
COPY packages/asf-events /packages/asf-events
|
||||||
|
COPY packages/asf-connectors /packages/asf-connectors
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
-e /packages/asf-domain \
|
||||||
|
-e /packages/asf-events \
|
||||||
|
-e /packages/asf-connectors
|
||||||
|
|
||||||
|
COPY services/asf-core/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY services/asf-core/app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/asf-web/package.json frontend/asf-web/package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY frontend/asf-web/ .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY packages/asf-domain /packages/asf-domain
|
||||||
|
COPY packages/asf-events /packages/asf-events
|
||||||
|
COPY packages/asf-connectors /packages/asf-connectors
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
-e /packages/asf-domain \
|
||||||
|
-e /packages/asf-events \
|
||||||
|
-e /packages/asf-connectors
|
||||||
|
|
||||||
|
COPY services/asf-worker/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY services/asf-worker/app ./app
|
||||||
|
|
||||||
|
CMD ["python", "-m", "app.worker"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""asf-connectors: Protocol-based interfaces for every external system ASF
|
||||||
|
can integrate with.
|
||||||
|
|
||||||
|
Concrete implementations (GitHub adapter, Jira adapter, ...) are provided
|
||||||
|
separately, often in their own installable extras. Business code MUST
|
||||||
|
depend only on the Protocols defined here. This keeps ASF Core decoupled
|
||||||
|
from any single provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
from asf_connectors.git import GitConnector, PullRequestInfo, CommitInfo
|
||||||
|
from asf_connectors.issues import IssueTrackerConnector, IssueInfo
|
||||||
|
from asf_connectors.ci import CICDConnector, BuildInfo, DeploymentInfo
|
||||||
|
from asf_connectors.observability import ObservabilityConnector
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GitConnector",
|
||||||
|
"PullRequestInfo",
|
||||||
|
"CommitInfo",
|
||||||
|
"IssueTrackerConnector",
|
||||||
|
"IssueInfo",
|
||||||
|
"CICDConnector",
|
||||||
|
"BuildInfo",
|
||||||
|
"DeploymentInfo",
|
||||||
|
"ObservabilityConnector",
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Abstract CI/CD connector interface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, Optional, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuildInfo:
|
||||||
|
build_id: str
|
||||||
|
pipeline_name: str
|
||||||
|
status: str # success | failed | running | cancelled
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
triggered_by: Optional[str] = None
|
||||||
|
branch: Optional[str] = None
|
||||||
|
commit_sha: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeploymentInfo:
|
||||||
|
deployment_id: str
|
||||||
|
environment: str # dev | staging | prod | ...
|
||||||
|
status: str # success | failed | in_progress
|
||||||
|
version_ref: Optional[str] = None
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
target: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CICDConnector(Protocol):
|
||||||
|
"""Jenkins / GitLab CI / GitHub Actions / ArgoCD / ..."""
|
||||||
|
|
||||||
|
async def get_build(self, pipeline: str, build_id: str) -> Optional[BuildInfo]: ...
|
||||||
|
async def list_builds(self, pipeline: str, limit: int = 50) -> Sequence[BuildInfo]: ...
|
||||||
|
|
||||||
|
async def get_deployment(self, deployment_id: str) -> Optional[DeploymentInfo]: ...
|
||||||
|
async def list_deployments(self, environment: str, limit: int = 50) -> Sequence[DeploymentInfo]: ...
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Abstract Git (repository / source-control) connector interface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, AsyncIterator, Optional, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommitInfo:
|
||||||
|
sha: str
|
||||||
|
message: str
|
||||||
|
author_email: str
|
||||||
|
author_name: str
|
||||||
|
authored_at: datetime
|
||||||
|
branch: Optional[str] = None
|
||||||
|
files_changed: Sequence[str] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PullRequestInfo:
|
||||||
|
number: int
|
||||||
|
title: str
|
||||||
|
state: str # open | closed | merged
|
||||||
|
branch: str
|
||||||
|
target_branch: str
|
||||||
|
author: str
|
||||||
|
created_at: datetime
|
||||||
|
merged_at: Optional[datetime] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
commit_shas: Sequence[str] = ()
|
||||||
|
labels: Sequence[str] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class GitConnector(Protocol):
|
||||||
|
"""Any Git-hosting provider: GitHub, GitLab, Bitbucket, local Gitea..."""
|
||||||
|
|
||||||
|
async def list_commits(
|
||||||
|
self, repo_ref: str, *, branch: Optional[str] = None, since: Optional[datetime] = None
|
||||||
|
) -> AsyncIterator[CommitInfo]: ...
|
||||||
|
|
||||||
|
async def get_commit(self, repo_ref: str, sha: str) -> Optional[CommitInfo]: ...
|
||||||
|
|
||||||
|
async def list_pull_requests(
|
||||||
|
self, repo_ref: str, *, state: str = "open"
|
||||||
|
) -> AsyncIterator[PullRequestInfo]: ...
|
||||||
|
|
||||||
|
async def get_pull_request(self, repo_ref: str, number: int) -> Optional[PullRequestInfo]: ...
|
||||||
|
|
||||||
|
async def index_repository(self, repo_ref: str) -> None:
|
||||||
|
"""Trigger a full sync (artifacts + relationships) for a repo.
|
||||||
|
|
||||||
|
Implementation must emit events; it must NOT mutate Core data
|
||||||
|
directly.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Abstract issue / requirement tracker connector (Jira, Linear, etc.)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, AsyncIterator, Optional, Any, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IssueInfo:
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
type: str # epic, story, bug, task, ...
|
||||||
|
status: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
assignee: Optional[str] = None
|
||||||
|
labels: Sequence[str] = ()
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
parent_key: Optional[str] = None
|
||||||
|
metadata: dict[str, Any] = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class IssueTrackerConnector(Protocol):
|
||||||
|
"""Provider interface for Jira / GitHub Issues / YouTrack / ..."""
|
||||||
|
|
||||||
|
async def search_issues(
|
||||||
|
self, query: str, *, project_ref: Optional[str] = None, limit: int = 100
|
||||||
|
) -> AsyncIterator[IssueInfo]: ...
|
||||||
|
|
||||||
|
async def get_issue(self, issue_key: str) -> Optional[IssueInfo]: ...
|
||||||
|
|
||||||
|
async def sync_project(self, project_ref: str) -> None: ...
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Abstract observability connector (Grafana / Prometheus / Datadog / ...)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityConnector(Protocol):
|
||||||
|
async def query_metric(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
step: Optional[str] = None,
|
||||||
|
) -> list[dict[str, Any]]: ...
|
||||||
|
|
||||||
|
async def get_incident(self, incident_id: str) -> Optional[dict[str, Any]]: ...
|
||||||
|
|
||||||
|
async def list_active_incidents(self, limit: int = 50) -> list[dict[str, Any]]: ...
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "asf-connectors"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "ASF external-system connector interfaces (Protocol-based, no concrete adapters yet)."
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"asf-domain>=0.1.0",
|
||||||
|
"asf-events>=0.1.0",
|
||||||
|
"pydantic>=2.7,<3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["asf_connectors*"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# asf-domain — Shared Pydantic Domain Models
|
||||||
|
|
||||||
|
> Package condiviso: *tutti* i servizi Python importano i modelli da qui.
|
||||||
|
> Non copiare-incollare modelli: se cambi un modello qui, cambia per tutti.
|
||||||
|
|
||||||
|
Install (local dev):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/asf-domain
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
|
||||||
|
- `asf_domain.base` — UUID ULID factory, tenant-aware base model, enums.
|
||||||
|
- `asf_domain.identity` — Organization, User, Team, Project.
|
||||||
|
- `asf_domain.artifact` — Artifact, ArtifactRelationship (the core "Everything is an Artifact" model).
|
||||||
|
- `asf_domain.job` — Job lifecycle & usage tracking.
|
||||||
|
- `asf_domain.audit` — AuditEvent.
|
||||||
|
- `asf_domain.integration` — Integration.
|
||||||
|
- `asf_domain.ai_execution` — AIExecution record.
|
||||||
|
- `asf_domain.common` — Typed aliases, shared types.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""asf-domain: shared Pydantic models for Azurian Software Factory.
|
||||||
|
|
||||||
|
Every Python service in ASF imports its domain models from this package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
from asf_domain.base import (
|
||||||
|
ASFBaseModel,
|
||||||
|
TenantScopedModel,
|
||||||
|
generate_id,
|
||||||
|
UTCDatetime,
|
||||||
|
ArtifactType,
|
||||||
|
RelationshipType,
|
||||||
|
JobStatus,
|
||||||
|
JobPriority,
|
||||||
|
JobType,
|
||||||
|
AuditAction,
|
||||||
|
)
|
||||||
|
from asf_domain.identity import (
|
||||||
|
Organization,
|
||||||
|
OrganizationCreate,
|
||||||
|
OrganizationUpdate,
|
||||||
|
Project,
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectUpdate,
|
||||||
|
Team,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from asf_domain.artifact import (
|
||||||
|
Artifact,
|
||||||
|
ArtifactCreate,
|
||||||
|
ArtifactUpdate,
|
||||||
|
ArtifactRelationship,
|
||||||
|
ArtifactRelationshipCreate,
|
||||||
|
RelationshipDirection,
|
||||||
|
)
|
||||||
|
from asf_domain.job import (
|
||||||
|
Job,
|
||||||
|
JobCreate,
|
||||||
|
JobUpdate,
|
||||||
|
JobUsage,
|
||||||
|
)
|
||||||
|
from asf_domain.ai_execution import (
|
||||||
|
AIExecution,
|
||||||
|
AIExecutionCreate,
|
||||||
|
)
|
||||||
|
from asf_domain.integration import Integration, IntegrationCreate
|
||||||
|
from asf_domain.audit import AuditEvent, AuditEventCreate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ASFBaseModel",
|
||||||
|
"TenantScopedModel",
|
||||||
|
"generate_id",
|
||||||
|
"UTCDatetime",
|
||||||
|
"ArtifactType",
|
||||||
|
"RelationshipType",
|
||||||
|
"JobStatus",
|
||||||
|
"JobPriority",
|
||||||
|
"JobType",
|
||||||
|
"AuditAction",
|
||||||
|
"Organization",
|
||||||
|
"OrganizationCreate",
|
||||||
|
"OrganizationUpdate",
|
||||||
|
"Project",
|
||||||
|
"ProjectCreate",
|
||||||
|
"ProjectUpdate",
|
||||||
|
"Team",
|
||||||
|
"User",
|
||||||
|
"Artifact",
|
||||||
|
"ArtifactCreate",
|
||||||
|
"ArtifactUpdate",
|
||||||
|
"ArtifactRelationship",
|
||||||
|
"ArtifactRelationshipCreate",
|
||||||
|
"RelationshipDirection",
|
||||||
|
"Job",
|
||||||
|
"JobCreate",
|
||||||
|
"JobUpdate",
|
||||||
|
"JobUsage",
|
||||||
|
"AIExecution",
|
||||||
|
"AIExecutionCreate",
|
||||||
|
"Integration",
|
||||||
|
"IntegrationCreate",
|
||||||
|
"AuditEvent",
|
||||||
|
"AuditEventCreate",
|
||||||
|
]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""AIExecution — immutable record of a single inference call.
|
||||||
|
|
||||||
|
Stored after every real (or mock) AI invocation so that:
|
||||||
|
- usage/cost is attributable to a tenant + project + job
|
||||||
|
- inputs/outputs can be audited / replayed
|
||||||
|
- the AI Gateway can train / evaluate routing policies later
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional, Dict
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from asf_domain.base import (
|
||||||
|
ASFBaseModel,
|
||||||
|
TenantScopedModel,
|
||||||
|
ASFFieldID,
|
||||||
|
UTCDatetime,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AIExecutionBase(ASFBaseModel):
|
||||||
|
capability: str = Field(
|
||||||
|
...,
|
||||||
|
max_length=128,
|
||||||
|
description="ASF capability requested (e.g. chat, analyze_code, generate_tests).",
|
||||||
|
)
|
||||||
|
provider: str = Field(
|
||||||
|
..., max_length=64, description="Adapter used (e.g. mock, openai, nvidia, local)."
|
||||||
|
)
|
||||||
|
model: str = Field(
|
||||||
|
..., max_length=128, description="Concrete model identifier reported by the adapter."
|
||||||
|
)
|
||||||
|
success: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AIExecutionCreate(AIExecutionBase):
|
||||||
|
job_id: Optional[ASFFieldID] = None
|
||||||
|
project_id: Optional[ASFFieldID] = None
|
||||||
|
input_ref: Optional[str] = Field(
|
||||||
|
None, max_length=512,
|
||||||
|
description="MinIO ref or short fingerprint of the input payload (never raw secrets).",
|
||||||
|
)
|
||||||
|
output_ref: Optional[str] = Field(
|
||||||
|
None, max_length=512, description="MinIO ref / fingerprint of the response."
|
||||||
|
)
|
||||||
|
input_tokens: Optional[int] = Field(None, ge=0)
|
||||||
|
output_tokens: Optional[int] = Field(None, ge=0)
|
||||||
|
duration_ms: Optional[int] = Field(None, ge=0)
|
||||||
|
estimated_cost: Optional[float] = Field(None, ge=0)
|
||||||
|
error_message: Optional[str] = Field(None, max_length=2000)
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AIExecution(TenantScopedModel, AIExecutionBase):
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
job_id: Optional[ASFFieldID] = None
|
||||||
|
project_id: Optional[ASFFieldID] = None
|
||||||
|
input_ref: Optional[str] = None
|
||||||
|
output_ref: Optional[str] = None
|
||||||
|
input_tokens: Optional[int] = None
|
||||||
|
output_tokens: Optional[int] = None
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
estimated_cost: Optional[float] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Artifact — the heart of the "Everything is an Artifact" principle.
|
||||||
|
|
||||||
|
One single generic model captures every lifecycle entity (requirement,
|
||||||
|
task, commit, test, deployment, incident, ...). Artifact subtypes are
|
||||||
|
expressed via the ``type`` enum / string and stored in the same MongoDB
|
||||||
|
``artifacts`` collection.
|
||||||
|
|
||||||
|
Relationships between artifacts live in ``artifact_relationships``
|
||||||
|
collection and are modelled as explicit directed edges — this keeps the
|
||||||
|
option open to project the same data onto a graph database later without
|
||||||
|
rewriting domain logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional, List, Dict
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from asf_domain.base import (
|
||||||
|
ASFBaseModel,
|
||||||
|
TenantScopedModel,
|
||||||
|
ASFFieldID,
|
||||||
|
UTCDatetime,
|
||||||
|
ArtifactType,
|
||||||
|
RelationshipType,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Artifact itself
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactBase(ASFBaseModel):
|
||||||
|
type: ArtifactType = Field(
|
||||||
|
..., description="Kind of artifact (requirement | task | commit | …)"
|
||||||
|
)
|
||||||
|
external_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=255,
|
||||||
|
description="Optional identifier in an external system "
|
||||||
|
"(e.g. GitHub PR id, Jira issue key)",
|
||||||
|
)
|
||||||
|
title: str = Field(..., min_length=1, max_length=500)
|
||||||
|
content: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Primary human readable body (markdown, description, diff, log body, …). "
|
||||||
|
"Large content should live in MinIO; store a reference here or in metadata.",
|
||||||
|
)
|
||||||
|
source: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=255,
|
||||||
|
description="Origin system: jira, github, manual, ai, …",
|
||||||
|
)
|
||||||
|
status: Optional[str] = Field(
|
||||||
|
None, max_length=80, description="Status (e.g. open, done, failed, released)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactCreate(ArtifactBase):
|
||||||
|
"""Create payload. ``tenant_id`` and ``project_id`` are set server-side."""
|
||||||
|
|
||||||
|
project_id: ASFFieldID = Field(
|
||||||
|
..., description="Owning project (validated against tenant context server side)."
|
||||||
|
)
|
||||||
|
metadata: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Arbitrary extensible fields. Use this for artifact subtype "
|
||||||
|
"specific attributes (e.g. commit.sha, test.outcome, deployment.env).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactUpdate(ASFBaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||||
|
content: Optional[str] = None
|
||||||
|
type: Optional[ArtifactType] = None
|
||||||
|
external_id: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
metadata: Optional[Dict[str, Any]] = Field(
|
||||||
|
None,
|
||||||
|
description="When provided, performs a shallow merge with existing metadata.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Artifact(TenantScopedModel, ArtifactBase):
|
||||||
|
"""Stored Artifact record.
|
||||||
|
|
||||||
|
``relationships`` is a denormalised, always-stale list kept for fast
|
||||||
|
reads in the UI. The authoritative source of truth for edges is the
|
||||||
|
``artifact_relationships`` collection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
project_id: ASFFieldID = ...
|
||||||
|
version: int = Field(
|
||||||
|
1, ge=1, description="Monotonic version bumped on every update."
|
||||||
|
)
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
|
updated_at: UTCDatetime = ...
|
||||||
|
created_by: Optional[ASFFieldID] = Field(
|
||||||
|
None, description="User id that created this artifact (null = system / import)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# ArtifactRelationship — the explicit edge
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipDirection(str, Enum):
|
||||||
|
FORWARD = "forward"
|
||||||
|
REVERSE = "reverse"
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRelationshipBase(ASFBaseModel):
|
||||||
|
relationship_type: RelationshipType = Field(
|
||||||
|
...,
|
||||||
|
description="Semantic predicate: ``source <relationship_type> target``.",
|
||||||
|
)
|
||||||
|
source_artifact_id: ASFFieldID = Field(
|
||||||
|
..., description="Source artifact id (subject)."
|
||||||
|
)
|
||||||
|
target_artifact_id: ASFFieldID = Field(
|
||||||
|
..., description="Target artifact id (object)."
|
||||||
|
)
|
||||||
|
metadata: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Free-form metadata on the edge (confidence, evidence ref, …).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRelationshipCreate(ArtifactRelationshipBase):
|
||||||
|
"""Create payload. tenant_id / project_ids are validated server side."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRelationship(TenantScopedModel, ArtifactRelationshipBase):
|
||||||
|
"""Stored edge between two artifacts.
|
||||||
|
|
||||||
|
Invariant: both source and target MUST belong to the same tenant.
|
||||||
|
They MAY live in different projects inside that tenant when an
|
||||||
|
explicit cross-project reference is intentional.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
source_project_id: ASFFieldID = ...
|
||||||
|
target_project_id: ASFFieldID = ...
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
|
updated_at: UTCDatetime = ...
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""AuditEvent — append-only security & business audit trail.
|
||||||
|
|
||||||
|
Audit events are never mutated or deleted. They record WHO did WHAT to
|
||||||
|
WHICH entity WHEN and from WHERE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional, Dict
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from asf_domain.base import (
|
||||||
|
ASFBaseModel,
|
||||||
|
TenantScopedModel,
|
||||||
|
ASFFieldID,
|
||||||
|
UTCDatetime,
|
||||||
|
AuditAction,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventBase(ASFBaseModel):
|
||||||
|
action: AuditAction = Field(..., description="What happened.")
|
||||||
|
resource_type: str = Field(
|
||||||
|
...,
|
||||||
|
max_length=80,
|
||||||
|
description="Affected entity kind (organization, project, artifact, job, api_key, …).",
|
||||||
|
)
|
||||||
|
resource_id: Optional[ASFFieldID] = Field(None, description="Affected entity id if applicable.")
|
||||||
|
actor_id: Optional[ASFFieldID] = Field(
|
||||||
|
None, description="User id that caused the event, or null for system actions."
|
||||||
|
)
|
||||||
|
actor_type: str = Field(
|
||||||
|
"user",
|
||||||
|
max_length=40,
|
||||||
|
description="user | system | api_key | service — kind of actor that caused the event.",
|
||||||
|
)
|
||||||
|
success: bool = True
|
||||||
|
reason: Optional[str] = Field(
|
||||||
|
None, max_length=500, description="For failed events, why the action was rejected."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventCreate(AuditEventBase):
|
||||||
|
project_id: Optional[ASFFieldID] = None
|
||||||
|
ip_address: Optional[str] = Field(None, max_length=64)
|
||||||
|
user_agent: Optional[str] = Field(None, max_length=512)
|
||||||
|
correlation_id: Optional[str] = Field(None, max_length=128)
|
||||||
|
metadata: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Structured detail of the change. NEVER log secrets here.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEvent(TenantScopedModel, AuditEventBase):
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
project_id: Optional[ASFFieldID] = None
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
correlation_id: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Base types and enums for all ASF domain models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Annotated
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
field_validator,
|
||||||
|
PlainSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Identifiers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ASFFieldID = Annotated[
|
||||||
|
UUID,
|
||||||
|
Field(
|
||||||
|
description="ASF unique identifier (UUID v4)",
|
||||||
|
examples=["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id() -> UUID:
|
||||||
|
"""Return a new UUID v4 identifier for an ASF entity."""
|
||||||
|
return uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Datetime helpers — force UTC on the wire
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_serialize(dt: datetime) -> str:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
UTCDatetime = Annotated[
|
||||||
|
datetime,
|
||||||
|
PlainSerializer(_utc_serialize, return_type=str, when_used="json"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base model configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ASFBaseModel(BaseModel):
|
||||||
|
"""Base Pydantic model used everywhere in ASF."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra="forbid",
|
||||||
|
populate_by_name=True,
|
||||||
|
use_enum_values=False,
|
||||||
|
from_attributes=True,
|
||||||
|
str_strip_whitespace=True,
|
||||||
|
json_encoders={datetime: _utc_serialize},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantScopedModel(ASFBaseModel):
|
||||||
|
"""Any record that belongs to a tenant (Organization)."""
|
||||||
|
|
||||||
|
tenant_id: ASFFieldID = Field(
|
||||||
|
description="Owning tenant (Organization) identifier. "
|
||||||
|
"Always validated against the authenticated context — never "
|
||||||
|
"trust the value supplied by an untrusted client."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Artifact type universe (extensible, open)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactType(str, enum.Enum):
|
||||||
|
"""Canonical set of Artifact subtypes in the initial bootstrap.
|
||||||
|
|
||||||
|
This list is intentionally extensible: adding a new kind of lifecycle
|
||||||
|
entity is a new ArtifactType value, NOT a new MongoDB collection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REQUIREMENT = "requirement"
|
||||||
|
EPIC = "epic"
|
||||||
|
STORY = "story"
|
||||||
|
TASK = "task"
|
||||||
|
ARCHITECTURE_DECISION = "architecture_decision"
|
||||||
|
DOCUMENT = "document"
|
||||||
|
SOURCE_FILE = "source_file"
|
||||||
|
COMMIT = "commit"
|
||||||
|
PULL_REQUEST = "pull_request"
|
||||||
|
BUILD = "build"
|
||||||
|
TEST = "test"
|
||||||
|
SECURITY_FINDING = "security_finding"
|
||||||
|
DEPLOYMENT = "deployment"
|
||||||
|
RELEASE = "release"
|
||||||
|
RUNTIME_EVENT = "runtime_event"
|
||||||
|
INCIDENT = "incident"
|
||||||
|
AI_ANALYSIS = "ai_analysis"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Relationship types between artifacts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipType(str, enum.Enum):
|
||||||
|
"""Canonical relationship predicates between two Artifacts.
|
||||||
|
|
||||||
|
Semantics: ``source <predicate> target``, e.g.
|
||||||
|
``REQ-1 implements TASK-7`` means TASK-7 implements REQ-1.
|
||||||
|
For the inverse use IMPLEMENTED_BY.
|
||||||
|
"""
|
||||||
|
|
||||||
|
IMPLEMENTS = "implements"
|
||||||
|
IMPLEMENTED_BY = "implemented_by"
|
||||||
|
VERIFIES = "verifies"
|
||||||
|
VERIFIED_BY = "verified_by"
|
||||||
|
GENERATED_FROM = "generated_from"
|
||||||
|
DOCUMENTED_BY = "documented_by"
|
||||||
|
DEPENDS_ON = "depends_on"
|
||||||
|
AFFECTS = "affects"
|
||||||
|
DEPLOYED_AS = "deployed_as"
|
||||||
|
CAUSED_BY = "caused_by"
|
||||||
|
SUPERSEDES = "supersedes"
|
||||||
|
RELATED_TO = "related_to"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(str, enum.Enum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class JobPriority(str, enum.Enum):
|
||||||
|
LOW = "low"
|
||||||
|
NORMAL = "normal"
|
||||||
|
HIGH = "high"
|
||||||
|
URGENT = "urgent"
|
||||||
|
|
||||||
|
|
||||||
|
class JobType(str, enum.Enum):
|
||||||
|
AI_INTERACTIVE = "ai_interactive"
|
||||||
|
AI_BACKGROUND = "ai_background"
|
||||||
|
INDEXING = "indexing"
|
||||||
|
CODE_ANALYSIS = "code_analysis"
|
||||||
|
TESTING = "testing"
|
||||||
|
DEVOPS = "devops"
|
||||||
|
NOTIFICATION = "notification"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAction(str, enum.Enum):
|
||||||
|
CREATED = "created"
|
||||||
|
UPDATED = "updated"
|
||||||
|
DELETED = "deleted"
|
||||||
|
ACCESSED = "accessed"
|
||||||
|
LOGGED_IN = "logged_in"
|
||||||
|
LOGGED_OUT = "logged_out"
|
||||||
|
PERMISSION_CHANGED = "permission_changed"
|
||||||
|
API_KEY_CREATED = "api_key_created"
|
||||||
|
API_KEY_REVOKED = "api_key_revoked"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Small shared type aliases & tiny helpers.
|
||||||
|
|
||||||
|
Put things here when they are used by multiple domain modules but don't
|
||||||
|
have a home of their own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypeAlias, Any, Dict, Awaitable, Callable
|
||||||
|
|
||||||
|
JSONDict: TypeAlias = Dict[str, Any]
|
||||||
|
"""Any loosely-typed JSON object (metadata, unstructured payloads, …)."""
|
||||||
|
|
||||||
|
AsyncHandler: TypeAlias = Callable[..., Awaitable[Any]]
|
||||||
|
"""Generic async callable type alias."""
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Identity domain: Organization, User, Team, Project.
|
||||||
|
|
||||||
|
SaaS tenancy:
|
||||||
|
Organization == tenant boundary
|
||||||
|
└── Teams
|
||||||
|
└── Projects
|
||||||
|
└── Artifacts + Relationships + Jobs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from asf_domain.base import (
|
||||||
|
ASFBaseModel,
|
||||||
|
TenantScopedModel,
|
||||||
|
ASFFieldID,
|
||||||
|
UTCDatetime,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Organization (tenant)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationBase(ASFBaseModel):
|
||||||
|
name: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=2,
|
||||||
|
max_length=120,
|
||||||
|
description="Human readable organization / tenant name",
|
||||||
|
examples=["Azurian Labs", "Contoso Retail"],
|
||||||
|
)
|
||||||
|
slug: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=3,
|
||||||
|
max_length=60,
|
||||||
|
pattern=r"^[a-z0-9][a-z0-9\-]{1,58}[a-z0-9]$",
|
||||||
|
description="URL-safe unique identifier for the tenant",
|
||||||
|
examples=["azurian-labs"],
|
||||||
|
)
|
||||||
|
plan: str = Field(
|
||||||
|
"free",
|
||||||
|
description="Subscription tier placeholder",
|
||||||
|
examples=["free", "team", "enterprise"],
|
||||||
|
)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationCreate(OrganizationBase):
|
||||||
|
"""Payload used to create a new Organization (tenant)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationUpdate(ASFBaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=2, max_length=120)
|
||||||
|
slug: Optional[str] = Field(None, min_length=3, max_length=60)
|
||||||
|
plan: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(OrganizationBase):
|
||||||
|
"""Stored Organization record — also the tenant boundary record."""
|
||||||
|
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
created_at: UTCDatetime = Field(
|
||||||
|
..., description="Creation timestamp (UTC, written on insert)"
|
||||||
|
)
|
||||||
|
updated_at: UTCDatetime = Field(
|
||||||
|
..., description="Last update timestamp (UTC, written on every write)"
|
||||||
|
)
|
||||||
|
metadata: dict = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Tenant-level extensible metadata (billing id, features, …)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# User (bootstrap skeleton; real auth lives outside domain)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class User(TenantScopedModel):
|
||||||
|
"""Minimal User record used for ownership / audit references."""
|
||||||
|
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
email: str = Field(..., max_length=255)
|
||||||
|
display_name: str = Field(..., max_length=120)
|
||||||
|
roles: List[str] = Field(default_factory=lambda: ["member"])
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
|
updated_at: UTCDatetime = ...
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def _lower_email(cls, v: str) -> str:
|
||||||
|
return v.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Team
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class Team(TenantScopedModel):
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
name: str = Field(..., min_length=2, max_length=80)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
member_ids: List[ASFFieldID] = Field(default_factory=list)
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
|
updated_at: UTCDatetime = ...
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Project
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectBase(ASFBaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=120)
|
||||||
|
key: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=2,
|
||||||
|
max_length=20,
|
||||||
|
pattern=r"^[A-Z][A-Z0-9]{1,19}$",
|
||||||
|
description="Short project key (e.g. ASF, PROJ) used in human readable refs",
|
||||||
|
examples=["ASF", "PRJ"],
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(None, max_length=1000)
|
||||||
|
default_branch: str = Field("main", max_length=100)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(ProjectBase):
|
||||||
|
"""Create payload; tenant_id is injected server side from auth context."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(ASFBaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=2, max_length=120)
|
||||||
|
key: Optional[str] = Field(None, min_length=2, max_length=20)
|
||||||
|
description: Optional[str] = Field(None, max_length=1000)
|
||||||
|
default_branch: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Project(TenantScopedModel, ProjectBase):
|
||||||
|
"""Stored Project record. Lives inside a tenant."""
|
||||||
|
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
team_ids: List[ASFFieldID] = Field(default_factory=list)
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
|
updated_at: UTCDatetime = ...
|
||||||
|
metadata: dict = Field(default_factory=dict)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Integration — registration of a connector bound to a tenant.
|
||||||
|
|
||||||
|
The connector itself lives in ``asf-connectors``; this record stores:
|
||||||
|
* which connector type is in use
|
||||||
|
* tenant-scoped configuration references (secrets live elsewhere)
|
||||||
|
* last health state
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional, Dict
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from asf_domain.base import (
|
||||||
|
ASFBaseModel,
|
||||||
|
TenantScopedModel,
|
||||||
|
ASFFieldID,
|
||||||
|
UTCDatetime,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorType(str, Enum):
|
||||||
|
GITHUB = "github"
|
||||||
|
GITLAB = "gitlab"
|
||||||
|
BITBUCKET = "bitbucket"
|
||||||
|
JIRA = "jira"
|
||||||
|
CONFLUENCE = "confluence"
|
||||||
|
NOTION = "notion"
|
||||||
|
JENKINS = "jenkins"
|
||||||
|
SONARQUBE = "sonarqube"
|
||||||
|
GRAFANA = "grafana"
|
||||||
|
GENERIC_WEBHOOK = "generic_webhook"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationBase(ASFBaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=80)
|
||||||
|
connector: ConnectorType = ConnectorType.CUSTOM
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationCreate(IntegrationBase):
|
||||||
|
config_ref: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=512,
|
||||||
|
description="Reference to a secure store (e.g. vault path) holding connector secrets. "
|
||||||
|
"NEVER inline credentials here.",
|
||||||
|
)
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Integration(TenantScopedModel, IntegrationBase):
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
config_ref: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
last_health_at: Optional[UTCDatetime] = None
|
||||||
|
last_health_status: Optional[str] = Field(None, max_length=40)
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
|
updated_at: UTCDatetime = ...
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Job model — persistent asynchronous job state.
|
||||||
|
|
||||||
|
RabbitMQ delivers work messages. MongoDB is the source of truth for job
|
||||||
|
lifecycle. Worker processes update Job records as they progress through
|
||||||
|
queued → running → completed|failed|cancelled.
|
||||||
|
|
||||||
|
Do not use RabbitMQ queues as the permanent job database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional, Dict
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from asf_domain.base import (
|
||||||
|
ASFBaseModel,
|
||||||
|
TenantScopedModel,
|
||||||
|
ASFFieldID,
|
||||||
|
UTCDatetime,
|
||||||
|
JobStatus,
|
||||||
|
JobPriority,
|
||||||
|
JobType,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobUsage(ASFBaseModel):
|
||||||
|
"""Optional resource / cost usage tracked for a job."""
|
||||||
|
|
||||||
|
input_tokens: Optional[int] = Field(None, ge=0)
|
||||||
|
output_tokens: Optional[int] = Field(None, ge=0)
|
||||||
|
gpu_seconds: Optional[float] = Field(None, ge=0)
|
||||||
|
cpu_seconds: Optional[float] = Field(None, ge=0)
|
||||||
|
estimated_cost: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Estimated cost in tenant currency (EUR/USD). Approximate, used for metering only.",
|
||||||
|
)
|
||||||
|
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class JobBase(ASFBaseModel):
|
||||||
|
type: JobType = Field(
|
||||||
|
...,
|
||||||
|
description="Logical job class. Used for routing to the correct RabbitMQ queue.",
|
||||||
|
)
|
||||||
|
priority: JobPriority = JobPriority.NORMAL
|
||||||
|
payload: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="The actual work payload (e.g. {artifact_id, prompt, model_preference}).",
|
||||||
|
)
|
||||||
|
correlation_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=128,
|
||||||
|
description="Optional correlation id propagated across logs, events, child jobs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobCreate(JobBase):
|
||||||
|
"""Create payload. tenant_id/project_id/requested_by are injected server-side."""
|
||||||
|
|
||||||
|
project_id: Optional[ASFFieldID] = None
|
||||||
|
model: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=128,
|
||||||
|
description="Preferred model / engine, only meaningful for AI job types. "
|
||||||
|
"Ultimate routing decision belongs to the AI Gateway.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobUpdate(ASFBaseModel):
|
||||||
|
"""Updates issued by workers as the job progresses."""
|
||||||
|
|
||||||
|
status: Optional[JobStatus] = None
|
||||||
|
worker: Optional[str] = Field(
|
||||||
|
None, max_length=255, description="Identifier of the worker process claiming this job."
|
||||||
|
)
|
||||||
|
started_at: Optional[UTCDatetime] = None
|
||||||
|
completed_at: Optional[UTCDatetime] = None
|
||||||
|
result: Optional[Dict[str, Any]] = None
|
||||||
|
error: Optional[str] = Field(None, max_length=2000)
|
||||||
|
error_detail: Optional[Dict[str, Any]] = None
|
||||||
|
usage: Optional[JobUsage] = None
|
||||||
|
progress_percent: Optional[int] = Field(None, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class Job(TenantScopedModel, JobBase):
|
||||||
|
"""Persistent job record — the authoritative source of truth."""
|
||||||
|
|
||||||
|
id: ASFFieldID = Field(default_factory=generate_id)
|
||||||
|
project_id: Optional[ASFFieldID] = None
|
||||||
|
status: JobStatus = JobStatus.QUEUED
|
||||||
|
requested_by: Optional[ASFFieldID] = Field(
|
||||||
|
None, description="User that requested the job, or null for system jobs."
|
||||||
|
)
|
||||||
|
worker: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
created_at: UTCDatetime = ...
|
||||||
|
started_at: Optional[UTCDatetime] = None
|
||||||
|
completed_at: Optional[UTCDatetime] = None
|
||||||
|
result: Optional[Dict[str, Any]] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
error_detail: Optional[Dict[str, Any]] = None
|
||||||
|
usage: JobUsage = Field(default_factory=JobUsage)
|
||||||
|
progress_percent: Optional[int] = Field(None, ge=0, le=100)
|
||||||
|
retries: int = Field(0, ge=0, description="How many times this job has been retried.")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "asf-domain"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "ASF shared domain models (Pydantic v2)"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "Azurian Software Factory Team" }]
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
dependencies = [
|
||||||
|
"pydantic>=2.7,<3",
|
||||||
|
"pydantic-settings>=2.2,<3",
|
||||||
|
"python-ulid>=2.7,<3",
|
||||||
|
"typing_extensions>=4.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["asf_domain*"]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests for core domain invariants around artifacts & relationships."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from asf_domain import (
|
||||||
|
Artifact,
|
||||||
|
ArtifactCreate,
|
||||||
|
ArtifactRelationshipCreate,
|
||||||
|
ArtifactRelationship,
|
||||||
|
ArtifactType,
|
||||||
|
RelationshipType,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_id_returns_unique_uuid4() -> None:
|
||||||
|
a = generate_id()
|
||||||
|
b = generate_id()
|
||||||
|
assert a != b
|
||||||
|
assert a.version == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_create_minimal() -> None:
|
||||||
|
body = ArtifactCreate(
|
||||||
|
project_id=uuid4(),
|
||||||
|
type=ArtifactType.REQUIREMENT,
|
||||||
|
title="Login page must support MFA",
|
||||||
|
)
|
||||||
|
assert body.type == ArtifactType.REQUIREMENT
|
||||||
|
assert body.metadata == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_stored_round_trip() -> None:
|
||||||
|
tenant_id = uuid4()
|
||||||
|
project_id = uuid4()
|
||||||
|
|
||||||
|
a = Artifact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
type=ArtifactType.TASK,
|
||||||
|
title="Wire MFA provider",
|
||||||
|
content="- [ ] choose TOTP lib\n- [ ] enroll flow",
|
||||||
|
metadata={"estimated_points": 5, "labels": ["auth", "security"]},
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
d = a.model_dump(mode="json")
|
||||||
|
# ensure UTC serialization works
|
||||||
|
assert d["created_at"].endswith("Z")
|
||||||
|
assert d["metadata"]["labels"] == ["auth", "security"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_rejects_empty_title() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ArtifactCreate(project_id=uuid4(), type=ArtifactType.STORY, title="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_relationship_create() -> None:
|
||||||
|
a = uuid4()
|
||||||
|
b = uuid4()
|
||||||
|
rel = ArtifactRelationshipCreate(
|
||||||
|
relationship_type=RelationshipType.IMPLEMENTED_BY,
|
||||||
|
source_artifact_id=a,
|
||||||
|
target_artifact_id=b,
|
||||||
|
)
|
||||||
|
assert rel.source_artifact_id == a
|
||||||
|
|
||||||
|
|
||||||
|
def test_relationship_stored_enforces_tenancy() -> None:
|
||||||
|
tenant_id = uuid4()
|
||||||
|
a, b = uuid4(), uuid4()
|
||||||
|
pa, pb = uuid4(), uuid4()
|
||||||
|
rel = ArtifactRelationship(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_project_id=pa,
|
||||||
|
target_project_id=pb,
|
||||||
|
relationship_type=RelationshipType.VERIFIED_BY,
|
||||||
|
source_artifact_id=a,
|
||||||
|
target_artifact_id=b,
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
# a relationship carries both project ids for tenant isolation queries
|
||||||
|
assert rel.tenant_id == tenant_id
|
||||||
|
assert rel.source_project_id == pa
|
||||||
|
assert rel.target_project_id == pb
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests for Organization / Project invariants."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from asf_domain import (
|
||||||
|
Organization,
|
||||||
|
OrganizationCreate,
|
||||||
|
Project,
|
||||||
|
ProjectCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_organization_create_validates_slug() -> None:
|
||||||
|
OrganizationCreate(name="Contoso", slug="contoso-retail")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
OrganizationCreate(name="Bad", slug="UPPERCASE no!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_create_validates_key_uppercase() -> None:
|
||||||
|
ProjectCreate(name="ASF Core", key="ASF")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProjectCreate(name="Bad", key="lowercase")
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_scoped_under_tenant() -> None:
|
||||||
|
tenant_id = uuid4()
|
||||||
|
p = Project(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name="ASF Web",
|
||||||
|
key="WEB",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
assert p.tenant_id == tenant_id
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Tests for job state transitions & usage tracking."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from asf_domain import (
|
||||||
|
Job,
|
||||||
|
JobCreate,
|
||||||
|
JobUpdate,
|
||||||
|
JobStatus,
|
||||||
|
JobPriority,
|
||||||
|
JobType,
|
||||||
|
JobUsage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_create_defaults() -> None:
|
||||||
|
jc = JobCreate(
|
||||||
|
type=JobType.AI_INTERACTIVE,
|
||||||
|
payload={"prompt": "summarize REQ-1", "artifact_id": str(uuid4())},
|
||||||
|
)
|
||||||
|
assert jc.priority == JobPriority.NORMAL
|
||||||
|
assert jc.model is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_lifecycle_updates() -> None:
|
||||||
|
tenant_id = uuid4()
|
||||||
|
j = Job(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
type=JobType.AI_BACKGROUND,
|
||||||
|
payload={"artifact_id": str(uuid4())},
|
||||||
|
created_at=NOW,
|
||||||
|
)
|
||||||
|
assert j.status == JobStatus.QUEUED
|
||||||
|
|
||||||
|
# worker claims it
|
||||||
|
update1 = JobUpdate(status=JobStatus.RUNNING, worker="worker-asf-ai-7", started_at=NOW)
|
||||||
|
data = j.model_copy(update=update1.model_dump(exclude_unset=True))
|
||||||
|
assert data.status == JobStatus.RUNNING
|
||||||
|
assert data.worker == "worker-asf-ai-7"
|
||||||
|
|
||||||
|
# worker completes with usage
|
||||||
|
usage = JobUsage(
|
||||||
|
input_tokens=1500, output_tokens=400, gpu_seconds=2.3, estimated_cost=0.012
|
||||||
|
)
|
||||||
|
update2 = JobUpdate(status=JobStatus.COMPLETED, completed_at=NOW, usage=usage)
|
||||||
|
final = data.model_copy(update=update2.model_dump(exclude_unset=True))
|
||||||
|
assert final.status == JobStatus.COMPLETED
|
||||||
|
final_usage = JobUsage.model_validate(final.usage)
|
||||||
|
assert final_usage.output_tokens == 400
|
||||||
|
assert final_usage.estimated_cost == pytest.approx(0.012)
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_update_validates_progress_bounds() -> None:
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
JobUpdate(progress_percent=150)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""asf-events: canonical contracts for every message that travels on the
|
||||||
|
ASF RabbitMQ backbone (both domain events and job-request envelopes).
|
||||||
|
|
||||||
|
Service code NEVER constructs a message dict inline: always use one of
|
||||||
|
the models in this package. This keeps producers and consumers typed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
from asf_events.base import ASFEvent, JobRequestEnvelope
|
||||||
|
from asf_events.artifact_events import (
|
||||||
|
ArtifactCreated,
|
||||||
|
ArtifactUpdated,
|
||||||
|
ArtifactDeleted,
|
||||||
|
ArtifactRelationshipCreated,
|
||||||
|
ArtifactRelationshipDeleted,
|
||||||
|
)
|
||||||
|
from asf_events.ai_events import (
|
||||||
|
AIInferenceRequested,
|
||||||
|
AIInferenceCompleted,
|
||||||
|
AIInferenceFailed,
|
||||||
|
)
|
||||||
|
from asf_events.identity_events import (
|
||||||
|
OrganizationCreated,
|
||||||
|
ProjectCreated,
|
||||||
|
ProjectUpdated,
|
||||||
|
)
|
||||||
|
from asf_events.job_events import (
|
||||||
|
JobQueued,
|
||||||
|
JobStarted,
|
||||||
|
JobCompleted,
|
||||||
|
JobFailed,
|
||||||
|
JobCancelled,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ASFEvent",
|
||||||
|
"JobRequestEnvelope",
|
||||||
|
"ArtifactCreated",
|
||||||
|
"ArtifactUpdated",
|
||||||
|
"ArtifactDeleted",
|
||||||
|
"ArtifactRelationshipCreated",
|
||||||
|
"ArtifactRelationshipDeleted",
|
||||||
|
"AIInferenceRequested",
|
||||||
|
"AIInferenceCompleted",
|
||||||
|
"AIInferenceFailed",
|
||||||
|
"OrganizationCreated",
|
||||||
|
"ProjectCreated",
|
||||||
|
"ProjectUpdated",
|
||||||
|
"JobQueued",
|
||||||
|
"JobStarted",
|
||||||
|
"JobCompleted",
|
||||||
|
"JobFailed",
|
||||||
|
"JobCancelled",
|
||||||
|
]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""AI-specific domain events (published by the AI Gateway / workers)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from asf_events.base import ASFEvent
|
||||||
|
|
||||||
|
|
||||||
|
class AIInferenceRequested(ASFEvent):
|
||||||
|
event_type: str = "ai.inference.requested"
|
||||||
|
job_id: Optional[UUID] = None
|
||||||
|
capability: str
|
||||||
|
provider: str
|
||||||
|
preferred_model: Optional[str] = None
|
||||||
|
payload: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AIInferenceCompleted(ASFEvent):
|
||||||
|
event_type: str = "ai.inference.completed"
|
||||||
|
job_id: Optional[UUID] = None
|
||||||
|
execution_id: UUID
|
||||||
|
capability: str
|
||||||
|
provider: str
|
||||||
|
model_used: str
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
input_tokens: Optional[int] = None
|
||||||
|
output_tokens: Optional[int] = None
|
||||||
|
estimated_cost: Optional[float] = None
|
||||||
|
payload: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AIInferenceFailed(ASFEvent):
|
||||||
|
event_type: str = "ai.inference.failed"
|
||||||
|
job_id: Optional[UUID] = None
|
||||||
|
execution_id: UUID
|
||||||
|
capability: str
|
||||||
|
provider: str
|
||||||
|
error: str
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Artifact + ArtifactRelationship domain events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from asf_events.base import ASFEvent
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactCreated(ASFEvent):
|
||||||
|
event_type: str = "artifact.created"
|
||||||
|
artifact_id: UUID
|
||||||
|
artifact_type: str
|
||||||
|
project_id: UUID
|
||||||
|
payload: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactUpdated(ASFEvent):
|
||||||
|
event_type: str = "artifact.updated"
|
||||||
|
artifact_id: UUID
|
||||||
|
artifact_type: str
|
||||||
|
project_id: UUID
|
||||||
|
changed_fields: list[str]
|
||||||
|
previous_version: int
|
||||||
|
payload: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactDeleted(ASFEvent):
|
||||||
|
event_type: str = "artifact.deleted"
|
||||||
|
artifact_id: UUID
|
||||||
|
artifact_type: str
|
||||||
|
project_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRelationshipCreated(ASFEvent):
|
||||||
|
event_type: str = "artifact.relationship.created"
|
||||||
|
relationship_id: UUID
|
||||||
|
source_artifact_id: UUID
|
||||||
|
target_artifact_id: UUID
|
||||||
|
relationship_type: str
|
||||||
|
source_project_id: UUID
|
||||||
|
target_project_id: UUID
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRelationshipDeleted(ASFEvent):
|
||||||
|
event_type: str = "artifact.relationship.deleted"
|
||||||
|
relationship_id: UUID
|
||||||
|
source_artifact_id: UUID
|
||||||
|
target_artifact_id: UUID
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Base event envelope + job request envelope.
|
||||||
|
|
||||||
|
Every RabbitMQ message is either:
|
||||||
|
1. An ``ASFEvent`` subclass → a fact that happened (domain event).
|
||||||
|
2. A ``JobRequestEnvelope`` → a unit of work to be executed (job).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional, Dict
|
||||||
|
from uuid import uuid4, UUID
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from asf_domain.base import ASFBaseModel, UTCDatetime, JobType, JobPriority
|
||||||
|
|
||||||
|
|
||||||
|
class ASFEvent(ASFBaseModel):
|
||||||
|
"""Abstract base for every domain event published on RabbitMQ.
|
||||||
|
|
||||||
|
Semantics: facts that happened, never commands.
|
||||||
|
Naming pattern: <Noun>.<Ved> (artifact.created, deployment.completed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
event_id: UUID = Field(default_factory=uuid4)
|
||||||
|
event_type: str = Field(
|
||||||
|
...,
|
||||||
|
max_length=128,
|
||||||
|
description="Dot-delimited routing key e.g. 'artifact.created'.",
|
||||||
|
)
|
||||||
|
timestamp: UTCDatetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(tz=timezone.utc)
|
||||||
|
)
|
||||||
|
tenant_id: UUID = Field(..., description="Owning tenant.")
|
||||||
|
project_id: Optional[UUID] = None
|
||||||
|
correlation_id: Optional[str] = Field(None, max_length=128)
|
||||||
|
causation_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=128,
|
||||||
|
description="Id of the previous event / request that caused this one.",
|
||||||
|
)
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class JobRequestEnvelope(ASFBaseModel):
|
||||||
|
"""Envelope delivered to a RabbitMQ worker queue.
|
||||||
|
|
||||||
|
The worker performs the work, then updates the corresponding Mongo
|
||||||
|
``Job`` record (its id is in this envelope). RabbitMQ is NOT the
|
||||||
|
permanent job state store — MongoDB is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
job_id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
project_id: Optional[UUID] = None
|
||||||
|
type: JobType
|
||||||
|
priority: JobPriority = JobPriority.NORMAL
|
||||||
|
correlation_id: Optional[str] = None
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
reply_to: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=255,
|
||||||
|
description="Optional queue/routing-key for completion events.",
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Organization / Project domain events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from asf_events.base import ASFEvent
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationCreated(ASFEvent):
|
||||||
|
event_type: str = "organization.created"
|
||||||
|
organization_id: UUID
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreated(ASFEvent):
|
||||||
|
event_type: str = "project.created"
|
||||||
|
project_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
project_key: str
|
||||||
|
name: str
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdated(ASFEvent):
|
||||||
|
event_type: str = "project.updated"
|
||||||
|
project_id: UUID
|
||||||
|
changed_fields: list[str]
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Job lifecycle domain events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from asf_events.base import ASFEvent
|
||||||
|
|
||||||
|
|
||||||
|
class JobQueued(ASFEvent):
|
||||||
|
event_type: str = "job.queued"
|
||||||
|
job_id: UUID
|
||||||
|
job_type: str
|
||||||
|
queue: str
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class JobStarted(ASFEvent):
|
||||||
|
event_type: str = "job.started"
|
||||||
|
job_id: UUID
|
||||||
|
worker: Optional[str] = None
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class JobCompleted(ASFEvent):
|
||||||
|
event_type: str = "job.completed"
|
||||||
|
job_id: UUID
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class JobFailed(ASFEvent):
|
||||||
|
event_type: str = "job.failed"
|
||||||
|
job_id: UUID
|
||||||
|
error: str
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class JobCancelled(ASFEvent):
|
||||||
|
event_type: str = "job.cancelled"
|
||||||
|
job_id: UUID
|
||||||
|
reason: Optional[str] = None
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "asf-events"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "ASF event schemas (RabbitMQ payload contracts)"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"asf-domain>=0.1.0",
|
||||||
|
"pydantic>=2.7,<3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["asf_events*"]
|
||||||
Executable
+258
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed source_file + code_smell artifacts per Code Analysis page."""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-ASF-Tenant-Id": "t-demo-003",
|
||||||
|
"X-ASF-User-Id": "u-mario",
|
||||||
|
}
|
||||||
|
BASE = "http://localhost:8000/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def req(method, path, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=data,
|
||||||
|
headers=HEADERS,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=15) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "null")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
txt = e.read().decode()
|
||||||
|
try:
|
||||||
|
payload = json.loads(txt)
|
||||||
|
except Exception:
|
||||||
|
payload = {"raw": txt}
|
||||||
|
return e.code, payload
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 1. list projects
|
||||||
|
code, projects = req("GET", "/projects?limit=10")
|
||||||
|
assert code == 200, f"projects {code} {projects}"
|
||||||
|
pid = projects[0]["id"]
|
||||||
|
print(f"project_id={pid} name={projects[0]['name']}")
|
||||||
|
|
||||||
|
# 2. list existing to dedupe by external_id
|
||||||
|
code, arts = req("GET", "/artifacts?limit=200")
|
||||||
|
existing_external = {
|
||||||
|
a["external_id"] for a in arts if a.get("external_id")
|
||||||
|
}
|
||||||
|
existing_types = {
|
||||||
|
(a["type"], a["title"]) for a in arts
|
||||||
|
}
|
||||||
|
print(f"existing artifacts total: {len(arts)} ext_ids: {len(existing_external)} type/titles: {len(existing_types)}")
|
||||||
|
|
||||||
|
SOURCE_FILES = [
|
||||||
|
{
|
||||||
|
"external_id": "SRC-CORE-MW",
|
||||||
|
"type": "source_file",
|
||||||
|
"title": "asf-core/app/middleware/correlation.py",
|
||||||
|
"status": "active",
|
||||||
|
"source": "git",
|
||||||
|
"content": (
|
||||||
|
"\"\"\"Tenant & correlation middleware.\n\n"
|
||||||
|
"Adds X-ASF-Correlation-Id and coerces X-ASF-Tenant-Id to UUID5.\n\"\"\"\n"
|
||||||
|
"import uuid\nfrom fastapi import Request, Response\nfrom starlette.middleware.base import BaseHTTPMiddleware\n\n"
|
||||||
|
"TENANT_NS = uuid.UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8')\n\n"
|
||||||
|
"def _coerce_uuid(val):\n"
|
||||||
|
" try:\n"
|
||||||
|
" return uuid.UUID(val)\n"
|
||||||
|
" except Exception:\n"
|
||||||
|
" return uuid.uuid5(TENANT_NS, val or 'default')\n\n"
|
||||||
|
"class CorrelationMiddleware(BaseHTTPMiddleware):\n"
|
||||||
|
" async def dispatch(self, request: Request, call_next):\n"
|
||||||
|
" cid = request.headers.get('X-ASF-Correlation-Id') or str(uuid.uuid4())\n"
|
||||||
|
" request.state.correlation_id = cid\n"
|
||||||
|
" tid = _coerce_uuid(request.headers.get('X-ASF-Tenant-Id'))\n"
|
||||||
|
" request.state.tenant_id = tid\n"
|
||||||
|
" response: Response = await call_next(request)\n"
|
||||||
|
" response.headers['X-ASF-Correlation-Id'] = cid\n"
|
||||||
|
" return response\n"
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"language": "python",
|
||||||
|
"path": "services/asf-core/app/middleware/correlation.py",
|
||||||
|
"repo": "git@github.com:azurian/asf.git",
|
||||||
|
"branch": "main",
|
||||||
|
"loc": 36,
|
||||||
|
"lines": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36],
|
||||||
|
"commit_sha": "a1f22c33",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"external_id": "SRC-WEB-REQ",
|
||||||
|
"type": "source_file",
|
||||||
|
"title": "asf-web/app/requirements/page.tsx",
|
||||||
|
"status": "active",
|
||||||
|
"source": "git",
|
||||||
|
"content": (
|
||||||
|
"\"use client\";\n"
|
||||||
|
"import React, { useMemo, useState } from \"react\";\n"
|
||||||
|
"import { useQuery } from \"@tanstack/react-query\";\n"
|
||||||
|
"import { ClipboardList, Search } from \"lucide-react\";\n"
|
||||||
|
"import { artifacts, projects } from \"@/lib/api\";\n"
|
||||||
|
"import { Card, CardBody, CardHeader, CardTitle } from \"@/components/ui/Card\";\n"
|
||||||
|
"\n"
|
||||||
|
"export default function RequirementsPage() {\n"
|
||||||
|
" const [projectFilter, setProjectFilter] = useState('all');\n"
|
||||||
|
" const [statusFilter, setStatusFilter] = useState('all');\n"
|
||||||
|
" const projectsQ = useQuery({ queryKey: ['req-projects'], queryFn: () => projects.list({ limit: 50 }) });\n"
|
||||||
|
" const listQ = useQuery({\n"
|
||||||
|
" queryKey: ['req-list', projectFilter, statusFilter],\n"
|
||||||
|
" queryFn: () => artifacts.list({\n"
|
||||||
|
" status: statusFilter !== 'all' ? statusFilter : undefined,\n"
|
||||||
|
" project_id: projectFilter !== 'all' ? projectFilter : undefined,\n"
|
||||||
|
" type: 'requirement',\n"
|
||||||
|
" limit: 200,\n"
|
||||||
|
" }),\n"
|
||||||
|
" });\n"
|
||||||
|
" return (<Card>...</Card>);\n"
|
||||||
|
"}\n"
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"language": "typescript",
|
||||||
|
"path": "frontend/asf-web/app/requirements/page.tsx",
|
||||||
|
"repo": "git@github.com:azurian/asf.git",
|
||||||
|
"branch": "main",
|
||||||
|
"loc": 394,
|
||||||
|
"framework": "nextjs14",
|
||||||
|
"commit_sha": "a1f22c33",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"external_id": "SRC-WORKER-CB",
|
||||||
|
"type": "source_file",
|
||||||
|
"title": "asf-worker/app/worker/consumer.py",
|
||||||
|
"status": "active",
|
||||||
|
"source": "git",
|
||||||
|
"content": (
|
||||||
|
"\"\"\"RabbitMQ consumer factory + sync callbacks for DIRECT job queues.\"\"\"\n"
|
||||||
|
"import asyncio\nimport structlog\nfrom aio_pika import IncomingMessage\n\n"
|
||||||
|
"log = structlog.get_logger(__name__)\n\n"
|
||||||
|
"def _make_consumer_callback(handler):\n"
|
||||||
|
" def _cb(message: IncomingMessage):\n"
|
||||||
|
" loop = asyncio.new_event_loop()\n"
|
||||||
|
" try:\n"
|
||||||
|
" loop.run_until_complete(handler(message))\n"
|
||||||
|
" except Exception as exc:\n"
|
||||||
|
" log.exception('consumer_handler_error', err=exc)\n"
|
||||||
|
" finally:\n"
|
||||||
|
" loop.close()\n"
|
||||||
|
" return _cb\n"
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"language": "python",
|
||||||
|
"path": "services/asf-worker/app/worker/consumer.py",
|
||||||
|
"repo": "git@github.com:azurian/asf.git",
|
||||||
|
"branch": "main",
|
||||||
|
"loc": 18,
|
||||||
|
"commit_sha": "b4812a01",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# code smells stored as type=ai_analysis with metadata.kind = "code_smell"
|
||||||
|
# (to keep ArtifactType enum closed — no tests break)
|
||||||
|
SMELLS = [
|
||||||
|
{
|
||||||
|
"external_id": "CS-001",
|
||||||
|
"type": "ai_analysis",
|
||||||
|
"title": "Bare except clause in CorrelationMiddleware.dispatch",
|
||||||
|
"status": "open",
|
||||||
|
"source": "ruff:EM102",
|
||||||
|
"content": (
|
||||||
|
"Rule Ruff E722 — bare `except Exception` without narrow clause may swallow "
|
||||||
|
"cancellation errors unrelated to UUID parse. Prefer explicit ValueError/AttributeError "
|
||||||
|
"or log.exception with correlation_id bound.\n"
|
||||||
|
"Lines affected: 14-16."
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"kind": "code_smell",
|
||||||
|
"language": "python",
|
||||||
|
"severity": "medium",
|
||||||
|
"rule_id": "E722",
|
||||||
|
"engine": "ruff 0.5.0",
|
||||||
|
"path": "services/asf-core/app/middleware/correlation.py",
|
||||||
|
"lines": [14, 15, 16],
|
||||||
|
"fingerprint": "corr_mw_except_001",
|
||||||
|
"confidence_pct": 98,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"external_id": "CS-002",
|
||||||
|
"type": "ai_analysis",
|
||||||
|
"title": "Missing N+1 query guard in requirements listQ (no select limit on projects)",
|
||||||
|
"status": "open",
|
||||||
|
"source": "eslint:perf-nplus1",
|
||||||
|
"content": (
|
||||||
|
"Requirements page fetches all 50 projects and maps via projectsQ even when a single project filter is selected. "
|
||||||
|
"Consider deriving projectNameById lazily and paginating projects lookup.\n"
|
||||||
|
"Affected page.tsx lines 78-89."
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"kind": "code_smell",
|
||||||
|
"language": "typescript",
|
||||||
|
"severity": "low",
|
||||||
|
"rule_id": "asf-perf-003",
|
||||||
|
"engine": "eslint custom",
|
||||||
|
"path": "frontend/asf-web/app/requirements/page.tsx",
|
||||||
|
"lines": [78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89],
|
||||||
|
"fingerprint": "req_list_nplus1_002",
|
||||||
|
"confidence_pct": 75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"external_id": "CS-003",
|
||||||
|
"type": "ai_analysis",
|
||||||
|
"title": "Coroutine leak risk: event loop not shared re-created per message",
|
||||||
|
"status": "open",
|
||||||
|
"source": "pytype:event-loop",
|
||||||
|
"content": (
|
||||||
|
"Worker consumer factory _cb spawns asyncio.new_event_loop() per IncomingMessage. "
|
||||||
|
"Long-lived handlers under high volume may accumulate GC pressure from leftover loop objects. "
|
||||||
|
"Recommendation: pass the shared running event loop to handler() via q.consume() and make handler truly async.\n"
|
||||||
|
"Severity HIGH — affects every DIRECT job 24/7."
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"kind": "code_smell",
|
||||||
|
"language": "python",
|
||||||
|
"severity": "high",
|
||||||
|
"rule_id": "ASF-SEC-203",
|
||||||
|
"engine": "pytype + manual review",
|
||||||
|
"path": "services/asf-worker/app/worker/consumer.py",
|
||||||
|
"lines": [8, 9, 10, 11, 12, 13, 14, 15, 16],
|
||||||
|
"fingerprint": "worker_loop_per_msg_003",
|
||||||
|
"confidence_pct": 88,
|
||||||
|
"cwe": "CWE-772",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
for body in SOURCE_FILES + SMELLS:
|
||||||
|
if (body["type"], body["title"]) in existing_types or body.get("external_id") in existing_external:
|
||||||
|
print(f" SKIP exists ext_id={body.get('external_id')} type={body['type']} title={body['title'][:60]}")
|
||||||
|
continue
|
||||||
|
body_full = {"project_id": pid, **body}
|
||||||
|
code, resp = req("POST", "/artifacts", body_full)
|
||||||
|
if code in (201, 200):
|
||||||
|
created += 1
|
||||||
|
print(f" OK [{code}] type={body['type']} id={str(resp['id'])[:8]} title={body['title'][:60]}")
|
||||||
|
else:
|
||||||
|
print(f" FAIL [{code}] type={body['type']} body={resp}")
|
||||||
|
|
||||||
|
print(f"\nDONE: created={created} new code artifacts")
|
||||||
|
|
||||||
|
# print summary counts per type
|
||||||
|
code, arts2 = req("GET", "/artifacts?limit=200")
|
||||||
|
from collections import Counter
|
||||||
|
cnt = Counter(a["type"] for a in arts2)
|
||||||
|
for t, c in cnt.items():
|
||||||
|
print(f" count {t}: {c}")
|
||||||
Executable
+197
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# seed-demo.sh — Popola ASF con i dati DEMO del tenant t-demo-003.
|
||||||
|
#
|
||||||
|
# Sequenza (idempotente dal lato API - POST ritorna 201 per nuovi artifact,
|
||||||
|
# ma se seed precedenti esistono già verrà creato duplicato perché non abbiamo
|
||||||
|
# un controllo unique ext_id in questa fase MVP. Per sicurezza cancella TUTTI
|
||||||
|
# artifacts + jobs prima di ricreare quando si specifica --clean.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/seed-demo.sh # seeds normali (3 blocchi trace + code + extra)
|
||||||
|
# ./scripts/seed-demo.sh --clean # PURGA artifacts + jobs t-demo-003 POI seed
|
||||||
|
# ./scripts/seed-demo.sh --jobs # + 5 job echo demo in coda (devops/testing/...)
|
||||||
|
#
|
||||||
|
# Richiede:
|
||||||
|
# • ASF Core attivo su http://localhost:8000 (scripts/start-stack.sh)
|
||||||
|
# • Python .venv attivo o eseguibile in /root/ASF/.venv/bin/python
|
||||||
|
# • Worker attivo se usi --jobs (altrimenti jobs stanno in QUEUED)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PY="$ROOT/.venv/bin/python"
|
||||||
|
CORE_URL="${ASF_CORE_URL:-http://localhost:8000}"
|
||||||
|
TENANT="${X_ASF_TENANT_ID:-t-demo-003}"
|
||||||
|
USER="${X_ASF_USER_ID:-u-mario}"
|
||||||
|
|
||||||
|
CLEAN=0
|
||||||
|
JOBS=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--clean) CLEAN=1 ;;
|
||||||
|
--jobs) JOBS=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,26p' "$0"
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { printf " \033[1;34m→\033[0m %s\n" "$*"; }
|
||||||
|
ok() { printf " \033[1;32m✔\033[0m %s\n" "$*"; }
|
||||||
|
fail() { printf " \033[1;31m✘\033[0m %s\n" "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
curl_h() {
|
||||||
|
# $1 = method, $2 = path, stdin optional body. Prints status + JSON body.
|
||||||
|
local m="$1" p="$2"
|
||||||
|
shift 2
|
||||||
|
curl -s -X "$m" -H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${CORE_URL}/api/v1${p}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Sanity check: Core UP ---
|
||||||
|
log "ASF Core health check su $CORE_URL"
|
||||||
|
H=$(curl -s -o /dev/null -w "%{http_code}" "${CORE_URL}/api/v1/health" || true)
|
||||||
|
if [[ "$H" != "200" ]]; then
|
||||||
|
fail "ASF Core non risponde (HTTP $H). Avvia con: bash scripts/start-stack.sh"
|
||||||
|
fi
|
||||||
|
ok "ASF Core UP (health=$H)"
|
||||||
|
|
||||||
|
# --- Cleanup (opzionale) ---
|
||||||
|
if [[ $CLEAN == 1 ]]; then
|
||||||
|
log "Cleanup artifacts + jobs per tenant $TENANT (DELETE /artifacts e /jobs listati)"
|
||||||
|
|
||||||
|
# MVP: asf-core non espone DELETE /artifacts bulk. Useremo GET lista + DELETE /artifacts/{id}.
|
||||||
|
A_IDS=$(curl_h GET "/artifacts?limit=500" | "$PY" -c '
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for a in d: print(a["id"])
|
||||||
|
')
|
||||||
|
N_A=$(echo -n "$A_IDS" | grep -c . || true)
|
||||||
|
log " DELETE $N_A artifacts..."
|
||||||
|
if [[ -n "$A_IDS" ]]; then
|
||||||
|
while read -r id; do
|
||||||
|
curl -s -o /dev/null -X DELETE \
|
||||||
|
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
|
||||||
|
"${CORE_URL}/api/v1/artifacts/${id}" || true
|
||||||
|
done <<< "$A_IDS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
J_IDS=$(curl_h GET "/jobs?limit=500" | "$PY" -c '
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for j in d: print(j["id"])
|
||||||
|
' 2>/dev/null || true)
|
||||||
|
N_J=$(echo -n "$J_IDS" | grep -c . || true)
|
||||||
|
log " PATCH CANCEL $N_J jobs..."
|
||||||
|
if [[ -n "$J_IDS" ]]; then
|
||||||
|
while read -r id; do
|
||||||
|
curl -s -o /dev/null -X PATCH \
|
||||||
|
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"cancelled"}' \
|
||||||
|
"${CORE_URL}/api/v1/jobs/${id}" || true
|
||||||
|
done <<< "$J_IDS"
|
||||||
|
fi
|
||||||
|
ok "Cleanup completato"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Seeds ---
|
||||||
|
log "Seed 1/4: Traceability artifacts (REQ → TASK → PR → TEST/BUILD → DEPLOY)"
|
||||||
|
"$PY" "$ROOT/scripts/seed-traceability.py" >/tmp/seed1.log 2>&1 \
|
||||||
|
|| fail "seed-traceability fallito. Log: /tmp/seed1.log"
|
||||||
|
ok " OK ($(wc -l < /tmp/seed1.log) righe log)"
|
||||||
|
|
||||||
|
log "Seed 2/4: Relazioni traceability (5 edges: implements/verifies/deployed_as/generated_from)"
|
||||||
|
"$PY" "$ROOT/scripts/seed-rels-only.py" >/tmp/seed2.log 2>&1 \
|
||||||
|
|| fail "seed-rels fallito. Log: /tmp/seed2.log"
|
||||||
|
ok " OK ($(wc -l < /tmp/seed2.log) righe log)"
|
||||||
|
|
||||||
|
log "Seed 3/4: Source files + Code smells (3 files, 3 smells)"
|
||||||
|
"$PY" "$ROOT/scripts/seed-code-artifacts.py" >/tmp/seed3.log 2>&1 \
|
||||||
|
|| fail "seed-code-artifacts fallito. Log: /tmp/seed3.log"
|
||||||
|
ok " OK ($(wc -l < /tmp/seed3.log) righe log)"
|
||||||
|
|
||||||
|
log "Seed 4/4: Extra DOC / COMMIT / SEC / INC + 4 relazioni (densify graph)"
|
||||||
|
"$PY" "$ROOT/scripts/seed-extra-rels.py" >/tmp/seed4.log 2>&1 \
|
||||||
|
|| fail "seed-extra-rels fallito. Log: /tmp/seed4.log"
|
||||||
|
ok " OK ($(wc -l < /tmp/seed4.log) righe log)"
|
||||||
|
|
||||||
|
# --- Jobs demo (opzionale) ---
|
||||||
|
if [[ $JOBS == 1 ]]; then
|
||||||
|
log "Enqueue 5 demo jobs (echo handler DEVOPS/TESTING/...)"
|
||||||
|
OUT=$(curl -s -X POST \
|
||||||
|
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data-raw '{
|
||||||
|
"jobs": [
|
||||||
|
{"type":"devops","priority":"normal","payload":{"name":"deploy-dev","env":"dev"}},
|
||||||
|
{"type":"testing","priority":"high","payload":{"name":"smoke-tests","framework":"pytest"}},
|
||||||
|
{"type":"devops","priority":"normal","payload":{"name":"build-image","image":"asf/app:latest"}},
|
||||||
|
{"type":"notifications","priority":"low","payload":{"channel":"email","to":"admins"}},
|
||||||
|
{"type":"indexing","priority":"normal","payload":{"source":"github/asf","branch":"main"}}
|
||||||
|
]
|
||||||
|
}' "${CORE_URL}/api/v1/jobs/batch" 2>&1 || echo "fallback")
|
||||||
|
# Fallback per /batch non esistente (MVP usa singolo POST per job)
|
||||||
|
if [[ "$OUT" == *"fallback"* || "$OUT" == *"Not Found"* || "$OUT" == *"404"* || "$OUT" == *"405"* ]]; then
|
||||||
|
log " /batch non disponibile → 5 POST singoli:"
|
||||||
|
TYPES=("devops" "testing" "devops" "notifications" "indexing")
|
||||||
|
PAYLOADS=(
|
||||||
|
'{"name":"deploy-dev","env":"dev"}'
|
||||||
|
'{"name":"smoke-tests","framework":"pytest"}'
|
||||||
|
'{"name":"build-image","image":"asf/app:latest"}'
|
||||||
|
'{"channel":"email","to":"admins"}'
|
||||||
|
'{"source":"github/asf","branch":"main"}'
|
||||||
|
)
|
||||||
|
PRIO=("normal" "high" "normal" "low" "normal")
|
||||||
|
for i in 0 1 2 3 4; do
|
||||||
|
JS=$(curl -s -X POST \
|
||||||
|
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"type\":\"${TYPES[$i]}\",\"priority\":\"${PRIO[$i]}\",\"payload\":${PAYLOADS[$i]}}" \
|
||||||
|
"${CORE_URL}/api/v1/jobs/submit")
|
||||||
|
echo -n "$JS" | "$PY" -c 'import json,sys; j=json.load(sys.stdin); print(" - job id=", j.get("id","?")[:8]+"…", "accepted=", j.get("accepted"))' 2>/dev/null
|
||||||
|
done
|
||||||
|
else
|
||||||
|
ok " /batch accettato"
|
||||||
|
fi
|
||||||
|
ok "5 demo jobs accodati (avvia asf-worker per completarli)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Report finale ---
|
||||||
|
log "Report finale tenant $TENANT:"
|
||||||
|
echo ""
|
||||||
|
curl_h GET "/artifacts?limit=500" | "$PY" - <<'PY'
|
||||||
|
import json,sys
|
||||||
|
from collections import Counter
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
c=Counter(a.get("type","?") for a in d)
|
||||||
|
print(" Artifacts totali:", len(d))
|
||||||
|
for k,v in sorted(c.items(), key=lambda x:-x[1]):
|
||||||
|
print(f" - {k:18s} {v}")
|
||||||
|
PY
|
||||||
|
echo ""
|
||||||
|
curl_h GET "/traceability/graph" 2>/dev/null | "$PY" -c '
|
||||||
|
import json,sys
|
||||||
|
g=json.load(sys.stdin)
|
||||||
|
print(f" Traceability graph: nodes={len(g.get(\"nodes\",[]))} edges={len(g.get(\"edges\",[]))}")
|
||||||
|
' 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
curl_h GET "/jobs" 2>/dev/null | "$PY" - <<'PY'
|
||||||
|
import json,sys
|
||||||
|
from collections import Counter
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
c=Counter(j.get("status","?") for j in d)
|
||||||
|
print(" Jobs totali:", len(d))
|
||||||
|
for k,v in sorted(c.items(), key=lambda x:-x[1]):
|
||||||
|
print(f" - {k:10s} {v}")
|
||||||
|
PY
|
||||||
|
echo ""
|
||||||
|
ok "SEED DEMO completato."
|
||||||
|
echo ""
|
||||||
|
echo " Suggerimenti:"
|
||||||
|
echo " • Apri http://localhost:3000 per vedere UI (hard refresh Ctrl+Shift+R se serve)"
|
||||||
|
echo " • bash scripts/status-stack.sh per stato core/web/worker"
|
||||||
|
echo " • bash scripts/seed-demo.sh --clean --jobs per ricreare TUTTO da zero"
|
||||||
Executable
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""POST extra artifacts + 4 relazioni per densificare Traceability + Context Explorer.
|
||||||
|
|
||||||
|
Creati in tenant t-demo-003 / unico project esistente:
|
||||||
|
1. DOC "Product Requirements.md" (DOCUMENT) --references--> REQ-103
|
||||||
|
2. COMMIT "0abc123 feat: REQ-103" (CODE_COMMIT) --generated_from--> PR#88
|
||||||
|
3. SEC "SEC-025 SQL injection input sanitization" (SECURITY_FINDING) --mitigated_by--> PR#88
|
||||||
|
4. INC "INC-77 Login timeout" (INCIDENT) --caused_by--> DEP-44 deployment
|
||||||
|
|
||||||
|
Rileva artifact IDs esistenti via LIST + ext_id exact match.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
BASE = "http://localhost:8000/api/v1"
|
||||||
|
H = {
|
||||||
|
"X-ASF-Tenant-Id": "t-demo-003",
|
||||||
|
"X-ASF-User-Id": "u-mario",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
LOOKUP = {
|
||||||
|
"REQ": "REQ-103",
|
||||||
|
"PR": "PR#88",
|
||||||
|
"DEP": "DEP-44",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None) -> tuple[int, object]:
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
r = urllib.request.Request(BASE + path, data=data, method=method, headers=H)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||||
|
text = resp.read().decode()
|
||||||
|
return resp.status, json.loads(text) if text else None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
print(f" !! {method} {path} -> {e.code}: {body[:300]}", file=sys.stderr)
|
||||||
|
return e.code, body
|
||||||
|
|
||||||
|
|
||||||
|
def list_artifacts() -> list[dict]:
|
||||||
|
s, d = req("GET", "/artifacts?limit=500")
|
||||||
|
assert s == 200 and isinstance(d, list), f"list artifacts failed: {s}"
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_id(arts: list[dict]) -> str:
|
||||||
|
"""Unico project del tenant: take artifact project_id non-None first."""
|
||||||
|
for a in arts:
|
||||||
|
if a.get("project_id"):
|
||||||
|
return a["project_id"]
|
||||||
|
s, pl = req("GET", "/projects?limit=50")
|
||||||
|
assert s == 200 and isinstance(pl, list) and pl
|
||||||
|
return pl[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_artifact(payload: dict) -> dict:
|
||||||
|
s, d = req("POST", "/artifacts", payload)
|
||||||
|
if s != 201 or not isinstance(d, dict):
|
||||||
|
raise RuntimeError(f"CREATE {payload.get('type')} {payload.get('external_id')} HTTP {s}")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def create_rel(src: str, tgt: str, rel_type: str) -> int:
|
||||||
|
body = {
|
||||||
|
"source_artifact_id": src,
|
||||||
|
"target_artifact_id": tgt,
|
||||||
|
"relationship_type": rel_type,
|
||||||
|
}
|
||||||
|
s, _ = req("POST", "/relationships", body)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arts = list_artifacts()
|
||||||
|
existing: dict[str, str] = {}
|
||||||
|
for k, ext in LOOKUP.items():
|
||||||
|
match = next((a for a in arts if a.get("external_id") == ext), None)
|
||||||
|
if match is None:
|
||||||
|
print(f" !! missing {ext}, aborting.")
|
||||||
|
return 2
|
||||||
|
existing[k] = match["id"]
|
||||||
|
print(f" {k:3s} {ext!r:12s} -> {match['id'][:10]}…")
|
||||||
|
|
||||||
|
pid = find_project_id(arts)
|
||||||
|
print(f" project id = {pid[:10]}…")
|
||||||
|
|
||||||
|
print("\n== Creating 4 extra artifacts ==")
|
||||||
|
extra = [
|
||||||
|
{
|
||||||
|
"project_id": pid,
|
||||||
|
"type": "document",
|
||||||
|
"external_id": "DOC-PRD-2026",
|
||||||
|
"title": "Product Requirements v2.3",
|
||||||
|
"status": "active",
|
||||||
|
"content": "# Product Requirements 2.3\n\nLiving PRD covering authentication, billing and reporting.\n\n## Goals\nProvide clear tenancy and billing.\n",
|
||||||
|
"metadata": {"format": "markdown", "page_count": 18, "owner": "pm"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project_id": pid,
|
||||||
|
"type": "commit",
|
||||||
|
"external_id": "COMMIT-0abc123",
|
||||||
|
"title": "feat(auth): implement REQ-103 SSO flow",
|
||||||
|
"content": "commit 0abc123df\nAuthor: mario\nDate: today\n\nAdds Okta SAML callback + session refresh route.\n",
|
||||||
|
"metadata": {"sha": "0abc123df", "author": "mario@asf.test", "repo": "asf/mono", "branch": "main"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project_id": pid,
|
||||||
|
"type": "security_finding",
|
||||||
|
"external_id": "SEC-025",
|
||||||
|
"title": "SQL injection risk in profile search (low confidence)",
|
||||||
|
"status": "open",
|
||||||
|
"content": "Input param q passed directly to $regex without sanitization. Scanner: semgrep. Confidence 82%.",
|
||||||
|
"metadata": {"cwe": "CWE-89", "severity": "high", "scanner": "semgrep", "confidence_pct": 82},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project_id": pid,
|
||||||
|
"type": "incident",
|
||||||
|
"external_id": "INC-77",
|
||||||
|
"title": "Login timeout spike 05:00-05:14 UTC",
|
||||||
|
"status": "mitigated",
|
||||||
|
"content": "Session refresh endpoint returned 5xx for 4% of users. MTTR 14 minutes. 27 customers impacted.",
|
||||||
|
"metadata": {"severity": "sev2", "mttr_min": 14, "customers_impacted": 27},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
ids: list[str] = []
|
||||||
|
for pl in extra:
|
||||||
|
d = create_artifact(pl)
|
||||||
|
print(f" [201] {pl['type']:15s} {pl['external_id']:18s} id={d['id'][:8]}…")
|
||||||
|
ids.append(d["id"])
|
||||||
|
|
||||||
|
doc_id, commit_id, sec_id, inc_id = ids
|
||||||
|
print("\n== Creating 4 extra relationships ==")
|
||||||
|
edges = [
|
||||||
|
(doc_id, existing["REQ"], "documented_by", "DOC --documented_by--> REQ"),
|
||||||
|
(commit_id, existing["PR"], "generated_from", "COMMIT --generated_from--> PR"),
|
||||||
|
(sec_id, existing["PR"], "related_to", "SEC --related_to--> PR"),
|
||||||
|
(inc_id, existing["DEP"], "caused_by", "INC --caused_by--> DEP"),
|
||||||
|
]
|
||||||
|
all_ok = True
|
||||||
|
for src, tgt, rt, label in edges:
|
||||||
|
s = create_rel(src, tgt, rt)
|
||||||
|
chk = "OK" if s == 201 else "FAIL"
|
||||||
|
print(f" [{s:3d}] {chk:4s} {label}")
|
||||||
|
if s != 201:
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
s, g = req("GET", f"/traceability/graph?project_id={pid}")
|
||||||
|
if s == 200 and isinstance(g, dict):
|
||||||
|
print(f"\n== Traceability graph now: nodes={len(g.get('nodes', []))} edges={len(g.get('edges', []))} ==")
|
||||||
|
return 0 if all_ok else 5
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Retry 5 POST relationships SENZA campo `meta` (was extra_forbidden 422). Chained graph DEP→BUILD→PR→TASK→REQ + TEST→PR."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json, subprocess, sys
|
||||||
|
BASE = "http://127.0.0.1:8000/api/v1"
|
||||||
|
H = ["-H","X-ASF-Tenant-Id: t-demo-003","-H","X-ASF-User-Id: u-mario","-H","Content-Type: application/json"]
|
||||||
|
|
||||||
|
def curl(method, path, body=None):
|
||||||
|
cmd = ["curl","-s","-m","15","-o","-","-w","\n%{http_code}","-X",method] + H
|
||||||
|
if body is not None: cmd += ["--data", json.dumps(body, ensure_ascii=False)]
|
||||||
|
cmd.append(BASE+path)
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
out = (r.stdout or "{}").strip()
|
||||||
|
if "\n" in out:
|
||||||
|
jp, cp = out.rsplit("\n", 1)
|
||||||
|
else:
|
||||||
|
jp, cp = "", out
|
||||||
|
try: code = int(cp)
|
||||||
|
except: code = 0
|
||||||
|
try: data = json.loads(jp or "null")
|
||||||
|
except Exception as exc: data = f"ERR {exc}: {jp[:200]!r}"
|
||||||
|
print(f" [{method:6s}{path:22s}] HTTP={code:3d}",
|
||||||
|
f"id={str(data.get('id',''))[:10] if isinstance(data,dict) else '?'}",
|
||||||
|
"rel=", (data.get("source_artifact_id","")[:8] if isinstance(data,dict) else "?"),
|
||||||
|
"→",(data.get("target_artifact_id","")[:8] if isinstance(data,dict) else "?"),
|
||||||
|
"type=",(data.get("relationship_type","?") if isinstance(data,dict) else "?"),
|
||||||
|
file=sys.stderr)
|
||||||
|
return code, data
|
||||||
|
|
||||||
|
def main():
|
||||||
|
_, arts = curl("GET", "/artifacts?limit=50")
|
||||||
|
assert isinstance(arts, list)
|
||||||
|
by_type = {}
|
||||||
|
for a in arts:
|
||||||
|
if isinstance(a,dict):
|
||||||
|
by_type.setdefault(a["type"], []).append(a)
|
||||||
|
req = by_type["requirement"][0]
|
||||||
|
task = by_type["task"][0]
|
||||||
|
pr = by_type["pull_request"][0]
|
||||||
|
test = by_type["test"][0]
|
||||||
|
build = by_type["build"][0]
|
||||||
|
deploy= by_type["deployment"][0]
|
||||||
|
print(f" req={str(req['id'])[:10]} task={str(task['id'])[:10]} pr={str(pr['id'])[:10]} test={str(test['id'])[:10]} build={str(build['id'])[:10]} deploy={str(deploy['id'])[:10]}", file=sys.stderr)
|
||||||
|
rels = [
|
||||||
|
(task["id"], req["id"], "implements"),
|
||||||
|
(pr["id"], task["id"], "generated_from"),
|
||||||
|
(build["id"], pr["id"], "generated_from"),
|
||||||
|
(test["id"], pr["id"], "verifies"),
|
||||||
|
(deploy["id"], build["id"], "deployed_as"),
|
||||||
|
]
|
||||||
|
oks=0
|
||||||
|
for s,t,k in rels:
|
||||||
|
code, d = curl("POST","/relationships", {"source_artifact_id": s, "target_artifact_id": t, "relationship_type": k})
|
||||||
|
if 200 <= code < 300: oks += 1
|
||||||
|
print(f"\n rels OK = {oks}/5", file=sys.stderr)
|
||||||
|
|
||||||
|
code, graph = curl("GET", "/traceability/graph?limit=200")
|
||||||
|
assert code == 200 and isinstance(graph, dict)
|
||||||
|
nodes = graph.get("nodes", []); edges = graph.get("edges", [])
|
||||||
|
print(json.dumps({"nodes":len(nodes),"edges":len(edges),
|
||||||
|
"node_types":[n.get("type") for n in nodes],
|
||||||
|
"edge_labels":[e.get("label") for e in edges]}))
|
||||||
|
return 0 if oks == 5 else 2
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed tenant t-demo-003 with 5 artifacts + 5 relationships for a rich Traceability Graph demo.
|
||||||
|
|
||||||
|
Endpoint testati via curl:
|
||||||
|
- POST /api/v1/artifacts
|
||||||
|
- POST /api/v1/relationships
|
||||||
|
- GET /api/v1/traceability/graph
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8000/api/v1"
|
||||||
|
H = [
|
||||||
|
"-H", "X-ASF-Tenant-Id: t-demo-003",
|
||||||
|
"-H", "X-ASF-User-Id: u-mario",
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def curl(method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any] | list | str]:
|
||||||
|
cmd = ["curl", "-s", "-m", "15", "-o", "-", "-w", "\n%{http_code}", "-X", method] + H
|
||||||
|
if body is not None:
|
||||||
|
cmd += ["--data", json.dumps(body, ensure_ascii=False)]
|
||||||
|
cmd.append(f"{BASE}{path}")
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
out_raw = r.stdout.strip() or "{}"
|
||||||
|
# Last line is the http code we injected with -w
|
||||||
|
if "\n" in out_raw:
|
||||||
|
json_part, code_part = out_raw.rsplit("\n", 1)
|
||||||
|
else:
|
||||||
|
json_part, code_part = "", out_raw
|
||||||
|
try:
|
||||||
|
code = int(code_part)
|
||||||
|
except ValueError:
|
||||||
|
code = 0
|
||||||
|
try:
|
||||||
|
data = json.loads(json_part or "null")
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
data = f"JSON_ERR {exc} RAW={json_part[:200]!r}"
|
||||||
|
print(f" [{method:6s} {path:24s}] HTTP={code:3d}",
|
||||||
|
f"type={data.get('type','?') if isinstance(data,dict) else '?'}\tid={str(data.get('id',''))[:10] if isinstance(data,dict) else '?'}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return code, data
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("# Step 1: list existing artifacts + project_id", file=sys.stderr)
|
||||||
|
_, arts = curl("GET", "/artifacts?limit=50")
|
||||||
|
assert isinstance(arts, list), f"artifacts list not a list: {arts!r}"
|
||||||
|
req_103 = None
|
||||||
|
project_id = None
|
||||||
|
for a in arts:
|
||||||
|
if isinstance(a, dict) and a.get("external_id") == "REQ-103":
|
||||||
|
req_103 = a
|
||||||
|
project_id = a.get("project_id")
|
||||||
|
break
|
||||||
|
if not req_103:
|
||||||
|
# fallback: first artifact
|
||||||
|
req_103 = arts[0] if arts else None
|
||||||
|
project_id = req_103.get("project_id") if isinstance(req_103, dict) else None
|
||||||
|
print(f" req_103 id={str(req_103['id'])[:10] if isinstance(req_103,dict) else None} project_id={str(project_id)[:10] if project_id else None}",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
def make(type_: str, ext: str, title: str, content: str) -> dict[str, Any]:
|
||||||
|
code, data = curl(
|
||||||
|
"POST", "/artifacts",
|
||||||
|
{
|
||||||
|
"type": type_,
|
||||||
|
"external_id": ext,
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"status": "done",
|
||||||
|
"project_id": project_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert code == 201 and isinstance(data, dict) and "id" in data, f"make {type_} FAILED HTTP={code} {data}"
|
||||||
|
return data
|
||||||
|
|
||||||
|
print(file=sys.stderr); print("# Step 2: create 5 extra artifacts", file=sys.stderr)
|
||||||
|
task = make("task", "TASK-101", "Implementa tenant trust header coerce", "FastAPI correlation middleware: UUID UUID5 per stringhe t-demo-xxx, no 400 invalid")
|
||||||
|
pr = make("pull_request", "PR#88", "feat(rbac): tenant trust + policies v1", "feat(rbac): add trust header middleware + policy service helpers")
|
||||||
|
test = make("test", "TEST-22", "cross-tenant reads/writes blocked", "Pytest suite proving cross-tenant artifact operations return 404/403")
|
||||||
|
build = make("build", "BUILD-910","asf-core build #910 (main)", "GitHub Actions build main branch, all checks green.")
|
||||||
|
deploy = make("deployment", "DEP-44", "asf-core v0.5.0 → prod-west-1", "ArgoCD sync asf-core to cluster prod-west-1 eu-west")
|
||||||
|
|
||||||
|
print(file=sys.stderr); print("# Step 3: create 5 relationships (chain: DEP → BUILD → PR → TASK → REQ, TEST → PR)", file=sys.stderr)
|
||||||
|
rels_pairs = [
|
||||||
|
(task, req_103, "implements"),
|
||||||
|
(pr, task, "generated_from"),
|
||||||
|
(build, pr, "generated_from"),
|
||||||
|
(test, pr, "verifies"),
|
||||||
|
(deploy, build, "deployed_as"),
|
||||||
|
]
|
||||||
|
for src, tgt, kind in rels_pairs:
|
||||||
|
code, rel = curl(
|
||||||
|
"POST", "/relationships",
|
||||||
|
{
|
||||||
|
"source_artifact_id": src["id"],
|
||||||
|
"target_artifact_id": tgt["id"],
|
||||||
|
"relationship_type": kind,
|
||||||
|
"meta": {"seed": "traceability_demo"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert code < 300 and isinstance(rel, dict) and "id" in rel, f"REL FAIL {kind} -> {rel}"
|
||||||
|
|
||||||
|
print(file=sys.stderr); print("# Step 4: GET traceability graph → counts", file=sys.stderr)
|
||||||
|
code, graph = curl("GET", "/traceability/graph?limit=200")
|
||||||
|
assert code == 200 and isinstance(graph, dict), f"GRAPH FAIL HTTP={code} {graph}"
|
||||||
|
nodes = graph.get("nodes", [])
|
||||||
|
edges = graph.get("edges", [])
|
||||||
|
print(f"\n=== SEED SUCCESS ===", file=sys.stderr)
|
||||||
|
print(f" graph.nodes = {len(nodes)} graph.edges = {len(edges)}", file=sys.stderr)
|
||||||
|
# print to stdout for bash pipeline capture (JSON):
|
||||||
|
print(json.dumps({"nodes": len(nodes), "edges": len(edges),
|
||||||
|
"node_ids": [{"id":str(n["id"])[:10],"type":n.get("type"),"label":n.get("label")} for n in nodes],
|
||||||
|
"edge_labels": [{"s":e["source"][:8],"t":e["target"][:8],"lbl":e.get("label")} for e in edges]}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+225
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# start-stack.sh — avvia ASF Core, ASF Web (Next.js), e opzionalmente ASF Worker.
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/start-stack.sh # core + web, reload + dev mode
|
||||||
|
# ./scripts/start-stack.sh --no-reload # core senza --reload (productionish)
|
||||||
|
# ./scripts/start-stack.sh --with-worker # avvia anche asf-worker (7 code)
|
||||||
|
# ./scripts/start-stack.sh --web-only # solo frontend Next.js
|
||||||
|
# ./scripts/start-stack.sh --core-only # solo backend Core (FastAPI)
|
||||||
|
# Bindings default: 0.0.0.0 (core 8000, web 3000).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && usage
|
||||||
|
|
||||||
|
WITH_WORKER=0
|
||||||
|
RELOAD=1
|
||||||
|
CORE=1
|
||||||
|
WEB=1
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--with-worker|-w) WITH_WORKER=1 ;;
|
||||||
|
--no-reload) RELOAD=0 ;;
|
||||||
|
--web-only) CORE=0; WITH_WORKER=0 ;;
|
||||||
|
--core-only) WEB=0 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "Unknown option: $arg"; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 1) Preflight checks
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
VENV_DIR="$ROOT_DIR/.venv"
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
echo "[start-stack] ❌ .venv non trovata in $VENV_DIR. Crea prima la venv: python -m venv .venv && source .venv/bin/activate" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
ENV_FILE="$ROOT_DIR/.env"
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "[start-stack] ⚠️ .env non trovato, uso .env.example (se esiste)."
|
||||||
|
if [ -f "$ROOT_DIR/.env.example" ]; then
|
||||||
|
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; [ -f "$ENV_FILE" ] && source "$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
export ASF_ROOT="$ROOT_DIR"
|
||||||
|
export PYTHONPATH="\
|
||||||
|
$ROOT_DIR/packages/asf-domain:\
|
||||||
|
$ROOT_DIR/services/asf-events:\
|
||||||
|
$ROOT_DIR/services/asf-connectors:\
|
||||||
|
$ROOT_DIR/services/asf-core:\
|
||||||
|
$ROOT_DIR/services/asf-worker:\
|
||||||
|
$ROOT_DIR/services/asf-ai:\
|
||||||
|
$ROOT_DIR/services/asf-context\
|
||||||
|
${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
|
|
||||||
|
export NEXT_PUBLIC_ASF_CORE_URL="${NEXT_PUBLIC_ASF_CORE_URL:-http://localhost:8000}"
|
||||||
|
export NEXT_PUBLIC_DEV_TENANT_ID="${NEXT_PUBLIC_DEV_TENANT_ID:-t-demo-001}"
|
||||||
|
export NEXT_PUBLIC_DEV_USER_ID="${NEXT_PUBLIC_DEV_USER_ID:-u-admin}"
|
||||||
|
|
||||||
|
LOG_DIR="$ROOT_DIR/var/log/asf"
|
||||||
|
PID_DIR="$ROOT_DIR/var/run"
|
||||||
|
mkdir -p "$LOG_DIR" "$PID_DIR"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 2) Helpers
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
pids=()
|
||||||
|
names=()
|
||||||
|
|
||||||
|
start_in_dir() {
|
||||||
|
local name="$1"; shift
|
||||||
|
local dir="$1"; shift
|
||||||
|
local log="$LOG_DIR/$name.log"
|
||||||
|
echo " starting $name ($(basename "$dir")) ..."
|
||||||
|
(
|
||||||
|
cd "$dir"
|
||||||
|
# shellcheck disable=SC2093
|
||||||
|
exec "$@" >"$log" 2>&1
|
||||||
|
) &
|
||||||
|
local pid=$!
|
||||||
|
pids+=("$pid")
|
||||||
|
names+=("$name")
|
||||||
|
echo " pid=$pid log=$log"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
local COLS
|
||||||
|
COLS=$(tput cols 2>/dev/null || echo 80)
|
||||||
|
printf '═%.0s' $(seq 1 "$COLS"); echo
|
||||||
|
printf " ASF local stack · %s · tenant=%s user=%s\n" \
|
||||||
|
"$(date '+%F %T %Z')" \
|
||||||
|
"$NEXT_PUBLIC_DEV_TENANT_ID" \
|
||||||
|
"$NEXT_PUBLIC_DEV_USER_ID"
|
||||||
|
printf '═%.0s' $(seq 1 "$COLS"); echo
|
||||||
|
}
|
||||||
|
|
||||||
|
graceful_stop() {
|
||||||
|
echo
|
||||||
|
echo "[start-stack] SIGINT/SIGTERM received — stopping ${#pids[@]} processes (${names[*]})."
|
||||||
|
for i in "${!pids[@]}"; do
|
||||||
|
local pid="${pids[$i]}"
|
||||||
|
local name="${names[$i]}"
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo " stopping $name (pid=$pid)"
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# wait them out
|
||||||
|
local waited=0
|
||||||
|
while [ $waited -lt 10 ]; do
|
||||||
|
local alive=0
|
||||||
|
for pid in "${pids[@]}"; do kill -0 "$pid" 2>/dev/null && alive=1; done
|
||||||
|
[ $alive -eq 0 ] && break
|
||||||
|
sleep 1; waited=$((waited+1))
|
||||||
|
done
|
||||||
|
# force kill any stragglers
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
kill -0 "$pid" 2>/dev/null && { echo " force kill pid=$pid"; kill -9 "$pid" 2>/dev/null || true; }
|
||||||
|
done
|
||||||
|
echo "[start-stack] stopped."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap graceful_stop INT TERM
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 3) Dependency quick liveness (Mongo + Rabbit — best effort)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
print_header
|
||||||
|
echo "[start-stack] python=$(python -V 2>&1 | cut -d' ' -f2) node=$(node -v) npm=v$(npm -v 2>/dev/null)"
|
||||||
|
echo "[start-stack] PYTHONPATH[0:80]=${PYTHONPATH:0:80}…"
|
||||||
|
echo
|
||||||
|
echo "[deps] Mongo / Rabbit best-effort reachability (5s timeout)…"
|
||||||
|
python - <<PY 2>/dev/null || true
|
||||||
|
import os,socket,time
|
||||||
|
for host,port,name in [("172.19.40.102",27017,"Mongo rs0 PRIMARY"),("172.19.40.103",27017,"Mongo rs0 SEED"),("172.19.40.201",5672,"RabbitMQ AMQP"),("172.19.40.201",15672,"RabbitMQ HTTP mgmt")]:
|
||||||
|
s=socket.socket(); s.settimeout(2)
|
||||||
|
try:
|
||||||
|
s.connect((host,port)); print(f" ✅ {name:22s} {host}:{port} reachable")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ {name:22s} {host}:{port} unreachable ({e})")
|
||||||
|
finally: s.close()
|
||||||
|
PY
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 4) Start processes
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
echo "[start-stack] launching services (bind 0.0.0.0)…"
|
||||||
|
|
||||||
|
# Core (FastAPI)
|
||||||
|
if [ $CORE -eq 1 ]; then
|
||||||
|
CORE_ARGS=(uvicorn app.main:app --host 0.0.0.0 --port 8000)
|
||||||
|
[ $RELOAD -eq 1 ] && CORE_ARGS+=(--reload)
|
||||||
|
start_in_dir "asf-core" "$ROOT_DIR/services/asf-core" "${CORE_ARGS[@]}"
|
||||||
|
echo " → http://0.0.0.0:8000/docs (OpenAPI)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Web (Next.js dev)
|
||||||
|
if [ $WEB -eq 1 ]; then
|
||||||
|
start_in_dir "asf-web" "$ROOT_DIR/frontend/asf-web" npm run dev -- --hostname 0.0.0.0 --port 3000
|
||||||
|
echo " → http://0.0.0.0:3000 (UI)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Worker
|
||||||
|
if [ $WITH_WORKER -eq 1 ]; then
|
||||||
|
start_in_dir "asf-worker" "$ROOT_DIR/services/asf-worker" python -m app.worker
|
||||||
|
echo " → queues=$ASF_WORKER_JOB_QUEUES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
# Save PIDs on disk for `stop-stack.sh`
|
||||||
|
: > "$PID_DIR/asf-stack.pids"
|
||||||
|
for i in "${!pids[@]}"; do
|
||||||
|
echo "${names[$i]} ${pids[$i]}" >> "$PID_DIR/asf-stack.pids"
|
||||||
|
done
|
||||||
|
echo "[start-stack] PIDs salvati in $PID_DIR/asf-stack.pids"
|
||||||
|
echo
|
||||||
|
echo "Tipi 'Ctrl+C' per arrestare TUTTI i processi. Oppure usa: ./scripts/stop-stack.sh"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 5) Smoke test (first 20s)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
deadline=$((SECONDS + 25))
|
||||||
|
ok_core=0; ok_web=0
|
||||||
|
while [ $SECONDS -lt $deadline ]; do
|
||||||
|
if [ $CORE -eq 1 ] && [ $ok_core -eq 0 ]; then
|
||||||
|
code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/api/v1/health || true)
|
||||||
|
[ "$code" = "200" ] && { ok_core=1; echo " ✅ asf-core up /health HTTP=$code"; }
|
||||||
|
fi
|
||||||
|
if [ $WEB -eq 1 ] && [ $ok_web -eq 0 ]; then
|
||||||
|
code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true)
|
||||||
|
[ "$code" = "200" ] && { ok_web=1; echo " ✅ asf-web up / HTTP=$code"; }
|
||||||
|
fi
|
||||||
|
need=0
|
||||||
|
[ $CORE -eq 1 ] && [ $ok_core -eq 0 ] && need=1
|
||||||
|
[ $WEB -eq 1 ] && [ $ok_web -eq 0 ] && need=1
|
||||||
|
[ $need -eq 0 ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ $CORE -eq 1 ] && [ $ok_core -eq 0 ] && echo " ⚠️ asf-core non ha risposto /health in tempo (guarda $LOG_DIR/asf-core.log)"
|
||||||
|
[ $WEB -eq 1 ] && [ $ok_web -eq 0 ] && echo " ⚠️ asf-web non ha risposto / in tempo (Next.js 14 first compile slow: attendi / guarda $LOG_DIR/asf-web.log)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "ASF stack ready. In attesa (foreground)… Ctrl+C = stop."
|
||||||
|
|
||||||
|
# wait
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
wait "$pid" || true
|
||||||
|
done
|
||||||
|
graceful_stop
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# status-stack.sh — PIDs, logs, HTTP live per ASF Core/Web/Worker.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PID_FILE="$ROOT_DIR/var/run/asf-stack.pids"
|
||||||
|
LOG_DIR="$ROOT_DIR/var/log/asf"
|
||||||
|
|
||||||
|
echo "ASF stack STATUS $(date '+%F %T %Z')"
|
||||||
|
echo
|
||||||
|
printf "%-18s %-8s %-6s %-6s %s\n" "NAME" "PID" "PORT" "HTTP" "LOG"
|
||||||
|
printf '─%.0s' $(seq 1 90); echo
|
||||||
|
|
||||||
|
check_http() {
|
||||||
|
local url="$1"
|
||||||
|
curl -s -m 3 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo "000"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Core
|
||||||
|
pid=$(pgrep -f "uvicorn app.main:app" 2>/dev/null | head -1 || true)
|
||||||
|
http=$(check_http "http://127.0.0.1:8000/api/v1/health")
|
||||||
|
printf "%-18s %-8s %-6s %-6s %s\n" "asf-core" "${pid:--}" "8000" "$http" "$LOG_DIR/asf-core.log"
|
||||||
|
|
||||||
|
# Web
|
||||||
|
pid=$(pgrep -af "next-server|next dev" 2>/dev/null | awk '{print $1}' | head -1 || true)
|
||||||
|
http=$(check_http "http://127.0.0.1:3000/")
|
||||||
|
printf "%-18s %-8s %-6s %-6s %s\n" "asf-web" "${pid:--}" "3000" "$http" "$LOG_DIR/asf-web.log"
|
||||||
|
|
||||||
|
# Worker
|
||||||
|
pid=$(pgrep -f "python -m app.worker" 2>/dev/null | head -1 || true)
|
||||||
|
printf "%-18s %-8s %-6s %-6s %s\n" "asf-worker" "${pid:--}" "AMQP" "-" "$LOG_DIR/asf-worker.log"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Pid file:"; [ -f "$PID_FILE" ] && cat "$PID_FILE" || echo " (nessuno)"
|
||||||
|
echo
|
||||||
|
echo "Logs tail (10 lines most recent each):"
|
||||||
|
for f in "$LOG_DIR"/*.log; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
echo
|
||||||
|
echo "───── $f (last 10 lines) ─────"
|
||||||
|
tail -10 "$f" | sed 's/^/ │ /'
|
||||||
|
done
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# stop-stack.sh — ferma i processi avviati da start-stack.sh.
|
||||||
|
# Legge i PIDs da var/run/asf-stack.pids, oppure cerca per pattern.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PID_FILE="$ROOT_DIR/var/run/asf-stack.pids"
|
||||||
|
|
||||||
|
echo "ASF stack STOP $(date '+%F %T %Z')"
|
||||||
|
echo
|
||||||
|
|
||||||
|
stop_group() {
|
||||||
|
local pid="$1" name="$2"
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo " ✗ $name (pid=$pid) già spento"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -n " ⏹ $name pid=$pid → "
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
local w=0
|
||||||
|
while [ $w -lt 8 ]; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 1; w=$((w+1))
|
||||||
|
done
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo -n "SIGKILL "
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
kill -0 "$pid" 2>/dev/null && echo "FAIL (ancora vivo)" || echo "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_patterns() {
|
||||||
|
echo "[fallback] kill via pattern match…"
|
||||||
|
for pattern in \
|
||||||
|
"uvicorn app.main:app" \
|
||||||
|
"next dev" "next-server" \
|
||||||
|
"python -m app.worker" "asf-worker.*run_worker"; do
|
||||||
|
pids=$(pgrep -af "$pattern" 2>/dev/null | awk '{print $1}' || true)
|
||||||
|
[ -z "$pids" ] && continue
|
||||||
|
echo " pattern '$pattern' → pids=$pids"
|
||||||
|
for p in $pids; do stop_group "$p" "$pattern"; done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
echo "[from pidfile] $PID_FILE"
|
||||||
|
while IFS=' ' read -r name pid rest; do
|
||||||
|
[ -z "${name:-}" ] && continue
|
||||||
|
[ -z "${pid:-}" ] && continue
|
||||||
|
stop_group "$pid" "$name"
|
||||||
|
done < "$PID_FILE"
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
else
|
||||||
|
echo "[no pidfile $PID_FILE]"
|
||||||
|
stop_patterns
|
||||||
|
fi
|
||||||
|
|
||||||
|
# final sweep
|
||||||
|
echo
|
||||||
|
echo "ports still listening:"
|
||||||
|
ss -ltnp 2>/dev/null | grep -E ":(8000|3000) " || echo " → nessuna porta 8000 / 3000 usata (ok)."
|
||||||
|
echo "done."
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""asf-ai: ASF AI Gateway — "l'ufficio analisti".
|
||||||
|
|
||||||
|
*Business code NEVER calls a specific LLM provider directly.*
|
||||||
|
It calls typed capability methods on this gateway.
|
||||||
|
|
||||||
|
Provider adapters (Mock, OpenAI-compat, NVIDIA, local, customer-hosted, ...)
|
||||||
|
implement the low-level interface. Routing, priority queues, tenant AI
|
||||||
|
policy, usage metering and cost tracking live here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
from .capabilities import (
|
||||||
|
CapabilityRequest,
|
||||||
|
CapabilityResponse,
|
||||||
|
ChatRequest,
|
||||||
|
ChatResponse,
|
||||||
|
CodeAnalysisRequest,
|
||||||
|
CodeAnalysisResult,
|
||||||
|
TestGenerationRequest,
|
||||||
|
GeneratedTests,
|
||||||
|
RequirementDecomposeRequest,
|
||||||
|
RequirementDecomposition,
|
||||||
|
TraceContext,
|
||||||
|
)
|
||||||
|
from .gateway import AIGateway, AIGatewayConfig
|
||||||
|
from .providers import (
|
||||||
|
AIProvider,
|
||||||
|
MockProvider,
|
||||||
|
ProviderRegistry,
|
||||||
|
build_default_gateway,
|
||||||
|
)
|
||||||
|
from .usage import UsageRecord
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CapabilityRequest",
|
||||||
|
"CapabilityResponse",
|
||||||
|
"ChatRequest",
|
||||||
|
"ChatResponse",
|
||||||
|
"CodeAnalysisRequest",
|
||||||
|
"CodeAnalysisResult",
|
||||||
|
"TestGenerationRequest",
|
||||||
|
"GeneratedTests",
|
||||||
|
"RequirementDecomposeRequest",
|
||||||
|
"RequirementDecomposition",
|
||||||
|
"TraceContext",
|
||||||
|
"AIGateway",
|
||||||
|
"AIGatewayConfig",
|
||||||
|
"AIProvider",
|
||||||
|
"MockProvider",
|
||||||
|
"ProviderRegistry",
|
||||||
|
"build_default_gateway",
|
||||||
|
"UsageRecord",
|
||||||
|
]
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Typed capability requests / responses.
|
||||||
|
|
||||||
|
Business services construct these Pydantic models and call methods on the
|
||||||
|
AIGateway. They never know which concrete model or provider was chosen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional, Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TraceContext(BaseModel):
|
||||||
|
"""Context propagated through every call for audit / logging."""
|
||||||
|
|
||||||
|
tenant_id: UUID
|
||||||
|
project_id: Optional[UUID] = None
|
||||||
|
user_id: Optional[UUID] = None
|
||||||
|
job_id: Optional[UUID] = None
|
||||||
|
correlation_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequest(BaseModel):
|
||||||
|
"""Base marker for every capability request supported by the gateway."""
|
||||||
|
|
||||||
|
trace: TraceContext
|
||||||
|
preferred_model: Optional[str] = Field(
|
||||||
|
None, max_length=128, description="Hint only. Gateway's routing decision is authoritative."
|
||||||
|
)
|
||||||
|
priority: str = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityResponse(BaseModel):
|
||||||
|
"""Every response carries at least trace + usage."""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
model_used: str
|
||||||
|
input_tokens: Optional[int] = None
|
||||||
|
output_tokens: Optional[int] = None
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
estimated_cost: Optional[float] = None
|
||||||
|
execution_id: UUID
|
||||||
|
raw: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None, description="Raw provider output, preserved for debugging."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Individual capabilities
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: str = Field(..., pattern="^(system|user|assistant|tool)$")
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(CapabilityRequest):
|
||||||
|
messages: Sequence[ChatMessage]
|
||||||
|
temperature: float = Field(0.2, ge=0.0, le=2.0)
|
||||||
|
max_tokens: Optional[int] = Field(None, ge=1)
|
||||||
|
context_summary: Optional[str] = Field(
|
||||||
|
None, description="Short plaintext summary of the Context Engine assembly."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(CapabilityResponse):
|
||||||
|
reply: str
|
||||||
|
references: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CodeAnalysisRequest(CapabilityRequest):
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
code_snippet: str = Field(..., min_length=1)
|
||||||
|
analysis_types: Sequence[str] = Field(
|
||||||
|
default_factory=lambda: ["bugs", "complexity", "security", "style"],
|
||||||
|
description="Which kinds of analyses to run.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Finding(BaseModel):
|
||||||
|
severity: str = Field(..., pattern="^(info|low|medium|high|critical)$")
|
||||||
|
kind: str
|
||||||
|
message: str
|
||||||
|
line_start: Optional[int] = None
|
||||||
|
line_end: Optional[int] = None
|
||||||
|
suggestion: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CodeAnalysisResult(CapabilityResponse):
|
||||||
|
summary: str
|
||||||
|
findings: list[Finding] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerationRequest(CapabilityRequest):
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
language: str = "python"
|
||||||
|
test_framework: str = "pytest"
|
||||||
|
code_snippet: str = Field(..., min_length=1)
|
||||||
|
goal: str = "Generate unit tests covering happy path + main failure modes"
|
||||||
|
existing_tests: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedTests(CapabilityResponse):
|
||||||
|
test_cases: list[str] = Field(default_factory=list)
|
||||||
|
fixtures_needed: list[str] = Field(default_factory=list)
|
||||||
|
coverage_notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementDecomposeRequest(CapabilityRequest):
|
||||||
|
requirement_text: str = Field(..., min_length=1)
|
||||||
|
context: Optional[str] = None
|
||||||
|
existing_subtasks: Sequence[str] = Field(default_factory=list)
|
||||||
|
max_subtasks: int = Field(20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class SubTask(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
estimated_complexity: str = Field(..., pattern="^(xs|s|m|l|xl)$")
|
||||||
|
depends_on: list[int] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Index (0-based) of sibling subtasks this one depends on.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementDecomposition(CapabilityResponse):
|
||||||
|
subtasks: list[SubTask] = Field(default_factory=list)
|
||||||
|
architecture_notes: list[str] = Field(default_factory=list)
|
||||||
|
open_questions: list[str] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""asf-ai settings surface (minimal; extensible)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AISettings(BaseSettings):
|
||||||
|
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||||
|
mongo_db: str = "asf"
|
||||||
|
|
||||||
|
default_provider: str = "mock"
|
||||||
|
|
||||||
|
# Provider-specific (optional — only required if the chosen provider is used)
|
||||||
|
openai_api_key: str | None = None
|
||||||
|
anthropic_api_key: str | None = None
|
||||||
|
nvidia_api_key: str | None = None
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> AISettings:
|
||||||
|
return AISettings()
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""AIGateway — the high-level capability surface called by business code.
|
||||||
|
|
||||||
|
It handles:
|
||||||
|
1. Routing: picks which provider + model to use given the request.
|
||||||
|
2. Policy enforcement: tenant-level AI policy (which providers/models are
|
||||||
|
allowed; budget caps will plug in here cleanly later).
|
||||||
|
3. Usage recording: emits UsageRecords for metering + future billing.
|
||||||
|
4. Error translation (provider-specific → stable ASF exceptions).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from .capabilities import (
|
||||||
|
ChatRequest,
|
||||||
|
ChatResponse,
|
||||||
|
CodeAnalysisRequest,
|
||||||
|
CodeAnalysisResult,
|
||||||
|
TestGenerationRequest,
|
||||||
|
GeneratedTests,
|
||||||
|
RequirementDecomposeRequest,
|
||||||
|
RequirementDecomposition,
|
||||||
|
)
|
||||||
|
from .providers import (
|
||||||
|
AIProvider,
|
||||||
|
ProviderRegistry,
|
||||||
|
default_registry,
|
||||||
|
)
|
||||||
|
from .usage import UsageRecord
|
||||||
|
|
||||||
|
log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIGatewayConfig:
|
||||||
|
default_provider: str = "mock"
|
||||||
|
default_model_by_capability: dict[str, str] = field(default_factory=dict)
|
||||||
|
# Hook for future tenant policy layer: given (tenant_id, capability),
|
||||||
|
# return the provider name to route to. None → use default_provider.
|
||||||
|
policy_selector: Optional[Callable[..., Optional[str]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIGateway:
|
||||||
|
"""Entry point for all ASF AI operations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
config: Optional[AIGatewayConfig] = None,
|
||||||
|
registry: Optional[ProviderRegistry] = None,
|
||||||
|
) -> None:
|
||||||
|
self._config = config or AIGatewayConfig()
|
||||||
|
self._registry = registry or default_registry()
|
||||||
|
self._usage_callbacks: list[Callable[[UsageRecord], None]] = []
|
||||||
|
|
||||||
|
# --- callback hooks (future usage metering / billing) ---
|
||||||
|
def on_usage(self, cb: Callable[[UsageRecord], None]) -> None:
|
||||||
|
self._usage_callbacks.append(cb)
|
||||||
|
|
||||||
|
# --- routing helper ---
|
||||||
|
def _route(self, req, capability: str) -> AIProvider:
|
||||||
|
tenant_id = str(req.trace.tenant_id) if req.trace else None
|
||||||
|
chosen: Optional[str] = None
|
||||||
|
if self._config.policy_selector and tenant_id:
|
||||||
|
try:
|
||||||
|
chosen = self._config.policy_selector(
|
||||||
|
tenant_id=req.trace.tenant_id,
|
||||||
|
capability=capability,
|
||||||
|
preferred=req.preferred_model,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("ai_policy_selector_failed")
|
||||||
|
|
||||||
|
chosen = chosen or self._config.default_provider
|
||||||
|
return self._registry.get(chosen)
|
||||||
|
|
||||||
|
def _emit_usage(self, record: UsageRecord) -> None:
|
||||||
|
for cb in self._usage_callbacks:
|
||||||
|
try:
|
||||||
|
cb(record)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("ai_usage_callback_failed", provider=record.provider)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Capability methods
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def chat(self, req: ChatRequest) -> ChatResponse:
|
||||||
|
provider = self._route(req, "chat")
|
||||||
|
log.debug("ai_chat_start", provider=provider.name, tenant=str(req.trace.tenant_id))
|
||||||
|
resp = await provider.chat(req)
|
||||||
|
self._emit_usage(
|
||||||
|
UsageRecord(
|
||||||
|
tenant_id=req.trace.tenant_id,
|
||||||
|
project_id=req.trace.project_id,
|
||||||
|
job_id=req.trace.job_id,
|
||||||
|
execution_id=resp.execution_id,
|
||||||
|
provider=resp.provider,
|
||||||
|
model=resp.model_used,
|
||||||
|
capability="chat",
|
||||||
|
input_tokens=resp.input_tokens,
|
||||||
|
output_tokens=resp.output_tokens,
|
||||||
|
estimated_cost=resp.estimated_cost,
|
||||||
|
duration_ms=resp.duration_ms,
|
||||||
|
recorded_at=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
|
||||||
|
provider = self._route(req, "analyze_code")
|
||||||
|
resp = await provider.analyze_code(req)
|
||||||
|
self._emit_usage(
|
||||||
|
UsageRecord(
|
||||||
|
tenant_id=req.trace.tenant_id,
|
||||||
|
project_id=req.trace.project_id,
|
||||||
|
job_id=req.trace.job_id,
|
||||||
|
execution_id=resp.execution_id,
|
||||||
|
provider=resp.provider,
|
||||||
|
model=resp.model_used,
|
||||||
|
capability="analyze_code",
|
||||||
|
input_tokens=resp.input_tokens,
|
||||||
|
output_tokens=resp.output_tokens,
|
||||||
|
estimated_cost=resp.estimated_cost,
|
||||||
|
duration_ms=resp.duration_ms,
|
||||||
|
recorded_at=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
|
||||||
|
provider = self._route(req, "generate_tests")
|
||||||
|
resp = await provider.generate_tests(req)
|
||||||
|
self._emit_usage(
|
||||||
|
UsageRecord(
|
||||||
|
tenant_id=req.trace.tenant_id,
|
||||||
|
project_id=req.trace.project_id,
|
||||||
|
job_id=req.trace.job_id,
|
||||||
|
execution_id=resp.execution_id,
|
||||||
|
provider=resp.provider,
|
||||||
|
model=resp.model_used,
|
||||||
|
capability="generate_tests",
|
||||||
|
input_tokens=resp.input_tokens,
|
||||||
|
output_tokens=resp.output_tokens,
|
||||||
|
estimated_cost=resp.estimated_cost,
|
||||||
|
duration_ms=resp.duration_ms,
|
||||||
|
recorded_at=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
async def decompose_requirement(
|
||||||
|
self, req: RequirementDecomposeRequest
|
||||||
|
) -> RequirementDecomposition:
|
||||||
|
provider = self._route(req, "decompose_requirement")
|
||||||
|
resp = await provider.decompose_requirement(req)
|
||||||
|
self._emit_usage(
|
||||||
|
UsageRecord(
|
||||||
|
tenant_id=req.trace.tenant_id,
|
||||||
|
project_id=req.trace.project_id,
|
||||||
|
job_id=req.trace.job_id,
|
||||||
|
execution_id=resp.execution_id,
|
||||||
|
provider=resp.provider,
|
||||||
|
model=resp.model_used,
|
||||||
|
capability="decompose_requirement",
|
||||||
|
input_tokens=resp.input_tokens,
|
||||||
|
output_tokens=resp.output_tokens,
|
||||||
|
estimated_cost=resp.estimated_cost,
|
||||||
|
duration_ms=resp.duration_ms,
|
||||||
|
recorded_at=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def build_default_gateway() -> AIGateway:
|
||||||
|
"""Default bootstrap-configured gateway — uses MockProvider, stdout usage logging."""
|
||||||
|
gw = AIGateway()
|
||||||
|
gw.on_usage(lambda record: log.debug("ai_usage_record", **record.model_dump(mode="json")))
|
||||||
|
return gw
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""AIProvider interface + mock + registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol, Optional, Type
|
||||||
|
from uuid import uuid4, UUID
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from .capabilities import (
|
||||||
|
CapabilityRequest,
|
||||||
|
CapabilityResponse,
|
||||||
|
ChatRequest,
|
||||||
|
ChatResponse,
|
||||||
|
CodeAnalysisRequest,
|
||||||
|
CodeAnalysisResult,
|
||||||
|
TestGenerationRequest,
|
||||||
|
GeneratedTests,
|
||||||
|
RequirementDecomposeRequest,
|
||||||
|
RequirementDecomposition,
|
||||||
|
SubTask,
|
||||||
|
Finding,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderCaps:
|
||||||
|
supports_chat: bool = True
|
||||||
|
supports_code_analysis: bool = True
|
||||||
|
supports_test_generation: bool = True
|
||||||
|
supports_decompose: bool = True
|
||||||
|
max_input_chars: int = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
class AIProvider(Protocol):
|
||||||
|
"""Low-level provider interface. The Gateway calls exactly one per request.
|
||||||
|
|
||||||
|
Concrete implementations: MockProvider, OpenAICompatProvider, NVIDIADGXProvider, ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str: ...
|
||||||
|
@property
|
||||||
|
def default_model(self) -> str: ...
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> ProviderCaps: ...
|
||||||
|
|
||||||
|
async def chat(self, req: ChatRequest) -> ChatResponse: ...
|
||||||
|
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult: ...
|
||||||
|
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests: ...
|
||||||
|
async def decompose_requirement(
|
||||||
|
self, req: RequirementDecomposeRequest
|
||||||
|
) -> RequirementDecomposition: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# MockProvider — deterministic, canned, latency-simulating responses
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MockProvider:
|
||||||
|
"""Provider that always returns deterministic, predictable responses.
|
||||||
|
|
||||||
|
Uses:
|
||||||
|
* unit tests of code that depends on the AI Gateway
|
||||||
|
* local dev when no API keys are set
|
||||||
|
* UI demo / smoke tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "mock"
|
||||||
|
default_model = "asf-mock-v1"
|
||||||
|
capabilities = ProviderCaps()
|
||||||
|
|
||||||
|
def __init__(self, *, response_latency_ms: int = 40) -> None:
|
||||||
|
self._latency = max(0, response_latency_ms)
|
||||||
|
|
||||||
|
async def _maybe_sleep(self) -> None:
|
||||||
|
if self._latency <= 0:
|
||||||
|
return
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(self._latency / 1000.0)
|
||||||
|
|
||||||
|
async def chat(self, req: ChatRequest) -> ChatResponse:
|
||||||
|
await self._maybe_sleep()
|
||||||
|
# Mirror the last user message back as a demo reply — never misleadingly smart
|
||||||
|
last_user = next(
|
||||||
|
(m.content for m in reversed(req.messages) if m.role == "user"),
|
||||||
|
"(no user message)",
|
||||||
|
)
|
||||||
|
reply = (
|
||||||
|
"[ASF Mock AI] This is a deterministic response from MockProvider. "
|
||||||
|
f"Your last message was: {last_user[:160]!r}. "
|
||||||
|
"To enable real AI, configure a non-mock provider in the ASF AI Gateway."
|
||||||
|
)
|
||||||
|
return ChatResponse(
|
||||||
|
provider=self.name,
|
||||||
|
model_used=self.default_model,
|
||||||
|
execution_id=uuid4(),
|
||||||
|
reply=reply,
|
||||||
|
references=["mock://no-real-data"],
|
||||||
|
input_tokens=len(last_user) // 4,
|
||||||
|
output_tokens=len(reply) // 4,
|
||||||
|
duration_ms=self._latency,
|
||||||
|
estimated_cost=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
|
||||||
|
await self._maybe_sleep()
|
||||||
|
lines = [ln for ln in req.code_snippet.splitlines() if ln.strip()]
|
||||||
|
findings: list[Finding] = []
|
||||||
|
if "TODO" in req.code_snippet:
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
severity="info",
|
||||||
|
kind="todo",
|
||||||
|
message="Mock analysis found TODO comment — remember to follow up.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "except Exception" in req.code_snippet:
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
severity="medium",
|
||||||
|
kind="broad_exception",
|
||||||
|
message="Broad except clause detected — consider narrowing exception types.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CodeAnalysisResult(
|
||||||
|
provider=self.name,
|
||||||
|
model_used=self.default_model,
|
||||||
|
execution_id=uuid4(),
|
||||||
|
summary=f"Mock: analyzed {len(lines)} non-empty lines; {len(findings)} informational finding(s).",
|
||||||
|
findings=findings,
|
||||||
|
input_tokens=max(10, len(req.code_snippet) // 4),
|
||||||
|
output_tokens=100,
|
||||||
|
duration_ms=self._latency,
|
||||||
|
estimated_cost=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
|
||||||
|
await self._maybe_sleep()
|
||||||
|
test_cases = [
|
||||||
|
"# Generated by MockProvider — placeholder skeleton\n"
|
||||||
|
"def test_mock_happy_path():\n"
|
||||||
|
" # TODO: replace with real assertions from a production AI provider\n"
|
||||||
|
" assert True\n",
|
||||||
|
"# Generated by MockProvider — placeholder skeleton\n"
|
||||||
|
"def test_mock_failure_mode():\n"
|
||||||
|
" # TODO: simulate and assert on failure behavior\n"
|
||||||
|
" assert True\n",
|
||||||
|
]
|
||||||
|
return GeneratedTests(
|
||||||
|
provider=self.name,
|
||||||
|
model_used=self.default_model,
|
||||||
|
execution_id=uuid4(),
|
||||||
|
test_cases=test_cases,
|
||||||
|
fixtures_needed=[
|
||||||
|
"NOTE: Mock provider — fixtures listed are placeholders only.",
|
||||||
|
],
|
||||||
|
coverage_notes="Mock response. Real AI provider will target specific branches.",
|
||||||
|
input_tokens=max(10, len(req.code_snippet) // 4),
|
||||||
|
output_tokens=300,
|
||||||
|
duration_ms=self._latency,
|
||||||
|
estimated_cost=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def decompose_requirement(
|
||||||
|
self, req: RequirementDecomposeRequest
|
||||||
|
) -> RequirementDecomposition:
|
||||||
|
await self._maybe_sleep()
|
||||||
|
limit = max(1, min(5, req.max_subtasks))
|
||||||
|
subtasks = [
|
||||||
|
SubTask(
|
||||||
|
title=f"(Mock) Clarify scope of requirement",
|
||||||
|
description="Mock placeholder — confirm acceptance criteria with stakeholders.",
|
||||||
|
estimated_complexity="s",
|
||||||
|
),
|
||||||
|
SubTask(
|
||||||
|
title=f"(Mock) Draft architecture",
|
||||||
|
description="Mock placeholder — identify modules, data flow, contracts.",
|
||||||
|
estimated_complexity="m",
|
||||||
|
depends_on=[0],
|
||||||
|
),
|
||||||
|
SubTask(
|
||||||
|
title=f"(Mock) Implement feature",
|
||||||
|
description="Mock placeholder — concrete coding work.",
|
||||||
|
estimated_complexity="l",
|
||||||
|
depends_on=[1],
|
||||||
|
),
|
||||||
|
SubTask(
|
||||||
|
title=f"(Mock) Add tests",
|
||||||
|
description="Mock placeholder — unit + integration coverage.",
|
||||||
|
estimated_complexity="m",
|
||||||
|
depends_on=[2],
|
||||||
|
),
|
||||||
|
SubTask(
|
||||||
|
title=f"(Mock) Deploy + monitor",
|
||||||
|
description="Mock placeholder — staging → production, observability.",
|
||||||
|
estimated_complexity="s",
|
||||||
|
depends_on=[3],
|
||||||
|
),
|
||||||
|
][:limit]
|
||||||
|
return RequirementDecomposition(
|
||||||
|
provider=self.name,
|
||||||
|
model_used=self.default_model,
|
||||||
|
execution_id=uuid4(),
|
||||||
|
subtasks=subtasks,
|
||||||
|
architecture_notes=["(Mock) Decomposition is deterministic placeholder data."],
|
||||||
|
open_questions=[
|
||||||
|
"(Mock) Which non-functional requirements apply?",
|
||||||
|
"(Mock) Any existing integration contracts to preserve?",
|
||||||
|
],
|
||||||
|
input_tokens=max(10, len(req.requirement_text) // 4),
|
||||||
|
output_tokens=400,
|
||||||
|
duration_ms=self._latency,
|
||||||
|
estimated_cost=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# ProviderRegistry — maps provider name → AIProvider instance
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._providers: dict[str, AIProvider] = {}
|
||||||
|
|
||||||
|
def register(self, name: str, provider: AIProvider) -> None:
|
||||||
|
self._providers[name] = provider
|
||||||
|
|
||||||
|
def get(self, name: str) -> AIProvider:
|
||||||
|
if name not in self._providers:
|
||||||
|
raise KeyError(f"Unknown AI provider: {name}. Registered: {list(self._providers)}")
|
||||||
|
return self._providers[name]
|
||||||
|
|
||||||
|
def names(self) -> list[str]:
|
||||||
|
return list(self._providers.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def default_registry() -> ProviderRegistry:
|
||||||
|
r = ProviderRegistry()
|
||||||
|
r.register("mock", MockProvider(response_latency_ms=40))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def build_default_gateway():
|
||||||
|
"""Instantiate the canonical production AIGateway (mock by default).
|
||||||
|
|
||||||
|
Imported lazily to avoid a cycle with ``app.gateway``.
|
||||||
|
"""
|
||||||
|
from .gateway import AIGateway, AIGatewayConfig
|
||||||
|
|
||||||
|
return AIGateway(config=AIGatewayConfig(), registry=default_registry())
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Usage record (for billing / metering later)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UsageRecord(BaseModel):
|
||||||
|
tenant_id: UUID
|
||||||
|
project_id: Optional[UUID] = None
|
||||||
|
job_id: Optional[UUID] = None
|
||||||
|
execution_id: UUID
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
capability: str
|
||||||
|
input_tokens: Optional[int] = None
|
||||||
|
output_tokens: Optional[int] = None
|
||||||
|
gpu_seconds: Optional[float] = None
|
||||||
|
estimated_cost: Optional[float] = None
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
recorded_at: datetime
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user