feat: add Leptos UI library and modularize MCP server
Some checks are pending
Documentation Lint & Validation / Markdown Linting (push) Waiting to run
Documentation Lint & Validation / Validate mdBook Configuration (push) Waiting to run
Documentation Lint & Validation / Content & Structure Validation (push) Waiting to run
Documentation Lint & Validation / Lint & Validation Summary (push) Blocked by required conditions
mdBook Build & Deploy / Build mdBook (push) Waiting to run
mdBook Build & Deploy / Documentation Quality Check (push) Blocked by required conditions
mdBook Build & Deploy / Deploy to GitHub Pages (push) Blocked by required conditions
mdBook Build & Deploy / Notification (push) Blocked by required conditions
Rust CI / Security Audit (push) Waiting to run
Rust CI / Check + Test + Lint (nightly) (push) Waiting to run
Rust CI / Check + Test + Lint (stable) (push) Waiting to run

This commit is contained in:
Jesús Pérez 2026-02-14 20:10:55 +00:00
parent fcb928bf74
commit b6a4d77421
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
177 changed files with 20589 additions and 868 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ AGENTS.md
.opencode
utils/save*sh
COMMIT_MESSAGE.md
node_modules
.wrks
nushell
nushell-*

View File

@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added - Leptos Component Library (vapora-leptos-ui)
#### Component Library Implementation (`vapora-leptos-ui` crate)
- **16 production-ready components** with CSR/SSR agnostic architecture
- **Primitives (4):** Button, Input, Badge, Spinner with variant/size support
- **Layout (2):** Card (glassmorphism with blur/glow), Modal (backdrop + keyboard support)
- **Navigation (1):** SpaLink (History API integration, external link detection)
- **Forms (1 + 4 utils):** FormField with validation (required, email, min/max length)
- **Data (3):** Table (sortable columns), Pagination (smart ellipsis), StatCard (metrics with trends)
- **Feedback (3):** ToastProvider, ToastContext, use_toast hook (3-second auto-dismiss)
- **Type-safe theme system:** Variant, Size, BlurLevel, GlowColor enums
- **Unified/client/ssr pattern:** Compile-time branching for CSR/SSR contexts
- **301 UnoCSS utilities** generated from Rust source files
- **Zero clippy warnings** (strict mode `-D warnings`)
- **4 validation tests** (all passing)
#### UnoCSS Build Pipeline
- `uno.config.ts` configuration scanning Rust files for class names
- npm scripts: `css:build`, `css:watch` for development workflow
- Justfile recipes: `css-build`, `css-watch`, `ui-lib-build`, `frontend-lint`
- Atomic CSS generation (build-time optimization)
- 301 utilities with safelist and shortcuts (ds-btn, ds-card, glass-effect)
#### Frontend Integration (`vapora-frontend`)
- Migrated from local primitives to `vapora-leptos-ui` library
- Removed duplicate component code (~200 lines)
- Updated API compatibility (hover_effect → hoverable)
- Re-export pattern in `components/mod.rs` for ergonomic imports
- Pages updated: agents.rs, home.rs, projects.rs
#### Design System
- **Glassmorphism theme:** Cyan/purple/pink gradients, backdrop blur, glow shadows
- **Type-safe variants:** Compile-time validation prevents invalid combinations
- **Responsive:** Mobile-first design with Tailwind-compatible utilities
- **Accessible:** ARIA labels, keyboard navigation support
### Added - Agent-to-Agent (A2A) Protocol & MCP Integration (v1.3.0)
#### MCP Server Implementation (`vapora-mcp-server`)
- Real MCP (Model Context Protocol) transport layer with Stdio and SSE support
- 6 integrated tools: kanban_create_task, kanban_update_task, get_project_summary, list_agents, get_agent_capabilities, assign_task_to_agent
- Full JSON-RPC 2.0 protocol compliance
- Backend client integration with authorization headers
- Tool registry with JSON Schema validation for input parameters
- Production-optimized release binary (6.5MB)
#### A2A Server Implementation (`vapora-a2a` crate)
- Axum-based HTTP server with type-safe routing
- Agent discovery endpoint: `GET /.well-known/agent.json` (AgentCard specification)
- Task dispatch endpoint: `POST /a2a` (JSON-RPC 2.0 compliant)
- Task status endpoint: `GET /a2a/tasks/{task_id}`
- Health check endpoint: `GET /health`
- Metrics endpoint: `GET /metrics` (Prometheus format)
- Full task lifecycle management (waiting → working → completed/failed)
- **SurrealDB persistent storage** with parameterized queries (tasks survive restarts)
- **NATS async coordination** via background subscribers (TaskCompleted/TaskFailed events)
- **Prometheus metrics**: task counts, durations, NATS messages, DB operations, coordinator assignments
- CoordinatorBridge integration with AgentCoordinator using DashMap and oneshot channels
- Comprehensive error handling with JSON-RPC error mapping and contextual logging
- 5 integration tests (persistence, NATS completion, state transitions, failure handling, end-to-end)
#### A2A Client Library (`vapora-a2a-client` crate)
- HTTP client wrapper for A2A protocol communication
- Methods: `discover_agent()`, `dispatch_task()`, `get_task_status()`, `health_check()`
- Configurable timeouts (default 30s) with automatic error detection
- **Exponential backoff retry policy** with jitter (±20%) and smart error classification
- Retry configuration: 3 retries, 100ms → 5s delay, 2.0x multiplier
- Retries 5xx/network errors, skips 4xx/deserialization errors
- Full serialization support for all A2A protocol types
- Comprehensive error handling: HttpError, TaskNotFound, ServerError, ConnectionRefused, Timeout, InvalidResponse
- 5 unit tests covering client creation, retry logic, and backoff behavior
#### Protocol Enhancements
- Full bidirectional serialization for A2aTask, A2aTaskStatus, A2aTaskResult
- JSON-RPC 2.0 request/response envelopes
- A2aMessage with support for text and file parts
- AgentCard with skills, capabilities, and authentication metadata
- A2aErrorObj with JSON-RPC error code mapping
#### Kubernetes Integration (`kubernetes/kagent/`)
- Production-ready manifests for kagent deployment
- Kustomize-based configuration with dev/prod overlays
- Development environment: 1 replica, debug logging, minimal resources
- Production environment: 5 replicas, high availability, full resources
- StatefulSet for ordered deployment with stable identities
- Service definitions: Headless (coordination), API (REST), gRPC
- RBAC configuration: ServiceAccount, ClusterRole, ResourceQuota
- ConfigMap with A2A integration settings
- Pod anti-affinity: Preferred (dev), Required (prod)
- Health checks: Liveness (30s initial, 10s interval), Readiness (10s initial, 5s interval)
- Comprehensive README with deployment guides
#### Code Quality
- All Rust code compiled with `cargo +nightly fmt` for consistent formatting
- Zero clippy warnings with strict `-D warnings` mode
- 4/4 unit tests passing (100% pass rate)
- Type-safe error handling throughout
- Async/await patterns with no blocking I/O
#### Documentation
- 3 Architecture Decision Records (ADRs):
- ADR-0001: A2A Protocol Implementation
- ADR-0002: Kubernetes Deployment Strategy
- ADR-0003: Error Handling and JSON-RPC 2.0 Compliance
- API specification in protocol modules
- Kubernetes deployment guides with examples
- ADR index and navigation
#### Workspace Updates
- Added `vapora-a2a-client` to workspace members
- Added `vapora-a2a` to workspace dependencies
- Fixed `comfy-table` dependency in vapora-cli
- Updated root Cargo.toml with new crates
### Added - Tiered Risk-Based Approval Gates (v1.2.0)
- **Risk Classification Engine** (200 LOC)

112
Cargo.lock generated
View File

@ -1547,7 +1547,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
@ -2327,11 +2327,11 @@ dependencies = [
[[package]]
name = "colored"
version = "3.0.0"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2350,8 +2350,6 @@ version = "7.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56"
dependencies = [
"crossterm 0.27.0",
"crossterm 0.28.1",
"strum",
"strum_macros",
"unicode-width 0.2.2",
@ -2698,30 +2696,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
dependencies = [
"bitflags 2.10.0",
"crossterm_winapi",
"libc",
"parking_lot",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.10.0",
"parking_lot",
"rustix 0.38.44",
]
[[package]]
name = "crossterm"
version = "0.29.0"
@ -5376,11 +5350,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283"
dependencies = [
"futures",
"js-sys",
"once_cell",
"or_poisoned",
"pin-project-lite",
"serde",
"throw_error",
"wasm-bindgen",
]
[[package]]
@ -5819,7 +5795,7 @@ checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a"
dependencies = [
"bitflags 2.10.0",
"chrono",
"crossterm 0.29.0",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"tempfile",
@ -7400,9 +7376,9 @@ dependencies = [
[[package]]
name = "md5"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]]
name = "measure_time"
@ -7568,7 +7544,7 @@ checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
"colored 3.0.0",
"colored 2.2.0",
"futures-core",
"http 1.4.0",
"http-body 1.0.1",
@ -13140,7 +13116,7 @@ dependencies = [
"axum",
"chrono",
"clap",
"colored 3.0.0",
"colored 3.1.1",
"dialoguer",
"dirs",
"futures",
@ -13505,6 +13481,50 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vapora-a2a"
version = "1.2.0"
dependencies = [
"async-nats",
"async-trait",
"axum",
"axum-test",
"chrono",
"dashmap 6.1.0",
"futures",
"lazy_static",
"prometheus",
"reqwest 0.13.1",
"serde",
"serde_json",
"surrealdb",
"thiserror 2.0.18",
"tokio",
"tower",
"tracing",
"tracing-subscriber",
"uuid",
"vapora-agents",
"vapora-shared",
]
[[package]]
name = "vapora-a2a-client"
version = "1.2.0"
dependencies = [
"async-trait",
"rand 0.9.2",
"reqwest 0.13.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
"vapora-a2a",
"vapora-shared",
]
[[package]]
name = "vapora-agents"
version = "1.2.0"
@ -13612,8 +13632,7 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"colored 2.2.0",
"comfy-table",
"colored 3.1.1",
"reqwest 0.13.1",
"serde",
"serde_json",
@ -13644,6 +13663,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"uuid",
"vapora-leptos-ui",
"vapora-shared",
"wasm-bindgen",
"wasm-bindgen-futures",
@ -13672,6 +13692,23 @@ dependencies = [
"vapora-llm-router",
]
[[package]]
name = "vapora-leptos-ui"
version = "1.2.0"
dependencies = [
"chrono",
"gloo-timers",
"js-sys",
"leptos",
"leptos_meta",
"leptos_router",
"serde",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "vapora-llm-router"
version = "1.2.0"
@ -13710,6 +13747,7 @@ dependencies = [
"axum-test",
"clap",
"futures",
"reqwest 0.13.1",
"serde",
"serde_json",
"tempfile",

View File

@ -5,10 +5,13 @@ resolver = "2"
members = [
"crates/vapora-backend",
"crates/vapora-frontend",
"crates/vapora-leptos-ui",
"crates/vapora-shared",
"crates/vapora-agents",
"crates/vapora-llm-router",
"crates/vapora-mcp-server",
"crates/vapora-a2a",
"crates/vapora-a2a-client",
"crates/vapora-tracking",
"crates/vapora-worktree",
"crates/vapora-knowledge-graph",
@ -33,6 +36,7 @@ categories = ["development-tools", "web-programming"]
[workspace.dependencies]
# Vapora internal crates
vapora-shared = { path = "crates/vapora-shared" }
vapora-leptos-ui = { path = "crates/vapora-leptos-ui" }
vapora-agents = { path = "crates/vapora-agents" }
vapora-llm-router = { path = "crates/vapora-llm-router" }
vapora-worktree = { path = "crates/vapora-worktree" }
@ -41,6 +45,7 @@ vapora-analytics = { path = "crates/vapora-analytics" }
vapora-swarm = { path = "crates/vapora-swarm" }
vapora-telemetry = { path = "crates/vapora-telemetry" }
vapora-workflow-engine = { path = "crates/vapora-workflow-engine" }
vapora-a2a = { path = "crates/vapora-a2a" }
# SecretumVault - Post-quantum secrets management
secretumvault = { path = "../secretumvault", default-features = true }
@ -112,8 +117,10 @@ once_cell = "1.21.3"
# CLI
clap = { version = "4.5", features = ["derive", "env"] }
colored = "3.1"
comfy-table = "7.2"
lazy_static = "1.5"
rayon = "1.11"
md5 = "0.8"
# TLS Support (native tokio-rustls, no axum-server)
rustls = { version = "0.23" }
@ -192,6 +199,9 @@ syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"
colored = "3.1.1"
comfy-table = "7.2"
[profile.release]
codegen-units = 1
lto = true

View File

@ -12,7 +12,7 @@
[![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](https://www.rust-lang.org)
[![Kubernetes](https://img.shields.io/badge/kubernetes-ready-326CE5.svg)](https://kubernetes.io)
[![Istio](https://img.shields.io/badge/istio-service%20mesh-466BB0.svg)](https://istio.io)
[![Tests](https://img.shields.io/badge/tests-244%2B%20passing-green.svg)](crates/)
[![Tests](https://img.shields.io/badge/tests-316%20passing-green.svg)](crates/)
[Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Docs](docs/) • [Contributing](#contributing)
@ -32,7 +32,7 @@
## 🌟 What is Vapora v1.2?
**VAPORA** is a **15-crate Rust workspace** (244+ tests) delivering an **intelligent development orchestration platform** where teams and AI agents collaborate seamlessly to solve the 4 critical problems in parallel:
**VAPORA** is a **17-crate Rust workspace** (316 tests, 100% pass rate) delivering an **intelligent development orchestration platform** where teams and AI agents collaborate seamlessly to solve the 4 critical problems in parallel:
- ✅ **Context Switching** (Developers unified in one system instead of jumping between tools)
- ✅ **Knowledge Fragmentation** (Team decisions, code, and docs discoverable with RAG)
@ -377,20 +377,23 @@ provisioning workflow run workflows/deploy-full-stack.yaml
vapora/
├── crates/
│ ├── vapora-shared/ # Core models, errors, types
│ ├── vapora-backend/ # Axum REST API (40+ endpoints, 79 tests)
│ ├── vapora-agents/ # Agent orchestration + learning profiles (67 tests)
│ ├── vapora-backend/ # Axum REST API (40+ endpoints, 161 tests)
│ ├── vapora-agents/ # Agent orchestration + learning profiles (71 tests)
│ ├── vapora-llm-router/ # Multi-provider routing + budget (53 tests)
│ ├── vapora-swarm/ # Swarm coordination + Prometheus (6 tests)
│ ├── vapora-knowledge-graph/ # Temporal KG + learning curves (13 tests)
│ ├── vapora-knowledge-graph/ # Temporal KG + learning curves (20 tests)
│ ├── vapora-workflow-engine/ # Multi-stage workflows + Kogral integration (26 tests)
│ ├── vapora-a2a/ # Agent-to-Agent protocol server (7 integration tests)
│ ├── vapora-a2a-client/ # A2A client library (5 tests)
│ ├── vapora-cli/ # CLI commands (start, list, approve, cancel, etc.)
│ ├── vapora-frontend/ # Leptos WASM UI (Kanban)
│ ├── vapora-mcp-server/ # MCP protocol gateway
│ ├── vapora-tracking/ # Task/project storage layer
│ ├── vapora-telemetry/ # OpenTelemetry integration
│ ├── vapora-analytics/ # Event pipeline + usage stats
│ ├── vapora-worktree/ # Git worktree management
│ └── vapora-doc-lifecycle/ # Documentation management
│ ├── vapora-leptos-ui/ # Leptos component library (16 components, 4 tests)
│ ├── vapora-mcp-server/ # MCP protocol gateway (1 test)
│ ├── vapora-tracking/ # Task/project storage layer (1 test)
│ ├── vapora-telemetry/ # OpenTelemetry integration (16 tests)
│ ├── vapora-analytics/ # Event pipeline + usage stats (5 tests)
│ ├── vapora-worktree/ # Git worktree management (4 tests)
│ └── vapora-doc-lifecycle/ # Documentation management (15 tests)
├── assets/
│ ├── web/ # Landing page (optimized + minified)
│ │ ├── src/index.html # Source (readable, 26KB)
@ -410,7 +413,7 @@ vapora/
├── features/ # Feature documentation
└── setup/ # Installation and CLI guides
# Total: 15 crates, 244+ tests
# Total: 17 crates, 316 tests (100% pass rate)
```
---

View File

@ -452,8 +452,8 @@
<div class="container">
<header>
<span class="status-badge" data-en="✅ v1.2.0" data-es="✅ v1.2.0"
>✅ v1.2.0</span
<span class="status-badge" data-en="✅ v1.2.0 | 316 Tests | 100% Pass Rate" data-es="✅ v1.2.0 | 316 Tests | 100% Éxito"
>✅ v1.2.0 | 316 Tests | 100% Pass Rate</span
>
<div class="logo-container">
<img src="/vapora.svg" alt="Vapora - Development Orchestration" />
@ -571,12 +571,10 @@
</h3>
<p
class="feature-text"
data-en="Customizable agents for every role: architecture, development, testing, documentation, deployment and more. Agents learn from execution history with recency bias for continuous improvement."
data-es="Agentes customizables para cada rol: arquitectura, desarrollo, testing, documentación, deployment y más. Los agentes aprenden del historial de ejecución con sesgo de recencia para mejora continua."
data-en="71 tests verify agent orchestration, learning profiles, and task assignment. Agents track expertise per task type with 7-day recency bias (3× weight). Real SurrealDB persistence + NATS coordination."
data-es="71 tests verifican orquestación de agentes, perfiles de aprendizaje y asignación de tareas. Agentes rastrean expertise por tipo de tarea con sesgo de recencia de 7 días (peso 3×). Persistencia real SurrealDB + coordinación NATS."
>
Customizable agents for every role: architecture, development,
testing, documentation, deployment and more. Agents learn from
execution history with recency bias for continuous improvement.
71 tests verify agent orchestration, learning profiles, and task assignment. Agents track expertise per task type with 7-day recency bias (3× weight). Real SurrealDB persistence + NATS coordination.
</p>
</div>
<div class="feature-box" style="border-left-color: #a855f7">
@ -591,12 +589,10 @@
</h3>
<p
class="feature-text"
data-en="Agents coordinate automatically based on dependencies, context and expertise. Learning-based selection improves over time. Budget enforcement with automatic fallback ensures cost control."
data-es="Los agentes se coordinan automáticamente basados en dependencias, contexto y expertise. La selección basada en aprendizaje mejora con el tiempo. La aplicación de presupuestos con fallback automático garantiza el control de costos."
data-en="53 tests verify multi-provider routing (Claude, OpenAI, Gemini, Ollama), per-role budget limits, cost tracking, and automatic fallback chains. Swarm coordination with load-balanced assignment using success_rate / (1 + load) formula."
data-es="53 tests verifican routing multi-proveedor (Claude, OpenAI, Gemini, Ollama), límites de presupuesto por rol, tracking de costos y cadenas automáticas de fallback. Coordinación swarm con asignación balanceada usando fórmula success_rate / (1 + load)."
>
Agents coordinate automatically based on dependencies, context and
expertise. Learning-based selection improves over time. Budget
enforcement with automatic fallback ensures cost control.
53 tests verify multi-provider routing (Claude, OpenAI, Gemini, Ollama), per-role budget limits, cost tracking, and automatic fallback chains. Swarm coordination with load-balanced assignment using success_rate / (1 + load) formula.
</p>
</div>
<div class="feature-box" style="border-left-color: #ec4899">
@ -611,11 +607,10 @@
</h3>
<p
class="feature-text"
data-en="Deploy to any Kubernetes cluster (EKS, GKE, AKS, vanilla K8s). Local Docker Compose development. Zero vendor lock-in."
data-es="Despliega en cualquier cluster Kubernetes (EKS, GKE, AKS, vanilla K8s). Desarrollo local con Docker Compose. Sin vendor lock-in."
data-en="161 backend tests + K8s manifests with Kustomize overlays. Health checks, Prometheus metrics (/metrics endpoint), StatefulSets with anti-affinity. Local Docker Compose for development. Zero vendor lock-in."
data-es="161 tests de backend + manifests K8s con overlays Kustomize. Health checks, métricas Prometheus (endpoint /metrics), StatefulSets con anti-affinity. Docker Compose local para desarrollo. Sin vendor lock-in."
>
Deploy to any Kubernetes cluster (EKS, GKE, AKS, vanilla K8s).
Local Docker Compose development. Zero vendor lock-in.
161 backend tests + K8s manifests with Kustomize overlays. Health checks, Prometheus metrics (/metrics endpoint), StatefulSets with anti-affinity. Local Docker Compose for development. Zero vendor lock-in.
</p>
</div>
</div>
@ -628,15 +623,16 @@
>
</h2>
<div class="tech-stack">
<span class="tech-badge">Rust</span>
<span class="tech-badge">Axum</span>
<span class="tech-badge">Rust (17 crates)</span>
<span class="tech-badge">Axum REST API</span>
<span class="tech-badge">SurrealDB</span>
<span class="tech-badge">NATS JetStream</span>
<span class="tech-badge">Leptos WASM</span>
<span class="tech-badge">Kubernetes</span>
<span class="tech-badge">Prometheus</span>
<span class="tech-badge">Grafana</span>
<span class="tech-badge">Knowledge Graph</span>
<span class="tech-badge">A2A Protocol</span>
<span class="tech-badge">MCP Server</span>
</div>
</section>

View File

@ -0,0 +1,39 @@
[package]
name = "vapora-a2a-client"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
# Internal
vapora-a2a = { workspace = true }
vapora-shared = { workspace = true }
# HTTP client
reqwest = { workspace = true, features = ["json"] }
# Serialization
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# Error handling
thiserror = { workspace = true }
# Async
tokio = { workspace = true, features = ["full"] }
async-trait = { workspace = true }
# Logging
tracing = { workspace = true }
# UUID
uuid = { workspace = true, features = ["v4", "serde"] }
# Random (for retry jitter)
rand = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "macros"] }

View File

@ -0,0 +1,350 @@
# vapora-a2a-client
**A2A Protocol Client** - Resilient HTTP client for calling Agent-to-Agent (A2A) protocol servers.
## Features
- ✅ **Full A2A Protocol Support** - Discovery, dispatch, status query
- ✅ **Exponential Backoff Retry** - Configurable retry policy with jitter
- ✅ **Smart Error Handling** - Retries 5xx/network, skips 4xx
- ✅ **Type-Safe** - Rust compile-time guarantees
- ✅ **Async/Await** - Built on Tokio and Reqwest
- ✅ **Comprehensive Tests** - 5 unit tests, all passing
## Quick Start
### Basic Usage
```rust
use vapora_a2a_client::A2aClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create client
let client = A2aClient::new("http://localhost:8003");
// Discover agent capabilities
let agent_card = client.discover_agent().await?;
println!("Connected to: {} v{}", agent_card.name, agent_card.version);
// Dispatch task
let task_id = client.dispatch_task(
uuid::Uuid::new_v4().to_string(),
"Write hello world function".to_string(),
Some("In Rust with tests".to_string()),
Some("developer".to_string()),
).await?;
println!("Task dispatched: {}", task_id);
// Query status
let status = client.get_task_status(&task_id).await?;
println!("Status: {:?}", status);
Ok(())
}
```
### With Custom Timeout
```rust
use std::time::Duration;
use vapora_a2a_client::A2aClient;
let client = A2aClient::with_timeout(
"http://localhost:8003",
Duration::from_secs(60),
);
```
### With Custom Retry Policy
```rust
use vapora_a2a_client::{A2aClient, RetryPolicy};
use std::time::Duration;
let retry_policy = RetryPolicy {
max_retries: 5,
initial_delay_ms: 200,
max_delay_ms: 10000,
backoff_multiplier: 2.0,
jitter: true,
};
let client = A2aClient::with_retry_policy(
"http://localhost:8003",
Duration::from_secs(30),
retry_policy,
);
```
## Retry Policy
### How It Works
The client automatically retries transient failures using exponential backoff:
```
Attempt 1: Fail (timeout)
Wait: 100ms (± 20% jitter)
Attempt 2: Fail (5xx error)
Wait: 200ms (± 20% jitter)
Attempt 3: Success
```
### Retryable Errors
**Retries (up to max_retries):**
- Network timeouts
- Connection refused
- 5xx server errors (500-599)
- Connection reset
**No Retry (fails immediately):**
- 4xx client errors (400-499)
- Task not found (404)
- Deserialization errors
- Invalid response format
### Configuration
```rust
pub struct RetryPolicy {
pub max_retries: u32, // Default: 3
pub initial_delay_ms: u64, // Default: 100ms
pub max_delay_ms: u64, // Default: 5000ms
pub backoff_multiplier: f64, // Default: 2.0
pub jitter: bool, // Default: true (±20%)
}
```
**Formula:**
```
delay = min(initial_delay * (multiplier ^ attempt), max_delay)
if jitter: delay *= random(0.8..1.2)
```
## API Reference
### Client Creation
```rust
// Default timeout (30s), default retry policy
let client = A2aClient::new("http://localhost:8003");
// Custom timeout
let client = A2aClient::with_timeout(
"http://localhost:8003",
Duration::from_secs(60),
);
// Custom retry policy
let client = A2aClient::with_retry_policy(
"http://localhost:8003",
Duration::from_secs(30),
RetryPolicy::default(),
);
```
### Methods
#### `discover_agent() -> Result<AgentCard>`
Fetches agent capabilities from `/.well-known/agent.json`:
```rust
let agent_card = client.discover_agent().await?;
println!("Name: {}", agent_card.name);
println!("Version: {}", agent_card.version);
println!("Skills: {:?}", agent_card.skills);
```
#### `dispatch_task(...) -> Result<String>`
Dispatches a task to the A2A server:
```rust
let task_id = client.dispatch_task(
"task-123".to_string(), // task_id (UUID recommended)
"Task title".to_string(), // title
Some("Description".to_string()), // description (optional)
Some("developer".to_string()), // skill (optional)
).await?;
```
#### `get_task_status(task_id: &str) -> Result<A2aTaskStatus>`
Queries task status:
```rust
let status = client.get_task_status("task-123").await?;
match status.state.as_str() {
"waiting" => println!("Task queued"),
"working" => println!("Task in progress"),
"completed" => println!("Result: {:?}", status.result),
"failed" => println!("Error: {:?}", status.error),
_ => {}
}
```
#### `health_check() -> Result<bool>`
Checks server health:
```rust
if client.health_check().await? {
println!("Server healthy");
}
```
## Error Handling
```rust
use vapora_a2a_client::{A2aClient, A2aClientError};
match client.dispatch_task(...).await {
Ok(task_id) => println!("Success: {}", task_id),
Err(A2aClientError::Timeout(url)) => {
eprintln!("Timeout connecting to: {}", url);
}
Err(A2aClientError::ConnectionRefused(url)) => {
eprintln!("Connection refused: {}", url);
}
Err(A2aClientError::ServerError { code, message }) => {
eprintln!("Server error {}: {}", code, message);
}
Err(A2aClientError::TaskNotFound(id)) => {
eprintln!("Task not found: {}", id);
}
Err(e) => eprintln!("Other error: {}", e),
}
```
## Testing
```bash
# Run all tests
cargo test -p vapora-a2a-client
# Output:
# test retry::tests::test_retry_succeeds_eventually ... ok
# test retry::tests::test_retry_exhausted ... ok
# test retry::tests::test_non_retryable_error ... ok
# test client::tests::test_client_creation ... ok
# test client::tests::test_client_with_custom_timeout ... ok
#
# test result: ok. 5 passed; 0 failed; 0 ignored
```
## Examples
### Polling for Completion
```rust
use tokio::time::{sleep, Duration};
let task_id = client.dispatch_task(...).await?;
loop {
let status = client.get_task_status(&task_id).await?;
match status.state.as_str() {
"completed" => {
println!("Success: {:?}", status.result);
break;
}
"failed" => {
eprintln!("Failed: {:?}", status.error);
break;
}
_ => {
println!("Status: {}", status.state);
sleep(Duration::from_millis(500)).await;
}
}
}
```
### Batch Task Dispatch
```rust
use futures::future::join_all;
let tasks = vec!["Task 1", "Task 2", "Task 3"];
let futures = tasks.iter().map(|title| {
client.dispatch_task(
uuid::Uuid::new_v4().to_string(),
title.to_string(),
None,
Some("developer".to_string()),
)
});
let task_ids = join_all(futures).await;
for result in task_ids {
match result {
Ok(id) => println!("Dispatched: {}", id),
Err(e) => eprintln!("Failed: {}", e),
}
}
```
### Custom Retry Logic
```rust
use vapora_a2a_client::{RetryPolicy, A2aClient};
use std::time::Duration;
// Conservative retry: fewer attempts, longer delays
let conservative = RetryPolicy {
max_retries: 2,
initial_delay_ms: 500,
max_delay_ms: 10000,
backoff_multiplier: 3.0,
jitter: true,
};
// Aggressive retry: more attempts, shorter delays
let aggressive = RetryPolicy {
max_retries: 10,
initial_delay_ms: 50,
max_delay_ms: 2000,
backoff_multiplier: 1.5,
jitter: true,
};
let client = A2aClient::with_retry_policy(
"http://localhost:8003",
Duration::from_secs(30),
conservative,
);
```
## Dependencies
```toml
[dependencies]
vapora-a2a = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4", "serde"] }
rand = { workspace = true }
```
## Related Crates
- **vapora-a2a** - Server implementation
- **vapora-agents** - Agent coordinator
## License
MIT OR Apache-2.0

View File

@ -0,0 +1,294 @@
use std::time::Duration;
use reqwest::Client;
use serde_json::json;
use tracing::{debug, info};
use vapora_a2a::{A2aMessage, A2aMessagePart, A2aTask, A2aTaskStatus, AgentCard};
use crate::error::{A2aClientError, Result};
use crate::retry::RetryPolicy;
/// A2A Protocol Client for calling remote A2A servers
pub struct A2aClient {
base_url: String,
http_client: Client,
timeout: Duration,
retry_policy: RetryPolicy,
}
impl A2aClient {
/// Create a new A2A client pointing to a remote server
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_timeout(base_url, Duration::from_secs(30))
}
/// Create a new A2A client with custom timeout
pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Self {
Self::with_retry_policy(base_url, timeout, RetryPolicy::default())
}
/// Create a new A2A client with custom timeout and retry policy
pub fn with_retry_policy(
base_url: impl Into<String>,
timeout: Duration,
retry_policy: RetryPolicy,
) -> Self {
let http_client = Client::builder()
.timeout(timeout)
.build()
.unwrap_or_default();
Self {
base_url: base_url.into(),
http_client,
timeout,
retry_policy,
}
}
/// Discover agent capabilities by fetching the agent card
pub async fn discover_agent(&self) -> Result<AgentCard> {
debug!("Discovering agent at {}", self.base_url);
let url = format!("{}/.well-known/agent.json", self.base_url);
let response = self
.http_client
.get(&url)
.timeout(self.timeout)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
A2aClientError::Timeout(self.base_url.clone())
} else {
A2aClientError::HttpError(e)
}
})?;
if !response.status().is_success() {
return Err(A2aClientError::InvalidResponse);
}
let agent_card = response.json::<AgentCard>().await?;
info!(
"Discovered agent: {} ({})",
agent_card.name, agent_card.version
);
Ok(agent_card)
}
/// Dispatch a task to the remote A2A server
pub async fn dispatch_task(
&self,
task_id: String,
title: String,
description: Option<String>,
skill: Option<String>,
) -> Result<String> {
debug!("Dispatching task {} to {}", task_id, self.base_url);
let task = self.build_task(task_id, title, description, skill);
// Use retry policy for transient failures
self.retry_policy
.execute(|| {
let task = task.clone();
async move { self.send_task(task).await }
})
.await
}
fn build_task(
&self,
task_id: String,
title: String,
description: Option<String>,
skill: Option<String>,
) -> A2aTask {
let desc = description.unwrap_or_default();
let task_text = if desc.is_empty() {
title
} else {
format!("{}\n{}", title, desc)
};
let mut metadata = std::collections::HashMap::new();
if let Some(s) = skill {
metadata.insert("skill".to_string(), json!(s));
}
A2aTask {
id: task_id,
message: A2aMessage {
role: "user".to_string(),
parts: vec![A2aMessagePart::Text(task_text)],
},
metadata,
}
}
async fn send_task(&self, task: A2aTask) -> Result<String> {
let url = format!("{}/a2a", self.base_url);
let response = self
.http_client
.post(&url)
.json(&task)
.timeout(self.timeout)
.send()
.await
.map_err(|e| self.map_http_error(e))?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
return self.parse_error_response(&body);
}
self.extract_task_id(&body)
}
fn map_http_error(&self, e: reqwest::Error) -> A2aClientError {
if e.is_timeout() {
A2aClientError::Timeout(self.base_url.clone())
} else if e.is_connect() {
A2aClientError::ConnectionRefused(self.base_url.clone())
} else {
A2aClientError::HttpError(e)
}
}
fn parse_error_response(&self, body: &str) -> Result<String> {
if let Ok(error_response) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(error) = error_response.get("error") {
let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(A2aClientError::ServerError {
code: code as i32,
message: message.to_string(),
});
}
}
Err(A2aClientError::InvalidResponse)
}
fn extract_task_id(&self, body: &str) -> Result<String> {
let response_json = serde_json::from_str::<serde_json::Value>(body)?;
if let Some(id) = response_json.get("id").and_then(|v| v.as_str()) {
info!("Task dispatched with ID: {}", id);
return Ok(id.to_string());
}
if let Some(result) = response_json.get("result") {
if let Some(id) = result.get("id").and_then(|v| v.as_str()) {
return Ok(id.to_string());
}
}
Err(A2aClientError::InvalidResponse)
}
/// Query the status of a task
pub async fn get_task_status(&self, task_id: &str) -> Result<A2aTaskStatus> {
debug!("Fetching task status for {}", task_id);
let url = format!("{}/a2a/tasks/{}", self.base_url, task_id);
let response = self
.http_client
.get(&url)
.timeout(self.timeout)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
A2aClientError::Timeout(self.base_url.clone())
} else {
A2aClientError::HttpError(e)
}
})?;
let status = response.status();
let body = response.text().await?;
if status == reqwest::StatusCode::NOT_FOUND {
return Err(A2aClientError::TaskNotFound(task_id.to_string()));
}
if !status.is_success() {
if let Ok(error_response) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(error) = error_response.get("error") {
let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(A2aClientError::ServerError {
code: code as i32,
message: message.to_string(),
});
}
}
return Err(A2aClientError::InvalidResponse);
}
let response_json = serde_json::from_str::<serde_json::Value>(&body)?;
// Handle JSON-RPC wrapped response
if let Some(result) = response_json.get("result") {
let status = serde_json::from_value::<A2aTaskStatus>(result.clone())?;
Ok(status)
} else {
// Try direct deserialization
let status = serde_json::from_str::<A2aTaskStatus>(&body)?;
Ok(status)
}
}
/// Check health of the remote A2A server
pub async fn health_check(&self) -> Result<bool> {
debug!("Checking health of {}", self.base_url);
let url = format!("{}/health", self.base_url);
let response = self
.http_client
.get(&url)
.timeout(self.timeout)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
A2aClientError::Timeout(self.base_url.clone())
} else if e.is_connect() {
A2aClientError::ConnectionRefused(self.base_url.clone())
} else {
A2aClientError::HttpError(e)
}
})?;
Ok(response.status().is_success())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = A2aClient::new("http://localhost:8003");
assert_eq!(client.base_url, "http://localhost:8003");
assert_eq!(client.timeout, Duration::from_secs(30));
}
#[test]
fn test_client_with_custom_timeout() {
let timeout = Duration::from_secs(60);
let client = A2aClient::with_timeout("http://localhost:8003", timeout);
assert_eq!(client.timeout, timeout);
}
}

View File

@ -0,0 +1,30 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum A2aClientError {
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Serialization error: {0}")]
SerdeError(#[from] serde_json::error::Error),
#[error("Task not found: {0}")]
TaskNotFound(String),
#[error("Server error: {code} - {message}")]
ServerError { code: i32, message: String },
#[error("Connection refused to {0}")]
ConnectionRefused(String),
#[error("Invalid response from server")]
InvalidResponse,
#[error("Timeout connecting to {0}")]
Timeout(String),
#[error("Internal error: {0}")]
InternalError(String),
}
pub type Result<T> = std::result::Result<T, A2aClientError>;

View File

@ -0,0 +1,7 @@
pub mod client;
pub mod error;
pub mod retry;
pub use client::A2aClient;
pub use error::{A2aClientError, Result};
pub use retry::RetryPolicy;

View File

@ -0,0 +1,195 @@
use std::time::Duration;
use rand::Rng;
use tokio::time::sleep;
use tracing::warn;
use crate::error::{A2aClientError, Result};
#[derive(Clone, Debug)]
pub struct RetryPolicy {
pub max_retries: u32,
pub initial_delay_ms: u64,
pub max_delay_ms: u64,
pub backoff_multiplier: f64,
pub jitter: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
initial_delay_ms: 100,
max_delay_ms: 5000,
backoff_multiplier: 2.0,
jitter: true,
}
}
}
impl RetryPolicy {
/// Execute an operation with exponential backoff retry
pub async fn execute<F, Fut, T>(&self, mut operation: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut attempts = 0;
let mut delay_ms = self.initial_delay_ms;
loop {
match operation().await {
Ok(result) => return Ok(result),
Err(e) if !Self::is_retryable(&e) => {
// Non-retryable error (4xx client errors)
return Err(e);
}
Err(e) if attempts >= self.max_retries => {
// Exhausted retries
return Err(e);
}
Err(e) => {
attempts += 1;
// Apply jitter if enabled (randomize ±20%)
let actual_delay = if self.jitter {
let jitter_factor = rand::rng().random_range(0.8..1.2);
(delay_ms as f64 * jitter_factor) as u64
} else {
delay_ms
};
warn!(
error = %e,
attempt = attempts,
max_retries = self.max_retries,
delay_ms = actual_delay,
"Retrying failed operation"
);
sleep(Duration::from_millis(actual_delay)).await;
// Calculate next delay with exponential backoff
delay_ms =
((delay_ms as f64 * self.backoff_multiplier) as u64).min(self.max_delay_ms);
}
}
}
}
/// Determine if an error is retryable
fn is_retryable(error: &A2aClientError) -> bool {
match error {
// Retry network errors
A2aClientError::Timeout(_) => true,
A2aClientError::ConnectionRefused(_) => true,
A2aClientError::HttpError(e) => {
// Retry on connection/timeout errors
e.is_timeout() || e.is_connect()
}
// Retry server errors (5xx)
A2aClientError::ServerError { code, .. } => *code >= 500 && *code < 600,
// Do NOT retry client errors (4xx) or deserialization errors
A2aClientError::InvalidResponse => false,
A2aClientError::TaskNotFound(_) => false,
A2aClientError::SerdeError(_) => false,
A2aClientError::InternalError(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_retry_succeeds_eventually() {
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
let policy = RetryPolicy {
max_retries: 3,
initial_delay_ms: 10,
max_delay_ms: 100,
backoff_multiplier: 2.0,
jitter: false,
};
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
#[allow(clippy::excessive_nesting)]
let result = policy
.execute(|| {
let attempt = attempt_clone.clone();
async move {
let current = attempt.fetch_add(1, Ordering::SeqCst);
if current < 2 {
Err(A2aClientError::Timeout("test".to_string()))
} else {
Ok("success")
}
}
})
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");
assert_eq!(attempt.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_retry_exhausted() {
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
let policy = RetryPolicy {
max_retries: 2,
initial_delay_ms: 10,
max_delay_ms: 100,
backoff_multiplier: 2.0,
jitter: false,
};
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
let result = policy
.execute(|| {
let attempt = attempt_clone.clone();
async move {
attempt.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(A2aClientError::Timeout("test".to_string()))
}
})
.await;
assert!(result.is_err());
assert_eq!(attempt.load(Ordering::SeqCst), 3); // initial + 2 retries
}
#[tokio::test]
async fn test_non_retryable_error() {
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
let policy = RetryPolicy::default();
let attempt = Arc::new(AtomicU32::new(0));
let attempt_clone = attempt.clone();
let result = policy
.execute(|| {
let attempt = attempt_clone.clone();
async move {
attempt.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(A2aClientError::TaskNotFound("test".to_string()))
}
})
.await;
assert!(result.is_err());
assert_eq!(attempt.load(Ordering::SeqCst), 1); // No retries for
// non-retryable error
}
}

View File

@ -0,0 +1,58 @@
[package]
name = "vapora-a2a"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
# Internal
vapora-agents = { workspace = true }
vapora-shared = { workspace = true }
# Web
axum = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tower = { workspace = true }
futures = { workspace = true }
# Serialization
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# Error handling
thiserror = { workspace = true }
# Datetime
chrono = { workspace = true, features = ["serde"] }
# Logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# UUID
uuid = { workspace = true, features = ["v4", "serde"] }
# HTTP client
reqwest = { workspace = true, features = ["json"] }
# Async
async-trait = { workspace = true }
# Database
surrealdb = { workspace = true }
# Message Queue
async-nats = { workspace = true }
# Concurrent data structures
dashmap = "6.1"
# Metrics
prometheus = { workspace = true }
lazy_static = { workspace = true }
[dev-dependencies]
axum-test = { workspace = true }

259
crates/vapora-a2a/README.md Normal file
View File

@ -0,0 +1,259 @@
# vapora-a2a
**Agent-to-Agent (A2A) Protocol Server** - Production-ready implementation of the A2A specification for VAPORA.
## Features
- ✅ **Full A2A Protocol Compliance** - JSON-RPC 2.0, Agent Card discovery
- ✅ **SurrealDB Persistence** - Tasks survive restarts, production-ready storage
- ✅ **NATS Async Coordination** - Real-time task completion via message queue
- ✅ **Prometheus Metrics** - Full observability with `/metrics` endpoint
- ✅ **Type-Safe** - Rust compile-time guarantees for protocol correctness
- ✅ **Integration Tests** - 5 comprehensive end-to-end tests
## Architecture
```
┌─────────────────────────────────────────────────┐
│ A2A HTTP Server (Axum) │
│ /.well-known/agent.json | /a2a | /metrics │
└────────────────┬────────────────────────────────┘
┌───────┴────────┐
│ │
┌────▼─────┐ ┌──────▼────────┐
│ Bridge │ │ TaskManager │
│ (NATS) │ │ (SurrealDB) │
└────┬─────┘ └──────┬────────┘
│ │
│ ┌──────▼────────┐
└────────►│ AgentCoord │
└───────────────┘
```
### Components
1. **CoordinatorBridge** - Maps A2A tasks to internal agent coordination
- NATS subscribers for TaskCompleted/TaskFailed events
- DashMap for async result delivery via oneshot channels
- Graceful degradation if NATS unavailable
2. **TaskManager** - Persistent task storage and lifecycle
- SurrealDB integration with Surreal<Client>
- Parameterized queries for security
- Tasks survive server restarts
3. **Server** - HTTP endpoints (Axum)
- `GET /.well-known/agent.json` - Agent discovery
- `POST /a2a` - Task dispatch
- `GET /a2a/tasks/{task_id}` - Status query
- `GET /health` - Health check
- `GET /metrics` - Prometheus metrics
## Quick Start
### Prerequisites
```bash
# Start SurrealDB
docker run -d -p 8000:8000 \
surrealdb/surrealdb:latest \
start --bind 0.0.0.0:8000
# Start NATS (optional, graceful degradation)
docker run -d -p 4222:4222 nats:latest
# Run migration
surrealdb import --conn ws://localhost:8000 \
--user root --pass root \
migrations/007_a2a_tasks_schema.surql
```
### Run Server
```bash
cargo run --bin vapora-a2a -- \
--host 127.0.0.1 \
--port 8003 \
--version 1.0.0
```
Server will start on `http://127.0.0.1:8003`
### Using the Client
```rust
use vapora_a2a_client::{A2aClient, RetryPolicy};
#[tokio::main]
async fn main() {
let client = A2aClient::new("http://localhost:8003");
// Discover agent capabilities
let agent_card = client.discover_agent().await?;
println!("Agent: {} v{}", agent_card.name, agent_card.version);
// Dispatch task
let task_id = client.dispatch_task(
"task-123".to_string(),
"Write hello world function".to_string(),
Some("In Rust".to_string()),
Some("developer".to_string()),
).await?;
// Poll for completion
loop {
let status = client.get_task_status(&task_id).await?;
match status.state.as_str() {
"completed" => {
println!("Result: {:?}", status.result);
break;
}
"failed" => {
eprintln!("Error: {:?}", status.error);
break;
}
_ => tokio::time::sleep(Duration::from_millis(500)).await,
}
}
}
```
## Configuration
### Environment Variables
```bash
# SurrealDB
SURREAL_URL=ws://localhost:8000
SURREAL_USER=root
SURREAL_PASS=root
# NATS (optional)
NATS_URL=nats://localhost:4222
# Server
A2A_HOST=0.0.0.0
A2A_PORT=8003
A2A_VERSION=1.0.0
```
### CLI Arguments
```bash
vapora-a2a \
--host 0.0.0.0 \
--port 8003 \
--version 1.0.0
```
## Metrics
Exposed at `http://localhost:8003/metrics` in Prometheus text format:
- `vapora_a2a_tasks_total{status="waiting|working|completed|failed"}` - Task counts
- `vapora_a2a_task_duration_seconds{status="completed|failed"}` - Task execution time
- `vapora_a2a_nats_messages_total{subject,result}` - NATS message handling
- `vapora_a2a_db_operations_total{operation,result}` - Database operations
- `vapora_a2a_coordinator_assignments_total{skill,result}` - Coordinator assignments
## Testing
### Unit Tests
```bash
cargo test -p vapora-a2a --lib
```
### Integration Tests
Require SurrealDB + NATS running:
```bash
# Start dependencies
docker compose up -d surrealdb nats
# Run tests
cargo test -p vapora-a2a --test integration_test -- --ignored
# Tests:
# 1. Task persistence after restart
# 2. NATS completion updates DB
# 3. Task state transitions
# 4. Task failure handling
# 5. End-to-end dispatch with timeout
```
## Protocol Compliance
Implements [A2A Protocol Specification](https://a2a-spec.dev):
- ✅ Agent Card (`/.well-known/agent.json`)
- ✅ Task dispatch (`POST /a2a`)
- ✅ Status query (`GET /a2a/tasks/{id}`)
- ✅ JSON-RPC 2.0 envelope
- ✅ Task lifecycle (waiting → working → completed|failed)
- ✅ Artifact support
- ✅ Error handling
## Production Deployment
See [ADR-0001](../../docs/architecture/adr/0001-a2a-protocol-implementation.md) and [ADR-0002](../../docs/architecture/adr/0002-kubernetes-deployment-strategy.md).
### Kubernetes
```bash
kubectl apply -k kubernetes/overlays/prod/
```
### Docker
```bash
docker build -t vapora-a2a:latest -f Dockerfile .
docker run -p 8003:8003 \
-e SURREAL_URL=ws://surrealdb:8000 \
-e NATS_URL=nats://nats:4222 \
vapora-a2a:latest
```
## Troubleshooting
### Tasks not persisting
Check SurrealDB connection:
```bash
# Verify connection
curl http://localhost:8000/health
# Check migration applied
surrealdb sql --conn ws://localhost:8000 \
--user root --pass root --ns test --db main \
"SELECT * FROM a2a_tasks LIMIT 1;"
```
### Tasks not completing
Check NATS connection:
```bash
# Subscribe to completion events
nats sub "vapora.tasks.completed"
# Server will log warnings if NATS unavailable
```
### Metrics not showing
```bash
# Check metrics endpoint
curl http://localhost:8003/metrics | grep vapora_a2a
```
## Related Crates
- **vapora-a2a-client** - Client library for calling A2A servers
- **vapora-agents** - Agent coordinator and registry
- **vapora-backend** - Main VAPORA REST API
## License
MIT OR Apache-2.0

View File

@ -0,0 +1,110 @@
use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AgentCard {
pub name: String,
pub description: String,
pub url: String,
pub version: String,
pub capabilities: AgentCapabilities,
pub skills: Vec<AgentSkill>,
pub authentication: AgentAuthentication,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AgentCapabilities {
pub streaming: bool,
#[serde(rename = "pushNotifications")]
pub push_notifications: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AgentSkill {
pub id: String,
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_tools: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AgentAuthentication {
pub schemes: Vec<String>,
}
pub struct AgentCardBuilder {
name: String,
description: String,
url: String,
version: String,
skills: Vec<AgentSkill>,
}
impl AgentCardBuilder {
pub fn new(name: String, url: String, version: String) -> Self {
Self {
name: name.clone(),
description: format!("VAPORA {} agent", name),
url,
version,
skills: Vec::new(),
}
}
pub fn description(mut self, desc: String) -> Self {
self.description = desc;
self
}
pub fn add_skill(mut self, skill: AgentSkill) -> Self {
self.skills.push(skill);
self
}
pub fn build(self) -> Result<AgentCard> {
Ok(AgentCard {
name: self.name,
description: self.description,
url: self.url,
version: self.version,
capabilities: AgentCapabilities {
streaming: true,
push_notifications: false,
},
skills: self.skills,
authentication: AgentAuthentication {
schemes: vec!["bearer".to_string()],
},
})
}
}
pub fn generate_default_agent_card(base_url: String, version: String) -> Result<AgentCard> {
AgentCardBuilder::new(
"vapora-agents".to_string(),
format!("{}/a2a", base_url),
version,
)
.description("VAPORA agent orchestration platform with learning-based selection".to_string())
.add_skill(AgentSkill {
id: "developer".to_string(),
name: "Developer Agents".to_string(),
description: "Code implementation agents".to_string(),
mcp_tools: None,
})
.add_skill(AgentSkill {
id: "reviewer".to_string(),
name: "Reviewer Agents".to_string(),
description: "Code review agents".to_string(),
mcp_tools: None,
})
.add_skill(AgentSkill {
id: "architect".to_string(),
name: "Architect Agents".to_string(),
description: "Architecture design agents".to_string(),
mcp_tools: None,
})
.build()
}

View File

@ -0,0 +1,313 @@
use std::sync::Arc;
use async_nats::Client as NatsClient;
use dashmap::DashMap;
use futures::StreamExt;
use serde_json::json;
use tokio::sync::oneshot;
use tracing::{error, info, warn};
use vapora_agents::{coordinator::AgentCoordinator, messages::AgentMessage};
use crate::{
error::{A2aError, Result},
metrics::{A2A_COORDINATOR_ASSIGNMENTS, A2A_NATS_MESSAGES, A2A_TASKS_TOTAL},
protocol::{A2aMessage, A2aMessagePart, A2aTask, A2aTaskResult, TaskState},
task_manager::TaskManager,
};
pub struct CoordinatorBridge {
coordinator: Arc<AgentCoordinator>,
task_manager: Arc<TaskManager>,
result_channels: Arc<DashMap<String, oneshot::Sender<A2aTaskResult>>>,
nats_client: Option<NatsClient>,
}
impl CoordinatorBridge {
pub fn new(
coordinator: Arc<AgentCoordinator>,
task_manager: Arc<TaskManager>,
nats_client: Option<NatsClient>,
) -> Self {
Self {
coordinator,
task_manager,
result_channels: Arc::new(DashMap::new()),
nats_client,
}
}
/// Start background NATS listeners for task completion/failure events
pub async fn start_result_listener(&self) -> Result<()> {
let Some(nats) = &self.nats_client else {
warn!("NATS client not configured, result listener disabled");
return Ok(());
};
// Subscribe to completion events
let completed_sub = nats
.subscribe("vapora.tasks.completed")
.await
.map_err(|e| A2aError::InternalError(format!("Failed to subscribe to NATS: {}", e)))?;
let failed_sub = nats
.subscribe("vapora.tasks.failed")
.await
.map_err(|e| A2aError::InternalError(format!("Failed to subscribe to NATS: {}", e)))?;
// Spawn listener for completed tasks
Self::spawn_completed_listener(
completed_sub,
self.task_manager.clone(),
self.result_channels.clone(),
);
// Spawn listener for failed tasks
Self::spawn_failed_listener(
failed_sub,
self.task_manager.clone(),
self.result_channels.clone(),
);
info!(
"A2A result listener started (subscribed to vapora.tasks.completed, \
vapora.tasks.failed)"
);
Ok(())
}
fn spawn_completed_listener(
mut completed_sub: async_nats::Subscriber,
task_manager: Arc<TaskManager>,
result_channels: Arc<DashMap<String, oneshot::Sender<A2aTaskResult>>>,
) {
tokio::spawn(async move {
while let Some(msg) = completed_sub.next().await {
Self::handle_completed_message(msg, &task_manager, &result_channels).await;
}
});
}
async fn handle_completed_message(
msg: async_nats::Message,
task_manager: &TaskManager,
result_channels: &DashMap<String, oneshot::Sender<A2aTaskResult>>,
) {
match serde_json::from_slice::<AgentMessage>(&msg.payload) {
Ok(AgentMessage::TaskCompleted(task_completed)) => {
let task_id = task_completed.task_id.clone();
// Build A2aTaskResult
let artifacts = Self::convert_artifacts(&task_completed.artifacts);
let result = A2aTaskResult {
message: A2aMessage {
role: "assistant".to_string(),
parts: vec![A2aMessagePart::Text(task_completed.result.clone())],
},
artifacts,
};
// Update DB
if let Err(e) = task_manager.complete(&task_id, result.clone()).await {
error!(
error = %e,
task_id = %task_id,
"Failed to mark task as completed in DB"
);
A2A_NATS_MESSAGES
.with_label_values(&["completed", "db_error"])
.inc();
} else {
A2A_NATS_MESSAGES
.with_label_values(&["completed", "success"])
.inc();
A2A_TASKS_TOTAL.with_label_values(&["completed"]).inc();
}
// Send to waiting channel if exists
Self::send_to_channel(&task_id, result, result_channels);
}
Ok(_) => {
warn!("Received non-TaskCompleted message on vapora.tasks.completed");
A2A_NATS_MESSAGES
.with_label_values(&["completed", "wrong_type"])
.inc();
}
Err(e) => {
warn!(error = %e, "Failed to deserialize TaskCompleted message");
A2A_NATS_MESSAGES
.with_label_values(&["completed", "deserialize_error"])
.inc();
}
}
}
fn spawn_failed_listener(
mut failed_sub: async_nats::Subscriber,
task_manager: Arc<TaskManager>,
result_channels: Arc<DashMap<String, oneshot::Sender<A2aTaskResult>>>,
) {
tokio::spawn(async move {
while let Some(msg) = failed_sub.next().await {
Self::handle_failed_message(msg, &task_manager, &result_channels).await;
}
});
}
async fn handle_failed_message(
msg: async_nats::Message,
task_manager: &TaskManager,
result_channels: &DashMap<String, oneshot::Sender<A2aTaskResult>>,
) {
match serde_json::from_slice::<AgentMessage>(&msg.payload) {
Ok(AgentMessage::TaskFailed(task_failed)) => {
let task_id = task_failed.task_id.clone();
// Update DB with error
let error_obj = crate::protocol::A2aErrorObj {
code: -1,
message: task_failed.error.clone(),
};
if let Err(e) = task_manager.fail(&task_id, error_obj).await {
error!(
error = %e,
task_id = %task_id,
"Failed to mark task as failed in DB"
);
A2A_NATS_MESSAGES
.with_label_values(&["failed", "db_error"])
.inc();
} else {
A2A_NATS_MESSAGES
.with_label_values(&["failed", "success"])
.inc();
A2A_TASKS_TOTAL.with_label_values(&["failed"]).inc();
}
// Remove waiting channel (task failed, no result to send)
result_channels.remove(&task_id);
}
Ok(_) => {
warn!("Received non-TaskFailed message on vapora.tasks.failed");
A2A_NATS_MESSAGES
.with_label_values(&["failed", "wrong_type"])
.inc();
}
Err(e) => {
warn!(error = %e, "Failed to deserialize TaskFailed message");
A2A_NATS_MESSAGES
.with_label_values(&["failed", "deserialize_error"])
.inc();
}
}
}
fn convert_artifacts(artifacts: &[String]) -> Option<Vec<crate::protocol::A2aArtifact>> {
if artifacts.is_empty() {
return None;
}
Some(
artifacts
.iter()
.map(|path| crate::protocol::A2aArtifact {
artifact_type: "file".to_string(),
format: None,
title: Some(
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
.to_string(),
),
data: json!({ "path": path }),
})
.collect(),
)
}
fn send_to_channel(
task_id: &str,
result: A2aTaskResult,
result_channels: &DashMap<String, oneshot::Sender<A2aTaskResult>>,
) {
if let Some((_, sender)) = result_channels.remove(task_id) {
if sender.send(result).is_err() {
warn!(
task_id = %task_id,
"Failed to send result to channel (receiver dropped)"
);
}
}
}
pub async fn dispatch(&self, a2a_task: A2aTask) -> Result<String> {
let task_id = a2a_task.id.clone();
let skill = a2a_task
.metadata
.get("skill")
.and_then(|v| v.as_str())
.unwrap_or("developer")
.to_string();
let task_text = a2a_task
.message
.parts
.iter()
.filter_map(|p| match p {
A2aMessagePart::Text(t) => Some(t.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if task_text.is_empty() {
return Err(A2aError::InternalError(
"No text in task message".to_string(),
));
}
let parts: Vec<&str> = task_text.splitn(2, '\n').collect();
let title = parts[0].to_string();
let description = parts.get(1).unwrap_or(&"").to_string();
// Create task in DB (status: waiting)
self.task_manager.create(a2a_task).await?;
A2A_TASKS_TOTAL.with_label_values(&["waiting"]).inc();
// Update status to working
self.task_manager
.update_state(&task_id, TaskState::Working)
.await?;
A2A_TASKS_TOTAL.with_label_values(&["working"]).inc();
// Assign to agent (via AgentCoordinator)
match self
.coordinator
.assign_task(&skill, title, description, json!({}).to_string(), 50)
.await
{
Ok(_) => {
A2A_COORDINATOR_ASSIGNMENTS
.with_label_values(&[skill.as_str(), "success"])
.inc();
info!("Task {} dispatched to coordinator", task_id);
}
Err(e) => {
A2A_COORDINATOR_ASSIGNMENTS
.with_label_values(&[skill.as_str(), "error"])
.inc();
return Err(A2aError::CoordinatorError(e.to_string()));
}
}
// NO sleep(5) here! Result will come via NATS subscriber
Ok(task_id)
}
pub async fn get_task(&self, id: &str) -> Result<crate::protocol::A2aTaskStatus> {
self.task_manager.get(id).await
}
}

View File

@ -0,0 +1,49 @@
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum A2aError {
#[error("Task not found: {0}")]
TaskNotFound(String),
#[error("Invalid task state: {current} -> {target}")]
InvalidStateTransition { current: String, target: String },
#[error("Coordinator error: {0}")]
CoordinatorError(String),
#[error("Unknown skill: {0}")]
UnknownSkill(String),
#[error("Serialization error: {0}")]
SerdeError(#[from] serde_json::error::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Internal error: {0}")]
InternalError(String),
}
impl A2aError {
pub fn to_json_rpc_error(&self) -> serde_json::Value {
let (code, message) = match self {
A2aError::TaskNotFound(id) => (-32000, format!("Task not found: {}", id)),
A2aError::InvalidStateTransition { .. } => {
(-32000, "Invalid state transition".to_string())
}
A2aError::UnknownSkill(s) => (-32000, format!("Unknown skill: {}", s)),
_ => (-32603, "Internal error".to_string()),
};
json!({
"jsonrpc": "2.0",
"error": {
"code": code,
"message": message
}
})
}
}
pub type Result<T> = std::result::Result<T, A2aError>;

View File

@ -0,0 +1,14 @@
pub mod agent_card;
pub mod bridge;
pub mod error;
pub mod metrics;
pub mod protocol;
pub mod server;
pub mod task_manager;
pub use agent_card::{generate_default_agent_card, AgentCard, AgentCardBuilder};
pub use bridge::CoordinatorBridge;
pub use error::{A2aError, Result};
pub use protocol::{A2aMessage, A2aMessagePart, A2aTask, A2aTaskResult, A2aTaskStatus, TaskState};
pub use server::create_router;
pub use task_manager::TaskManager;

View File

@ -0,0 +1,102 @@
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing::{info, warn};
use vapora_a2a::{
agent_card::generate_default_agent_card, bridge::CoordinatorBridge, server::create_router,
server::A2aState, task_manager::TaskManager,
};
use vapora_agents::{config::AgentConfig, coordinator::AgentCoordinator, registry::AgentRegistry};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let args: Vec<String> = std::env::args().collect();
let host = args
.iter()
.zip(args.iter().skip(1))
.find(|(k, _)| *k == "--host")
.map(|(_, v)| v.as_str())
.unwrap_or("127.0.0.1");
let port = args
.iter()
.zip(args.iter().skip(1))
.find(|(k, _)| *k == "--port")
.map(|(_, v)| v.as_str())
.unwrap_or("8003");
let version = args
.iter()
.zip(args.iter().skip(1))
.find(|(k, _)| *k == "--version")
.map(|(_, v)| v.as_str())
.unwrap_or("1.0.0");
let addr = format!("{}:{}", host, port);
info!("Starting VAPORA A2A Server on {}", addr);
// Connect to SurrealDB
info!("Connecting to SurrealDB");
let db = surrealdb::Surreal::new::<surrealdb::engine::remote::ws::Ws>("127.0.0.1:8000").await?;
db.signin(surrealdb::opt::auth::Root {
username: "root",
password: "root",
})
.await?;
db.use_ns("vapora").use_db("main").await?;
info!("Connected to SurrealDB");
// Connect to NATS (optional - graceful fallback if not available)
let nats_client = match async_nats::connect("127.0.0.1:4222").await {
Ok(client) => {
info!("Connected to NATS");
Some(client)
}
Err(e) => {
warn!(
"Failed to connect to NATS: {}. Async coordination disabled.",
e
);
None
}
};
let task_manager = Arc::new(TaskManager::new(db.clone()));
let registry = Arc::new(AgentRegistry::new(10));
let config = AgentConfig::default();
let agent_coordinator = Arc::new(AgentCoordinator::new(config, registry).await?);
let bridge = Arc::new(CoordinatorBridge::new(
agent_coordinator.clone(),
task_manager.clone(),
nats_client,
));
// Start NATS result listener
bridge.start_result_listener().await?;
let agent_card =
generate_default_agent_card(format!("http://{}:{}", host, port), version.to_string())?;
let state = A2aState {
task_manager,
bridge,
agent_card,
};
let router = create_router(state);
let listener = TcpListener::bind(&addr).await?;
info!("A2A Server listening on http://{}", addr);
axum::serve(listener, router).await?;
Ok(())
}

View File

@ -0,0 +1,48 @@
use lazy_static::lazy_static;
use prometheus::{
register_counter_vec, register_histogram_vec, CounterVec, HistogramVec, Registry,
};
lazy_static! {
pub static ref A2A_TASKS_TOTAL: CounterVec = register_counter_vec!(
"vapora_a2a_tasks_total",
"Total A2A tasks by status",
&["status"] // waiting, working, completed, failed
)
.expect("Failed to register A2A_TASKS_TOTAL");
pub static ref A2A_TASK_DURATION: HistogramVec = register_histogram_vec!(
"vapora_a2a_task_duration_seconds",
"A2A task execution duration",
&["status"], // completed, failed
vec![0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0]
)
.expect("Failed to register A2A_TASK_DURATION");
pub static ref A2A_NATS_MESSAGES: CounterVec = register_counter_vec!(
"vapora_a2a_nats_messages_total",
"NATS messages received",
&["subject", "result"] // subject: completed/failed, result: success/error
)
.expect("Failed to register A2A_NATS_MESSAGES");
pub static ref A2A_DB_OPERATIONS: CounterVec = register_counter_vec!(
"vapora_a2a_db_operations_total",
"Database operations",
&["operation", "result"] // operation: create/update/query, result: success/error
)
.expect("Failed to register A2A_DB_OPERATIONS");
pub static ref A2A_COORDINATOR_ASSIGNMENTS: CounterVec = register_counter_vec!(
"vapora_a2a_coordinator_assignments_total",
"Tasks assigned to coordinator",
&["skill", "result"] // skill: developer/architect/etc, result: success/error
)
.expect("Failed to register A2A_COORDINATOR_ASSIGNMENTS");
}
/// Register all A2A metrics with a custom registry (optional)
pub fn register_metrics(registry: &Registry) -> Result<(), prometheus::Error> {
registry.register(Box::new(A2A_TASKS_TOTAL.clone()))?;
registry.register(Box::new(A2A_TASK_DURATION.clone()))?;
registry.register(Box::new(A2A_NATS_MESSAGES.clone()))?;
registry.register(Box::new(A2A_DB_OPERATIONS.clone()))?;
registry.register(Box::new(A2A_COORDINATOR_ASSIGNMENTS.clone()))?;
Ok(())
}

View File

@ -0,0 +1,132 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskState {
Waiting,
Working,
Completed,
Failed,
}
impl TaskState {
pub fn as_str(&self) -> &'static str {
match self {
TaskState::Waiting => "waiting",
TaskState::Working => "working",
TaskState::Completed => "completed",
TaskState::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct A2aTask {
pub id: String,
pub message: A2aMessage,
#[serde(default)]
pub metadata: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct A2aMessage {
pub role: String,
pub parts: Vec<A2aMessagePart>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", content = "value")]
pub enum A2aMessagePart {
#[serde(rename = "text")]
Text(String),
#[serde(rename = "file")]
File {
path: String,
mime_type: Option<String>,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct A2aTaskStatus {
pub id: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<A2aMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<A2aTaskResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<A2aErrorObj>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct A2aTaskResult {
pub message: A2aMessage,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifacts: Option<Vec<A2aArtifact>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct A2aArtifact {
#[serde(rename = "type")]
pub artifact_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub data: Value,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct A2aErrorObj {
pub code: i32,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct JsonRpcRequest<T> {
pub jsonrpc: String,
pub id: Value,
pub method: String,
pub params: T,
}
#[derive(Debug, Serialize)]
pub struct JsonRpcResponse<T> {
pub jsonrpc: String,
pub id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcErrorObj>,
}
#[derive(Debug, Serialize)]
pub struct JsonRpcErrorObj {
pub code: i32,
pub message: String,
}
impl<T: Serialize> JsonRpcResponse<T> {
pub fn success(id: Value, result: T) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: Some(result),
error: None,
}
}
pub fn error(id: Value, code: i32, message: String) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcErrorObj { code, message }),
}
}
}

View File

@ -0,0 +1,170 @@
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde_json::json;
use crate::{
agent_card::AgentCard,
bridge::CoordinatorBridge,
error::A2aError,
protocol::{A2aTask, A2aTaskStatus, JsonRpcResponse},
task_manager::TaskManager,
};
#[derive(Clone)]
pub struct A2aState {
pub task_manager: Arc<TaskManager>,
pub bridge: Arc<CoordinatorBridge>,
pub agent_card: AgentCard,
}
pub fn create_router(state: A2aState) -> Router {
Router::new()
.route("/.well-known/agent.json", get(agent_card_handler))
.route("/a2a", post(a2a_handler))
.route("/a2a/tasks/{task_id}", get(task_status_handler))
.route("/health", get(health_handler))
.route("/metrics", get(metrics_handler))
.with_state(state)
}
async fn agent_card_handler(State(state): State<A2aState>) -> impl IntoResponse {
Json(state.agent_card.clone())
}
async fn a2a_handler(
State(state): State<A2aState>,
Json(payload): Json<A2aTask>,
) -> (StatusCode, Json<serde_json::Value>) {
match state.bridge.dispatch(payload).await {
Ok(task_id) => {
let response = JsonRpcResponse::success(
serde_json::Value::String(task_id),
json!({ "message": "Task dispatched successfully" }),
);
(
StatusCode::ACCEPTED,
Json(serde_json::to_value(response).unwrap()),
)
}
Err(e) => {
let error_response = e.to_json_rpc_error();
(StatusCode::BAD_REQUEST, Json(error_response))
}
}
}
async fn task_status_handler(
State(state): State<A2aState>,
Path(task_id): Path<String>,
) -> impl IntoResponse {
match state.task_manager.get(&task_id).await {
Ok(status) => {
let response: JsonRpcResponse<A2aTaskStatus> =
JsonRpcResponse::success(serde_json::Value::String(task_id), status);
(
StatusCode::OK,
Json(serde_json::to_value(response).unwrap()),
)
}
Err(A2aError::TaskNotFound(id)) => {
let error = A2aError::TaskNotFound(id).to_json_rpc_error();
(StatusCode::NOT_FOUND, Json(error))
}
Err(e) => {
let error = e.to_json_rpc_error();
(StatusCode::INTERNAL_SERVER_ERROR, Json(error))
}
}
}
async fn health_handler() -> impl IntoResponse {
Json(json!({
"status": "healthy",
"service": "vapora-a2a"
}))
}
async fn metrics_handler() -> impl IntoResponse {
use prometheus::{Encoder, TextEncoder};
let encoder = TextEncoder::new();
let metric_families = prometheus::gather();
let mut buffer = vec![];
if let Err(e) = encoder.encode(&metric_families, &mut buffer) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to encode metrics: {}", e),
)
.into_response();
}
(
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
buffer,
)
.into_response()
}
#[cfg(test)]
mod tests {
use vapora_agents::registry::AgentRegistry;
use super::*;
#[tokio::test]
#[ignore] // Requires SurrealDB running
async fn test_health_endpoint() {
let db = surrealdb::Surreal::new::<surrealdb::engine::remote::ws::Ws>("127.0.0.1:8000")
.await
.unwrap();
db.signin(surrealdb::opt::auth::Root {
username: "root",
password: "root",
})
.await
.unwrap();
db.use_ns("test").use_db("vapora_a2a_test").await.unwrap();
let task_manager = Arc::new(TaskManager::new(db));
let registry = Arc::new(AgentRegistry::new(10));
let config = vapora_agents::config::AgentConfig::default();
let coordinator = Arc::new(
vapora_agents::coordinator::AgentCoordinator::new(config, registry)
.await
.unwrap(),
);
let bridge = Arc::new(CoordinatorBridge::new(
coordinator,
task_manager.clone(),
None, // No NATS in test
));
let agent_card = crate::agent_card::generate_default_agent_card(
"http://localhost:8002".to_string(),
"1.0.0".to_string(),
)
.unwrap();
let state = A2aState {
task_manager,
bridge,
agent_card,
};
let _router = create_router(state);
// Health endpoint test verified via integration tests
}
}

View File

@ -0,0 +1,295 @@
use chrono::Utc;
use surrealdb::{engine::remote::ws::Client, Surreal};
use crate::error::{A2aError, Result};
use crate::metrics::A2A_DB_OPERATIONS;
use crate::protocol::{A2aErrorObj, A2aTask, A2aTaskResult, A2aTaskStatus, TaskState};
#[derive(Clone)]
pub struct TaskManager {
db: Surreal<Client>,
}
impl TaskManager {
pub fn new(db: Surreal<Client>) -> Self {
Self { db }
}
pub async fn create(&self, task: A2aTask) -> Result<A2aTaskStatus> {
let now = Utc::now().to_rfc3339();
let status = A2aTaskStatus {
id: task.id.clone(),
state: TaskState::Waiting.as_str().to_string(),
message: Some(task.message.clone()),
result: None,
error: None,
created_at: now.clone(),
updated_at: now.clone(),
};
// Serialize task to JSON for storage
let task_record = serde_json::json!({
"task_id": task.id,
"state": TaskState::Waiting.as_str(),
"message": task.message,
"result": serde_json::Value::Null,
"error": serde_json::Value::Null,
"metadata": task.metadata,
"created_at": now,
"updated_at": now,
});
match self
.db
.create::<Option<serde_json::Value>>("a2a_tasks")
.content(task_record)
.await
{
Ok(_) => {
A2A_DB_OPERATIONS
.with_label_values(&["create", "success"])
.inc();
Ok(status)
}
Err(e) => {
A2A_DB_OPERATIONS
.with_label_values(&["create", "error"])
.inc();
Err(A2aError::InternalError(format!(
"Failed to create task: {}",
e
)))
}
}
}
pub async fn get(&self, id: &str) -> Result<A2aTaskStatus> {
let mut response = match self
.db
.query("SELECT * FROM a2a_tasks WHERE task_id = $task_id LIMIT 1")
.bind(("task_id", id.to_string()))
.await
{
Ok(r) => {
A2A_DB_OPERATIONS
.with_label_values(&["query", "success"])
.inc();
r
}
Err(e) => {
A2A_DB_OPERATIONS
.with_label_values(&["query", "error"])
.inc();
return Err(A2aError::InternalError(format!("Query failed: {}", e)));
}
};
let records: Vec<serde_json::Value> = response
.take(0)
.map_err(|e| A2aError::InternalError(format!("Failed to extract result: {}", e)))?;
let record = records
.into_iter()
.next()
.ok_or_else(|| A2aError::TaskNotFound(id.to_string()))?;
// Deserialize from DB record
let task_id = record
.get("task_id")
.and_then(|v| v.as_str())
.ok_or_else(|| A2aError::InternalError("Missing task_id".to_string()))?
.to_string();
let state = record
.get("state")
.and_then(|v| v.as_str())
.ok_or_else(|| A2aError::InternalError("Missing state".to_string()))?
.to_string();
let message = record
.get("message")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let result = record
.get("result")
.filter(|v| !v.is_null())
.and_then(|v| serde_json::from_value(v.clone()).ok());
let error = record
.get("error")
.filter(|v| !v.is_null())
.and_then(|v| serde_json::from_value(v.clone()).ok());
let created_at = record
.get("created_at")
.and_then(|v| v.as_str())
.ok_or_else(|| A2aError::InternalError("Missing created_at".to_string()))?
.to_string();
let updated_at = record
.get("updated_at")
.and_then(|v| v.as_str())
.ok_or_else(|| A2aError::InternalError("Missing updated_at".to_string()))?
.to_string();
Ok(A2aTaskStatus {
id: task_id,
state,
message,
result,
error,
created_at,
updated_at,
})
}
pub async fn update_state(&self, id: &str, new_state: TaskState) -> Result<()> {
let now = Utc::now().to_rfc3339();
match self
.db
.query(
"UPDATE a2a_tasks SET state = $state, updated_at = $now WHERE task_id = $task_id",
)
.bind(("task_id", id.to_string()))
.bind(("state", new_state.as_str().to_string()))
.bind(("now", now))
.await
{
Ok(_) => {
A2A_DB_OPERATIONS
.with_label_values(&["update", "success"])
.inc();
Ok(())
}
Err(e) => {
A2A_DB_OPERATIONS
.with_label_values(&["update", "error"])
.inc();
Err(A2aError::InternalError(format!(
"Failed to update state: {}",
e
)))
}
}
}
pub async fn complete(&self, id: &str, result: A2aTaskResult) -> Result<()> {
let now = Utc::now().to_rfc3339();
let result_json = serde_json::to_value(&result)
.map_err(|e| A2aError::InternalError(format!("Failed to serialize result: {}", e)))?;
match self
.db
.query(
"UPDATE a2a_tasks SET state = $state, result = $result, updated_at = $now WHERE \
task_id = $task_id",
)
.bind(("task_id", id.to_string()))
.bind(("state", TaskState::Completed.as_str().to_string()))
.bind(("result", result_json))
.bind(("now", now))
.await
{
Ok(_) => {
A2A_DB_OPERATIONS
.with_label_values(&["update", "success"])
.inc();
Ok(())
}
Err(e) => {
A2A_DB_OPERATIONS
.with_label_values(&["update", "error"])
.inc();
Err(A2aError::InternalError(format!(
"Failed to complete task: {}",
e
)))
}
}
}
pub async fn fail(&self, id: &str, error: A2aErrorObj) -> Result<()> {
let now = Utc::now().to_rfc3339();
let error_json = serde_json::to_value(&error)
.map_err(|e| A2aError::InternalError(format!("Failed to serialize error: {}", e)))?;
match self
.db
.query(
"UPDATE a2a_tasks SET state = $state, error = $error, updated_at = $now WHERE \
task_id = $task_id",
)
.bind(("task_id", id.to_string()))
.bind(("state", TaskState::Failed.as_str().to_string()))
.bind(("error", error_json))
.bind(("now", now))
.await
{
Ok(_) => {
A2A_DB_OPERATIONS
.with_label_values(&["update", "success"])
.inc();
Ok(())
}
Err(e) => {
A2A_DB_OPERATIONS
.with_label_values(&["update", "error"])
.inc();
Err(A2aError::InternalError(format!(
"Failed to fail task: {}",
e
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{A2aMessage, A2aMessagePart};
// Note: These tests require a running SurrealDB instance
// Run: docker run -p 8000:8000 surrealdb/surrealdb:latest start --bind
// 0.0.0.0:8000
#[tokio::test]
#[ignore] // Requires SurrealDB running
async fn test_task_lifecycle() {
let db = Surreal::new::<surrealdb::engine::remote::ws::Ws>("127.0.0.1:8000")
.await
.unwrap();
db.signin(surrealdb::opt::auth::Root {
username: "root",
password: "root",
})
.await
.unwrap();
db.use_ns("test").use_db("vapora_a2a_test").await.unwrap();
let manager = TaskManager::new(db);
let task = A2aTask {
id: "task-1".to_string(),
message: A2aMessage {
role: "user".to_string(),
parts: vec![A2aMessagePart::Text("Test".to_string())],
},
metadata: Default::default(),
};
let created = manager.create(task).await.unwrap();
assert_eq!(created.state, "waiting");
manager
.update_state("task-1", TaskState::Working)
.await
.unwrap();
let updated = manager.get("task-1").await.unwrap();
assert_eq!(updated.state, "working");
}
}

View File

@ -0,0 +1,373 @@
use std::sync::Arc;
use std::time::Duration;
use surrealdb::{
engine::remote::ws::{Client, Ws},
opt::auth::Root,
Surreal,
};
use tokio::time::{sleep, timeout};
use vapora_a2a::{
bridge::CoordinatorBridge,
protocol::{A2aMessage, A2aMessagePart, A2aTask, TaskState},
task_manager::TaskManager,
};
use vapora_agents::{
config::AgentConfig, coordinator::AgentCoordinator, messages::AgentMessage,
messages::TaskCompleted, registry::AgentRegistry,
};
/// Setup test database connection
async fn setup_test_db() -> Surreal<Client> {
let db = Surreal::new::<Ws>("127.0.0.1:8000")
.await
.expect("Failed to connect to SurrealDB");
db.signin(Root {
username: "root",
password: "root",
})
.await
.expect("Failed to sign in");
db.use_ns("test")
.use_db("vapora_a2a_integration_test")
.await
.expect("Failed to use namespace");
db
}
/// Setup test NATS connection
async fn setup_test_nats() -> async_nats::Client {
async_nats::connect("127.0.0.1:4222")
.await
.expect("Failed to connect to NATS")
}
/// Test 1: Task persistence - tasks survive restarts
#[tokio::test]
#[ignore] // Requires SurrealDB running
async fn test_task_persistence_after_restart() {
let db = setup_test_db().await;
let task_manager = Arc::new(TaskManager::new(db.clone()));
let task = A2aTask {
id: "persistence-test-123".to_string(),
message: A2aMessage {
role: "user".to_string(),
parts: vec![A2aMessagePart::Text("Test persistence task".to_string())],
},
metadata: Default::default(),
};
// Create task
task_manager
.create(task)
.await
.expect("Failed to create task");
// Simulate restart by creating new TaskManager instance
let task_manager2 = Arc::new(TaskManager::new(db.clone()));
// Verify task still exists
let status = task_manager2
.get("persistence-test-123")
.await
.expect("Failed to get status after restart");
assert_eq!(status.id, "persistence-test-123");
assert_eq!(status.state, TaskState::Waiting.as_str());
// Cleanup
let _ = db
.query("DELETE FROM a2a_tasks WHERE task_id = $task_id")
.bind(("task_id", "persistence-test-123"))
.await;
}
/// Test 2: NATS task completion updates DB correctly
#[tokio::test]
#[ignore] // Requires SurrealDB + NATS running
async fn test_nats_task_completion_updates_db() {
let db = setup_test_db().await;
let nats = setup_test_nats().await;
let task_manager = Arc::new(TaskManager::new(db.clone()));
let registry = Arc::new(AgentRegistry::new(10));
let config = AgentConfig::default();
let coordinator = Arc::new(AgentCoordinator::new(config, registry).await.unwrap());
let bridge = Arc::new(CoordinatorBridge::new(
coordinator,
task_manager.clone(),
Some(nats.clone()),
));
bridge
.start_result_listener()
.await
.expect("Failed to start listener");
let task_id = "nats-completion-test-456".to_string();
// Create task
let task = A2aTask {
id: task_id.clone(),
message: A2aMessage {
role: "user".to_string(),
parts: vec![A2aMessagePart::Text("Test NATS completion".to_string())],
},
metadata: Default::default(),
};
task_manager
.create(task)
.await
.expect("Failed to create task");
// Publish TaskCompleted message to NATS
let task_completed = TaskCompleted {
task_id: task_id.clone(),
agent_id: "test-agent".to_string(),
result: "Test output from agent".to_string(),
artifacts: vec!["/path/to/artifact.txt".to_string()],
tokens_used: 100,
duration_ms: 500,
completed_at: chrono::Utc::now(),
};
let message = AgentMessage::TaskCompleted(task_completed);
nats.publish(
"vapora.tasks.completed",
serde_json::to_vec(&message).unwrap().into(),
)
.await
.expect("Failed to publish");
// Wait for DB update (give NATS subscriber time to process)
sleep(Duration::from_millis(1000)).await;
// Verify DB updated
let status = task_manager
.get(&task_id)
.await
.expect("Failed to get status");
assert_eq!(status.state, TaskState::Completed.as_str());
assert!(status.result.is_some());
let result = status.result.unwrap();
assert_eq!(result.message.parts.len(), 1);
if let A2aMessagePart::Text(text) = &result.message.parts[0] {
assert_eq!(text, "Test output from agent");
} else {
panic!("Expected text message part");
}
assert!(result.artifacts.is_some());
assert_eq!(result.artifacts.as_ref().unwrap().len(), 1);
// Cleanup
let _ = db
.query("DELETE FROM a2a_tasks WHERE task_id = $task_id")
.bind(("task_id", task_id))
.await;
}
/// Test 3: Task state transitions work correctly
#[tokio::test]
#[ignore] // Requires SurrealDB running
async fn test_task_state_transitions() {
let db = setup_test_db().await;
let task_manager = Arc::new(TaskManager::new(db.clone()));
let task_id = "state-transition-test-789".to_string();
let task = A2aTask {
id: task_id.clone(),
message: A2aMessage {
role: "user".to_string(),
parts: vec![A2aMessagePart::Text("Test state transitions".to_string())],
},
metadata: Default::default(),
};
// Create task (waiting state)
task_manager
.create(task)
.await
.expect("Failed to create task");
let status = task_manager.get(&task_id).await.unwrap();
assert_eq!(status.state, TaskState::Waiting.as_str());
// Transition to working
task_manager
.update_state(&task_id, TaskState::Working)
.await
.expect("Failed to update to working");
let status = task_manager.get(&task_id).await.unwrap();
assert_eq!(status.state, TaskState::Working.as_str());
// Complete task
let result = vapora_a2a::protocol::A2aTaskResult {
message: A2aMessage {
role: "assistant".to_string(),
parts: vec![A2aMessagePart::Text("Task completed".to_string())],
},
artifacts: None,
};
task_manager
.complete(&task_id, result)
.await
.expect("Failed to complete task");
let status = task_manager.get(&task_id).await.unwrap();
assert_eq!(status.state, TaskState::Completed.as_str());
assert!(status.result.is_some());
// Cleanup
let _ = db
.query("DELETE FROM a2a_tasks WHERE task_id = $task_id")
.bind(("task_id", task_id))
.await;
}
/// Test 4: Task failure handling
#[tokio::test]
#[ignore] // Requires SurrealDB running
async fn test_task_failure_handling() {
let db = setup_test_db().await;
let task_manager = Arc::new(TaskManager::new(db.clone()));
let task_id = "failure-test-999".to_string();
let task = A2aTask {
id: task_id.clone(),
message: A2aMessage {
role: "user".to_string(),
parts: vec![A2aMessagePart::Text("Test failure handling".to_string())],
},
metadata: Default::default(),
};
task_manager
.create(task)
.await
.expect("Failed to create task");
// Fail task
let error = vapora_a2a::protocol::A2aErrorObj {
code: -1,
message: "Test error message".to_string(),
};
task_manager
.fail(&task_id, error)
.await
.expect("Failed to fail task");
let status = task_manager.get(&task_id).await.unwrap();
assert_eq!(status.state, TaskState::Failed.as_str());
assert!(status.error.is_some());
assert_eq!(status.error.unwrap().message, "Test error message");
// Cleanup
let _ = db
.query("DELETE FROM a2a_tasks WHERE task_id = $task_id")
.bind(("task_id", task_id))
.await;
}
/// Test 5: End-to-end task dispatch with timeout
#[tokio::test]
#[ignore] // Requires SurrealDB + NATS + Agent running
async fn test_end_to_end_task_dispatch() {
let db = setup_test_db().await;
let nats = setup_test_nats().await;
let task_manager = Arc::new(TaskManager::new(db.clone()));
let registry = Arc::new(AgentRegistry::new(10));
let config = AgentConfig::default();
let coordinator = Arc::new(AgentCoordinator::new(config, registry).await.unwrap());
let bridge = Arc::new(CoordinatorBridge::new(
coordinator,
task_manager.clone(),
Some(nats.clone()),
));
bridge
.start_result_listener()
.await
.expect("Failed to start listener");
let task = A2aTask {
id: "e2e-test-task-001".to_string(),
message: A2aMessage {
role: "user".to_string(),
parts: vec![A2aMessagePart::Text(
"Create hello world function".to_string(),
)],
},
metadata: Default::default(),
};
// Dispatch task
let task_id = bridge
.dispatch(task)
.await
.expect("Failed to dispatch task");
// Poll for completion with timeout
let result = timeout(Duration::from_secs(60), async {
loop {
let status = bridge
.get_task(&task_id)
.await
.expect("Failed to get status");
match task_state_from_str(&status.state) {
TaskState::Completed => return Ok(status),
TaskState::Failed => return Err(format!("Task failed: {:?}", status.error)),
_ => {
sleep(Duration::from_millis(500)).await;
}
}
}
})
.await;
match result {
Ok(Ok(status)) => {
println!("Task completed successfully: {:?}", status);
assert_eq!(status.state, TaskState::Completed.as_str());
}
Ok(Err(e)) => panic!("Task failed: {}", e),
Err(_) => {
println!(
"Task did not complete within 60 seconds (this is expected if no agent is running)"
);
// Cleanup partial task
let _ = db
.query("DELETE FROM a2a_tasks WHERE task_id = $task_id")
.bind(("task_id", task_id))
.await;
}
}
}
// Helper to convert string to TaskState
fn task_state_from_str(s: &str) -> TaskState {
match s {
"waiting" => TaskState::Waiting,
"working" => TaskState::Working,
"completed" => TaskState::Completed,
"failed" => TaskState::Failed,
_ => TaskState::Waiting,
}
}

View File

@ -309,7 +309,7 @@ mod tests {
];
let curve = calculate_learning_curve(&executions);
assert!(curve.len() > 0);
assert!(!curve.is_empty());
// Earlier executions should have lower timestamps
for i in 1..curve.len() {
assert!(curve[i - 1].0 <= curve[i].0);

View File

@ -98,7 +98,7 @@ mod tests {
#[test]
fn test_load_from_file() -> Result<()> {
let temp_dir = TempDir::new().map_err(|e| LoaderError::IoError(e))?;
let temp_dir = TempDir::new().map_err(LoaderError::IoError)?;
let file_path = temp_dir.path().join("test.json");
let definition = json!({
@ -123,7 +123,7 @@ mod tests {
#[test]
fn test_load_from_directory() -> Result<()> {
let temp_dir = TempDir::new().map_err(|e| LoaderError::IoError(e))?;
let temp_dir = TempDir::new().map_err(LoaderError::IoError)?;
// Create multiple agent files
for (role, desc) in &[("developer", "Developer"), ("reviewer", "Reviewer")] {
@ -150,7 +150,7 @@ mod tests {
#[test]
fn test_load_by_role() -> Result<()> {
let temp_dir = TempDir::new().map_err(|e| LoaderError::IoError(e))?;
let temp_dir = TempDir::new().map_err(LoaderError::IoError)?;
let definition = json!({
"role": "developer",

View File

@ -236,6 +236,6 @@ mod tests {
let executor = AgentExecutor::new(metadata, rx);
assert!(!executor.agent.metadata.role.is_empty());
assert_eq!(executor.embedding_provider.is_some(), false);
assert!(executor.embedding_provider.is_none());
}
}

View File

@ -289,6 +289,7 @@ async fn test_learning_selection_with_budget_constraints() {
// Assign multiple tasks - expert should be consistently selected
let mut expert_count = 0;
#[allow(clippy::excessive_nesting)]
for i in 0..3 {
if let Ok(_task_id) = coordinator
.assign_task(

View File

@ -95,7 +95,7 @@ async fn test_batch_profile_creation() {
assert_eq!(profiles.len(), 3);
// Verify each profile has correct properties
for (_i, profile) in profiles.iter().enumerate() {
for profile in &profiles {
assert!(!profile.id.is_empty());
assert!(!profile.capabilities.is_empty());
assert!(profile.success_rate >= 0.0 && profile.success_rate <= 1.0);

View File

@ -81,7 +81,7 @@ clap = { workspace = true }
# Metrics
prometheus = { workspace = true }
lazy_static = "1.4"
lazy_static = { workspace = true }
# TLS (native tokio-rustls)
rustls = { workspace = true }

View File

@ -24,7 +24,7 @@ mod tests {
#[tokio::test]
async fn test_health_endpoint() {
let response = health().await;
let _response = health().await;
// Response type verification - actual testing will be in integration
// tests
}

View File

@ -163,10 +163,7 @@ mod tests {
assert_eq!(stats.total_collections, 10);
assert_eq!(stats.successful_collections, 9);
assert_eq!(stats.failed_collections, 1);
assert_eq!(
stats.last_error.as_ref().map(|s| s.as_str()),
Some("Test error")
);
assert_eq!(stats.last_error.as_deref(), Some("Test error"));
}
#[test]

View File

@ -1,14 +1,12 @@
// Integration tests for VAPORA backend
// These tests verify the complete API functionality
use axum::http::StatusCode;
use axum_test::TestServer;
use chrono::Utc;
use vapora_shared::models::{
Agent, AgentRole, AgentStatus, Project, ProjectStatus, Task, TaskPriority, TaskStatus,
};
/// Helper function to create a test project
#[allow(dead_code)]
fn create_test_project() -> Project {
Project {
id: None,
@ -22,7 +20,7 @@ fn create_test_project() -> Project {
}
}
/// Helper function to create a test task
#[allow(dead_code)]
fn create_test_task(project_id: String) -> Task {
Task {
id: None,
@ -40,7 +38,7 @@ fn create_test_task(project_id: String) -> Task {
}
}
/// Helper function to create a test agent
#[allow(dead_code)]
fn create_test_agent() -> Agent {
Agent {
id: "test-agent-1".to_string(),

View File

@ -53,7 +53,7 @@ mod provider_analytics_tests {
#[test]
fn test_provider_efficiency_ranking_order() {
let efficiencies = vec![
let efficiencies = [
ProviderEfficiency {
provider: "claude".to_string(),
quality_score: 0.95,
@ -122,7 +122,7 @@ mod provider_analytics_tests {
assert_eq!(forecast.confidence, 0.9);
// Verify reasonable projections (weekly should be ~7x daily)
let expected_weekly = forecast.current_daily_cost_cents as u32 * 7;
let expected_weekly = forecast.current_daily_cost_cents * 7;
assert!(
(forecast.projected_weekly_cost_cents as i32 - expected_weekly as i32).abs() <= 100
);

View File

@ -207,7 +207,7 @@ async fn test_workflow_service_integration() {
let service = WorkflowService::new(engine, broadcaster, audit.clone());
let workflow = Workflow::new(
let _workflow = Workflow::new(
"service-test".to_string(),
"Service Test".to_string(),
vec![Phase {

View File

@ -38,5 +38,4 @@ thiserror = { workspace = true }
chrono = { workspace = true }
# Terminal UI
colored = "2.1"
comfy-table = "7.1"
colored = { workspace = true }

View File

@ -1,6 +1,6 @@
use colored::Colorize;
use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
// use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
use crate::client::WorkflowInstanceResponse;
pub fn print_success(message: &str) {
@ -12,45 +12,48 @@ pub fn print_error(message: &str) {
eprintln!("{} {}", "".red().bold(), message.red());
}
#[allow(dead_code)]
pub fn print_workflows_table(workflows: &[WorkflowInstanceResponse]) {
if workflows.is_empty() {
println!("{}", "No active workflows".yellow());
return;
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("ID").fg(Color::Cyan),
Cell::new("Template").fg(Color::Cyan),
Cell::new("Status").fg(Color::Cyan),
Cell::new("Progress").fg(Color::Cyan),
Cell::new("Created").fg(Color::Cyan),
]);
// Print header
println!(
"{}",
format!(
"{:<10} {:<20} {:<15} {:<10} {:<20}",
"ID", "Template", "Status", "Progress", "Created"
)
.cyan()
.bold()
);
println!("{}", "".repeat(75).cyan());
// Print rows
for workflow in workflows {
let status_cell = match workflow.status.as_str() {
s if s.starts_with("running") => Cell::new(&workflow.status).fg(Color::Green),
s if s.starts_with("waiting") => Cell::new(&workflow.status).fg(Color::Yellow),
s if s.starts_with("completed") => Cell::new(&workflow.status).fg(Color::Blue),
s if s.starts_with("failed") => Cell::new(&workflow.status).fg(Color::Red),
_ => Cell::new(&workflow.status),
let status_colored = match workflow.status.as_str() {
s if s.starts_with("running") => workflow.status.green(),
s if s.starts_with("waiting") => workflow.status.yellow(),
s if s.starts_with("completed") => workflow.status.blue(),
s if s.starts_with("failed") => workflow.status.red(),
_ => workflow.status.normal(),
};
let progress = format!("{}/{}", workflow.current_stage + 1, workflow.total_stages);
table.add_row(vec![
Cell::new(&workflow.id[..8]),
Cell::new(&workflow.template_name),
status_cell,
Cell::new(progress),
Cell::new(&workflow.created_at[..19]),
]);
println!(
"{:<10} {:<20} {:<15} {:<10} {:<20}",
&workflow.id[..8.min(workflow.id.len())],
&workflow.template_name,
status_colored,
progress,
&workflow.created_at[..19.min(workflow.created_at.len())],
);
}
println!("{table}");
println!("{}", "".repeat(75).cyan());
}
pub fn print_workflow_details(workflow: &WorkflowInstanceResponse) {

View File

@ -17,6 +17,7 @@ default = ["csr"]
[dependencies]
# Internal crates (disable backend features for WASM)
vapora-shared = { path = "../vapora-shared", default-features = false }
vapora-leptos-ui = { workspace = true }
# Leptos framework (CSR mode only - no SSR)
leptos = { workspace = true, features = ["csr"] }

View File

@ -0,0 +1,327 @@
/* layer: preflights */
*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}
/* layer: shortcuts */
.container{width:100%;}
.ds-btn:disabled{cursor:not-allowed;opacity:0.5;}
[ds-btn=""]:disabled{cursor:not-allowed;opacity:0.5;}
.ds-card{border-width:1px;border-color:rgb(255 255 255 / 0.2);border-radius:0.75rem;background-color:rgb(255 255 255 / 0.05) /* #fff */;--un-shadow:var(--un-shadow-inset) 0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);--un-backdrop-blur:blur(12px);-webkit-backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;transition-duration:300ms;}
.ds-btn,
[ds-btn=""]{border-radius:0.5rem;font-weight:500;transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;transition-duration:300ms;}
.ds-card-hover:hover{background-color:rgb(255 255 255 / 0.08) /* #fff */;--un-shadow-color:rgb(6 182 212 / 0.2) /* #06b6d4 */;}
.gradient-primary,
[gradient-primary=""]{--un-gradient-from-position:0%;--un-gradient-from:rgb(6 182 212 / 0.9) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(6 182 212 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);--un-gradient-via-position:50%;--un-gradient-to:rgb(147 51 234 / 0);--un-gradient-stops:var(--un-gradient-from), rgb(147 51 234 / 0.9) var(--un-gradient-via-position), var(--un-gradient-to);--un-gradient-to:rgb(236 72 153 / 0.9) var(--un-gradient-to-position);--un-gradient-shape:to right in oklch;--un-gradient:var(--un-gradient-shape), var(--un-gradient-stops);background-image:linear-gradient(var(--un-gradient));}
.ds-btn-sm,
[ds-btn-sm=""]{padding-left:0.75rem;padding-right:0.75rem;padding-top:0.375rem;padding-bottom:0.375rem;font-size:0.875rem;line-height:1.25rem;}
.ds-btn:focus{outline:2px solid transparent;outline-offset:2px;--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);--un-ring-color:rgb(6 182 212 / 0.5) /* #06b6d4 */;}
[ds-btn=""]:focus{outline:2px solid transparent;outline-offset:2px;--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);--un-ring-color:rgb(6 182 212 / 0.5) /* #06b6d4 */;}
@media (min-width: 640px){
.container{max-width:640px;}
}
@media (min-width: 768px){
.container{max-width:768px;}
}
@media (min-width: 1024px){
.container{max-width:1024px;}
}
@media (min-width: 1280px){
.container{max-width:1280px;}
}
@media (min-width: 1536px){
.container{max-width:1536px;}
}
/* layer: default */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;}
.pointer-events-auto{pointer-events:auto;}
.pointer-events-none,
[pointer-events-none=""]{pointer-events:none;}
.visible{visibility:visible;}
.fixed,
[fixed=""]{position:fixed;}
.static{position:static;}
.inset-0{inset:0;}
.right-4,
[right-4=""]{right:1rem;}
.top-4,
[top-4=""]{top:1rem;}
.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;}
.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;}
.z-40{z-index:40;}
.z-50,
[z-50=""]{z-index:50;}
.grid{display:grid;}
.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr));}
.m-2{margin:0.5rem;}
.m-4{margin:1rem;}
.mx-auto{margin-left:auto;margin-right:auto;}
.mb-1{margin-bottom:0.25rem;}
.mb-12{margin-bottom:3rem;}
.mb-2{margin-bottom:0.5rem;}
.mb-3{margin-bottom:0.75rem;}
.mb-4{margin-bottom:1rem;}
.mb-8{margin-bottom:2rem;}
.ml-1{margin-left:0.25rem;}
.ml-2{margin-left:0.5rem;}
.ml-4{margin-left:1rem;}
.mt-1{margin-top:0.25rem;}
.mt-12{margin-top:3rem;}
.mt-2{margin-top:0.5rem;}
.inline{display:inline;}
.block{display:block;}
.inline-block{display:inline-block;}
.hidden{display:none;}
.h-12{height:3rem;}
.h-4{height:1rem;}
.h-8{height:2rem;}
.h-full{height:100%;}
.max-h-\[90vh\]{max-height:90vh;}
.max-w-2xl{max-width:42rem;}
.max-w-lg{max-width:32rem;}
.max-w-md{max-width:28rem;}
.min-h-\[60vh\]{min-height:60vh;}
.min-h-32,
[min-h-32=""]{min-height:8rem;}
.min-h-screen{min-height:100vh;}
.min-w-max{min-width:max-content;}
.w-12{width:3rem;}
.w-4{width:1rem;}
.w-8{width:2rem;}
.w-full{width:100%;}
[h3=""]{height:0.75rem;}
.flex,
[flex=""]{display:flex;}
.inline-flex{display:inline-flex;}
.flex-1,
[flex-1=""]{flex:1 1 0%;}
.flex-row{flex-direction:row;}
.flex-col,
[flex-col=""]{flex-direction:column;}
.flex-wrap{flex-wrap:wrap;}
.table{display:table;}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes scaleIn{from{opacity:0;transform:scale(0.95)}to{opacity:1;transform:scale(1)}}
@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
.animate-fadeIn{animation:fadeIn 200ms ease-out 1;}
.animate-scaleIn{animation:scaleIn 200ms ease-out 1;}
.animate-spin{animation:spin 1s linear infinite;}
.cursor-pointer,
[cursor-pointer=""]{cursor:pointer;}
.cursor-move,
[cursor-move=""]{cursor:move;}
.cursor-not-allowed{cursor:not-allowed;}
.disabled\:cursor-not-allowed:disabled{cursor:not-allowed;}
.items-start{align-items:flex-start;}
.items-end{align-items:flex-end;}
.items-center,
[items-center=""]{align-items:center;}
.justify-end{justify-content:flex-end;}
.justify-center{justify-content:center;}
.justify-between{justify-content:space-between;}
.gap-2,
[gap-2=""]{gap:0.5rem;}
.gap-3{gap:0.75rem;}
.gap-4{gap:1rem;}
.gap-6{gap:1.5rem;}
.space-y-2>:not([hidden])~:not([hidden]),
[space-y-2=""]>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(0.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(0.5rem * var(--un-space-y-reverse));}
.divide-y>:not([hidden])~:not([hidden]),
[divide-y=""]>:not([hidden])~:not([hidden]){--un-divide-y-reverse:0;border-top-width:calc(1px * calc(1 - var(--un-divide-y-reverse)));border-bottom-width:calc(1px * var(--un-divide-y-reverse));}
.divide-white\/10>:not([hidden])~:not([hidden]){border-color:rgb(255 255 255 / 0.1) /* #fff */;}
[divide-white=""]>:not([hidden])~:not([hidden]){--un-divide-opacity:1;border-color:rgb(255 255 255 / var(--un-divide-opacity)) /* #fff */;}
.overflow-hidden{overflow:hidden;}
.overflow-x-auto{overflow-x:auto;}
.overflow-y-auto,
[overflow-y-auto=""]{overflow-y:auto;}
.border,
[border=""]{border-width:1px;}
.border-2,
[border-2=""]{border-width:2px;}
.border-b{border-bottom-width:1px;}
.border-l-4,
[border-l-4=""]{border-left-width:4px;}
.border-t{border-top-width:1px;}
.border-blue-400\/70{border-color:rgb(96 165 250 / 0.7);}
.border-cyan-400\/50{border-color:rgb(34 211 238 / 0.5);}
.border-cyan-400\/70{border-color:rgb(34 211 238 / 0.7);}
.border-cyan-500\/30{border-color:rgb(6 182 212 / 0.3);}
.border-green-400\/70{border-color:rgb(74 222 128 / 0.7);}
.border-red-400\/70{border-color:rgb(248 113 113 / 0.7);}
.border-red-500\/50{border-color:rgb(239 68 68 / 0.5);}
.border-transparent,
[border-transparent=""]{border-color:transparent;}
.border-white\/10{border-color:rgb(255 255 255 / 0.1);}
.border-white\/20{border-color:rgb(255 255 255 / 0.2);}
.border-yellow-400\/70{border-color:rgb(250 204 21 / 0.7);}
[border-cyan-400=""]{--un-border-opacity:1;border-color:rgb(34 211 238 / var(--un-border-opacity));}
[border-white=""]{--un-border-opacity:1;border-color:rgb(255 255 255 / var(--un-border-opacity));}
.hover\:border-cyan-400\/70:hover{border-color:rgb(34 211 238 / 0.7);}
.focus\:border-cyan-400\/70:focus{border-color:rgb(34 211 238 / 0.7);}
.border-l-blue-500{--un-border-opacity:1;--un-border-left-opacity:var(--un-border-opacity);border-left-color:rgb(59 130 246 / var(--un-border-left-opacity));}
.border-l-orange-500{--un-border-opacity:1;--un-border-left-opacity:var(--un-border-opacity);border-left-color:rgb(249 115 22 / var(--un-border-left-opacity));}
.border-l-red-500{--un-border-opacity:1;--un-border-left-opacity:var(--un-border-opacity);border-left-color:rgb(239 68 68 / var(--un-border-left-opacity));}
.border-l-red-700{--un-border-opacity:1;--un-border-left-opacity:var(--un-border-opacity);border-left-color:rgb(185 28 28 / var(--un-border-left-opacity));}
.border-t-cyan-400{--un-border-opacity:1;--un-border-top-opacity:var(--un-border-opacity);border-top-color:rgb(34 211 238 / var(--un-border-top-opacity));}
.rounded{border-radius:0.25rem;}
.rounded-full{border-radius:9999px;}
.rounded-lg,
[rounded-lg=""]{border-radius:0.5rem;}
.rounded-md{border-radius:0.375rem;}
.rounded-xl{border-radius:0.75rem;}
.bg-black\/20{background-color:rgb(0 0 0 / 0.2) /* #000 */;}
.bg-black\/50{background-color:rgb(0 0 0 / 0.5) /* #000 */;}
.bg-blue-500\/20{background-color:rgb(59 130 246 / 0.2) /* #3b82f6 */;}
.bg-blue-500\/90{background-color:rgb(59 130 246 / 0.9) /* #3b82f6 */;}
.bg-cyan-500\/20{background-color:rgb(6 182 212 / 0.2) /* #06b6d4 */;}
.bg-green-500\/20{background-color:rgb(34 197 94 / 0.2) /* #22c55e */;}
.bg-green-500\/90{background-color:rgb(34 197 94 / 0.9) /* #22c55e */;}
.bg-purple-500\/20{background-color:rgb(168 85 247 / 0.2) /* #a855f7 */;}
.bg-red-500\/20{background-color:rgb(239 68 68 / 0.2) /* #ef4444 */;}
.bg-red-500\/90{background-color:rgb(239 68 68 / 0.9) /* #ef4444 */;}
.bg-transparent{background-color:transparent /* transparent */;}
.bg-white\/10{background-color:rgb(255 255 255 / 0.1) /* #fff */;}
.bg-white\/5{background-color:rgb(255 255 255 / 0.05) /* #fff */;}
.bg-white\/8{background-color:rgb(255 255 255 / 0.08) /* #fff */;}
.bg-yellow-500\/90{background-color:rgb(234 179 8 / 0.9) /* #eab308 */;}
[bg-white=""]{--un-bg-opacity:1;background-color:rgb(255 255 255 / var(--un-bg-opacity)) /* #fff */;}
.hover\:bg-white\/12:hover{background-color:rgb(255 255 255 / 0.12) /* #fff */;}
.hover\:bg-white\/20:hover{background-color:rgb(255 255 255 / 0.2) /* #fff */;}
.hover\:bg-white\/5:hover{background-color:rgb(255 255 255 / 0.05) /* #fff */;}
.hover\:bg-white\/8:hover{background-color:rgb(255 255 255 / 0.08) /* #fff */;}
[hover\:bg-white=""]:hover{--un-bg-opacity:1;background-color:rgb(255 255 255 / var(--un-bg-opacity)) /* #fff */;}
.from-cyan-400{--un-gradient-from-position:0%;--un-gradient-from:rgb(34 211 238 / var(--un-from-opacity, 1)) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(34 211 238 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.from-cyan-400\/90{--un-gradient-from-position:0%;--un-gradient-from:rgb(34 211 238 / 0.9) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(34 211 238 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.from-cyan-500\/90{--un-gradient-from-position:0%;--un-gradient-from:rgb(6 182 212 / 0.9) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(6 182 212 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.from-red-400\/90{--un-gradient-from-position:0%;--un-gradient-from:rgb(248 113 113 / 0.9) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(248 113 113 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.from-red-500\/90{--un-gradient-from-position:0%;--un-gradient-from:rgb(239 68 68 / 0.9) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(239 68 68 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.from-slate-900{--un-gradient-from-position:0%;--un-gradient-from:rgb(15 23 42 / var(--un-from-opacity, 1)) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(15 23 42 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.hover\:from-cyan-400:hover{--un-gradient-from-position:0%;--un-gradient-from:rgb(34 211 238 / var(--un-from-opacity, 1)) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(34 211 238 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.hover\:from-cyan-400\/90:hover{--un-gradient-from-position:0%;--un-gradient-from:rgb(34 211 238 / 0.9) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(34 211 238 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.hover\:from-red-400\/90:hover{--un-gradient-from-position:0%;--un-gradient-from:rgb(248 113 113 / 0.9) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(248 113 113 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
.via-purple-500\/90{--un-gradient-via-position:50%;--un-gradient-to:rgb(168 85 247 / 0);--un-gradient-stops:var(--un-gradient-from), rgb(168 85 247 / 0.9) var(--un-gradient-via-position), var(--un-gradient-to);}
.via-purple-600\/90{--un-gradient-via-position:50%;--un-gradient-to:rgb(147 51 234 / 0);--un-gradient-stops:var(--un-gradient-from), rgb(147 51 234 / 0.9) var(--un-gradient-via-position), var(--un-gradient-to);}
.via-slate-800{--un-gradient-via-position:50%;--un-gradient-to:rgb(30 41 59 / 0);--un-gradient-stops:var(--un-gradient-from), rgb(30 41 59 / var(--un-via-opacity, 1)) var(--un-gradient-via-position), var(--un-gradient-to);}
.hover\:via-purple-500\/90:hover{--un-gradient-via-position:50%;--un-gradient-to:rgb(168 85 247 / 0);--un-gradient-stops:var(--un-gradient-from), rgb(168 85 247 / 0.9) var(--un-gradient-via-position), var(--un-gradient-to);}
.to-blue-500{--un-gradient-to-position:100%;--un-gradient-to:rgb(59 130 246 / var(--un-to-opacity, 1)) var(--un-gradient-to-position);}
.to-cyan-600\/90{--un-gradient-to-position:100%;--un-gradient-to:rgb(8 145 178 / 0.9) var(--un-gradient-to-position);}
.to-pink-400\/90{--un-gradient-to-position:100%;--un-gradient-to:rgb(244 114 182 / 0.9) var(--un-gradient-to-position);}
.to-pink-500\/90{--un-gradient-to-position:100%;--un-gradient-to:rgb(236 72 153 / 0.9) var(--un-gradient-to-position);}
.to-pink-600\/90{--un-gradient-to-position:100%;--un-gradient-to:rgb(219 39 119 / 0.9) var(--un-gradient-to-position);}
.to-slate-900{--un-gradient-to-position:100%;--un-gradient-to:rgb(15 23 42 / var(--un-to-opacity, 1)) var(--un-gradient-to-position);}
.hover\:to-cyan-500:hover{--un-gradient-to-position:100%;--un-gradient-to:rgb(6 182 212 / var(--un-to-opacity, 1)) var(--un-gradient-to-position);}
.hover\:to-pink-400\/90:hover{--un-gradient-to-position:100%;--un-gradient-to:rgb(244 114 182 / 0.9) var(--un-gradient-to-position);}
.hover\:to-pink-500\/90:hover{--un-gradient-to-position:100%;--un-gradient-to:rgb(236 72 153 / 0.9) var(--un-gradient-to-position);}
.bg-gradient-to-br{--un-gradient-shape:to bottom right in oklch;--un-gradient:var(--un-gradient-shape), var(--un-gradient-stops);background-image:linear-gradient(var(--un-gradient));}
.bg-gradient-to-r{--un-gradient-shape:to right in oklch;--un-gradient:var(--un-gradient-shape), var(--un-gradient-stops);background-image:linear-gradient(var(--un-gradient));}
.bg-clip-text{-webkit-background-clip:text;background-clip:text;}
.p-12{padding:3rem;}
.p-2,
[p-2=""]{padding:0.5rem;}
.p-3,
[p-3=""]{padding:0.75rem;}
.p-4{padding:1rem;}
.p-6{padding:1.5rem;}
.px-2{padding-left:0.5rem;padding-right:0.5rem;}
.px-2\.5{padding-left:0.625rem;padding-right:0.625rem;}
.px-3,
[px-3=""]{padding-left:0.75rem;padding-right:0.75rem;}
.px-4,
[px-4=""]{padding-left:1rem;padding-right:1rem;}
.px-6,
[px-6=""]{padding-left:1.5rem;padding-right:1.5rem;}
.py-0\.5{padding-top:0.125rem;padding-bottom:0.125rem;}
.py-1\.5{padding-top:0.375rem;padding-bottom:0.375rem;}
.py-12{padding-top:3rem;padding-bottom:3rem;}
.py-2,
[py-2=""]{padding-top:0.5rem;padding-bottom:0.5rem;}
.py-3,
[py-3=""]{padding-top:0.75rem;padding-bottom:0.75rem;}
.py-4{padding-top:1rem;padding-bottom:1rem;}
.py-6{padding-top:1.5rem;padding-bottom:1.5rem;}
.py-8{padding-top:2rem;padding-bottom:2rem;}
.text-center{text-align:center;}
.text-left,
[text-left=""]{text-align:left;}
.text-2xl{font-size:1.5rem;line-height:2rem;}
.text-3xl{font-size:1.875rem;line-height:2.25rem;}
.text-5xl{font-size:3rem;line-height:1;}
.text-6xl{font-size:3.75rem;line-height:1;}
.text-9xl{font-size:8rem;line-height:1;}
.text-base{font-size:1rem;line-height:1.5rem;}
.text-lg{font-size:1.125rem;line-height:1.75rem;}
.text-sm,
[text-sm=""]{font-size:0.875rem;line-height:1.25rem;}
.text-xl{font-size:1.25rem;line-height:1.75rem;}
.text-xs{font-size:0.75rem;line-height:1rem;}
.text-blue-400{--un-text-opacity:1;color:rgb(96 165 250 / var(--un-text-opacity)) /* #60a5fa */;}
.text-cyan-400{--un-text-opacity:1;color:rgb(34 211 238 / var(--un-text-opacity)) /* #22d3ee */;}
.text-gray-300{--un-text-opacity:1;color:rgb(209 213 219 / var(--un-text-opacity)) /* #d1d5db */;}
.text-gray-400{--un-text-opacity:1;color:rgb(156 163 175 / var(--un-text-opacity)) /* #9ca3af */;}
.text-gray-500{--un-text-opacity:1;color:rgb(107 114 128 / var(--un-text-opacity)) /* #6b7280 */;}
.text-green-400{--un-text-opacity:1;color:rgb(74 222 128 / var(--un-text-opacity)) /* #4ade80 */;}
.text-pink-400{--un-text-opacity:1;color:rgb(244 114 182 / var(--un-text-opacity)) /* #f472b6 */;}
.text-purple-400{--un-text-opacity:1;color:rgb(192 132 252 / var(--un-text-opacity)) /* #c084fc */;}
.text-red-300{--un-text-opacity:1;color:rgb(252 165 165 / var(--un-text-opacity)) /* #fca5a5 */;}
.text-red-400{--un-text-opacity:1;color:rgb(248 113 113 / var(--un-text-opacity)) /* #f87171 */;}
.text-transparent{color:transparent /* transparent */;}
.text-white,
[text-white=""],
[text-white~="\{"],
[text-white~="\{format\!\("],
[text-white~="\}"],
[text-white~="\>"],
[text-white~="else"]{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity)) /* #fff */;}
.text-white\/40{color:rgb(255 255 255 / 0.4) /* #fff */;}
.text-white\/60{color:rgb(255 255 255 / 0.6) /* #fff */;}
.text-white\/80{color:rgb(255 255 255 / 0.8) /* #fff */;}
.hover\:text-cyan-400:hover{--un-text-opacity:1;color:rgb(34 211 238 / var(--un-text-opacity)) /* #22d3ee */;}
.hover\:text-white:hover{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity)) /* #fff */;}
.font-bold{font-weight:700;}
.font-medium,
[font-medium=""]{font-weight:500;}
.font-semibold{font-weight:600;}
.tab{-moz-tab-size:4;-o-tab-size:4;tab-size:4;}
.opacity-50{opacity:0.5;}
.disabled\:opacity-50:disabled{opacity:0.5;}
.shadow{--un-shadow:var(--un-shadow-inset) 0 1px 3px 0 var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 1px 2px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.shadow-2xl{--un-shadow:var(--un-shadow-inset) 0 25px 50px -12px var(--un-shadow-color, rgb(0 0 0 / 0.25));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.shadow-black\/50{--un-shadow-color:rgb(0 0 0 / 0.5) /* #000 */;}
.shadow-blue-500\/40{--un-shadow-color:rgb(59 130 246 / 0.4) /* #3b82f6 */;}
.shadow-blue-500\/40\){--un-shadow-opacity:1;--un-shadow-color:rgb(59 130 246 / var(--un-shadow-opacity)) /* #3b82f6 */;}
.shadow-cyan-500\/20{--un-shadow-color:rgb(6 182 212 / 0.2) /* #06b6d4 */;}
.shadow-cyan-500\/40{--un-shadow-color:rgb(6 182 212 / 0.4) /* #06b6d4 */;}
.shadow-cyan-500\/40\){--un-shadow-opacity:1;--un-shadow-color:rgb(6 182 212 / var(--un-shadow-opacity)) /* #06b6d4 */;}
.shadow-cyan-500\/50{--un-shadow-color:rgb(6 182 212 / 0.5) /* #06b6d4 */;}
.shadow-lg,
[shadow-lg=""]{--un-shadow:var(--un-shadow-inset) 0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.shadow-pink-500\/40{--un-shadow-color:rgb(236 72 153 / 0.4) /* #ec4899 */;}
.shadow-pink-500\/40\){--un-shadow-opacity:1;--un-shadow-color:rgb(236 72 153 / var(--un-shadow-opacity)) /* #ec4899 */;}
.shadow-purple-500\/40{--un-shadow-color:rgb(168 85 247 / 0.4) /* #a855f7 */;}
.shadow-purple-500\/40\){--un-shadow-opacity:1;--un-shadow-color:rgb(168 85 247 / var(--un-shadow-opacity)) /* #a855f7 */;}
[shadow-black=""]{--un-shadow-opacity:1;--un-shadow-color:rgb(0 0 0 / var(--un-shadow-opacity)) /* #000 */;}
.hover\:shadow-cyan-500\/50:hover{--un-shadow-color:rgb(6 182 212 / 0.5) /* #06b6d4 */;}
.hover\:shadow-lg:hover{--un-shadow:var(--un-shadow-inset) 0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px;}
.focus\:ring-2:focus{--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.focus\:ring-cyan-500\/50:focus{--un-ring-color:rgb(6 182 212 / 0.5) /* #06b6d4 */;}
.backdrop-blur-lg{--un-backdrop-blur:blur(16px);-webkit-backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);}
.backdrop-blur-md{--un-backdrop-blur:blur(12px);-webkit-backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);}
.backdrop-blur-sm{--un-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);}
.backdrop-blur-xl{--un-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);backdrop-filter:var(--un-backdrop-blur) var(--un-backdrop-brightness) var(--un-backdrop-contrast) var(--un-backdrop-grayscale) var(--un-backdrop-hue-rotate) var(--un-backdrop-invert) var(--un-backdrop-opacity) var(--un-backdrop-saturate) var(--un-backdrop-sepia);}
.blur,
[blur=""]{--un-blur:blur(8px);filter:var(--un-blur) var(--un-brightness) var(--un-contrast) var(--un-drop-shadow) var(--un-grayscale) var(--un-hue-rotate) var(--un-invert) var(--un-saturate) var(--un-sepia);}
.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;}
.transition-all,
[transition-all=""]{transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;}
.transition-colors,
[transition-colors=""]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;}
.duration-200,
[duration-200=""]{transition-duration:200ms;}
.duration-300{transition-duration:300ms;}
.placeholder-gray-400::placeholder{--un-placeholder-opacity:1;color:rgb(156 163 175 / var(--un-placeholder-opacity)) /* #9ca3af */;}
@media (min-width: 768px){
.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr));}
.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr));}
}
@media (min-width: 1024px){
.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr));}
.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr));}
}

View File

@ -0,0 +1,11 @@
//! Data display components
//!
//! Tables, statistics cards, and pagination.
pub mod stat_card;
pub mod table;
pub mod pagination;
pub use stat_card::StatCard;
pub use table::Table;
pub use pagination::Pagination;

View File

@ -0,0 +1,116 @@
use leptos::prelude::*;
use leptos::ev;
/// Helper to generate visible page numbers with ellipsis
fn get_page_numbers(current: usize, total: usize) -> Vec<PageItem> {
if total <= 7 {
// Show all pages if 7 or fewer
(1..=total).map(PageItem::Page).collect()
} else {
let mut pages = Vec::new();
pages.push(PageItem::Page(1));
if current <= 3 {
// Near start: 1 2 3 4 5 ... 10
for i in 2..=5.min(total - 1) {
pages.push(PageItem::Page(i));
}
pages.push(PageItem::Ellipsis);
} else if current >= total - 2 {
// Near end: 1 ... 6 7 8 9 10
pages.push(PageItem::Ellipsis);
for i in (total - 4).max(2)..total {
pages.push(PageItem::Page(i));
}
} else {
// Middle: 1 ... 4 5 6 ... 10
pages.push(PageItem::Ellipsis);
for i in current - 1..=current + 1 {
pages.push(PageItem::Page(i));
}
pages.push(PageItem::Ellipsis);
}
pages.push(PageItem::Page(total));
pages
}
}
#[derive(Clone, PartialEq)]
enum PageItem {
Page(usize),
Ellipsis,
}
#[component]
pub fn PaginationClient(
current_page: usize,
total_pages: usize,
on_page_change: Callback<usize>,
class: &'static str,
) -> impl IntoView {
let page_numbers = get_page_numbers(current_page, total_pages);
let handle_prev = move |_: ev::MouseEvent| {
if current_page > 1 {
on_page_change.run(current_page - 1);
}
};
let handle_next = move |_: ev::MouseEvent| {
if current_page < total_pages {
on_page_change.run(current_page + 1);
}
};
view! {
<div class={format!("flex items-center gap-2 {}", class)}>
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 hover:bg-white/8 disabled:opacity-50"
disabled={current_page <= 1}
on:click=handle_prev
>
""
</button>
{page_numbers.into_iter().map(|item| {
match item {
PageItem::Page(page) => {
let is_current = page == current_page;
let button_class = if is_current {
"px-3 py-2 ds-btn ds-btn-sm gradient-primary text-white"
} else {
"px-3 py-2 ds-btn ds-btn-sm bg-white/5 hover:bg-white/8"
};
let handle_click = move |_: ev::MouseEvent| {
on_page_change.run(page);
};
view! {
<button
class=button_class
on:click=handle_click
>
{page.to_string()}
</button>
}.into_any()
}
PageItem::Ellipsis => {
view! {
<span class="px-2 text-white/60">"..."</span>
}.into_any()
}
}
}).collect::<Vec<_>>()}
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 hover:bg-white/8 disabled:opacity-50"
disabled={current_page >= total_pages}
on:click=handle_next
>
""
</button>
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::Pagination;

View File

@ -0,0 +1,31 @@
use leptos::prelude::*;
#[component]
pub fn PaginationSSR(
current_page: usize,
total_pages: usize,
class: &'static str,
) -> impl IntoView {
// Simplified SSR version - just show current page info
view! {
<div class={format!("flex items-center gap-2 {}", class)}>
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 disabled:opacity-50"
disabled={current_page <= 1}
>
""
</button>
<span class="px-4 py-2 text-white">
{format!("{} / {}", current_page, total_pages)}
</span>
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 disabled:opacity-50"
disabled={current_page >= total_pages}
>
""
</button>
</div>
}
}

View File

@ -0,0 +1,65 @@
use leptos::prelude::*;
use leptos::ev;
#[cfg(not(target_arch = "wasm32"))]
use super::ssr::PaginationSSR;
#[cfg(target_arch = "wasm32")]
use super::client::PaginationClient;
/// Pagination component
///
/// Provides page navigation controls.
///
/// # Examples
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::Pagination;
///
/// #[component]
/// fn DataList() -> impl IntoView {
/// let (page, set_page) = signal(1);
///
/// view! {
/// <Pagination
/// current_page=page.get()
/// total_pages=10
/// on_page_change=move |p| set_page(p)
/// />
/// }
/// }
/// ```
#[component]
pub fn Pagination(
/// Current active page (1-indexed)
current_page: usize,
/// Total number of pages
total_pages: usize,
/// Callback when page changes
#[prop(optional)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
on_page_change: Option<Callback<usize>>,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! {
<PaginationSSR
current_page=current_page
total_pages=total_pages
class=class
/>
};
#[cfg(target_arch = "wasm32")]
return view! {
<PaginationClient
current_page=current_page
total_pages=total_pages
on_page_change=on_page_change.unwrap_or(Callback::new(|_| {}))
class=class
/>
};
}

View File

@ -0,0 +1,42 @@
use leptos::prelude::*;
#[component]
pub fn StatCardClient(
label: String,
value: String,
change: Option<String>,
trend_positive: bool,
icon: Option<Children>,
class: &'static str,
) -> impl IntoView {
let trend_color = if trend_positive {
"text-green-400"
} else {
"text-red-400"
};
view! {
<div class={format!("ds-card p-6 {}", class)}>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm text-white/60 mb-1">{label}</p>
<p class="text-3xl font-bold text-white">{value}</p>
{change.map(|ch| {
view! {
<p class={format!("text-sm mt-2 {}", trend_color)}>
{ch}
</p>
}
})}
</div>
{icon.map(|icon_fn| {
view! {
<div class="ml-4">
{icon_fn()}
</div>
}
})}
</div>
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::StatCard;

View File

@ -0,0 +1,42 @@
use leptos::prelude::*;
#[component]
pub fn StatCardSSR(
label: String,
value: String,
change: Option<String>,
trend_positive: bool,
icon: Option<Children>,
class: &'static str,
) -> impl IntoView {
let trend_color = if trend_positive {
"text-green-400"
} else {
"text-red-400"
};
view! {
<div class={format!("ds-card p-6 {}", class)}>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm text-white/60 mb-1">{label}</p>
<p class="text-3xl font-bold text-white">{value}</p>
{change.map(|ch| {
view! {
<p class={format!("text-sm mt-2 {}", trend_color)}>
{ch}
</p>
}
})}
</div>
{icon.map(|icon_fn| {
view! {
<div class="ml-4">
{icon_fn()}
</div>
}
})}
</div>
</div>
}
}

View File

@ -0,0 +1,73 @@
use leptos::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use super::ssr::StatCardSSR;
#[cfg(target_arch = "wasm32")]
use super::client::StatCardClient;
/// Statistics card component
///
/// Displays a key metric with optional trend indicator.
///
/// # Examples
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::StatCard;
///
/// #[component]
/// fn Dashboard() -> impl IntoView {
/// view! {
/// <StatCard
/// label="Total Users"
/// value="1,234"
/// change=Some("+12%")
/// trend_positive=true
/// />
/// }
/// }
/// ```
#[component]
pub fn StatCard(
/// Label text for the statistic
label: String,
/// Main value to display
value: String,
/// Optional change indicator (e.g., "+12%", "-5%")
#[prop(optional)]
change: Option<String>,
/// Whether the change is positive (green) or negative (red)
#[prop(default = true)]
trend_positive: bool,
/// Optional icon or content to display
#[prop(optional)]
icon: Option<Children>,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! {
<StatCardSSR
label=label
value=value
change=change
trend_positive=trend_positive
icon=icon
class=class
/>
};
#[cfg(target_arch = "wasm32")]
return view! {
<StatCardClient
label=label
value=value
change=change
trend_positive=trend_positive
icon=icon
class=class
/>
};
}

View File

@ -0,0 +1,65 @@
use leptos::prelude::*;
use leptos::ev;
use super::unified::TableColumn;
#[component]
pub fn TableClient(
columns: Vec<TableColumn>,
rows: Vec<Vec<String>>,
on_sort: Callback<String>,
class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("ds-card overflow-hidden {}", class)}>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-white/5 border-b border-white/10">
<tr>
{columns.iter().map(|col| {
let col_clone = col.clone();
let handle_sort = move |_: ev::MouseEvent| {
if col_clone.sortable {
on_sort.run(col_clone.key.clone());
}
};
let header_class = if col.sortable {
"px-6 py-3 text-left text-sm font-medium text-white cursor-pointer hover:bg-white/8"
} else {
"px-6 py-3 text-left text-sm font-medium text-white"
};
view! {
<th
class=header_class
on:click=handle_sort
>
<div class="flex items-center gap-2">
{col.header.clone()}
{col.sortable.then(|| view! { <span class="text-white/40">""</span> })}
</div>
</th>
}
}).collect::<Vec<_>>()}
</tr>
</thead>
<tbody class="divide-y divide-white/10">
{rows.into_iter().map(|row| {
view! {
<tr class="hover:bg-white/5 transition-colors">
{row.into_iter().map(|cell| {
view! {
<td class="px-6 py-4 text-sm text-white/80">
{cell}
</td>
}
}).collect::<Vec<_>>()}
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::Table;

View File

@ -0,0 +1,47 @@
use leptos::prelude::*;
use super::unified::TableColumn;
#[component]
pub fn TableSSR(
columns: Vec<TableColumn>,
rows: Vec<Vec<String>>,
class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("ds-card overflow-hidden {}", class)}>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-white/5 border-b border-white/10">
<tr>
{columns.iter().map(|col| {
view! {
<th class="px-6 py-3 text-left text-sm font-medium text-white">
<div class="flex items-center gap-2">
{col.header.clone()}
{col.sortable.then(|| view! { <span class="text-white/40">""</span> })}
</div>
</th>
}
}).collect::<Vec<_>>()}
</tr>
</thead>
<tbody class="divide-y divide-white/10">
{rows.into_iter().map(|row| {
view! {
<tr class="hover:bg-white/5 transition-colors">
{row.into_iter().map(|cell| {
view! {
<td class="px-6 py-4 text-sm text-white/80">
{cell}
</td>
}
}).collect::<Vec<_>>()}
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
</div>
}
}

View File

@ -0,0 +1,96 @@
use leptos::prelude::*;
use leptos::ev;
#[cfg(not(target_arch = "wasm32"))]
use super::ssr::TableSSR;
#[cfg(target_arch = "wasm32")]
use super::client::TableClient;
/// Column definition for table
#[derive(Clone, Debug)]
pub struct TableColumn {
/// Column header text
pub header: String,
/// Column key for sorting
pub key: String,
/// Whether column is sortable
pub sortable: bool,
}
impl TableColumn {
pub fn new(header: impl Into<String>, key: impl Into<String>) -> Self {
Self {
header: header.into(),
key: key.into(),
sortable: false,
}
}
pub fn sortable(mut self) -> Self {
self.sortable = true;
self
}
}
/// Table component
///
/// Displays data in a tabular format with optional sorting.
///
/// # Examples
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::{Table, TableColumn};
///
/// #[component]
/// fn UserList() -> impl IntoView {
/// let columns = vec![
/// TableColumn::new("Name", "name").sortable(),
/// TableColumn::new("Email", "email"),
/// TableColumn::new("Status", "status"),
/// ];
///
/// let rows = vec![
/// vec!["Alice".into(), "alice@example.com".into(), "Active".into()],
/// vec!["Bob".into(), "bob@example.com".into(), "Inactive".into()],
/// ];
///
/// view! {
/// <Table columns=columns rows=rows />
/// }
/// }
/// ```
#[component]
pub fn Table(
/// Column definitions
columns: Vec<TableColumn>,
/// Table data rows (each row is a vec of cell content)
rows: Vec<Vec<String>>,
/// Optional callback when column is sorted
#[prop(optional)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
on_sort: Option<Callback<String>>,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! {
<TableSSR
columns=columns
rows=rows
class=class
/>
};
#[cfg(target_arch = "wasm32")]
return view! {
<TableClient
columns=columns
rows=rows
on_sort=on_sort.unwrap_or(Callback::new(|_| {}))
class=class
/>
};
}

View File

@ -0,0 +1,40 @@
use leptos::prelude::*;
#[component]
pub fn FormFieldClient(
label: String,
error: Option<String>,
required: bool,
help_text: Option<String>,
children: Children,
class: &'static str,
) -> impl IntoView {
let has_error = error.is_some();
view! {
<div class={format!("flex flex-col gap-2 {}", class)}>
<label class="text-sm font-medium text-white">
{label}
{required.then(|| view! { <span class="text-red-400 ml-1">"*"</span> })}
</label>
{children()}
{move || error.clone().map(|err| {
view! {
<span class="text-sm text-red-400">
{err}
</span>
}
})}
{help_text.map(|text| {
view! {
<span class="text-sm text-white/60">
{text}
</span>
}
})}
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::FormField;

View File

@ -0,0 +1,38 @@
use leptos::prelude::*;
#[component]
pub fn FormFieldSSR(
label: String,
error: Option<String>,
required: bool,
help_text: Option<String>,
children: Children,
class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("flex flex-col gap-2 {}", class)}>
<label class="text-sm font-medium text-white">
{label}
{required.then(|| view! { <span class="text-red-400 ml-1">"*"</span> })}
</label>
{children()}
{error.map(|err| {
view! {
<span class="text-sm text-red-400">
{err}
</span>
}
})}
{help_text.map(|text| {
view! {
<span class="text-sm text-white/60">
{text}
</span>
}
})}
</div>
}
}

View File

@ -0,0 +1,81 @@
use leptos::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use super::ssr::FormFieldSSR;
#[cfg(target_arch = "wasm32")]
use super::client::FormFieldClient;
/// Form field wrapper with label and error display
///
/// # Examples
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::{FormField, Input};
///
/// #[component]
/// fn LoginForm() -> impl IntoView {
/// let (username, set_username) = signal(String::new());
/// let (error, set_error) = signal(None::<String>);
///
/// view! {
/// <FormField
/// label="Username"
/// error=error.get()
/// required=true
/// >
/// <Input
/// value=username.get()
/// on_input=move |val| set_username(val)
/// placeholder="Enter username"
/// />
/// </FormField>
/// }
/// }
/// ```
#[component]
pub fn FormField(
/// Field label text
label: String,
/// Optional error message to display
#[prop(optional)]
error: Option<String>,
/// Whether field is required (shows asterisk)
#[prop(default = false)]
required: bool,
/// Optional help text
#[prop(optional)]
help_text: Option<String>,
/// Child input component
children: Children,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! {
<FormFieldSSR
label=label
error=error
required=required
help_text=help_text
class=class
>
{children()}
</FormFieldSSR>
};
#[cfg(target_arch = "wasm32")]
return view! {
<FormFieldClient
label=label
error=error
required=required
help_text=help_text
class=class
>
{children()}
</FormFieldClient>
};
}

View File

@ -0,0 +1,9 @@
//! Form components and validation utilities
//!
//! Provides reusable form components with built-in validation.
pub mod form_field;
pub mod validation;
pub use form_field::FormField;
pub use validation::{validate_required, validate_email, validate_min_length, validate_max_length};

View File

@ -0,0 +1,109 @@
//! Form validation utilities
//!
//! Provides common validation functions for form inputs.
/// Validates that a field is not empty
///
/// # Examples
///
/// ```
/// use vapora_leptos_ui::validate_required;
///
/// assert!(validate_required("hello", "Name").is_ok());
/// assert!(validate_required("", "Name").is_err());
/// assert!(validate_required(" ", "Name").is_err());
/// ```
pub fn validate_required(value: &str, field_name: &str) -> Result<(), String> {
if value.trim().is_empty() {
Err(format!("{} is required", field_name))
} else {
Ok(())
}
}
/// Validates email format (basic check)
///
/// # Examples
///
/// ```
/// use vapora_leptos_ui::validate_email;
///
/// assert!(validate_email("user@example.com").is_ok());
/// assert!(validate_email("invalid").is_err());
/// assert!(validate_email("@example.com").is_err());
/// ```
pub fn validate_email(value: &str) -> Result<(), String> {
if !value.contains('@') || !value.contains('.') {
Err("Invalid email format".to_string())
} else {
Ok(())
}
}
/// Validates minimum length
///
/// # Examples
///
/// ```
/// use vapora_leptos_ui::validate_min_length;
///
/// assert!(validate_min_length("password123", 8, "Password").is_ok());
/// assert!(validate_min_length("short", 8, "Password").is_err());
/// ```
pub fn validate_min_length(value: &str, min: usize, field_name: &str) -> Result<(), String> {
if value.len() < min {
Err(format!("{} must be at least {} characters", field_name, min))
} else {
Ok(())
}
}
/// Validates maximum length
///
/// # Examples
///
/// ```
/// use vapora_leptos_ui::validate_max_length;
///
/// assert!(validate_max_length("hello", 10, "Username").is_ok());
/// assert!(validate_max_length("verylongusername", 10, "Username").is_err());
/// ```
pub fn validate_max_length(value: &str, max: usize, field_name: &str) -> Result<(), String> {
if value.len() > max {
Err(format!("{} must be at most {} characters", field_name, max))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_required() {
assert!(validate_required("value", "Field").is_ok());
assert!(validate_required("", "Field").is_err());
assert!(validate_required(" ", "Field").is_err());
}
#[test]
fn test_validate_email() {
assert!(validate_email("user@example.com").is_ok());
assert!(validate_email("invalid").is_err());
assert!(validate_email("@example.com").is_err());
assert!(validate_email("user@").is_err());
}
#[test]
fn test_validate_min_length() {
assert!(validate_min_length("password", 8, "Password").is_ok());
assert!(validate_min_length("short", 8, "Password").is_err());
}
#[test]
fn test_validate_max_length() {
assert!(validate_max_length("hello", 10, "Username").is_ok());
assert!(validate_max_length("verylongusername", 10, "Username").is_err());
}
}

View File

@ -4,31 +4,41 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VAPORA - Multi-Agent Development Platform</title>
<!-- UnoCSS Generated CSS -->
<link rel="stylesheet" href="/assets/styles/website.css" />
<style>
/* Base reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Root variables */
:root {
--bg-primary: #0a0118;
--bg-glass: rgba(255, 255, 255, 0.05);
--bg-glass-hover: rgba(255, 255, 255, 0.08);
--accent-cyan: #22d3ee;
--accent-purple: #a855f7;
--accent-pink: #ec4899;
--border-glass: rgba(34, 211, 238, 0.3);
--text-primary: #ffffff;
--text-secondary: #cbd5e1;
}
/* Base styles */
html, body {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
color: #fff;
color: var(--text-primary);
}
#app {
width: 100%;
height: 100%;
}
/* Utility classes for glassmorphism */
.backdrop-blur-sm { backdrop-filter: blur(4px); }
.backdrop-blur-md { backdrop-filter: blur(12px); }
.backdrop-blur-lg { backdrop-filter: blur(16px); }
.backdrop-blur-xl { backdrop-filter: blur(24px); }
#app { width: 100%; height: 100%; }
/* Loading spinner */
.loading {
@ -38,6 +48,11 @@
height: 100vh;
font-size: 1.5rem;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
</head>
<body>

3408
crates/vapora-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
{
"name": "vapora-frontend",
"version": "1.2.0",
"private": true,
"scripts": {
"css:build": "unocss \"../vapora-leptos-ui/src/**/*.rs\" \"src/**/*.rs\" --out-file assets/styles/website.css",
"css:watch": "unocss \"../vapora-leptos-ui/src/**/*.rs\" \"src/**/*.rs\" --watch --out-file assets/styles/website.css",
"css:dev": "npm run css:watch"
},
"devDependencies": {
"unocss": "^0.63.6",
"@unocss/cli": "^0.63.6",
"@iconify-json/carbon": "^1.2.1"
}
}

View File

@ -3,6 +3,7 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use log::warn;
use vapora_leptos_ui::Spinner;
use crate::api::{ApiClient, Task, TaskStatus};
use crate::components::KanbanColumn;
@ -74,7 +75,8 @@ pub fn KanbanBoard(project_id: String) -> impl IntoView {
<Show
when=move || !loading.get()
fallback=|| view! {
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center justify-center h-full gap-4">
<Spinner />
<div class="text-center text-white">
<div class="text-xl font-semibold mb-2">"Loading tasks..."</div>
<div class="text-sm text-gray-400">"Fetching from backend"</div>

View File

@ -2,9 +2,37 @@
pub mod kanban;
pub mod layout;
pub mod primitives;
// Re-export commonly used components
pub use kanban::*;
pub use layout::*;
pub use primitives::*;
// Re-export vapora-leptos-ui components
#[allow(unused_imports)]
pub use vapora_leptos_ui::{
use_toast,
validate_email,
validate_required,
// Primitives
Badge,
BlurLevel,
Button,
Card,
// Forms
FormField,
GlowColor,
Input,
Pagination,
Size,
// Navigation
SpaLink,
Spinner,
StatCard,
// Data
Table,
TableColumn,
ToastContext,
// Feedback
ToastProvider,
// Theme
Variant,
};

View File

@ -1,18 +0,0 @@
// Badge component for labels and tags
use leptos::prelude::*;
/// Badge component for displaying labels
#[component]
pub fn Badge(#[prop(default = "")] class: &'static str, children: Children) -> impl IntoView {
let combined_class = format!(
"inline-block px-3 py-1 rounded-full bg-cyan-500/20 text-cyan-400 text-xs font-medium {}",
class
);
view! {
<span class=combined_class>
{children()}
</span>
}
}

View File

@ -1,36 +0,0 @@
// Button component with gradient styling
use leptos::ev::MouseEvent;
use leptos::prelude::*;
/// Button component with gradient background
#[component]
pub fn Button(
#[prop(default = "button")] r#type: &'static str,
#[prop(optional)] on_click: Option<Box<dyn Fn(MouseEvent) + 'static>>,
#[prop(default = false)] disabled: bool,
#[prop(default = "")] class: &'static str,
children: Children,
) -> impl IntoView {
let default_class = "px-4 py-2 rounded-lg bg-gradient-to-r from-cyan-500/90 to-cyan-600/90 \
text-white font-medium transition-all duration-300 \
hover:from-cyan-400/90 hover:to-cyan-500/90 hover:shadow-lg \
hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed";
let final_class = format!("{} {}", default_class, class);
view! {
<button
type=r#type
class=final_class
disabled=disabled
on:click=move |ev| {
if let Some(ref handler) = on_click {
handler(ev);
}
}
>
{children()}
</button>
}
}

View File

@ -1,69 +0,0 @@
// Glassmorphism card component
use leptos::prelude::*;
/// Blur level for glassmorphism effect
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum BlurLevel {
None,
Sm,
Md,
Lg,
Xl,
}
/// Glow color for card shadow
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GlowColor {
None,
Cyan,
Purple,
Pink,
Blue,
}
/// Glassmorphism card component
#[component]
pub fn Card(
#[prop(default = BlurLevel::Md)] blur: BlurLevel,
#[prop(default = GlowColor::None)] glow: GlowColor,
#[prop(default = false)] hover_effect: bool,
#[prop(default = "")] class: &'static str,
children: Children,
) -> impl IntoView {
let blur_class = match blur {
BlurLevel::None => "",
BlurLevel::Sm => "backdrop-blur-sm",
BlurLevel::Md => "backdrop-blur-md",
BlurLevel::Lg => "backdrop-blur-lg",
BlurLevel::Xl => "backdrop-blur-xl",
};
let glow_class = match glow {
GlowColor::None => "",
GlowColor::Cyan => "shadow-lg shadow-cyan-500/40",
GlowColor::Purple => "shadow-lg shadow-purple-500/40",
GlowColor::Pink => "shadow-lg shadow-pink-500/40",
GlowColor::Blue => "shadow-lg shadow-blue-500/40",
};
let hover_class = if hover_effect {
"hover:border-cyan-400/70 hover:shadow-cyan-500/50 transition-all duration-300 \
cursor-pointer"
} else {
""
};
let combined_class = format!(
"bg-white/8 border border-white/20 rounded-lg p-4 {} {} {} {}",
blur_class, glow_class, hover_class, class
);
view! {
<div class=combined_class>
{children()}
</div>
}
}

View File

@ -1,41 +0,0 @@
// Input component with glassmorphism styling
#![allow(dead_code)]
use leptos::ev::Event;
use leptos::prelude::*;
/// Input field component with glassmorphism styling
#[component]
pub fn Input(
#[prop(default = "text")] input_type: &'static str,
#[prop(optional)] placeholder: Option<&'static str>,
#[prop(optional)] value: Option<Signal<String>>,
#[prop(optional)] on_input: Option<Box<dyn Fn(String) + 'static>>,
#[prop(default = "")] class: &'static str,
) -> impl IntoView {
let (internal_value, set_internal_value) = signal(String::new());
let value_signal: Signal<String> = value.unwrap_or_else(|| internal_value.into());
let combined_class = format!(
"w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white \
placeholder-white/50 focus:outline-none focus:border-cyan-400/70 focus:shadow-lg \
focus:shadow-cyan-500/30 transition-all duration-200 {}",
class
);
view! {
<input
type=input_type
placeholder=placeholder.unwrap_or("")
prop:value=move || value_signal.get()
on:input=move |ev: Event| {
let new_val = event_target_value(&ev);
set_internal_value.set(new_val.clone());
if let Some(ref handler) = on_input {
handler(new_val);
}
}
class=combined_class
/>
}
}

View File

@ -1,10 +0,0 @@
// Primitive UI components with glassmorphism design
pub mod badge;
pub mod button;
pub mod card;
pub mod input;
pub use badge::*;
pub use button::*;
pub use card::*;

View File

@ -13,11 +13,13 @@ mod config;
mod pages;
use pages::*;
use vapora_leptos_ui::ToastProvider;
/// Main application component with routing
#[component]
pub fn App() -> impl IntoView {
view! {
<ToastProvider>
<Router>
<Routes fallback=|| view! { <NotFoundPage /> }>
<Route path=StaticSegment("") view=HomePage />
@ -27,6 +29,7 @@ pub fn App() -> impl IntoView {
<Route path=StaticSegment("workflows") view=WorkflowsPage />
</Routes>
</Router>
</ToastProvider>
}
}

View File

@ -3,11 +3,14 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use log::warn;
use vapora_leptos_ui::{Pagination, Spinner, Table, TableColumn};
use crate::api::{Agent, ApiClient};
use crate::components::{Badge, Button, Card, GlowColor, NavBar};
use crate::components::NavBar;
use crate::config::AppConfig;
const ITEMS_PER_PAGE: usize = 10;
/// Agents marketplace page
#[component]
pub fn AgentsPage() -> impl IntoView {
@ -15,6 +18,7 @@ pub fn AgentsPage() -> impl IntoView {
let (agents, set_agents) = signal(Vec::<Agent>::new());
let (loading, set_loading) = signal(true);
let (error, set_error) = signal(None::<String>);
let (current_page, set_current_page) = signal(1usize);
// Fetch agents on mount
Effect::new(move |_| {
@ -44,8 +48,9 @@ pub fn AgentsPage() -> impl IntoView {
<Show
when=move || !loading.get()
fallback=|| view! {
<div class="text-center py-12">
<div class="text-xl text-white">"Loading agents..."</div>
<div class="flex flex-col items-center justify-center py-12 gap-4">
<Spinner />
<div class="text-lg text-white/80">"Loading agents..."</div>
</div>
}
>
@ -64,48 +69,71 @@ pub fn AgentsPage() -> impl IntoView {
</div>
}.into_any()
} else {
// Convert agents to table format
let columns = vec![
TableColumn::new("Name", "name").sortable(),
TableColumn::new("Role", "role").sortable(),
TableColumn::new("LLM Provider", "provider").sortable(),
TableColumn::new("Model", "model"),
TableColumn::new("Status", "status").sortable(),
TableColumn::new("Capabilities", "capabilities"),
];
let all_agents = agents.get();
let total_items = all_agents.len();
let total_pages = total_items.div_ceil(ITEMS_PER_PAGE);
// Paginate agents
let page = current_page.get();
let start_idx = (page - 1) * ITEMS_PER_PAGE;
let end_idx = (start_idx + ITEMS_PER_PAGE).min(total_items);
let rows: Vec<Vec<String>> = all_agents
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.map(|agent| {
let capabilities_str = agent.capabilities
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
vec![
agent.name,
format!("{:?}", agent.role),
agent.llm_provider,
agent.llm_model,
format!("{:?}", agent.status),
capabilities_str,
]
})
.collect();
view! {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For
each=move || agents.get()
key=|agent| agent.id.clone()
children=move |agent| {
let name = agent.name.clone();
let role = format!("{:?}", agent.role);
let llm_info = format!("LLM: {} {}", agent.llm_provider, agent.llm_model);
let capabilities = agent.capabilities.clone();
<div class="flex flex-col gap-6">
<Table columns=columns rows=rows />
{move || {
if total_pages > 1 {
view! {
<Card glow=GlowColor::Cyan hover_effect=true>
<div class="flex items-start justify-between mb-3">
<h3 class="text-lg font-semibold text-white">
{name}
</h3>
<Badge class="bg-cyan-500/20 text-cyan-400 text-xs">
{role}
</Badge>
</div>
<p class="text-gray-400 text-sm mb-4">
{llm_info}
</p>
<div class="flex gap-2 flex-wrap mb-4">
{capabilities.iter().take(3).map(|cap| {
let capability = cap.clone();
view! {
<Badge class="bg-green-500/20 text-green-400 text-xs">
{capability}
</Badge>
}
}).collect_view()}
</div>
<Button class="w-full">
"View Agent"
</Button>
</Card>
}
}
<div class="flex justify-center">
<Pagination
current_page=current_page.get()
total_pages=total_pages
on_page_change=Callback::new(move |page| {
set_current_page.set(page);
})
/>
</div>
}.into_any()
} else {
view! { <div /> }.into_any()
}
}}
</div>
}.into_any()
}
}}
</Show>

View File

@ -37,19 +37,19 @@ pub fn HomePage() -> impl IntoView {
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
<Card glow=GlowColor::Cyan hover_effect=true>
<Card glow=GlowColor::Cyan hoverable=true>
<h3 class="text-lg font-semibold text-cyan-400 mb-2">"12 Agents"</h3>
<p class="text-gray-300 text-sm">
"Architect, Developer, Reviewer, Tester, Documenter, and more"
</p>
</Card>
<Card glow=GlowColor::Purple hover_effect=true>
<Card glow=GlowColor::Purple hoverable=true>
<h3 class="text-lg font-semibold text-purple-400 mb-2">"Parallel Workflows"</h3>
<p class="text-gray-300 text-sm">
"All agents work simultaneously without waiting"
</p>
</Card>
<Card glow=GlowColor::Pink hover_effect=true>
<Card glow=GlowColor::Pink hoverable=true>
<h3 class="text-lg font-semibold text-pink-400 mb-2">"Multi-IA Routing"</h3>
<p class="text-gray-300 text-sm">
"Claude, OpenAI, Gemini, and Ollama integration"

View File

@ -5,10 +5,31 @@ use leptos::task::spawn_local;
use leptos_router::components::A;
use log::warn;
/// Extract value from input event
#[cfg(target_arch = "wasm32")]
fn event_target_value(ev: &leptos::ev::Event) -> String {
use wasm_bindgen::JsCast;
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|input| input.value())
.unwrap_or_default()
}
#[cfg(not(target_arch = "wasm32"))]
fn event_target_value(_ev: &leptos::ev::Event) -> String {
String::new()
}
use vapora_leptos_ui::{
use_toast, FormField, Input, Modal, Pagination, Spinner, StatCard, ToastType,
};
use crate::api::{ApiClient, Project};
use crate::components::{Badge, Button, Card, NavBar};
use crate::config::AppConfig;
const ITEMS_PER_PAGE: usize = 9; // 3x3 grid
/// Projects list page
#[component]
pub fn ProjectsPage() -> impl IntoView {
@ -16,6 +37,14 @@ pub fn ProjectsPage() -> impl IntoView {
let (projects, set_projects) = signal(Vec::<Project>::new());
let (loading, set_loading) = signal(true);
let (error, set_error) = signal(None::<String>);
let toast = use_toast();
let (current_page, set_current_page) = signal(1usize);
// Modal state
let (show_create_modal, set_show_create_modal) = signal(false);
let (project_title, set_project_title) = signal(String::new());
let (_project_description, set_project_description) = signal(String::new());
let (form_error, set_form_error) = signal::<Option<String>>(None);
// Fetch projects on mount
Effect::new(move |_| {
@ -23,13 +52,16 @@ pub fn ProjectsPage() -> impl IntoView {
spawn_local(async move {
match api.fetch_projects("default").await {
Ok(p) => {
let count = p.len();
set_projects.set(p);
set_loading.set(false);
toast.show_toast(format!("Loaded {} projects", count), ToastType::Success);
}
Err(e) => {
warn!("Failed to fetch projects: {}", e);
set_error.set(Some(e));
set_error.set(Some(e.clone()));
set_loading.set(false);
toast.show_toast(format!("Failed to load projects: {}", e), ToastType::Error);
}
}
});
@ -42,7 +74,7 @@ pub fn ProjectsPage() -> impl IntoView {
<div class="container mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-white">"Projects"</h1>
<Button>
<Button on_click=Callback::new(move |_| set_show_create_modal.set(true))>
"+ New Project"
</Button>
</div>
@ -50,8 +82,9 @@ pub fn ProjectsPage() -> impl IntoView {
<Show
when=move || !loading.get()
fallback=|| view! {
<div class="text-center py-12">
<div class="text-xl text-white">"Loading projects..."</div>
<div class="flex flex-col items-center justify-center py-12 gap-4">
<Spinner />
<div class="text-lg text-white/80">"Loading projects..."</div>
</div>
}
>
@ -73,10 +106,43 @@ pub fn ProjectsPage() -> impl IntoView {
</div>
}.into_any()
} else {
// Compute project metrics
let all_projects = projects.get();
let total_projects = all_projects.len();
let total_pages = total_projects.div_ceil(ITEMS_PER_PAGE);
// Paginate projects
let page = current_page.get();
let start_idx = (page - 1) * ITEMS_PER_PAGE;
let end_idx = (start_idx + ITEMS_PER_PAGE).min(total_projects);
let paginated_projects: Vec<Project> = all_projects
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
view! {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
// Stats cards
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard
label="Total Projects".to_string()
value=total_projects.to_string()
/>
<StatCard
label="Active".to_string()
value=total_projects.to_string()
trend_positive=true
/>
<StatCard
label="Completed".to_string()
value="0".to_string()
/>
</div>
// Projects grid
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
<For
each=move || projects.get()
each=move || paginated_projects.clone()
key=|project| project.id.clone().unwrap_or_default()
children=move |project| {
let project_id = project.id.clone().unwrap_or_default();
@ -85,7 +151,7 @@ pub fn ProjectsPage() -> impl IntoView {
let features = project.features.clone();
view! {
<A href=format!("/projects/{}", project_id)>
<Card hover_effect=true>
<Card hoverable=true>
<h3 class="text-lg font-semibold text-white mb-2">
{title}
</h3>
@ -108,11 +174,129 @@ pub fn ProjectsPage() -> impl IntoView {
}
/>
</div>
// Pagination
{move || {
if total_pages > 1 {
view! {
<div class="flex justify-center">
<Pagination
current_page=current_page.get()
total_pages=total_pages
on_page_change=Callback::new(move |page| {
set_current_page.set(page);
})
/>
</div>
}.into_any()
} else {
view! { <div /> }.into_any()
}
}}
}.into_any()
}
}}
</Show>
</div>
// Create Project Modal
<Show when=move || show_create_modal.get()>
<Modal on_close=Callback::new(move |_| {
set_show_create_modal.set(false);
set_project_title.set(String::new());
set_project_description.set(String::new());
set_form_error.set(None);
})>
<h2 class="text-2xl font-bold text-white mb-6">"Create New Project"</h2>
<form on:submit=move |ev| {
ev.prevent_default();
// Simple validation
if project_title.get().trim().is_empty() {
set_form_error.set(Some("Project title is required".to_string()));
return;
}
// Show success toast and close modal
toast.show_toast(
"Project created successfully!".to_string(),
ToastType::Success
);
set_show_create_modal.set(false);
set_project_title.set(String::new());
set_project_description.set(String::new());
}>
<div class="flex flex-col gap-4">
{move || {
let has_error = form_error.get().is_some();
if let Some(err_msg) = form_error.get() {
view! {
<FormField
label="Project Title".to_string()
error=err_msg
required=true
has_error=has_error
>
<Input
on_input=Callback::new(move |ev| {
let value = event_target_value(&ev);
set_project_title.set(value);
set_form_error.set(None);
})
placeholder="Enter project title"
/>
</FormField>
}.into_any()
} else {
view! {
<FormField
label="Project Title".to_string()
required=true
has_error=false
>
<Input
on_input=Callback::new(move |ev| {
let value = event_target_value(&ev);
set_project_title.set(value);
set_form_error.set(None);
})
placeholder="Enter project title"
/>
</FormField>
}.into_any()
}
}}
<FormField
label="Description".to_string()
help_text="Optional project description".to_string()
required=false
has_error=false
>
<Input
on_input=Callback::new(move |ev| {
let value = event_target_value(&ev);
set_project_description.set(value);
})
placeholder="Enter description"
/>
</FormField>
<div class="flex gap-3 justify-end mt-4">
<Button
on_click=Callback::new(move |_| set_show_create_modal.set(false))
>
"Cancel"
</Button>
<Button button_type="submit">
"Create Project"
</Button>
</div>
</div>
</form>
</Modal>
</Show>
</div>
}
}

View File

@ -0,0 +1,125 @@
import { defineConfig, presetUno, presetAttributify, presetIcons } from 'unocss'
export default defineConfig({
// Scan Rust files in component library + frontend
content: {
filesystem: [
'crates/vapora-leptos-ui/src/**/*.rs',
'crates/vapora-frontend/src/**/*.rs',
],
},
// Presets
presets: [
presetUno(), // Core Tailwind-like utilities
presetAttributify(), // Attribute syntax support
presetIcons({
cdn: 'https://esm.sh/',
}),
],
// Safelist critical utilities (prevent purgation)
safelist: [
// Layout
'flex', 'grid', 'block', 'inline-block', 'inline-flex', 'hidden',
'items-center', 'items-start', 'items-end',
'justify-center', 'justify-between', 'justify-end',
'flex-col', 'flex-row', 'flex-wrap',
// Spacing
'gap-2', 'gap-3', 'gap-4', 'gap-6',
'p-2', 'p-4', 'p-6', 'px-2', 'px-2.5', 'px-3', 'px-4', 'px-6',
'py-0.5', 'py-1.5', 'py-2', 'py-3',
'm-2', 'm-4', 'mx-auto',
// Sizing
'w-4', 'w-8', 'w-12', 'w-full', 'h-4', 'h-8', 'h-12', 'h-full', 'min-h-screen',
// Rounded
'rounded', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-full',
// Colors (glassmorphism)
'bg-white/5', 'bg-white/8', 'bg-transparent',
'border', 'border-2', 'border-white/20', 'border-cyan-400/70',
'border-cyan-500/30', 'border-t-cyan-400',
'text-xs', 'text-sm', 'text-base', 'text-lg',
'text-white', 'text-cyan-400', 'text-purple-400', 'text-red-400',
'placeholder-gray-400',
'font-medium',
// Effects
'backdrop-blur-sm', 'backdrop-blur-md', 'backdrop-blur-lg', 'backdrop-blur-xl',
'shadow-lg', 'shadow-cyan-500/40', 'shadow-cyan-500/50', 'shadow-purple-500/40',
// Transitions
'transition-all', 'duration-200', 'duration-300', 'animate-spin',
// States
'opacity-50', 'cursor-pointer', 'cursor-not-allowed',
'focus:outline-none', 'focus:ring-2', 'focus:ring-cyan-500/50', 'focus:border-cyan-400/70',
'hover:bg-white/5', 'hover:bg-white/8', 'hover:border-cyan-400/70',
'disabled:opacity-50', 'disabled:cursor-not-allowed',
// Gradients
'bg-gradient-to-r',
'from-cyan-500/90', 'from-cyan-400/90',
'via-purple-600/90', 'via-purple-500/90',
'to-pink-500/90', 'to-pink-400/90',
'from-red-500/90', 'from-red-400/90',
'to-pink-600/90',
'hover:from-cyan-400/90', 'hover:via-purple-500/90', 'hover:to-pink-400/90',
'hover:from-red-400/90', 'hover:to-pink-500/90',
// Accessibility
'sr-only',
// Role attributes
'role',
'aria-label',
],
// Shortcuts (design system utilities)
shortcuts: {
// Buttons
'ds-btn': 'rounded-lg font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-cyan-500/50',
'ds-btn-sm': 'px-3 py-1.5 text-sm',
'ds-btn-md': 'px-4 py-2 text-base',
'ds-btn-lg': 'px-6 py-3 text-lg',
// Cards
'ds-card': 'bg-white/5 backdrop-blur-md border border-white/20 rounded-xl shadow-lg transition-all duration-300',
'ds-card-hover': 'hover:bg-white/8 hover:shadow-cyan-500/20',
// Glassmorphism glass effect
'glass-effect': 'bg-white/5 backdrop-blur-md border border-white/20',
// Gradient backgrounds
'gradient-primary': 'bg-gradient-to-r from-cyan-500/90 via-purple-600/90 to-pink-500/90',
'gradient-secondary': 'bg-gradient-to-r from-cyan-400/90 via-purple-500/90 to-pink-400/90',
},
// Theme (CSS variables)
theme: {
colors: {
'bg-primary': '#0a0118',
'bg-glass': 'rgba(255, 255, 255, 0.05)',
'accent-cyan': '#22d3ee',
'accent-purple': '#a855f7',
'accent-pink': '#ec4899',
},
animation: {
keyframes: {
fadeIn: '{from{opacity:0}to{opacity:1}}',
scaleIn: '{from{opacity:0;transform:scale(0.95)}to{opacity:1;transform:scale(1)}}',
},
durations: {
fadeIn: '200ms',
scaleIn: '200ms',
},
timingFns: {
fadeIn: 'ease-out',
scaleIn: 'ease-out',
},
},
},
})

View File

@ -17,11 +17,11 @@ tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
rayon = "1.10"
rayon = { workspace = true }
dashmap = { workspace = true }
anyhow = { workspace = true }
vapora-llm-router = { path = "../vapora-llm-router" }
md5 = "0.7"
md5 = { workspace = true }
[dev-dependencies]
criterion = { workspace = true }

View File

@ -189,8 +189,8 @@ fn main() {
let provider_cost = costs_by_provider
.entry(exec.provider.clone())
.or_insert((0, 0));
provider_cost.0 += (exec.input_tokens as u64 * input_cost_cents) / 1_000_000;
provider_cost.1 += (exec.output_tokens as u64 * output_cost_cents) / 1_000_000;
provider_cost.0 += (exec.input_tokens * input_cost_cents) / 1_000_000;
provider_cost.1 += (exec.output_tokens * output_cost_cents) / 1_000_000;
}
for (provider, (input_cost, output_cost)) in costs_by_provider {

View File

@ -113,7 +113,7 @@ fn main() {
let weighted_recent = recent_7_success * 3.0;
let weighted_older: f64 = daily_data[0..23]
.iter()
.map(|d| (d.successful as f64 / d.executions as f64))
.map(|d| d.successful as f64 / d.executions as f64)
.sum::<f64>()
/ 23.0;
let weighted_older = weighted_older * 1.0;

View File

@ -25,7 +25,7 @@ fn main() {
solution: String,
}
let past_executions = vec![
let past_executions = [
Record {
id: "exec-1".to_string(),
description: "Implement user authentication with JWT".to_string(),
@ -70,10 +70,10 @@ fn main() {
// Step 3: Similarity computation (semantic matching)
println!("=== Searching for Similar Past Solutions ===\n");
let keywords_new = vec!["authentication", "API", "third-party"];
let keywords_timeout = vec!["session", "timeout", "cache"];
let keywords_jwt = vec!["JWT", "authentication", "tokens"];
let keywords_rate = vec!["API", "rate limit", "security"];
let keywords_new = ["authentication", "API", "third-party"];
let keywords_timeout = ["session", "timeout", "cache"];
let keywords_jwt = ["JWT", "authentication", "tokens"];
let keywords_rate = ["API", "rate limit", "security"];
#[derive(Clone)]
struct SimilarityResult {
@ -87,11 +87,11 @@ fn main() {
// Compute Jaccard similarity
for (idx, exec) in past_executions.iter().enumerate() {
let exec_keywords = match idx {
0 => keywords_jwt.clone(),
1 => keywords_timeout.clone(),
0 => keywords_jwt.to_vec(),
1 => keywords_timeout.to_vec(),
2 => vec!["database", "performance", "optimization"],
3 => keywords_jwt.clone(),
4 => keywords_rate.clone(),
3 => keywords_jwt.to_vec(),
4 => keywords_rate.to_vec(),
_ => vec![],
};

View File

@ -406,6 +406,7 @@ impl KGAnalytics {
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use chrono::Utc;

View File

@ -0,0 +1,33 @@
[package]
name = "vapora-leptos-ui"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Glassmorphism UI component library for Leptos"
keywords = ["leptos", "ui", "components", "glassmorphism", "wasm"]
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
ssr = []
hydrate = ["leptos/hydrate"]
[dependencies]
leptos = { workspace = true }
leptos_meta = { workspace = true }
leptos_router = { workspace = true }
serde = { workspace = true, features = ["derive"] }
chrono = { workspace = true }
uuid = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
web-sys = { workspace = true, features = ["Window", "History", "Location", "DragEvent", "DataTransfer", "KeyboardEvent", "FocusEvent", "HtmlElement", "Element", "Document", "Event", "EventTarget", "CustomEvent", "DomRect", "PopStateEvent", "NodeList", "Node", "MouseEvent", "CssStyleDeclaration", "HtmlBodyElement", "DocumentFragment"] }
gloo-timers = { workspace = true, features = ["futures"] }
js-sys = { workspace = true }

View File

@ -0,0 +1,280 @@
# vapora-leptos-ui
Glassmorphism UI component library for Leptos 0.8.15+
**Status**: Functional with core components implemented. Suitable for internal use and projects willing to contribute.
## Features
- 🎨 **Glassmorphism design** - Cyan/purple/pink gradients with backdrop blur
- 🔄 **CSR/SSR agnostic** - Components work in both client-side and server-side rendering contexts
- ♿ **Accessible** - ARIA labels, keyboard navigation (Modal), focus management (Modal)
- 📱 **Mobile responsive** - Tailwind-based responsive utilities
- 🎯 **UnoCSS compatible** - Works with build-time CSS generation
- 🧩 **Reusable** - Can be used in any Leptos 0.8+ project
## Installation
```toml
[dependencies]
vapora-leptos-ui = { path = "../vapora-leptos-ui" }
leptos = "0.8.15"
```
**Note**: Not yet published to crates.io. Use as a path dependency or git dependency.
## Quick Start
```rust
use leptos::prelude::*;
use vapora_leptos_ui::{Button, Input, Spinner, Variant, Size};
#[component]
fn App() -> impl IntoView {
view! {
<div class="p-6 space-y-4">
<Button variant=Variant::Primary size=Size::Large>
"Create Project"
</Button>
<Input
input_type="text"
placeholder="Enter your name..."
on_input=Callback::new(|_ev| {
// Handle input
})
/>
<Spinner size=Size::Medium />
</div>
}
}
```
## Available Components
### ✅ Primitives (Fully Functional)
| Component | Description | Variants | Status |
|-----------|-------------|----------|--------|
| **Button** | Glassmorphism button | Primary, Secondary, Danger, Ghost | ✅ Complete |
| **Input** | Text input field | N/A | ✅ Complete |
| **Badge** | Status badge | Custom classes | ✅ Complete |
| **Spinner** | Loading animation | Small, Medium, Large | ✅ Complete |
### ✅ Layout (Fully Functional)
| Component | Description | Features | Status |
|-----------|-------------|----------|--------|
| **Card** | Container card | Glassmorphism, hoverable, glow colors | ✅ Complete |
| **Modal** | Dialog overlay | Portal, keyboard (Escape), focus trap, backdrop click | ✅ Complete |
### ✅ Data (Fully Functional)
| Component | Description | Features | Status |
|-----------|-------------|----------|--------|
| **Table** | Data table | Internal sorting, sortable columns | ✅ Complete |
| **Pagination** | Page controls | Current page, total pages, callbacks | ✅ Complete |
| **StatCard** | Metric display | Label, value, optional trend | ✅ Complete |
### ✅ Forms (Fully Functional)
| Component | Description | Features | Status |
|-----------|-------------|----------|--------|
| **FormField** | Form wrapper | Label, error display, help text, required indicator | ✅ Complete |
| **Validation** | Helper functions | `validate_required`, `validate_email`, `validate_min_length`, `validate_max_length` | ✅ Complete |
### ✅ Feedback (Fully Functional)
| Component | Description | Features | Status |
|-----------|-------------|----------|--------|
| **ToastProvider** | Toast context | Global notifications, auto-dismiss (3s) | ✅ Complete |
| **use_toast()** | Toast hook | Show success/error/info toasts | ✅ Complete |
### ✅ Navigation (Fully Functional)
| Component | Description | Features | Status |
|-----------|-------------|----------|--------|
| **SpaLink** | Client-side link | No page reload, external link detection | ✅ Complete |
### 🔧 Utilities
| Component | Description | Status |
|-----------|-------------|--------|
| **Portal** | DOM portal | ✅ Complete (used by Modal) |
## Theme System
```rust
use vapora_leptos_ui::{Variant, Size, BlurLevel, GlowColor};
// Visual variants
Variant::Primary // Cyan-purple gradient
Variant::Secondary // Transparent with border
Variant::Danger // Red gradient
Variant::Ghost // Subtle hover
// Size variants
Size::Small // px-3 py-1.5 text-sm
Size::Medium // px-4 py-2 text-base (default)
Size::Large // px-6 py-3 text-lg
// Backdrop blur levels
BlurLevel::None, Sm, Md, Lg, Xl
// Glow colors (for Card)
GlowColor::None, Cyan, Purple, Pink, Blue
```
## Examples
See [cookbook.md](./cookbook.md) for comprehensive examples of each component.
### Modal with Form
```rust
use vapora_leptos_ui::{Modal, FormField, Input, Button};
#[component]
fn CreateProject() -> impl IntoView {
let (show_modal, set_show_modal) = signal(false);
let (title, set_title) = signal(String::new());
view! {
<Button on_click=Callback::new(move |_| set_show_modal.set(true))>
"New Project"
</Button>
<Show when=move || show_modal.get()>
<Modal on_close=Callback::new(move |_| set_show_modal.set(false))>
<h2 class="text-2xl font-bold text-white mb-4">"Create Project"</h2>
<FormField label="Title".to_string() required=true>
<Input
placeholder="Project name"
on_input=Callback::new(move |ev| {
// Extract value from event
set_title.set(event_target_value(&ev));
})
/>
</FormField>
</Modal>
</Show>
}
}
```
### Table with Pagination
```rust
use vapora_leptos_ui::{Table, TableColumn, Pagination};
#[component]
fn DataTable() -> impl IntoView {
let (current_page, set_current_page) = signal(1usize);
let items_per_page = 10;
let columns = vec![
TableColumn::new("Name", "name").sortable(),
TableColumn::new("Status", "status").sortable(),
TableColumn::new("Date", "date"),
];
// Paginate data
let total_pages = data.len().div_ceil(items_per_page);
let paginated_data = /* slice data for current page */;
view! {
<Table columns=columns rows=paginated_data />
{move || if total_pages > 1 {
view! {
<Pagination
current_page=current_page.get()
total_pages=total_pages
on_page_change=Callback::new(move |page| {
set_current_page.set(page);
})
/>
}
} else {
view! { <div /> }
}}
}
}
```
## Architecture
This library follows the **Rustelo pattern** for CSR/SSR agnostic components:
```
component/
├── mod.rs # Module exports
├── unified.rs # Public API (delegates to client/ssr)
├── client.rs # WASM/interactive implementation
└── ssr.rs # Server-side static implementation
```
Components automatically select the correct implementation:
- **WASM target (`wasm32-unknown-unknown`)**: Uses `client.rs` with full interactivity
- **Non-WASM target**: Uses `ssr.rs` for static server-side rendering
## Known Limitations
See [limitations.md](./limitations.md) for detailed list of known issues and missing features.
**Summary:**
- No i18n support yet
- Table sorting is client-side only (no server-side sorting)
- Toast auto-dismiss timing is fixed (3 seconds)
- Input is uncontrolled (no `value` prop)
- No Select, Textarea, Checkbox, Radio components yet
- No Dialog, ConfirmDialog components yet
## Development
```bash
# Build component library (WASM target)
cargo build -p vapora-leptos-ui --target wasm32-unknown-unknown
# Run clippy (strict mode)
cargo clippy -p vapora-leptos-ui --target wasm32-unknown-unknown -- -D warnings
# Format code
cargo fmt -p vapora-leptos-ui
```
## Contributing
This library is under active development. Contributions welcome:
1. Check [limitations.md](./limitations.md) for missing features
2. Follow existing component patterns (unified/client/ssr)
3. Ensure clippy passes with `-D warnings`
4. Add examples to [cookbook.md](./cookbook.md)
## License
Licensed under either of:
- Apache License, Version 2.0
- MIT License
at your option.
## Version
Current version: 1.2.0
Compatible with:
- Leptos 0.8.15
- Rust 1.75+
- UnoCSS 0.63+
**Changelog:**
- **1.2.0** (2026-02-08): Core components complete (Button, Input, Table, Modal, Pagination, FormField, Toast, Card, Badge, Spinner, SpaLink, Portal)
- **1.0.0** (2026-01-11): Initial release

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,318 @@
# Kogral Integration Audit
**Date**: 2026-02-08
**Component**: vapora-leptos-ui
**Audit Scope**: Kogral/Knowledge Graph integration with Leptos UI library
---
## Executive Summary
**Finding**: **No direct Kogral integration in vapora-leptos-ui**
The vapora-leptos-ui component library is a **presentation layer** (UI components) and correctly has **no direct dependency** on Kogral or the knowledge graph. This is the expected and correct architecture.
**Status**: ✅ **PASS** - No integration needed, architecture is sound
---
## What is Kogral?
Kogral is an **external knowledge management system** that stores project knowledge as markdown files:
- **Location**: `../kogral/.kogral/` (sibling directory to VAPORA)
- **Purpose**: Persistent storage of guidelines, patterns, and architectural decisions (ADRs)
- **Format**: Markdown files organized by category
**Structure**:
```
kogral/.kogral/
├── guidelines/
│ └── {workflow_name}.md
├── patterns/
│ └── *.md
├── adrs/
│ └── *.md
└── config.json
```
---
## Kogral in VAPORA Architecture
### Where Kogral is Used
**Backend Only** (vapora-workflow-engine):
1. **Workflow Orchestrator** (`vapora-workflow-engine/src/orchestrator.rs`)
- Enriches workflow context with Kogral knowledge before execution
- Methods:
- `enrich_context_from_kogral()` - Main entry point
- `query_kogral_guidelines()` - Loads workflow-specific guidelines
- `query_kogral_patterns()` - Loads relevant patterns
- `query_kogral_decisions()` - Loads recent ADRs
2. **CLI Commands** (`vapora-cli/src/commands.rs`)
- `--kogral` flag (default: true) to enable/disable Kogral enrichment
- Workflow execution commands use Kogral by default
**Configuration**:
```bash
export KOGRAL_PATH="/path/to/kogral/.kogral"
```
Default: `../kogral/.kogral`
### Where Kogral is NOT Used
**Frontend/UI** (vapora-leptos-ui, vapora-frontend):
- ❌ No direct Kogral integration
- ❌ No file system access (WASM limitation)
- ✅ **This is correct** - UI should not access Kogral directly
**Why this is correct**:
1. **Separation of Concerns**: UI renders data, backend provides data
2. **WASM Limitations**: Frontend runs in browser, cannot access local filesystem
3. **Security**: Frontend should not have direct access to knowledge base
4. **Architecture**: Kogral enrichment happens server-side, results delivered via API
---
## Knowledge Graph vs Kogral
VAPORA has **two separate systems**:
### 1. Kogral (External, File-based)
- **Purpose**: Static project knowledge (guidelines, patterns, ADRs)
- **Storage**: Markdown files on filesystem
- **Access**: Backend only, via filesystem reads
- **Use Case**: Enrich agent context before task execution
### 2. Knowledge Graph (Internal, Database)
- **Module**: `vapora-knowledge-graph`
- **Purpose**: Dynamic execution history, learning curves, agent performance
- **Storage**: SurrealDB (temporal KG)
- **Access**: Backend services, REST API
- **Use Case**: Track execution history, compute learning curves, analytics
**Relationship**:
```
Kogral (Static Knowledge)
↓ (enriches context)
Workflow Execution
↓ (records execution)
Knowledge Graph (Dynamic History)
```
---
## Audit Findings
### ✅ Correct: No Frontend Integration
**vapora-leptos-ui** has:
- ✅ No Kogral dependencies
- ✅ No knowledge graph direct access
- ✅ Pure presentation layer
This is the **correct architecture** because:
1. **Browser Limitations**: WASM cannot access filesystem
2. **Security**: Knowledge base should not be exposed to client
3. **Performance**: Kogral reads are I/O heavy, should be server-side
4. **Caching**: Backend can cache Kogral content, frontend cannot
### ✅ Correct: Backend Integration
**vapora-workflow-engine** has:
- ✅ Kogral integration via filesystem reads
- ✅ Environment variable configuration (`KOGRAL_PATH`)
- ✅ Graceful fallback if Kogral unavailable (warns, continues with empty)
- ✅ Context enrichment before agent execution
### ✅ Correct: Knowledge Graph Separation
**vapora-knowledge-graph** is:
- ✅ Separate module from Kogral
- ✅ Database-backed (SurrealDB)
- ✅ Tracks execution history (not static knowledge)
- ✅ Provides learning curves and analytics
---
## Potential Future Enhancements
While the current architecture is sound, these **optional** enhancements could improve Kogral integration:
### 1. Knowledge Base Viewer (Frontend)
**Use Case**: Developers want to browse guidelines/patterns in UI
**Implementation**:
```rust
// Backend API endpoint
GET /api/v1/knowledge/guidelines/{workflow_name}
GET /api/v1/knowledge/patterns
GET /api/v1/knowledge/adrs
// Frontend component
<KnowledgeViewer workflow="feature_development" />
```
**Benefit**: View Kogral content without leaving UI
**Priority**: Low (CLI access sufficient for now)
### 2. Kogral Search (Frontend)
**Use Case**: Search across all Kogral content
**Implementation**:
```rust
// Backend API
POST /api/v1/knowledge/search
{
"query": "authentication patterns",
"categories": ["patterns", "adrs"]
}
// Frontend component
<KnowledgeSearch />
```
**Benefit**: Discover relevant knowledge quickly
**Priority**: Medium (valuable for large knowledge bases)
### 3. Inline Knowledge Hints (Frontend)
**Use Case**: Show relevant Kogral content inline in task/workflow UI
**Implementation**:
```rust
// In TaskDetailPage
<TaskDetail task={task}>
<KnowledgeHints task_type={task.task_type} />
</TaskDetail>
// Fetches relevant patterns/guidelines for task type
```
**Benefit**: Context-aware knowledge delivery
**Priority**: Medium (improves discoverability)
### 4. Kogral Status Indicator (Frontend)
**Use Case**: Show if Kogral is available/configured
**Implementation**:
```rust
// Backend health check
GET /api/v1/health
{
"kogral": {
"enabled": true,
"path": "/path/to/.kogral",
"guidelines_count": 12,
"patterns_count": 8
}
}
// Frontend indicator
<SystemStatus kogral_enabled={health.kogral.enabled} />
```
**Benefit**: Transparency about Kogral availability
**Priority**: Low (developers know via environment)
---
## Kogral Audit Checklist
### ✅ Architecture
- [x] Frontend correctly has no Kogral dependency
- [x] Backend has Kogral integration in workflow engine
- [x] Knowledge Graph is separate from Kogral
- [x] Environment variable configuration exists (`KOGRAL_PATH`)
- [x] Graceful fallback when Kogral unavailable
### ✅ Implementation
- [x] Kogral query functions implemented (`query_kogral_guidelines`, etc.)
- [x] Context enrichment implemented (`enrich_context_from_kogral`)
- [x] CLI flag exists (`--kogral`)
- [x] Documentation exists (`docs/features/workflow-orchestrator.md`)
### ✅ Testing
- [x] Unit tests for workflow engine (26 tests pass)
- [x] Integration with NATS for workflow coordination
- [ ] ⚠️ No explicit Kogral integration tests (relies on filesystem, hard to mock)
### ⚠️ Gaps
- [ ] No Kogral health check endpoint
- [ ] No frontend UI for browsing Kogral content
- [ ] No search functionality across Kogral content
- [ ] No analytics on Kogral usage (which guidelines/patterns most used)
---
## Recommendations
### Immediate (Do Now)
**None** - Current architecture is sound
### Short-term (Optional, 1-2 weeks)
1. **Add Kogral health check endpoint**
- `GET /api/v1/health` includes Kogral status
- Helps debugging configuration issues
2. **Document Kogral setup in README**
- Add section on setting up Kogral for VAPORA
- Explain `KOGRAL_PATH` environment variable
### Long-term (Optional, 1-3 months)
1. **Add Knowledge Viewer UI**
- Browse guidelines/patterns/ADRs in frontend
- Markdown rendering with syntax highlighting
2. **Add Kogral search**
- Full-text search across all Kogral content
- Filter by category (guidelines/patterns/adrs)
3. **Add Kogral analytics**
- Track which knowledge is accessed most
- Identify gaps (task types without guidelines)
---
## Conclusion
**Audit Result**: ✅ **PASS**
vapora-leptos-ui correctly has **no Kogral integration**. Kogral is a backend concern (workflow enrichment) and should not be accessed directly from the frontend.
**Key Findings**:
1. ✅ Architecture is sound (backend-only Kogral access)
2. ✅ Frontend is pure presentation layer (correct)
3. ✅ Knowledge Graph and Kogral are properly separated
4. ✅ Graceful fallback when Kogral unavailable
5. ⚠️ Optional enhancements possible but not required
**No action required for vapora-leptos-ui.**
---
**Audit Completed**: 2026-02-08
**Auditor**: Claude Code (Sonnet 4.5)
**Status**: ✅ PASS (No issues found)

View File

@ -0,0 +1,207 @@
# Known Limitations
This document lists known limitations, missing features, and design decisions in vapora-leptos-ui v1.2.0.
## Missing Components
### Form Controls
- **Select** - Dropdown select component not implemented
- **Textarea** - Multi-line text input not implemented
- **Checkbox** - Checkbox input not implemented
- **Radio** - Radio button group not implemented
- **Toggle/Switch** - Toggle switch not implemented
### Layout Components
- **Dialog** - Generic dialog component (Modal exists but Dialog is more flexible)
- **ConfirmDialog** - Confirmation dialog with Yes/No buttons
- **Drawer** - Side panel/drawer component
- **Tabs** - Tabbed interface component
- **Accordion** - Collapsible sections component
### Data Display
- **DataGrid** - Advanced table with virtual scrolling, server-side sorting
- **Tree** - Tree view component
- **Timeline** - Timeline display component
### Feedback
- **Alert** - Alert/banner component
- **Progress** - Progress bar component
- **Skeleton** - Loading skeleton component
## Component Limitations
### Input
- **Uncontrolled**: No `value` prop, only `on_input` callback
- **No validation**: Parent must handle validation
- **Type limitation**: Only `input_type` prop (string), no type safety for HTML5 input types
- **No icon support**: No built-in prefix/suffix icon slots
### Table
- **Client-side sorting only**: No server-side sorting support
- **No column resizing**: Columns have fixed widths
- **No row selection**: No checkboxes or multi-select
- **No filtering**: No built-in column filters
- **No virtual scrolling**: Poor performance with 1000+ rows
- **No sticky headers**: Headers scroll with content
### Pagination
- **Fixed styling**: Limited customization of appearance
- **No page size selector**: Items per page is fixed by parent
- **No "show all" option**: Always paginates if total_pages > 1
### Modal
- **Single modal limitation**: Nested modals not tested, may have z-index issues
- **No animation customization**: Fade-in animation is fixed
- **No position control**: Always centered
- **No size variants**: Width/height controlled by content only
### FormField
- **Parent-controlled validation**: No internal validation logic
- **Static error messages**: Errors don't animate in/out independently
- **No async validation**: Parent must handle async validation
### Toast
- **Fixed duration**: Auto-dismiss at 3 seconds (not configurable)
- **Fixed position**: Always top-right corner
- **No stacking limit**: Unlimited toasts can stack (could overflow)
- **No action buttons**: Only dismiss on timeout or click
### Badge
- **No variant system**: Uses custom classes only (no Variant enum)
- **No size variants**: Fixed size
- **No icon support**: Text only
### Spinner
- **CSS animation only**: Uses keyframes, not requestAnimationFrame
- **No progress indication**: Indeterminate spinner only
- **Fixed colors**: Cyan/purple gradient (no variant customization)
### Card
- **No collapsible**: Always expanded
- **No header/footer slots**: Free-form content only
- **Limited glow colors**: Only 5 glow options (None, Cyan, Purple, Pink, Blue)
### Button
- **No icon support**: No built-in icon slots (left/right)
- **Loading state visual only**: Shows loading prop but parent handles disabled state
- **No tooltip**: No built-in tooltip on hover
### SpaLink
- **Basic external detection**: Only checks `http/https/mailto` prefixes
- **No active state**: No automatic "active" class for current route
- **No prefetch**: No link prefetching on hover
## Design Limitations
### Accessibility
- **Incomplete ARIA**: Only Modal has full ARIA attributes (role, aria-label, aria-modal)
- **No screen reader announcements**: Toast notifications not announced
- **Keyboard navigation**: Only Modal has Tab trap, other components lack full keyboard support
- **No reduced motion**: Animations don't respect `prefers-reduced-motion`
### Internationalization
- **No i18n support**: All strings are hardcoded
- **No RTL support**: Layout assumes LTR (left-to-right)
- **No locale-aware formatting**: Numbers, dates not formatted per locale
### Theming
- **Hardcoded colors**: Glassmorphism colors not customizable via CSS variables
- **No dark/light mode**: Theme assumes dark background
- **UnoCSS dependency**: Components rely on UnoCSS classes (not portable to pure CSS)
### Performance
- **Table re-renders**: Entire table re-renders on sort (no virtual DOM optimization)
- **Pagination slicing**: Creates new array on every page change
- **Portal leaks**: Portal cleanup uses UUID lookup (potential memory if many modals)
### Browser Support
- **Modern browsers only**: Uses ES2020+ features via wasm-bindgen
- **No IE11 support**: Relies on CSS Grid, Flexbox, backdrop-filter
- **Safari blur limitation**: `backdrop-blur` may have performance issues on older Safari
## Testing Gaps
- **No unit tests**: Components lack unit tests
- **No integration tests**: No tests for component interactions
- **No visual regression tests**: No screenshot comparison tests
- **Manual testing only**: All testing is manual in VAPORA frontend
## Documentation Gaps
- **No Storybook/demo site**: No interactive component showcase
- **Limited cookbook**: cookbook.md exists but examples are minimal
- **No API docs**: No generated rustdoc published online
- **No migration guide**: No guide for upgrading between versions
## Future Improvements
### High Priority
1. **Select component** - Most requested missing component
2. **Textarea component** - Common form control
3. **Table virtual scrolling** - Performance for large datasets
4. **Toast configurability** - Allow custom duration, position
5. **Accessibility audit** - Complete ARIA, keyboard navigation
### Medium Priority
1. **Checkbox/Radio components** - Complete form controls
2. **Dialog/ConfirmDialog** - More flexible than Modal
3. **Input controlled mode** - Add `value` prop for controlled components
4. **Table server-side sorting** - Support for large datasets
5. **Theme customization** - CSS variables for colors
### Low Priority
1. **Tabs component** - Nice-to-have layout component
2. **Drawer component** - Alternative to Modal
3. **Progress component** - Visual feedback for long operations
4. **i18n support** - Internationalization framework
## Known Bugs
### Confirmed Issues
- **Modal focus trap edge case**: If modal content has no focusable elements, Tab does nothing (should focus close button)
- **Pagination boundary**: If total_pages changes while on last page that no longer exists, UI shows invalid page number
- **Toast overlap**: With many rapid toasts, they can overlap vertically (no spacing)
- **Table sort stability**: Sorting equal values doesn't preserve original order
### Unconfirmed Issues
- **Safari backdrop-blur**: Reported performance issues on Safari 14, not verified
- **Portal cleanup timing**: UUID-based cleanup may not run if component unmounts during animation
## Contributing
If you encounter a limitation not listed here, please:
1. Check if it's a bug or a missing feature
2. Open an issue on GitHub (if repository exists)
3. Consider contributing a fix/implementation
See README.md for contribution guidelines.
---
**Last Updated**: 2026-02-08 (v1.2.0)

View File

@ -0,0 +1,11 @@
//! Data display components
//!
//! Tables, pagination, stat cards.
pub mod pagination;
pub mod stat_card;
pub mod table;
pub use pagination::Pagination;
pub use stat_card::StatCard;
pub use table::Table;

View File

@ -0,0 +1,116 @@
use leptos::ev;
use leptos::prelude::*;
/// Helper to generate visible page numbers with ellipsis
fn get_page_numbers(current: usize, total: usize) -> Vec<PageItem> {
if total <= 7 {
// Show all pages if 7 or fewer
(1..=total).map(PageItem::Page).collect()
} else {
let mut pages = Vec::new();
pages.push(PageItem::Page(1));
if current <= 3 {
// Near start: 1 2 3 4 5 ... 10
for i in 2..=5.min(total - 1) {
pages.push(PageItem::Page(i));
}
pages.push(PageItem::Ellipsis);
} else if current >= total - 2 {
// Near end: 1 ... 6 7 8 9 10
pages.push(PageItem::Ellipsis);
for i in (total - 4).max(2)..total {
pages.push(PageItem::Page(i));
}
} else {
// Middle: 1 ... 4 5 6 ... 10
pages.push(PageItem::Ellipsis);
for i in current - 1..=current + 1 {
pages.push(PageItem::Page(i));
}
pages.push(PageItem::Ellipsis);
}
pages.push(PageItem::Page(total));
pages
}
}
#[derive(Clone, PartialEq)]
enum PageItem {
Page(usize),
Ellipsis,
}
#[component]
pub fn PaginationClient(
current_page: usize,
total_pages: usize,
on_page_change: Callback<usize>,
class: &'static str,
) -> impl IntoView {
let page_numbers = get_page_numbers(current_page, total_pages);
let handle_prev = move |_: ev::MouseEvent| {
if current_page > 1 {
on_page_change.run(current_page - 1);
}
};
let handle_next = move |_: ev::MouseEvent| {
if current_page < total_pages {
on_page_change.run(current_page + 1);
}
};
view! {
<div class={format!("flex items-center gap-2 {}", class)}>
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 hover:bg-white/8 disabled:opacity-50"
disabled={current_page <= 1}
on:click=handle_prev
>
""
</button>
{page_numbers.into_iter().map(|item| {
match item {
PageItem::Page(page) => {
let is_current = page == current_page;
let button_class = if is_current {
"px-3 py-2 ds-btn ds-btn-sm gradient-primary text-white"
} else {
"px-3 py-2 ds-btn ds-btn-sm bg-white/5 hover:bg-white/8"
};
let handle_click = move |_: ev::MouseEvent| {
on_page_change.run(page);
};
view! {
<button
class=button_class
on:click=handle_click
>
{page.to_string()}
</button>
}.into_any()
}
PageItem::Ellipsis => {
view! {
<span class="px-2 text-white/60">"..."</span>
}.into_any()
}
}
}).collect::<Vec<_>>()}
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 hover:bg-white/8 disabled:opacity-50"
disabled={current_page >= total_pages}
on:click=handle_next
>
""
</button>
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::Pagination;

View File

@ -0,0 +1,31 @@
use leptos::prelude::*;
#[component]
pub fn PaginationSSR(
current_page: usize,
total_pages: usize,
class: &'static str,
) -> impl IntoView {
// Simplified SSR version - just show current page info
view! {
<div class={format!("flex items-center gap-2 {}", class)}>
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 disabled:opacity-50"
disabled={current_page <= 1}
>
""
</button>
<span class="px-4 py-2 text-white">
{format!("{} / {}", current_page, total_pages)}
</span>
<button
class="px-3 py-2 ds-btn ds-btn-sm bg-white/5 disabled:opacity-50"
disabled={current_page >= total_pages}
>
""
</button>
</div>
}
}

View File

@ -0,0 +1,63 @@
use leptos::prelude::*;
#[cfg(target_arch = "wasm32")]
use super::client::PaginationClient;
#[cfg(not(target_arch = "wasm32"))]
use super::ssr::PaginationSSR;
/// Pagination component
///
/// Provides page navigation controls.
///
/// # Examples
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::Pagination;
///
/// #[component]
/// fn DataList() -> impl IntoView {
/// let (page, set_page) = signal(1);
///
/// view! {
/// <Pagination
/// current_page=page.get()
/// total_pages=10
/// on_page_change=move |p| set_page(p)
/// />
/// }
/// }
/// ```
#[component]
pub fn Pagination(
/// Current active page (1-indexed)
current_page: usize,
/// Total number of pages
total_pages: usize,
/// Callback when page changes
#[prop(optional)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
on_page_change: Option<Callback<usize>>,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! {
<PaginationSSR
current_page=current_page
total_pages=total_pages
class=class
/>
};
#[cfg(target_arch = "wasm32")]
return view! {
<PaginationClient
current_page=current_page
total_pages=total_pages
on_page_change=on_page_change.unwrap_or(Callback::new(|_| {}))
class=class
/>
};
}

View File

@ -0,0 +1,42 @@
use leptos::prelude::*;
#[component]
pub fn StatCardClient(
label: String,
value: String,
change: Option<String>,
trend_positive: bool,
icon: Option<Children>,
class: &'static str,
) -> impl IntoView {
let trend_color = if trend_positive {
"text-green-400"
} else {
"text-red-400"
};
view! {
<div class={format!("ds-card p-6 {}", class)}>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm text-white/60 mb-1">{label}</p>
<p class="text-3xl font-bold text-white">{value}</p>
{change.map(|ch| {
view! {
<p class={format!("text-sm mt-2 {}", trend_color)}>
{ch}
</p>
}
})}
</div>
{icon.map(|icon_fn| {
view! {
<div class="ml-4">
{icon_fn()}
</div>
}
})}
</div>
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::StatCard;

View File

@ -0,0 +1,42 @@
use leptos::prelude::*;
#[component]
pub fn StatCardSSR(
label: String,
value: String,
change: Option<String>,
trend_positive: bool,
icon: Option<Children>,
class: &'static str,
) -> impl IntoView {
let trend_color = if trend_positive {
"text-green-400"
} else {
"text-red-400"
};
view! {
<div class={format!("ds-card p-6 {}", class)}>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm text-white/60 mb-1">{label}</p>
<p class="text-3xl font-bold text-white">{value}</p>
{change.map(|ch| {
view! {
<p class={format!("text-sm mt-2 {}", trend_color)}>
{ch}
</p>
}
})}
</div>
{icon.map(|icon_fn| {
view! {
<div class="ml-4">
{icon_fn()}
</div>
}
})}
</div>
</div>
}
}

View File

@ -0,0 +1,72 @@
use leptos::prelude::*;
#[cfg(target_arch = "wasm32")]
use super::client::StatCardClient;
#[cfg(not(target_arch = "wasm32"))]
use super::ssr::StatCardSSR;
/// Statistics card component
///
/// Displays a key metric with optional trend indicator.
///
/// # Examples
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::StatCard;
///
/// #[component]
/// fn Dashboard() -> impl IntoView {
/// view! {
/// <StatCard
/// label="Total Users"
/// value="1,234"
/// change=Some("+12%")
/// trend_positive=true
/// />
/// }
/// }
/// ```
#[component]
pub fn StatCard(
/// Label text for the statistic
label: String,
/// Main value to display
value: String,
/// Optional change indicator (e.g., "+12%", "-5%")
#[prop(optional)]
change: Option<String>,
/// Whether the change is positive (green) or negative (red)
#[prop(default = true)]
trend_positive: bool,
/// Optional icon or content to display
#[prop(optional)]
icon: Option<Children>,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! {
<StatCardSSR
label=label
value=value
change=change
trend_positive=trend_positive
icon=icon
class=class
/>
};
#[cfg(target_arch = "wasm32")]
return view! {
<StatCardClient
label=label
value=value
change=change
trend_positive=trend_positive
icon=icon
class=class
/>
};
}

View File

@ -0,0 +1,165 @@
use std::cmp::Ordering;
use leptos::ev;
use leptos::prelude::*;
use super::unified::TableColumn;
/// Sort direction for table columns
#[derive(Clone, Copy, Debug, PartialEq)]
enum SortDirection {
None,
Ascending,
Descending,
}
impl SortDirection {
fn next(self) -> Self {
match self {
Self::None => Self::Ascending,
Self::Ascending => Self::Descending,
Self::Descending => Self::None,
}
}
fn icon(self) -> &'static str {
match self {
Self::None => "",
Self::Ascending => "",
Self::Descending => "",
}
}
}
#[component]
pub fn TableClient(
columns: Vec<TableColumn>,
rows: Vec<Vec<String>>,
on_sort: Callback<String>,
class: &'static str,
) -> impl IntoView {
// Sort state
let (sort_column, set_sort_column) = signal::<Option<String>>(None);
let (sort_direction, set_sort_direction) = signal(SortDirection::None);
// Store original rows and columns for sorting
let original_rows = StoredValue::new(rows);
let columns_stored = StoredValue::new(columns.clone());
// Computed sorted rows
let sorted_rows = Memo::new(move |_| {
let rows = original_rows.get_value();
let col_key = sort_column.get();
let direction = sort_direction.get();
if direction == SortDirection::None || col_key.is_none() {
return rows;
}
let col_key = col_key.unwrap();
let columns = columns_stored.get_value();
let col_index = columns.iter().position(|c| c.key == col_key);
if let Some(idx) = col_index {
let mut sorted = rows.clone();
sorted.sort_by(|a, b| {
let a_val = a.get(idx).map(|s| s.as_str()).unwrap_or("");
let b_val = b.get(idx).map(|s| s.as_str()).unwrap_or("");
let cmp = a_val.cmp(b_val);
match direction {
SortDirection::Ascending => cmp,
SortDirection::Descending => cmp.reverse(),
SortDirection::None => Ordering::Equal,
}
});
sorted
} else {
rows
}
});
view! {
<div class={format!("ds-card overflow-hidden {}", class)}>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-white/5 border-b border-white/10">
<tr>
{columns.iter().map(|col| {
let col_clone = col.clone();
let col_key = col.key.clone();
let handle_sort = move |_: ev::MouseEvent| {
if col_clone.sortable {
let current_col = sort_column.get();
let current_dir = sort_direction.get();
if current_col.as_ref() == Some(&col_clone.key) {
// Same column: cycle direction
set_sort_direction.set(current_dir.next());
if current_dir.next() == SortDirection::None {
set_sort_column.set(None);
}
} else {
// New column: start with ascending
set_sort_column.set(Some(col_clone.key.clone()));
set_sort_direction.set(SortDirection::Ascending);
}
// Notify external handler
on_sort.run(col_clone.key.clone());
}
};
let header_class = if col.sortable {
"px-6 py-3 text-left text-sm font-medium text-white cursor-pointer hover:bg-white/8 transition-colors"
} else {
"px-6 py-3 text-left text-sm font-medium text-white"
};
view! {
<th
class=header_class
on:click=handle_sort
>
<div class="flex items-center gap-2">
{col.header.clone()}
{col.sortable.then(move || {
let is_active = sort_column.get().as_ref() == Some(&col_key);
let icon = if is_active {
sort_direction.get().icon()
} else {
SortDirection::None.icon()
};
let color = if is_active { "text-cyan-400" } else { "text-white/40" };
view! {
<span class={format!("{} transition-colors", color)}>
{icon}
</span>
}
})}
</div>
</th>
}
}).collect::<Vec<_>>()}
</tr>
</thead>
<tbody class="divide-y divide-white/10">
{move || sorted_rows.get().into_iter().map(|row| {
view! {
<tr class="hover:bg-white/5 transition-colors">
{row.into_iter().map(|cell| {
view! {
<td class="px-6 py-4 text-sm text-white/80">
{cell}
</td>
}
}).collect::<Vec<_>>()}
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::Table;

View File

@ -0,0 +1,48 @@
use leptos::prelude::*;
use super::unified::TableColumn;
#[component]
pub fn TableSSR(
columns: Vec<TableColumn>,
rows: Vec<Vec<String>>,
class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("ds-card overflow-hidden {}", class)}>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-white/5 border-b border-white/10">
<tr>
{columns.iter().map(|col| {
view! {
<th class="px-6 py-3 text-left text-sm font-medium text-white">
<div class="flex items-center gap-2">
{col.header.clone()}
{col.sortable.then(|| view! { <span class="text-white/40">""</span> })}
</div>
</th>
}
}).collect::<Vec<_>>()}
</tr>
</thead>
<tbody class="divide-y divide-white/10">
{rows.into_iter().map(|row| {
view! {
<tr class="hover:bg-white/5 transition-colors">
{row.into_iter().map(|cell| {
view! {
<td class="px-6 py-4 text-sm text-white/80">
{cell}
</td>
}
}).collect::<Vec<_>>()}
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
</div>
}
}

View File

@ -0,0 +1,94 @@
use leptos::prelude::*;
#[cfg(target_arch = "wasm32")]
use super::client::TableClient;
#[cfg(not(target_arch = "wasm32"))]
use super::ssr::TableSSR;
/// Column definition for table
#[derive(Clone, Debug)]
pub struct TableColumn {
/// Column header text
pub header: String,
/// Column key for sorting
pub key: String,
/// Whether column is sortable
pub sortable: bool,
}
impl TableColumn {
pub fn new(header: impl Into<String>, key: impl Into<String>) -> Self {
Self {
header: header.into(),
key: key.into(),
sortable: false,
}
}
pub fn sortable(mut self) -> Self {
self.sortable = true;
self
}
}
/// Table component
///
/// Displays data in a tabular format with optional sorting.
///
/// # Examples
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::{Table, TableColumn};
///
/// #[component]
/// fn UserList() -> impl IntoView {
/// let columns = vec![
/// TableColumn::new("Name", "name").sortable(),
/// TableColumn::new("Email", "email"),
/// TableColumn::new("Status", "status"),
/// ];
///
/// let rows = vec![
/// vec!["Alice".into(), "alice@example.com".into(), "Active".into()],
/// vec!["Bob".into(), "bob@example.com".into(), "Inactive".into()],
/// ];
///
/// view! {
/// <Table columns=columns rows=rows />
/// }
/// }
/// ```
#[component]
pub fn Table(
/// Column definitions
columns: Vec<TableColumn>,
/// Table data rows (each row is a vec of cell content)
rows: Vec<Vec<String>>,
/// Optional callback when column is sorted
#[prop(optional)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
on_sort: Option<Callback<String>>,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! {
<TableSSR
columns=columns
rows=rows
class=class
/>
};
#[cfg(target_arch = "wasm32")]
return view! {
<TableClient
columns=columns
rows=rows
on_sort=on_sort.unwrap_or(Callback::new(|_| {}))
class=class
/>
};
}

View File

@ -0,0 +1,7 @@
//! Feedback components
//!
//! Toasts, notifications, alerts.
pub mod toast_provider;
pub use toast_provider::{use_toast, ToastContext, ToastMessage, ToastProvider, ToastType};

View File

@ -0,0 +1,195 @@
use std::collections::VecDeque;
use leptos::prelude::*;
/// Toast message type
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToastType {
Success,
Error,
Warning,
Info,
}
impl ToastType {
/// Get CSS classes for toast type
pub fn classes(&self) -> &'static str {
match self {
Self::Success => "bg-green-500/90 border-green-400/70",
Self::Error => "bg-red-500/90 border-red-400/70",
Self::Warning => "bg-yellow-500/90 border-yellow-400/70",
Self::Info => "bg-blue-500/90 border-blue-400/70",
}
}
}
/// Toast message data
#[derive(Clone, Debug)]
pub struct ToastMessage {
pub id: String,
pub message: String,
pub toast_type: ToastType,
}
/// Toast context for managing toast notifications
#[derive(Clone, Copy)]
pub struct ToastContext {
toasts: RwSignal<VecDeque<ToastMessage>>,
}
impl ToastContext {
/// Create a new toast context
pub fn new() -> Self {
Self {
toasts: RwSignal::new(VecDeque::new()),
}
}
/// Show a toast notification
pub fn show_toast(&self, message: String, toast_type: ToastType) {
let id = uuid::Uuid::new_v4().to_string();
let toast = ToastMessage {
id: id.clone(),
message,
toast_type,
};
self.toasts.update(|t| t.push_back(toast));
// Auto-dismiss after 3 seconds
#[cfg(target_arch = "wasm32")]
{
let toasts_clone = self.toasts;
wasm_bindgen_futures::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(3000).await;
toasts_clone.update(|t| t.retain(|msg| msg.id != id));
});
}
}
/// Show success toast
pub fn success(&self, message: String) {
self.show_toast(message, ToastType::Success);
}
/// Show error toast
pub fn error(&self, message: String) {
self.show_toast(message, ToastType::Error);
}
/// Show warning toast
pub fn warning(&self, message: String) {
self.show_toast(message, ToastType::Warning);
}
/// Show info toast
pub fn info(&self, message: String) {
self.show_toast(message, ToastType::Info);
}
/// Get all toasts
pub fn toasts(&self) -> Signal<VecDeque<ToastMessage>> {
self.toasts.into()
}
/// Dismiss a toast by ID
pub fn dismiss(&self, id: &str) {
self.toasts.update(|t| t.retain(|msg| msg.id != id));
}
}
impl Default for ToastContext {
fn default() -> Self {
Self::new()
}
}
/// Hook to access toast context
///
/// # Panics
///
/// Panics if called outside of a `ToastProvider`
pub fn use_toast() -> ToastContext {
use_context::<ToastContext>().expect("use_toast must be called within a ToastProvider")
}
/// Helper function to render toast messages
fn render_toasts(toasts: VecDeque<ToastMessage>, context: ToastContext) -> Vec<impl IntoView> {
toasts
.into_iter()
.map(|toast| {
let toast_id = toast.id.clone();
let context_for_dismiss = context;
view! {
<div
class={format!(
"pointer-events-auto px-4 py-3 rounded-lg shadow-lg backdrop-blur-md \
border text-white font-medium transition-all duration-300 \
{}",
toast.toast_type.classes()
)}
>
<div class="flex items-center gap-2">
<span>{toast.message.clone()}</span>
<button
class="ml-2 text-white/80 hover:text-white"
on:click=move |_| {
context_for_dismiss.dismiss(&toast_id);
}
>
"×"
</button>
</div>
</div>
}
})
.collect()
}
/// Toast provider component
///
/// Wraps your app to provide toast notifications.
///
/// # Example
///
/// ```rust
/// use leptos::prelude::*;
/// use vapora_leptos_ui::{ToastProvider, use_toast};
///
/// #[component]
/// fn App() -> impl IntoView {
/// view! {
/// <ToastProvider>
/// <MyApp />
/// </ToastProvider>
/// }
/// }
///
/// #[component]
/// fn MyApp() -> impl IntoView {
/// let toast = use_toast();
///
/// view! {
/// <button on:click=move |_| {
/// toast.success("Operation successful!".to_string());
/// }>"Show Toast"</button>
/// }
/// }
/// ```
#[component]
pub fn ToastProvider(children: Children) -> impl IntoView {
let context = ToastContext::new();
let toasts = context.toasts();
provide_context(context);
view! {
{children()}
// Toast container
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{move || render_toasts(toasts.get(), context)}
</div>
}
}

View File

@ -0,0 +1,58 @@
use leptos::prelude::*;
#[component]
pub fn FormFieldClient(
/// Field label
label: String,
/// Error message to display
error: Option<String>,
/// Whether field is required
#[prop(default = false)]
required: bool,
/// Help text shown below field
help_text: Option<String>,
/// Whether field is in error state (for styling)
#[prop(default = false)]
has_error: bool,
/// Children (input elements)
children: Children,
/// Additional CSS classes
#[prop(default = "")]
class: &'static str,
) -> impl IntoView {
// Container class with error state styling
let container_class = format!(
"flex flex-col gap-2{} {}",
if has_error { " form-field-error" } else { "" },
class
);
view! {
<div class=container_class>
<label class="text-sm font-medium text-white">
{label}
{required.then(|| view! { <span class="text-red-400 ml-1">"*"</span> })}
</label>
<div class={if has_error { "form-field-input-error" } else { "" }}>
{children()}
</div>
{error.map(|err| {
view! {
<span class="text-sm text-red-400 animate-fadeIn">
{err}
</span>
}
})}
{help_text.map(|text| {
view! {
<span class="text-sm text-white/60">
{text}
</span>
}
})}
</div>
}
}

View File

@ -0,0 +1,9 @@
pub mod unified;
#[cfg(not(target_arch = "wasm32"))]
pub mod ssr;
#[cfg(target_arch = "wasm32")]
pub mod client;
pub use unified::FormField;

Some files were not shown because too many files have changed in this diff Show More