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
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:
parent
fcb928bf74
commit
b6a4d77421
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,6 +4,7 @@ AGENTS.md
|
|||||||
.opencode
|
.opencode
|
||||||
utils/save*sh
|
utils/save*sh
|
||||||
COMMIT_MESSAGE.md
|
COMMIT_MESSAGE.md
|
||||||
|
node_modules
|
||||||
.wrks
|
.wrks
|
||||||
nushell
|
nushell
|
||||||
nushell-*
|
nushell-*
|
||||||
|
|||||||
114
CHANGELOG.md
114
CHANGELOG.md
@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
### Added - Tiered Risk-Based Approval Gates (v1.2.0)
|
||||||
|
|
||||||
- **Risk Classification Engine** (200 LOC)
|
- **Risk Classification Engine** (200 LOC)
|
||||||
|
|||||||
112
Cargo.lock
generated
112
Cargo.lock
generated
@ -1547,7 +1547,7 @@ dependencies = [
|
|||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"cexpr",
|
"cexpr",
|
||||||
"clang-sys",
|
"clang-sys",
|
||||||
"itertools 0.10.5",
|
"itertools 0.13.0",
|
||||||
"log",
|
"log",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@ -2327,11 +2327,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colored"
|
name = "colored"
|
||||||
version = "3.0.0"
|
version = "3.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
|
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2350,8 +2350,6 @@ version = "7.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56"
|
checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm 0.27.0",
|
|
||||||
"crossterm 0.28.1",
|
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width 0.2.2",
|
||||||
@ -2698,30 +2696,6 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
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]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@ -5376,11 +5350,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283"
|
checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
|
"js-sys",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"or_poisoned",
|
"or_poisoned",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde",
|
"serde",
|
||||||
"throw_error",
|
"throw_error",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -5819,7 +5795,7 @@ checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm 0.29.0",
|
"crossterm",
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"fuzzy-matcher",
|
"fuzzy-matcher",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@ -7400,9 +7376,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md5"
|
name = "md5"
|
||||||
version = "0.7.0"
|
version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "measure_time"
|
name = "measure_time"
|
||||||
@ -7568,7 +7544,7 @@ checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"assert-json-diff",
|
"assert-json-diff",
|
||||||
"bytes",
|
"bytes",
|
||||||
"colored 3.0.0",
|
"colored 2.2.0",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
@ -13140,7 +13116,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"colored 3.0.0",
|
"colored 3.1.1",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
"dirs",
|
"dirs",
|
||||||
"futures",
|
"futures",
|
||||||
@ -13505,6 +13481,50 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
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]]
|
[[package]]
|
||||||
name = "vapora-agents"
|
name = "vapora-agents"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@ -13612,8 +13632,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"colored 2.2.0",
|
"colored 3.1.1",
|
||||||
"comfy-table",
|
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -13644,6 +13663,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"vapora-leptos-ui",
|
||||||
"vapora-shared",
|
"vapora-shared",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
@ -13672,6 +13692,23 @@ dependencies = [
|
|||||||
"vapora-llm-router",
|
"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]]
|
[[package]]
|
||||||
name = "vapora-llm-router"
|
name = "vapora-llm-router"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@ -13710,6 +13747,7 @@ dependencies = [
|
|||||||
"axum-test",
|
"axum-test",
|
||||||
"clap",
|
"clap",
|
||||||
"futures",
|
"futures",
|
||||||
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|||||||
14
Cargo.toml
14
Cargo.toml
@ -5,10 +5,13 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"crates/vapora-backend",
|
"crates/vapora-backend",
|
||||||
"crates/vapora-frontend",
|
"crates/vapora-frontend",
|
||||||
|
"crates/vapora-leptos-ui",
|
||||||
"crates/vapora-shared",
|
"crates/vapora-shared",
|
||||||
"crates/vapora-agents",
|
"crates/vapora-agents",
|
||||||
"crates/vapora-llm-router",
|
"crates/vapora-llm-router",
|
||||||
"crates/vapora-mcp-server",
|
"crates/vapora-mcp-server",
|
||||||
|
"crates/vapora-a2a",
|
||||||
|
"crates/vapora-a2a-client",
|
||||||
"crates/vapora-tracking",
|
"crates/vapora-tracking",
|
||||||
"crates/vapora-worktree",
|
"crates/vapora-worktree",
|
||||||
"crates/vapora-knowledge-graph",
|
"crates/vapora-knowledge-graph",
|
||||||
@ -33,6 +36,7 @@ categories = ["development-tools", "web-programming"]
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Vapora internal crates
|
# Vapora internal crates
|
||||||
vapora-shared = { path = "crates/vapora-shared" }
|
vapora-shared = { path = "crates/vapora-shared" }
|
||||||
|
vapora-leptos-ui = { path = "crates/vapora-leptos-ui" }
|
||||||
vapora-agents = { path = "crates/vapora-agents" }
|
vapora-agents = { path = "crates/vapora-agents" }
|
||||||
vapora-llm-router = { path = "crates/vapora-llm-router" }
|
vapora-llm-router = { path = "crates/vapora-llm-router" }
|
||||||
vapora-worktree = { path = "crates/vapora-worktree" }
|
vapora-worktree = { path = "crates/vapora-worktree" }
|
||||||
@ -41,6 +45,7 @@ vapora-analytics = { path = "crates/vapora-analytics" }
|
|||||||
vapora-swarm = { path = "crates/vapora-swarm" }
|
vapora-swarm = { path = "crates/vapora-swarm" }
|
||||||
vapora-telemetry = { path = "crates/vapora-telemetry" }
|
vapora-telemetry = { path = "crates/vapora-telemetry" }
|
||||||
vapora-workflow-engine = { path = "crates/vapora-workflow-engine" }
|
vapora-workflow-engine = { path = "crates/vapora-workflow-engine" }
|
||||||
|
vapora-a2a = { path = "crates/vapora-a2a" }
|
||||||
|
|
||||||
# SecretumVault - Post-quantum secrets management
|
# SecretumVault - Post-quantum secrets management
|
||||||
secretumvault = { path = "../secretumvault", default-features = true }
|
secretumvault = { path = "../secretumvault", default-features = true }
|
||||||
@ -112,8 +117,10 @@ once_cell = "1.21.3"
|
|||||||
|
|
||||||
# CLI
|
# CLI
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
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)
|
# TLS Support (native tokio-rustls, no axum-server)
|
||||||
rustls = { version = "0.23" }
|
rustls = { version = "0.23" }
|
||||||
@ -192,6 +199,9 @@ syn = { version = "2.0", features = ["full"] }
|
|||||||
quote = "1.0"
|
quote = "1.0"
|
||||||
proc-macro2 = "1.0"
|
proc-macro2 = "1.0"
|
||||||
|
|
||||||
|
colored = "3.1.1"
|
||||||
|
comfy-table = "7.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
27
README.md
27
README.md
@ -12,7 +12,7 @@
|
|||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
[](https://kubernetes.io)
|
[](https://kubernetes.io)
|
||||||
[](https://istio.io)
|
[](https://istio.io)
|
||||||
[](crates/)
|
[](crates/)
|
||||||
|
|
||||||
[Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Docs](docs/) • [Contributing](#contributing)
|
[Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Docs](docs/) • [Contributing](#contributing)
|
||||||
|
|
||||||
@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
## 🌟 What is Vapora v1.2?
|
## 🌟 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)
|
- ✅ **Context Switching** (Developers unified in one system instead of jumping between tools)
|
||||||
- ✅ **Knowledge Fragmentation** (Team decisions, code, and docs discoverable with RAG)
|
- ✅ **Knowledge Fragmentation** (Team decisions, code, and docs discoverable with RAG)
|
||||||
@ -377,20 +377,23 @@ provisioning workflow run workflows/deploy-full-stack.yaml
|
|||||||
vapora/
|
vapora/
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── vapora-shared/ # Core models, errors, types
|
│ ├── vapora-shared/ # Core models, errors, types
|
||||||
│ ├── vapora-backend/ # Axum REST API (40+ endpoints, 79 tests)
|
│ ├── vapora-backend/ # Axum REST API (40+ endpoints, 161 tests)
|
||||||
│ ├── vapora-agents/ # Agent orchestration + learning profiles (67 tests)
|
│ ├── vapora-agents/ # Agent orchestration + learning profiles (71 tests)
|
||||||
│ ├── vapora-llm-router/ # Multi-provider routing + budget (53 tests)
|
│ ├── vapora-llm-router/ # Multi-provider routing + budget (53 tests)
|
||||||
│ ├── vapora-swarm/ # Swarm coordination + Prometheus (6 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-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-cli/ # CLI commands (start, list, approve, cancel, etc.)
|
||||||
│ ├── vapora-frontend/ # Leptos WASM UI (Kanban)
|
│ ├── vapora-frontend/ # Leptos WASM UI (Kanban)
|
||||||
│ ├── vapora-mcp-server/ # MCP protocol gateway
|
│ ├── vapora-leptos-ui/ # Leptos component library (16 components, 4 tests)
|
||||||
│ ├── vapora-tracking/ # Task/project storage layer
|
│ ├── vapora-mcp-server/ # MCP protocol gateway (1 test)
|
||||||
│ ├── vapora-telemetry/ # OpenTelemetry integration
|
│ ├── vapora-tracking/ # Task/project storage layer (1 test)
|
||||||
│ ├── vapora-analytics/ # Event pipeline + usage stats
|
│ ├── vapora-telemetry/ # OpenTelemetry integration (16 tests)
|
||||||
│ ├── vapora-worktree/ # Git worktree management
|
│ ├── vapora-analytics/ # Event pipeline + usage stats (5 tests)
|
||||||
│ └── vapora-doc-lifecycle/ # Documentation management
|
│ ├── vapora-worktree/ # Git worktree management (4 tests)
|
||||||
|
│ └── vapora-doc-lifecycle/ # Documentation management (15 tests)
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── web/ # Landing page (optimized + minified)
|
│ ├── web/ # Landing page (optimized + minified)
|
||||||
│ │ ├── src/index.html # Source (readable, 26KB)
|
│ │ ├── src/index.html # Source (readable, 26KB)
|
||||||
@ -410,7 +413,7 @@ vapora/
|
|||||||
├── features/ # Feature documentation
|
├── features/ # Feature documentation
|
||||||
└── setup/ # Installation and CLI guides
|
└── setup/ # Installation and CLI guides
|
||||||
|
|
||||||
# Total: 15 crates, 244+ tests
|
# Total: 17 crates, 316 tests (100% pass rate)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -452,8 +452,8 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<span class="status-badge" data-en="✅ v1.2.0" data-es="✅ v1.2.0"
|
<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</span
|
>✅ v1.2.0 | 316 Tests | 100% Pass Rate</span
|
||||||
>
|
>
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<img src="/vapora.svg" alt="Vapora - Development Orchestration" />
|
<img src="/vapora.svg" alt="Vapora - Development Orchestration" />
|
||||||
@ -571,12 +571,10 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class="feature-text"
|
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-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="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-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,
|
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.
|
||||||
testing, documentation, deployment and more. Agents learn from
|
|
||||||
execution history with recency bias for continuous improvement.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-box" style="border-left-color: #a855f7">
|
<div class="feature-box" style="border-left-color: #a855f7">
|
||||||
@ -591,12 +589,10 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class="feature-text"
|
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-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="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-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
|
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.
|
||||||
expertise. Learning-based selection improves over time. Budget
|
|
||||||
enforcement with automatic fallback ensures cost control.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-box" style="border-left-color: #ec4899">
|
<div class="feature-box" style="border-left-color: #ec4899">
|
||||||
@ -611,11 +607,10 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class="feature-text"
|
class="feature-text"
|
||||||
data-en="Deploy to any Kubernetes cluster (EKS, GKE, AKS, vanilla K8s). Local Docker Compose development. Zero 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="Despliega en cualquier cluster Kubernetes (EKS, GKE, AKS, vanilla K8s). Desarrollo local con Docker Compose. Sin 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).
|
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.
|
||||||
Local Docker Compose development. Zero vendor lock-in.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -628,15 +623,16 @@
|
|||||||
>
|
>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="tech-stack">
|
<div class="tech-stack">
|
||||||
<span class="tech-badge">Rust</span>
|
<span class="tech-badge">Rust (17 crates)</span>
|
||||||
<span class="tech-badge">Axum</span>
|
<span class="tech-badge">Axum REST API</span>
|
||||||
<span class="tech-badge">SurrealDB</span>
|
<span class="tech-badge">SurrealDB</span>
|
||||||
<span class="tech-badge">NATS JetStream</span>
|
<span class="tech-badge">NATS JetStream</span>
|
||||||
<span class="tech-badge">Leptos WASM</span>
|
<span class="tech-badge">Leptos WASM</span>
|
||||||
<span class="tech-badge">Kubernetes</span>
|
<span class="tech-badge">Kubernetes</span>
|
||||||
<span class="tech-badge">Prometheus</span>
|
<span class="tech-badge">Prometheus</span>
|
||||||
<span class="tech-badge">Grafana</span>
|
|
||||||
<span class="tech-badge">Knowledge Graph</span>
|
<span class="tech-badge">Knowledge Graph</span>
|
||||||
|
<span class="tech-badge">A2A Protocol</span>
|
||||||
|
<span class="tech-badge">MCP Server</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
39
crates/vapora-a2a-client/Cargo.toml
Normal file
39
crates/vapora-a2a-client/Cargo.toml
Normal 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"] }
|
||||||
350
crates/vapora-a2a-client/README.md
Normal file
350
crates/vapora-a2a-client/README.md
Normal 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
|
||||||
294
crates/vapora-a2a-client/src/client.rs
Normal file
294
crates/vapora-a2a-client/src/client.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/vapora-a2a-client/src/error.rs
Normal file
30
crates/vapora-a2a-client/src/error.rs
Normal 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>;
|
||||||
7
crates/vapora-a2a-client/src/lib.rs
Normal file
7
crates/vapora-a2a-client/src/lib.rs
Normal 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;
|
||||||
195
crates/vapora-a2a-client/src/retry.rs
Normal file
195
crates/vapora-a2a-client/src/retry.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/vapora-a2a/Cargo.toml
Normal file
58
crates/vapora-a2a/Cargo.toml
Normal 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
259
crates/vapora-a2a/README.md
Normal 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
|
||||||
110
crates/vapora-a2a/src/agent_card.rs
Normal file
110
crates/vapora-a2a/src/agent_card.rs
Normal 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()
|
||||||
|
}
|
||||||
313
crates/vapora-a2a/src/bridge.rs
Normal file
313
crates/vapora-a2a/src/bridge.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
49
crates/vapora-a2a/src/error.rs
Normal file
49
crates/vapora-a2a/src/error.rs
Normal 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>;
|
||||||
14
crates/vapora-a2a/src/lib.rs
Normal file
14
crates/vapora-a2a/src/lib.rs
Normal 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;
|
||||||
102
crates/vapora-a2a/src/main.rs
Normal file
102
crates/vapora-a2a/src/main.rs
Normal 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(())
|
||||||
|
}
|
||||||
48
crates/vapora-a2a/src/metrics.rs
Normal file
48
crates/vapora-a2a/src/metrics.rs
Normal 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(())
|
||||||
|
}
|
||||||
132
crates/vapora-a2a/src/protocol.rs
Normal file
132
crates/vapora-a2a/src/protocol.rs
Normal 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 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
crates/vapora-a2a/src/server.rs
Normal file
170
crates/vapora-a2a/src/server.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
295
crates/vapora-a2a/src/task_manager.rs
Normal file
295
crates/vapora-a2a/src/task_manager.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
373
crates/vapora-a2a/tests/integration_test.rs
Normal file
373
crates/vapora-a2a/tests/integration_test.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -309,7 +309,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let curve = calculate_learning_curve(&executions);
|
let curve = calculate_learning_curve(&executions);
|
||||||
assert!(curve.len() > 0);
|
assert!(!curve.is_empty());
|
||||||
// Earlier executions should have lower timestamps
|
// Earlier executions should have lower timestamps
|
||||||
for i in 1..curve.len() {
|
for i in 1..curve.len() {
|
||||||
assert!(curve[i - 1].0 <= curve[i].0);
|
assert!(curve[i - 1].0 <= curve[i].0);
|
||||||
|
|||||||
@ -98,7 +98,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_from_file() -> Result<()> {
|
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 file_path = temp_dir.path().join("test.json");
|
||||||
|
|
||||||
let definition = json!({
|
let definition = json!({
|
||||||
@ -123,7 +123,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_from_directory() -> Result<()> {
|
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
|
// Create multiple agent files
|
||||||
for (role, desc) in &[("developer", "Developer"), ("reviewer", "Reviewer")] {
|
for (role, desc) in &[("developer", "Developer"), ("reviewer", "Reviewer")] {
|
||||||
@ -150,7 +150,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_by_role() -> Result<()> {
|
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!({
|
let definition = json!({
|
||||||
"role": "developer",
|
"role": "developer",
|
||||||
|
|||||||
@ -236,6 +236,6 @@ mod tests {
|
|||||||
let executor = AgentExecutor::new(metadata, rx);
|
let executor = AgentExecutor::new(metadata, rx);
|
||||||
|
|
||||||
assert!(!executor.agent.metadata.role.is_empty());
|
assert!(!executor.agent.metadata.role.is_empty());
|
||||||
assert_eq!(executor.embedding_provider.is_some(), false);
|
assert!(executor.embedding_provider.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -289,6 +289,7 @@ async fn test_learning_selection_with_budget_constraints() {
|
|||||||
|
|
||||||
// Assign multiple tasks - expert should be consistently selected
|
// Assign multiple tasks - expert should be consistently selected
|
||||||
let mut expert_count = 0;
|
let mut expert_count = 0;
|
||||||
|
#[allow(clippy::excessive_nesting)]
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
if let Ok(_task_id) = coordinator
|
if let Ok(_task_id) = coordinator
|
||||||
.assign_task(
|
.assign_task(
|
||||||
|
|||||||
@ -95,7 +95,7 @@ async fn test_batch_profile_creation() {
|
|||||||
assert_eq!(profiles.len(), 3);
|
assert_eq!(profiles.len(), 3);
|
||||||
|
|
||||||
// Verify each profile has correct properties
|
// Verify each profile has correct properties
|
||||||
for (_i, profile) in profiles.iter().enumerate() {
|
for profile in &profiles {
|
||||||
assert!(!profile.id.is_empty());
|
assert!(!profile.id.is_empty());
|
||||||
assert!(!profile.capabilities.is_empty());
|
assert!(!profile.capabilities.is_empty());
|
||||||
assert!(profile.success_rate >= 0.0 && profile.success_rate <= 1.0);
|
assert!(profile.success_rate >= 0.0 && profile.success_rate <= 1.0);
|
||||||
|
|||||||
@ -81,7 +81,7 @@ clap = { workspace = true }
|
|||||||
|
|
||||||
# Metrics
|
# Metrics
|
||||||
prometheus = { workspace = true }
|
prometheus = { workspace = true }
|
||||||
lazy_static = "1.4"
|
lazy_static = { workspace = true }
|
||||||
|
|
||||||
# TLS (native tokio-rustls)
|
# TLS (native tokio-rustls)
|
||||||
rustls = { workspace = true }
|
rustls = { workspace = true }
|
||||||
|
|||||||
@ -24,7 +24,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_endpoint() {
|
async fn test_health_endpoint() {
|
||||||
let response = health().await;
|
let _response = health().await;
|
||||||
// Response type verification - actual testing will be in integration
|
// Response type verification - actual testing will be in integration
|
||||||
// tests
|
// tests
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,10 +163,7 @@ mod tests {
|
|||||||
assert_eq!(stats.total_collections, 10);
|
assert_eq!(stats.total_collections, 10);
|
||||||
assert_eq!(stats.successful_collections, 9);
|
assert_eq!(stats.successful_collections, 9);
|
||||||
assert_eq!(stats.failed_collections, 1);
|
assert_eq!(stats.failed_collections, 1);
|
||||||
assert_eq!(
|
assert_eq!(stats.last_error.as_deref(), Some("Test error"));
|
||||||
stats.last_error.as_ref().map(|s| s.as_str()),
|
|
||||||
Some("Test error")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
// Integration tests for VAPORA backend
|
// Integration tests for VAPORA backend
|
||||||
// These tests verify the complete API functionality
|
// These tests verify the complete API functionality
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum_test::TestServer;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use vapora_shared::models::{
|
use vapora_shared::models::{
|
||||||
Agent, AgentRole, AgentStatus, Project, ProjectStatus, Task, TaskPriority, TaskStatus,
|
Agent, AgentRole, AgentStatus, Project, ProjectStatus, Task, TaskPriority, TaskStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Helper function to create a test project
|
#[allow(dead_code)]
|
||||||
fn create_test_project() -> Project {
|
fn create_test_project() -> Project {
|
||||||
Project {
|
Project {
|
||||||
id: None,
|
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 {
|
fn create_test_task(project_id: String) -> Task {
|
||||||
Task {
|
Task {
|
||||||
id: None,
|
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 {
|
fn create_test_agent() -> Agent {
|
||||||
Agent {
|
Agent {
|
||||||
id: "test-agent-1".to_string(),
|
id: "test-agent-1".to_string(),
|
||||||
|
|||||||
@ -53,7 +53,7 @@ mod provider_analytics_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_provider_efficiency_ranking_order() {
|
fn test_provider_efficiency_ranking_order() {
|
||||||
let efficiencies = vec![
|
let efficiencies = [
|
||||||
ProviderEfficiency {
|
ProviderEfficiency {
|
||||||
provider: "claude".to_string(),
|
provider: "claude".to_string(),
|
||||||
quality_score: 0.95,
|
quality_score: 0.95,
|
||||||
@ -122,7 +122,7 @@ mod provider_analytics_tests {
|
|||||||
assert_eq!(forecast.confidence, 0.9);
|
assert_eq!(forecast.confidence, 0.9);
|
||||||
|
|
||||||
// Verify reasonable projections (weekly should be ~7x daily)
|
// 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!(
|
assert!(
|
||||||
(forecast.projected_weekly_cost_cents as i32 - expected_weekly as i32).abs() <= 100
|
(forecast.projected_weekly_cost_cents as i32 - expected_weekly as i32).abs() <= 100
|
||||||
);
|
);
|
||||||
|
|||||||
@ -207,7 +207,7 @@ async fn test_workflow_service_integration() {
|
|||||||
|
|
||||||
let service = WorkflowService::new(engine, broadcaster, audit.clone());
|
let service = WorkflowService::new(engine, broadcaster, audit.clone());
|
||||||
|
|
||||||
let workflow = Workflow::new(
|
let _workflow = Workflow::new(
|
||||||
"service-test".to_string(),
|
"service-test".to_string(),
|
||||||
"Service Test".to_string(),
|
"Service Test".to_string(),
|
||||||
vec![Phase {
|
vec![Phase {
|
||||||
|
|||||||
@ -38,5 +38,4 @@ thiserror = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|
||||||
# Terminal UI
|
# Terminal UI
|
||||||
colored = "2.1"
|
colored = { workspace = true }
|
||||||
comfy-table = "7.1"
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use colored::Colorize;
|
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;
|
use crate::client::WorkflowInstanceResponse;
|
||||||
|
|
||||||
pub fn print_success(message: &str) {
|
pub fn print_success(message: &str) {
|
||||||
@ -12,45 +12,48 @@ pub fn print_error(message: &str) {
|
|||||||
eprintln!("{} {}", "✗".red().bold(), message.red());
|
eprintln!("{} {}", "✗".red().bold(), message.red());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn print_workflows_table(workflows: &[WorkflowInstanceResponse]) {
|
pub fn print_workflows_table(workflows: &[WorkflowInstanceResponse]) {
|
||||||
if workflows.is_empty() {
|
if workflows.is_empty() {
|
||||||
println!("{}", "No active workflows".yellow());
|
println!("{}", "No active workflows".yellow());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut table = Table::new();
|
// Print header
|
||||||
table
|
println!(
|
||||||
.load_preset(UTF8_FULL)
|
"{}",
|
||||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
format!(
|
||||||
.set_header(vec![
|
"{:<10} {:<20} {:<15} {:<10} {:<20}",
|
||||||
Cell::new("ID").fg(Color::Cyan),
|
"ID", "Template", "Status", "Progress", "Created"
|
||||||
Cell::new("Template").fg(Color::Cyan),
|
)
|
||||||
Cell::new("Status").fg(Color::Cyan),
|
.cyan()
|
||||||
Cell::new("Progress").fg(Color::Cyan),
|
.bold()
|
||||||
Cell::new("Created").fg(Color::Cyan),
|
);
|
||||||
]);
|
println!("{}", "─".repeat(75).cyan());
|
||||||
|
|
||||||
|
// Print rows
|
||||||
for workflow in workflows {
|
for workflow in workflows {
|
||||||
let status_cell = match workflow.status.as_str() {
|
let status_colored = match workflow.status.as_str() {
|
||||||
s if s.starts_with("running") => Cell::new(&workflow.status).fg(Color::Green),
|
s if s.starts_with("running") => workflow.status.green(),
|
||||||
s if s.starts_with("waiting") => Cell::new(&workflow.status).fg(Color::Yellow),
|
s if s.starts_with("waiting") => workflow.status.yellow(),
|
||||||
s if s.starts_with("completed") => Cell::new(&workflow.status).fg(Color::Blue),
|
s if s.starts_with("completed") => workflow.status.blue(),
|
||||||
s if s.starts_with("failed") => Cell::new(&workflow.status).fg(Color::Red),
|
s if s.starts_with("failed") => workflow.status.red(),
|
||||||
_ => Cell::new(&workflow.status),
|
_ => workflow.status.normal(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let progress = format!("{}/{}", workflow.current_stage + 1, workflow.total_stages);
|
let progress = format!("{}/{}", workflow.current_stage + 1, workflow.total_stages);
|
||||||
|
|
||||||
table.add_row(vec![
|
println!(
|
||||||
Cell::new(&workflow.id[..8]),
|
"{:<10} {:<20} {:<15} {:<10} {:<20}",
|
||||||
Cell::new(&workflow.template_name),
|
&workflow.id[..8.min(workflow.id.len())],
|
||||||
status_cell,
|
&workflow.template_name,
|
||||||
Cell::new(progress),
|
status_colored,
|
||||||
Cell::new(&workflow.created_at[..19]),
|
progress,
|
||||||
]);
|
&workflow.created_at[..19.min(workflow.created_at.len())],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{table}");
|
println!("{}", "─".repeat(75).cyan());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_workflow_details(workflow: &WorkflowInstanceResponse) {
|
pub fn print_workflow_details(workflow: &WorkflowInstanceResponse) {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ default = ["csr"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# Internal crates (disable backend features for WASM)
|
# Internal crates (disable backend features for WASM)
|
||||||
vapora-shared = { path = "../vapora-shared", default-features = false }
|
vapora-shared = { path = "../vapora-shared", default-features = false }
|
||||||
|
vapora-leptos-ui = { workspace = true }
|
||||||
|
|
||||||
# Leptos framework (CSR mode only - no SSR)
|
# Leptos framework (CSR mode only - no SSR)
|
||||||
leptos = { workspace = true, features = ["csr"] }
|
leptos = { workspace = true, features = ["csr"] }
|
||||||
|
|||||||
327
crates/vapora-frontend/assets/styles/website.css
Normal file
327
crates/vapora-frontend/assets/styles/website.css
Normal 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));}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
/>
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
/>
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
/>
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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};
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,34 +1,44 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>VAPORA - Multi-Agent Development Platform</title>
|
<title>VAPORA - Multi-Agent Development Platform</title>
|
||||||
|
|
||||||
|
<!-- UnoCSS Generated CSS -->
|
||||||
|
<link rel="stylesheet" href="/assets/styles/website.css" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Base reset */
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
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 {
|
html, body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
|
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app { width: 100%; height: 100%; }
|
||||||
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); }
|
|
||||||
|
|
||||||
/* Loading spinner */
|
/* Loading spinner */
|
||||||
.loading {
|
.loading {
|
||||||
@ -38,11 +48,16 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="loading">Loading VAPORA...</div>
|
<div class="loading">Loading VAPORA...</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
3408
crates/vapora-frontend/package-lock.json
generated
Normal file
3408
crates/vapora-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
crates/vapora-frontend/package.json
Normal file
15
crates/vapora-frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
|
use vapora_leptos_ui::Spinner;
|
||||||
|
|
||||||
use crate::api::{ApiClient, Task, TaskStatus};
|
use crate::api::{ApiClient, Task, TaskStatus};
|
||||||
use crate::components::KanbanColumn;
|
use crate::components::KanbanColumn;
|
||||||
@ -74,7 +75,8 @@ pub fn KanbanBoard(project_id: String) -> impl IntoView {
|
|||||||
<Show
|
<Show
|
||||||
when=move || !loading.get()
|
when=move || !loading.get()
|
||||||
fallback=|| view! {
|
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-center text-white">
|
||||||
<div class="text-xl font-semibold mb-2">"Loading tasks..."</div>
|
<div class="text-xl font-semibold mb-2">"Loading tasks..."</div>
|
||||||
<div class="text-sm text-gray-400">"Fetching from backend"</div>
|
<div class="text-sm text-gray-400">"Fetching from backend"</div>
|
||||||
|
|||||||
@ -2,9 +2,37 @@
|
|||||||
|
|
||||||
pub mod kanban;
|
pub mod kanban;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod primitives;
|
|
||||||
|
|
||||||
// Re-export commonly used components
|
// Re-export commonly used components
|
||||||
pub use kanban::*;
|
pub use kanban::*;
|
||||||
pub use layout::*;
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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::*;
|
|
||||||
@ -13,11 +13,13 @@ mod config;
|
|||||||
mod pages;
|
mod pages;
|
||||||
|
|
||||||
use pages::*;
|
use pages::*;
|
||||||
|
use vapora_leptos_ui::ToastProvider;
|
||||||
|
|
||||||
/// Main application component with routing
|
/// Main application component with routing
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
|
<ToastProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes fallback=|| view! { <NotFoundPage /> }>
|
<Routes fallback=|| view! { <NotFoundPage /> }>
|
||||||
<Route path=StaticSegment("") view=HomePage />
|
<Route path=StaticSegment("") view=HomePage />
|
||||||
@ -27,6 +29,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<Route path=StaticSegment("workflows") view=WorkflowsPage />
|
<Route path=StaticSegment("workflows") view=WorkflowsPage />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</ToastProvider>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,14 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
|
use vapora_leptos_ui::{Pagination, Spinner, Table, TableColumn};
|
||||||
|
|
||||||
use crate::api::{Agent, ApiClient};
|
use crate::api::{Agent, ApiClient};
|
||||||
use crate::components::{Badge, Button, Card, GlowColor, NavBar};
|
use crate::components::NavBar;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE: usize = 10;
|
||||||
|
|
||||||
/// Agents marketplace page
|
/// Agents marketplace page
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AgentsPage() -> impl IntoView {
|
pub fn AgentsPage() -> impl IntoView {
|
||||||
@ -15,6 +18,7 @@ pub fn AgentsPage() -> impl IntoView {
|
|||||||
let (agents, set_agents) = signal(Vec::<Agent>::new());
|
let (agents, set_agents) = signal(Vec::<Agent>::new());
|
||||||
let (loading, set_loading) = signal(true);
|
let (loading, set_loading) = signal(true);
|
||||||
let (error, set_error) = signal(None::<String>);
|
let (error, set_error) = signal(None::<String>);
|
||||||
|
let (current_page, set_current_page) = signal(1usize);
|
||||||
|
|
||||||
// Fetch agents on mount
|
// Fetch agents on mount
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
@ -44,8 +48,9 @@ pub fn AgentsPage() -> impl IntoView {
|
|||||||
<Show
|
<Show
|
||||||
when=move || !loading.get()
|
when=move || !loading.get()
|
||||||
fallback=|| view! {
|
fallback=|| view! {
|
||||||
<div class="text-center py-12">
|
<div class="flex flex-col items-center justify-center py-12 gap-4">
|
||||||
<div class="text-xl text-white">"Loading agents..."</div>
|
<Spinner />
|
||||||
|
<div class="text-lg text-white/80">"Loading agents..."</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -64,48 +69,71 @@ pub fn AgentsPage() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
} else {
|
} 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! {
|
view! {
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<For
|
<Table columns=columns rows=rows />
|
||||||
each=move || agents.get()
|
|
||||||
key=|agent| agent.id.clone()
|
{move || {
|
||||||
children=move |agent| {
|
if total_pages > 1 {
|
||||||
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();
|
|
||||||
view! {
|
view! {
|
||||||
<Card glow=GlowColor::Cyan hover_effect=true>
|
<div class="flex justify-center">
|
||||||
<div class="flex items-start justify-between mb-3">
|
<Pagination
|
||||||
<h3 class="text-lg font-semibold text-white">
|
current_page=current_page.get()
|
||||||
{name}
|
total_pages=total_pages
|
||||||
</h3>
|
on_page_change=Callback::new(move |page| {
|
||||||
<Badge class="bg-cyan-500/20 text-cyan-400 text-xs">
|
set_current_page.set(page);
|
||||||
{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>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div /> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@ -37,19 +37,19 @@ pub fn HomePage() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
|
<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>
|
<h3 class="text-lg font-semibold text-cyan-400 mb-2">"12 Agents"</h3>
|
||||||
<p class="text-gray-300 text-sm">
|
<p class="text-gray-300 text-sm">
|
||||||
"Architect, Developer, Reviewer, Tester, Documenter, and more"
|
"Architect, Developer, Reviewer, Tester, Documenter, and more"
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</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>
|
<h3 class="text-lg font-semibold text-purple-400 mb-2">"Parallel Workflows"</h3>
|
||||||
<p class="text-gray-300 text-sm">
|
<p class="text-gray-300 text-sm">
|
||||||
"All agents work simultaneously without waiting"
|
"All agents work simultaneously without waiting"
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</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>
|
<h3 class="text-lg font-semibold text-pink-400 mb-2">"Multi-IA Routing"</h3>
|
||||||
<p class="text-gray-300 text-sm">
|
<p class="text-gray-300 text-sm">
|
||||||
"Claude, OpenAI, Gemini, and Ollama integration"
|
"Claude, OpenAI, Gemini, and Ollama integration"
|
||||||
|
|||||||
@ -5,10 +5,31 @@ use leptos::task::spawn_local;
|
|||||||
use leptos_router::components::A;
|
use leptos_router::components::A;
|
||||||
use log::warn;
|
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::api::{ApiClient, Project};
|
||||||
use crate::components::{Badge, Button, Card, NavBar};
|
use crate::components::{Badge, Button, Card, NavBar};
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE: usize = 9; // 3x3 grid
|
||||||
|
|
||||||
/// Projects list page
|
/// Projects list page
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ProjectsPage() -> impl IntoView {
|
pub fn ProjectsPage() -> impl IntoView {
|
||||||
@ -16,6 +37,14 @@ pub fn ProjectsPage() -> impl IntoView {
|
|||||||
let (projects, set_projects) = signal(Vec::<Project>::new());
|
let (projects, set_projects) = signal(Vec::<Project>::new());
|
||||||
let (loading, set_loading) = signal(true);
|
let (loading, set_loading) = signal(true);
|
||||||
let (error, set_error) = signal(None::<String>);
|
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
|
// Fetch projects on mount
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
@ -23,13 +52,16 @@ pub fn ProjectsPage() -> impl IntoView {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api.fetch_projects("default").await {
|
match api.fetch_projects("default").await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
|
let count = p.len();
|
||||||
set_projects.set(p);
|
set_projects.set(p);
|
||||||
set_loading.set(false);
|
set_loading.set(false);
|
||||||
|
toast.show_toast(format!("Loaded {} projects", count), ToastType::Success);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to fetch projects: {}", e);
|
warn!("Failed to fetch projects: {}", e);
|
||||||
set_error.set(Some(e));
|
set_error.set(Some(e.clone()));
|
||||||
set_loading.set(false);
|
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="container mx-auto px-6 py-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<h1 class="text-3xl font-bold text-white">"Projects"</h1>
|
<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"
|
"+ New Project"
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -50,8 +82,9 @@ pub fn ProjectsPage() -> impl IntoView {
|
|||||||
<Show
|
<Show
|
||||||
when=move || !loading.get()
|
when=move || !loading.get()
|
||||||
fallback=|| view! {
|
fallback=|| view! {
|
||||||
<div class="text-center py-12">
|
<div class="flex flex-col items-center justify-center py-12 gap-4">
|
||||||
<div class="text-xl text-white">"Loading projects..."</div>
|
<Spinner />
|
||||||
|
<div class="text-lg text-white/80">"Loading projects..."</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -73,10 +106,43 @@ pub fn ProjectsPage() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
} else {
|
} 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! {
|
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
|
<For
|
||||||
each=move || projects.get()
|
each=move || paginated_projects.clone()
|
||||||
key=|project| project.id.clone().unwrap_or_default()
|
key=|project| project.id.clone().unwrap_or_default()
|
||||||
children=move |project| {
|
children=move |project| {
|
||||||
let project_id = project.id.clone().unwrap_or_default();
|
let project_id = project.id.clone().unwrap_or_default();
|
||||||
@ -85,7 +151,7 @@ pub fn ProjectsPage() -> impl IntoView {
|
|||||||
let features = project.features.clone();
|
let features = project.features.clone();
|
||||||
view! {
|
view! {
|
||||||
<A href=format!("/projects/{}", project_id)>
|
<A href=format!("/projects/{}", project_id)>
|
||||||
<Card hover_effect=true>
|
<Card hoverable=true>
|
||||||
<h3 class="text-lg font-semibold text-white mb-2">
|
<h3 class="text-lg font-semibold text-white mb-2">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
@ -108,11 +174,129 @@ pub fn ProjectsPage() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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()
|
}.into_any()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
crates/vapora-frontend/uno.config.ts
Normal file
125
crates/vapora-frontend/uno.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -17,11 +17,11 @@ tracing = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
rayon = "1.10"
|
rayon = { workspace = true }
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
vapora-llm-router = { path = "../vapora-llm-router" }
|
vapora-llm-router = { path = "../vapora-llm-router" }
|
||||||
md5 = "0.7"
|
md5 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
|
|||||||
@ -189,8 +189,8 @@ fn main() {
|
|||||||
let provider_cost = costs_by_provider
|
let provider_cost = costs_by_provider
|
||||||
.entry(exec.provider.clone())
|
.entry(exec.provider.clone())
|
||||||
.or_insert((0, 0));
|
.or_insert((0, 0));
|
||||||
provider_cost.0 += (exec.input_tokens as u64 * input_cost_cents) / 1_000_000;
|
provider_cost.0 += (exec.input_tokens * input_cost_cents) / 1_000_000;
|
||||||
provider_cost.1 += (exec.output_tokens as u64 * output_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 {
|
for (provider, (input_cost, output_cost)) in costs_by_provider {
|
||||||
|
|||||||
@ -113,7 +113,7 @@ fn main() {
|
|||||||
let weighted_recent = recent_7_success * 3.0;
|
let weighted_recent = recent_7_success * 3.0;
|
||||||
let weighted_older: f64 = daily_data[0..23]
|
let weighted_older: f64 = daily_data[0..23]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|d| (d.successful as f64 / d.executions as f64))
|
.map(|d| d.successful as f64 / d.executions as f64)
|
||||||
.sum::<f64>()
|
.sum::<f64>()
|
||||||
/ 23.0;
|
/ 23.0;
|
||||||
let weighted_older = weighted_older * 1.0;
|
let weighted_older = weighted_older * 1.0;
|
||||||
|
|||||||
@ -25,7 +25,7 @@ fn main() {
|
|||||||
solution: String,
|
solution: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
let past_executions = vec![
|
let past_executions = [
|
||||||
Record {
|
Record {
|
||||||
id: "exec-1".to_string(),
|
id: "exec-1".to_string(),
|
||||||
description: "Implement user authentication with JWT".to_string(),
|
description: "Implement user authentication with JWT".to_string(),
|
||||||
@ -70,10 +70,10 @@ fn main() {
|
|||||||
// Step 3: Similarity computation (semantic matching)
|
// Step 3: Similarity computation (semantic matching)
|
||||||
println!("=== Searching for Similar Past Solutions ===\n");
|
println!("=== Searching for Similar Past Solutions ===\n");
|
||||||
|
|
||||||
let keywords_new = vec!["authentication", "API", "third-party"];
|
let keywords_new = ["authentication", "API", "third-party"];
|
||||||
let keywords_timeout = vec!["session", "timeout", "cache"];
|
let keywords_timeout = ["session", "timeout", "cache"];
|
||||||
let keywords_jwt = vec!["JWT", "authentication", "tokens"];
|
let keywords_jwt = ["JWT", "authentication", "tokens"];
|
||||||
let keywords_rate = vec!["API", "rate limit", "security"];
|
let keywords_rate = ["API", "rate limit", "security"];
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct SimilarityResult {
|
struct SimilarityResult {
|
||||||
@ -87,11 +87,11 @@ fn main() {
|
|||||||
// Compute Jaccard similarity
|
// Compute Jaccard similarity
|
||||||
for (idx, exec) in past_executions.iter().enumerate() {
|
for (idx, exec) in past_executions.iter().enumerate() {
|
||||||
let exec_keywords = match idx {
|
let exec_keywords = match idx {
|
||||||
0 => keywords_jwt.clone(),
|
0 => keywords_jwt.to_vec(),
|
||||||
1 => keywords_timeout.clone(),
|
1 => keywords_timeout.to_vec(),
|
||||||
2 => vec!["database", "performance", "optimization"],
|
2 => vec!["database", "performance", "optimization"],
|
||||||
3 => keywords_jwt.clone(),
|
3 => keywords_jwt.to_vec(),
|
||||||
4 => keywords_rate.clone(),
|
4 => keywords_rate.to_vec(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -406,6 +406,7 @@ impl KGAnalytics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::items_after_test_module)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
|
|||||||
33
crates/vapora-leptos-ui/Cargo.toml
Normal file
33
crates/vapora-leptos-ui/Cargo.toml
Normal 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 }
|
||||||
280
crates/vapora-leptos-ui/README.md
Normal file
280
crates/vapora-leptos-ui/README.md
Normal 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
|
||||||
1205
crates/vapora-leptos-ui/cookbook.md
Normal file
1205
crates/vapora-leptos-ui/cookbook.md
Normal file
File diff suppressed because it is too large
Load Diff
318
crates/vapora-leptos-ui/kogral-audit.md
Normal file
318
crates/vapora-leptos-ui/kogral-audit.md
Normal 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)
|
||||||
207
crates/vapora-leptos-ui/limitations.md
Normal file
207
crates/vapora-leptos-ui/limitations.md
Normal 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)
|
||||||
11
crates/vapora-leptos-ui/src/data/mod.rs
Normal file
11
crates/vapora-leptos-ui/src/data/mod.rs
Normal 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;
|
||||||
116
crates/vapora-leptos-ui/src/data/pagination/client.rs
Normal file
116
crates/vapora-leptos-ui/src/data/pagination/client.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/vapora-leptos-ui/src/data/pagination/mod.rs
Normal file
9
crates/vapora-leptos-ui/src/data/pagination/mod.rs
Normal 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;
|
||||||
31
crates/vapora-leptos-ui/src/data/pagination/ssr.rs
Normal file
31
crates/vapora-leptos-ui/src/data/pagination/ssr.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
63
crates/vapora-leptos-ui/src/data/pagination/unified.rs
Normal file
63
crates/vapora-leptos-ui/src/data/pagination/unified.rs
Normal 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
|
||||||
|
/>
|
||||||
|
};
|
||||||
|
}
|
||||||
42
crates/vapora-leptos-ui/src/data/stat_card/client.rs
Normal file
42
crates/vapora-leptos-ui/src/data/stat_card/client.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/vapora-leptos-ui/src/data/stat_card/mod.rs
Normal file
9
crates/vapora-leptos-ui/src/data/stat_card/mod.rs
Normal 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;
|
||||||
42
crates/vapora-leptos-ui/src/data/stat_card/ssr.rs
Normal file
42
crates/vapora-leptos-ui/src/data/stat_card/ssr.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
72
crates/vapora-leptos-ui/src/data/stat_card/unified.rs
Normal file
72
crates/vapora-leptos-ui/src/data/stat_card/unified.rs
Normal 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
|
||||||
|
/>
|
||||||
|
};
|
||||||
|
}
|
||||||
165
crates/vapora-leptos-ui/src/data/table/client.rs
Normal file
165
crates/vapora-leptos-ui/src/data/table/client.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/vapora-leptos-ui/src/data/table/mod.rs
Normal file
9
crates/vapora-leptos-ui/src/data/table/mod.rs
Normal 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;
|
||||||
48
crates/vapora-leptos-ui/src/data/table/ssr.rs
Normal file
48
crates/vapora-leptos-ui/src/data/table/ssr.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
94
crates/vapora-leptos-ui/src/data/table/unified.rs
Normal file
94
crates/vapora-leptos-ui/src/data/table/unified.rs
Normal 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
|
||||||
|
/>
|
||||||
|
};
|
||||||
|
}
|
||||||
7
crates/vapora-leptos-ui/src/feedback/mod.rs
Normal file
7
crates/vapora-leptos-ui/src/feedback/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
//! Feedback components
|
||||||
|
//!
|
||||||
|
//! Toasts, notifications, alerts.
|
||||||
|
|
||||||
|
pub mod toast_provider;
|
||||||
|
|
||||||
|
pub use toast_provider::{use_toast, ToastContext, ToastMessage, ToastProvider, ToastType};
|
||||||
195
crates/vapora-leptos-ui/src/feedback/toast_provider.rs
Normal file
195
crates/vapora-leptos-ui/src/feedback/toast_provider.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/vapora-leptos-ui/src/forms/form_field/client.rs
Normal file
58
crates/vapora-leptos-ui/src/forms/form_field/client.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/vapora-leptos-ui/src/forms/form_field/mod.rs
Normal file
9
crates/vapora-leptos-ui/src/forms/form_field/mod.rs
Normal 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
Loading…
x
Reference in New Issue
Block a user