From eb6498c3fb8eb7ef53a1e7d556f870434b201ce1 Mon Sep 17 00:00:00 2001 From: Kevin Karsopawiro Date: Thu, 23 Oct 2025 13:27:25 +0200 Subject: [PATCH 1/7] feat: initial setup --- publar/.gitignore | 6 + publar/Cargo.toml | 36 + publar/Dioxus.toml | 12 + publar/LICENSE | 21 + publar/README.md | 354 +++++ publar/SCENARIOS.md | 228 ++++ publar/assets/input.css | 41 + publar/assets/tailwind.css | 1 + publar/build.rs | 29 + publar/examples/testnet_write_read.rs | 139 ++ publar/package.json | 21 + publar/src/api.rs | 43 + publar/src/components/context_sidebar.rs | 581 ++++++++ publar/src/components/mod.rs | 7 + .../src/components/network_visualization.rs | 534 ++++++++ publar/src/components/topbar.rs | 249 ++++ publar/src/force_layout.rs | 158 +++ publar/src/main.rs | 1204 +++++++++++++++++ publar/src/scenario.rs | 371 +++++ publar/src/testnet.rs | 212 +++ publar/tailwind.config.js | 11 + 21 files changed, 4258 insertions(+) create mode 100644 publar/.gitignore create mode 100644 publar/Cargo.toml create mode 100644 publar/Dioxus.toml create mode 100644 publar/LICENSE create mode 100644 publar/README.md create mode 100644 publar/SCENARIOS.md create mode 100644 publar/assets/input.css create mode 100644 publar/assets/tailwind.css create mode 100644 publar/build.rs create mode 100644 publar/examples/testnet_write_read.rs create mode 100644 publar/package.json create mode 100644 publar/src/api.rs create mode 100644 publar/src/components/context_sidebar.rs create mode 100644 publar/src/components/mod.rs create mode 100644 publar/src/components/network_visualization.rs create mode 100644 publar/src/components/topbar.rs create mode 100644 publar/src/force_layout.rs create mode 100644 publar/src/main.rs create mode 100644 publar/src/scenario.rs create mode 100644 publar/src/testnet.rs create mode 100644 publar/tailwind.config.js diff --git a/publar/.gitignore b/publar/.gitignore new file mode 100644 index 0000000..a5fafab --- /dev/null +++ b/publar/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock +node_modules/ +package-lock.json +.claude +.DS_STORE \ No newline at end of file diff --git a/publar/Cargo.toml b/publar/Cargo.toml new file mode 100644 index 0000000..3b70169 --- /dev/null +++ b/publar/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "publar" +version = "0.1.0" +edition = "2021" +authors = ["Kevin Karsopawiro "] +description = "A desktop application for visualizing, testing, and debugging Pubky network topologies" +license = "MIT" +repository = "https://github.com/pubky/publar" + +[dependencies] +dioxus = { version = "0.6", features = ["desktop"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +pubky-testnet = "0.6.0-rc.6" +pubky = "0.6.0-rc.6" +axum = "0.7" +tower-http = { version = "0.5", features = ["cors"] } +chrono = "0.4" +reqwest = "0.11" +rfd = "0.14" + +[profile] + +[profile.wasm-dev] +inherits = "dev" +opt-level = 1 + +[profile.server-dev] +inherits = "dev" + +[profile.android-dev] +inherits = "dev" diff --git a/publar/Dioxus.toml b/publar/Dioxus.toml new file mode 100644 index 0000000..8e2ccd2 --- /dev/null +++ b/publar/Dioxus.toml @@ -0,0 +1,12 @@ +[application] +name = "publar" +default_platform = "desktop" + +[bundle] +identifier = "com.synonym.publar" +publisher = "Synonym" +icon = [] + +[application.desktop] +window_width = 800 +window_height = 600 diff --git a/publar/LICENSE b/publar/LICENSE new file mode 100644 index 0000000..cfe65fa --- /dev/null +++ b/publar/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Synonym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/publar/README.md b/publar/README.md new file mode 100644 index 0000000..d6b15b8 --- /dev/null +++ b/publar/README.md @@ -0,0 +1,354 @@ +# Publar + +A desktop application for visualizing, testing, and debugging Pubky network topologies, but most importantly to make development on top of Pubky easier. + +## What It Does + +Publar (Pubky + Polar) is a visual testing and simulation tool for the Pubky protocol that lets you: + +- **Create and manage local testnet nodes**: Spin up multiple homeservers and clients with a single click +- **Visualize network topology**: See homeservers, clients, and their connections in an interactive force-directed graph +- **Test data operations**: Connect clients to homeservers, write data, and read it back with real-time feedback +- **Run automated scenarios**: Execute pre-built test sequences to validate network behavior under various conditions +- **Debug network issues**: Monitor all operations through a detailed event log with timestamps + +Think of it as a development sandbox where you can experiment with Pubky networks before deploying to production. + +## Why It Matters + +Developing distributed systems is hard. Publar makes it easier by: + +1. **Reducing iteration time**: No need to manually spin up servers, create clients, and connect them via CLI +2. **Visual debugging**: See exactly what's happening in your network at a glance +3. **Reproducible testing**: Scenarios ensure consistent test conditions across development sessions +4. **Learning tool**: Perfect for understanding how Pubky homeservers and clients interact +5. **Integration testing**: Test how your application behaves with multiple homeservers and concurrent clients + +If you're building on Pubky, Publar helps you move faster and catch issues early. + +## Setup & Run + +### Prerequisites + +- **Rust** (1.70+): Install from [rustup.rs](https://rustup.rs) +- **Node.js** (16+): Required for Tailwind CSS compilation + +### Quick Start + +```bash +# Clone the repository +git clone https://github.com/yourusername/publar.git +cd publar + +# Install npm dependencies for Tailwind CSS +npm install + +# Build and run (Tailwind CSS compiles automatically) +cargo run +``` + +The application window will open automatically. Start by adding a homeserver or running a pre-built scenario. + +### Running Examples + +To test external connections to Publar-managed homeservers: + +```bash +# 1. Start Publar and create a homeserver (note its URL and public key from the UI) +cargo run + +# 2. In a separate terminal, run the example +cargo run --example testnet_write_read +``` + +Example: + +```bash +cargo run --example testnet_write_read http://127.0.0.1:50000/ z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK +``` + +## Usage + +### Manual Network Management + +1. **Add nodes**: Click "Add Homeserver" or "Add Client" to create new nodes +2. **Select nodes**: Click any node to view details and available actions in the right sidebar +3. **Connect clients**: Select a client, choose a homeserver from the dropdown, and click "Connect to Homeserver" +4. **Write data**: Select a connected client, enter a path (e.g., `/pub/publar/test.txt`) and content, then click "Write" +5. **Read data**: Select a connected client, enter a path, and click "Read" +6. **Interact with visualization**: Drag nodes to reposition them, resize panels by dragging edges + +### Automated Scenarios + +Select a scenario from the dropdown and click "Play Scenario": + +- **Simple Connection**: 1 homeserver + 1 client with a write/read operation +- **Multi Client**: 1 homeserver + 3 clients, each writing data independently +- **Rate Limiting**: 1 homeserver + 5 clients writing rapidly to test concurrent operations + +Scenarios run automatically with timed operations, perfect for regression testing. + +### Reset + +Click "Reset" to clear all nodes and connections while keeping the testnet running. + +## Architecture + +### High-Level Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Publar UI (Dioxus) │ +│ ┌─────────────┐ ┌──────────────────┐ ┌───────────────┐ │ +│ │ Topbar │ │ Visualization │ │ Sidebar │ │ +│ │ Controls │ │ Force-Directed │ │ Node Details │ │ +│ │ │ │ Graph │ │ Event Log │ │ +│ └─────────────┘ └──────────────────┘ └───────────────┘ │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Testnet Manager │ + │ (pubky-testnet) │ + └──────────┬───────────┘ + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │Homeserver│ │Homeserver│ │Homeserver│ + │ :50000 │ │ :50001 │ │ :50002 │ + └─────────┘ └─────────┘ └─────────┘ + ▲ ▲ ▲ + │ │ │ + ┌────┴───┐ ┌───┴────┐ ┌───┴────┐ + │Client 1│ │Client 2│ │Client 3│ + └────────┘ └────────┘ └────────┘ +``` + +### Components + +**Frontend (Dioxus 0.6)** + +- **Topbar**: Controls for adding nodes, running scenarios, and resetting +- **Network Visualization**: Interactive SVG graph with force-directed layout (Fruchterman-Reingold algorithm) + - Homeservers: White circles with port labels + - Clients: Lime green (#c7ff00) circles with truncated public keys + - Connections: Lime green lines showing client-homeserver relationships +- **Context Sidebar**: Resizable panel with node details, actions, and event log + +**Backend (pubky-testnet + pubky)** + +- **Testnet Manager**: Manages multiple homeserver processes via pubky-testnet +- **Session Management**: Maintains client sessions with homeservers +- **Scenario Engine**: Executes timed operations (create, connect, write, read) + +**State Management** + +- Dioxus signals for reactive UI updates +- Shared Arc> for cross-task state access + +### Data Flow + +``` +User Action → Dioxus Event Handler → Testnet Manager → Homeserver HTTP API + ↓ + Update Signals + ↓ + UI Re-renders + ↓ + Log Event Entry +``` + +### Key Algorithms + +**Force-Directed Layout** + +- **Repulsion**: All nodes push away from each other (prevents overlap) +- **Spring Forces**: Connected nodes maintain ideal distance (~150px) +- **Damping**: Velocity decay creates smooth stabilization +- Runs continuously every 50ms for dynamic repositioning + +**Scenario Execution** + +- Operations grouped by timestamp +- Sequential execution with precise timing +- Async/await for non-blocking UI + +## File Structure + +``` +publar/ +├── src/ +│ ├── main.rs # App entry, state, event handlers +│ ├── components/ +│ │ ├── topbar.rs # Top control bar +│ │ ├── network_visualization.rs # SVG graph with force layout +│ │ └── context_sidebar.rs # Right panel (details + log) +│ ├── testnet.rs # Wrapper around pubky-testnet +│ ├── scenario.rs # Scenario definitions and operations +│ ├── force_layout.rs # Fruchterman-Reingold algorithm +│ └── api.rs # REST API (future) +├── examples/ +│ └── testnet_write_read.rs # External connection example +├── assets/ +│ ├── input.css # Tailwind source +│ └── tailwind.css # Generated CSS +├── build.rs # Compiles Tailwind on build +├── tailwind.config.js # Tailwind configuration +├── package.json # npm dependencies +├── Cargo.toml # Rust dependencies +├── Dioxus.toml # Dioxus bundling configuration +└── SCENARIOS.md # JSON scenario documentation +``` + +## Key Technologies + +- **[Dioxus 0.6](https://dioxuslabs.com/)**: Cross-platform UI framework (desktop, web, mobile) +- **[Tailwind CSS v3](https://tailwindcss.com/)**: Utility-first styling +- **[Tokio](https://tokio.rs/)**: Async runtime +- **[pubky-testnet 0.6.0-rc.6](https://github.com/pubky/pubky)**: Manages local homeserver processes +- **[pubky 0.6.0-rc.6](https://github.com/pubky/pubky)**: Client library for Pubky protocol + +## Development + +### Building + +```bash +# Development build (faster compilation, slower runtime) +cargo build + +# Release build (optimized) +cargo build --release + +# Run with logging +RUST_LOG=debug cargo run +``` + +### CSS Changes + +Tailwind CSS recompiles automatically on `cargo build`. To manually rebuild: + +```bash +npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --watch +``` + +### Adding Scenarios + +Scenarios are stored in `~/.publar/scenarios/` as JSON files. See [SCENARIOS.md](SCENARIOS.md) for the complete JSON schema and examples. + +You can also add scenarios programmatically in `src/scenario.rs` by editing the `built_in_scenarios()` function. + +## Building for Distribution + +### Prerequisites + +Install the Dioxus CLI: + +```bash +cargo install dioxus-cli +``` + +### Building a macOS .app Bundle + +To create a distributable macOS application: + +```bash +dx bundle --platform desktop --package-types "macos" +``` + +The `.app` bundle will be created at: +``` +target/dx/publar/bundle/macos/bundle/macos/Publar.app +``` + +You can then: +- Copy it to `/Applications/` or anywhere else +- Distribute it to users (no Rust installation required) +- Double-click to run like any native macOS app + +**Size**: ~23MB + +**Requirements**: macOS 10.15+ (ARM64 for Apple Silicon, x86_64 for Intel) + +### Distribution Checklist + +For official distribution, you should: + +1. **Code Signing** (macOS): + ```bash + codesign --force --deep --sign "Developer ID Application: Your Name" Publar.app + ``` + +2. **Notarization** (macOS 10.15+): + - Submit to Apple for notarization + - Required for users to run the app without security warnings + +3. **Create DMG** (optional): + ```bash + # Use tools like create-dmg or node-appdmg + create-dmg Publar.app + ``` + +### Cross-Platform Builds + +The `dx bundle` command supports multiple platforms: + +```bash +# macOS (on macOS) +dx bundle --platform desktop --package-types "macos" + +# Windows (on Windows) +dx bundle --platform desktop --package-types "msi" + +# Linux (on Linux) +dx bundle --platform desktop --package-types "deb" +dx bundle --platform desktop --package-types "appimage" +``` + +**Note**: You can only bundle for your current platform. Cross-compilation is not supported. + +## Troubleshooting + +**Issue**: App crashes on startup + +- **Solution**: Ensure no other processes are using ports 50000-51000 + +**Issue**: Homeserver fails to start + +- **Solution**: Check `RUST_LOG=debug cargo run` for detailed error messages + +**Issue**: Connections fail + +- **Solution**: Wait for homeserver to show "Running" status before connecting clients + +**Issue**: Force layout looks chaotic + +- **Solution**: Click "Reset" and recreate nodes with fewer initial connections + +## Contributing + +Contributions are welcome! Areas that need help: + +- [ ] Add more pre-built scenarios +- [ ] Implement REST API for external control +- [ ] Add export/import for network topologies +- [ ] Improve force-directed layout performance +- [ ] Add search/filter for event log + +Please open an issue before starting work on major features. + +## License + +MIT License - see [LICENSE](LICENSE) file for details + +## Related Projects + +- **[Pubky](https://github.com/pubky/pubky)**: The core Pubky protocol and client library +- **[Polar](https://github.com/jamaljsr/polar)**: Similar tool for Bitcoin Lightning Network (inspiration for Publar) +- **[pubky-nexus](https://github.com/pubky/pubky-nexus)**: Social graph indexer for Pubky + +## Acknowledgments + +- Inspired by [Polar](https://github.com/jamaljsr/polar) for Lightning Network development +- Built on the excellent [Dioxus](https://dioxuslabs.com/) framework +- Thanks to the Pubky team for the testnet library diff --git a/publar/SCENARIOS.md b/publar/SCENARIOS.md new file mode 100644 index 0000000..b406be5 --- /dev/null +++ b/publar/SCENARIOS.md @@ -0,0 +1,228 @@ +# Publar Scenarios + +Scenarios are stored in `~/.publar/scenarios/` and define automated test sequences for Publar. + +## JSON Schema + +Scenarios are defined in JSON format with the following structure: + +```json +{ + "name": "Scenario Name", + "description": "What this scenario tests", + "operations": [ + { + "at_seconds": 0.0, + "type": "create_homeserver", + "id": "homeserver-1" + }, + { + "at_seconds": 1.0, + "type": "wait_for_homeserver", + "homeserver_id": "homeserver-1", + "timeout_seconds": 5.0 + }, + { + "at_seconds": 2.0, + "type": "create_client", + "id": "client-1" + }, + { + "at_seconds": 3.0, + "type": "connect_client", + "client_id": "client-1", + "homeserver_id": "homeserver-1" + }, + { + "at_seconds": 4.0, + "type": "write_data", + "client_id": "client-1", + "path": "/pub/publar/test.txt", + "content": "Hello from Publar!" + }, + { + "at_seconds": 5.0, + "type": "read_data", + "client_id": "client-1", + "path": "/pub/publar/test.txt" + } + ] +} +``` + +## Action Types + +### create_homeserver +Creates a new homeserver instance. + +```json +{ + "type": "create_homeserver", + "id": "unique-homeserver-id" +} +``` + +**Fields:** +- `id` (string): Unique identifier for this homeserver + +--- + +### wait_for_homeserver +Waits for a homeserver to become ready before proceeding. + +```json +{ + "type": "wait_for_homeserver", + "homeserver_id": "homeserver-1", + "timeout_seconds": 5.0 +} +``` + +**Fields:** +- `homeserver_id` (string): ID of the homeserver to wait for +- `timeout_seconds` (number): Maximum time to wait (default: 5.0) + +--- + +### create_client +Creates a new client with a generated keypair. + +```json +{ + "type": "create_client", + "id": "unique-client-id" +} +``` + +**Fields:** +- `id` (string): Unique identifier for this client + +--- + +### connect_client +Connects a client to a homeserver (performs signup). + +```json +{ + "type": "connect_client", + "client_id": "client-1", + "homeserver_id": "homeserver-1" +} +``` + +**Fields:** +- `client_id` (string): ID of the client to connect +- `homeserver_id` (string): ID of the homeserver to connect to + +--- + +### write_data +Writes data to a homeserver via a connected client. + +```json +{ + "type": "write_data", + "client_id": "client-1", + "path": "/pub/publar/example.txt", + "content": "Data to write" +} +``` + +**Fields:** +- `client_id` (string): ID of the connected client +- `path` (string): Path to write to (e.g., `/pub/publar/file.txt`) +- `content` (string): Content to write + +--- + +### read_data +Reads data from a homeserver via a connected client. + +```json +{ + "type": "read_data", + "client_id": "client-1", + "path": "/pub/publar/example.txt" +} +``` + +**Fields:** +- `client_id` (string): ID of the connected client +- `path` (string): Path to read from + +--- + +## Timing + +The `at_seconds` field determines when each operation executes relative to the scenario start time. + +- Operations execute sequentially in the order they appear +- Use precise timing for realistic network behavior +- Consider homeserver startup time (usually ~1 second) + +## Examples + +Example scenario files included with Publar (automatically copied to `~/.publar/scenarios/` on first run): +- `simple_connection.json` - Basic homeserver + client + write/read +- `multi_client.json` - One homeserver with multiple clients +- `rate_limiting.json` - Stress test with rapid operations + +## Loading Scenarios + +### From UI +1. Click "Import" button in the topbar (center section) +2. Select a JSON file from your filesystem +3. The scenario will be copied to `~/.publar/scenarios/` +4. Select the scenario from the dropdown +5. Click "Play" to execute + +### From Code +```rust +use std::fs; +let json = fs::read_to_string("/path/to/my_scenario.json")?; +let scenario = Scenario::from_json(&json)?; +``` + +## Saving Scenarios + +### From UI +1. Create your network topology manually (homeservers, clients, connections) +2. Click "Export" button in the topbar (center section) +3. Choose a filename (will be saved to `~/.publar/scenarios/`) +4. The scenario is automatically available in the dropdown + +### From Code +```rust +let json = scenario.to_json()?; +fs::write("~/.publar/scenarios/my_scenario.json", json)?; +``` + +## Best Practices + +1. **Use descriptive IDs**: `homeserver-main` instead of `hs1` +2. **Add wait operations**: Allow time for homeservers to become ready +3. **Stagger connections**: Don't connect all clients at once unless testing load +4. **Comment with description**: Use the description field to explain what's being tested +5. **Start simple**: Test basic operations before complex scenarios + +## Troubleshooting + +**Issue**: Scenario fails with "not ready" error +- **Solution**: Increase wait time or add `wait_for_homeserver` operations + +**Issue**: Connections fail +- **Solution**: Ensure homeserver is created and ready before connecting clients + +**Issue**: Write operations fail +- **Solution**: Ensure client is connected to homeserver first + +**Issue**: Invalid JSON error +- **Solution**: Validate JSON syntax, check all required fields are present + +## Scenario Storage Location + +All scenarios are stored in `~/.publar/scenarios/`. This location: +- Persists across application updates +- Is accessible from any terminal/file manager +- Works correctly when Publar is distributed as a binary +- Is automatically created if it doesn't exist diff --git a/publar/assets/input.css b/publar/assets/input.css new file mode 100644 index 0000000..76de83f --- /dev/null +++ b/publar/assets/input.css @@ -0,0 +1,41 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + /* Custom scrollbar styling */ + * { + scrollbar-width: thin; + scrollbar-color: #3f3f46 #18181b; + } + + /* Webkit browsers (Chrome, Safari, Edge) */ + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-track { + background: #18181b; + border-radius: 4px; + } + + *::-webkit-scrollbar-thumb { + background: #3f3f46; + border-radius: 4px; + transition: background 0.2s; + } + + *::-webkit-scrollbar-thumb:hover { + background: #52525b; + } + + *::-webkit-scrollbar-thumb:active { + background: #71717a; + } + + /* Scrollbar corner (when both scrollbars are present) */ + *::-webkit-scrollbar-corner { + background: #18181b; + } +} diff --git a/publar/assets/tailwind.css b/publar/assets/tailwind.css new file mode 100644 index 0000000..d2bfaf4 --- /dev/null +++ b/publar/assets/tailwind.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{scrollbar-width:thin;scrollbar-color:#3f3f46 #18181b}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:#18181b;border-radius:4px}::-webkit-scrollbar-thumb{background:#3f3f46;border-radius:4px;-webkit-transition:background .2s;transition:background .2s}::-webkit-scrollbar-thumb:hover{background:#52525b}::-webkit-scrollbar-thumb:active{background:#71717a}::-webkit-scrollbar-corner{background:#18181b}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.left-0{left:0}.left-4{left:1rem}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-4{top:1rem}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-1{margin-top:.25rem}.block{display:block}.flex{display:flex}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-ew-resize{cursor:ew-resize}.cursor-not-allowed{cursor:not-allowed}.cursor-ns-resize{cursor:ns-resize}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-blue-500\/20{border-color:rgba(59,130,246,.2)}.border-zinc-800{--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-zinc-600{--tw-bg-opacity:1;background-color:rgb(82 82 91/var(--tw-bg-opacity,1))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-900\/50{background-color:rgba(24,24,27,.5)}.bg-zinc-900\/90{background-color:rgba(24,24,27,.9)}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pt-16{padding-top:4rem}.pt-4{padding-top:1rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.italic{font-style:italic}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-zinc-700:hover{--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.hover\:bg-blue-500\/20:hover{background-color:rgba(59,130,246,.2)}.hover\:bg-green-500\/50:hover{background-color:rgba(34,197,94,.5)}.hover\:bg-zinc-800:hover{--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.focus\:border-zinc-600:focus{--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5} \ No newline at end of file diff --git a/publar/build.rs b/publar/build.rs new file mode 100644 index 0000000..d0f559e --- /dev/null +++ b/publar/build.rs @@ -0,0 +1,29 @@ +use std::process::Command; + +fn main() { + // Tell Cargo to rerun this script if any Rust source files change + println!("cargo:rerun-if-changed=src/"); + println!("cargo:rerun-if-changed=assets/input.css"); + println!("cargo:rerun-if-changed=tailwind.config.js"); + + // Run Tailwind CSS build + let output = Command::new("npx") + .args([ + "tailwindcss", + "-i", + "./assets/input.css", + "-o", + "./assets/tailwind.css", + "--minify", + ]) + .output() + .expect("Failed to run Tailwind CSS build"); + + if !output.status.success() { + eprintln!("Tailwind CSS build failed:"); + eprintln!("{}", String::from_utf8_lossy(&output.stderr)); + std::process::exit(1); + } + + println!("Tailwind CSS build complete"); +} diff --git a/publar/examples/testnet_write_read.rs b/publar/examples/testnet_write_read.rs new file mode 100644 index 0000000..b0ffb61 --- /dev/null +++ b/publar/examples/testnet_write_read.rs @@ -0,0 +1,139 @@ +/// Example: Connect to a homeserver URL spun up by Publar and perform write/read operations +/// +/// This example demonstrates: +/// 1. Taking a homeserver URL from Publar +/// 2. Creating a client with a random keypair +/// 3. Connecting the client to the homeserver +/// 4. Writing data to the homeserver +/// 5. Reading data back from the homeserver +/// +/// Usage: +/// cargo run --example testnet_write_read +/// +/// Example: +/// cargo run --example testnet_write_read http://localhost:5111/ 8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo +use anyhow::{Context, Result}; +use pubky::{Keypair, Pubky, PublicKey}; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::fmt::init(); + + // Get homeserver URL and public key from command-line arguments + let args: Vec = std::env::args().collect(); + if args.len() != 3 { + eprintln!("Usage: {} ", args[0]); + eprintln!("\nExample:"); + eprintln!( + " {} http://localhost:5111/ 8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo", + args[0] + ); + eprintln!("\nYou can find the homeserver URL and public key in the Publar app"); + eprintln!("after creating a homeserver in the network visualization."); + std::process::exit(1); + } + + let homeserver_url = &args[1]; + let homeserver_pubkey_str = &args[2]; + + println!("🚀 Connecting to homeserver..."); + println!(" URL: {}", homeserver_url); + + // Wait for homeserver to be ready + println!("\n⏳ Checking if homeserver is ready..."); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Poll homeserver until ready + let mut ready = false; + for i in 1..=10 { + match reqwest::get(homeserver_url).await { + Ok(response) + if response.status().is_success() || response.status().is_client_error() => + { + ready = true; + break; + } + _ => { + if i < 10 { + println!(" Attempt {}/10: Not ready yet, waiting...", i); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + } + } + + if !ready { + anyhow::bail!("Homeserver failed to become ready after 10 attempts"); + } + println!("✓ Homeserver is ready"); + + // Parse the homeserver public key + println!("\n📡 Parsing homeserver public key..."); + let homeserver_pubkey = PublicKey::try_from(homeserver_pubkey_str.as_str()) + .context("Failed to parse homeserver public key")?; + println!("✓ Homeserver public key: {}", homeserver_pubkey.to_z32()); + + println!("\n👤 Creating client..."); + let client_keypair = Keypair::random(); + let client_pubkey = client_keypair.public_key(); + println!("✓ Client created:"); + println!(" Public Key: {}", client_pubkey.to_z32()); + + println!("\n🔗 Connecting client to homeserver..."); + // Create Pubky client (for testnet, this should use testnet config) + let pubky = Pubky::testnet().context("Failed to create Pubky client")?; + + // Sign up to the homeserver + let session = pubky + .signer(client_keypair.clone()) + .signup(&homeserver_pubkey, None) + .await + .context("Failed to sign up to homeserver")?; + println!("✓ Client connected to homeserver"); + + // Write data + println!("\n📝 Writing data to homeserver..."); + let test_path = "/pub/publar/example.txt"; + let test_content = "Hello from Pubky! This is a test write/read operation from the example."; + + session + .storage() + .put(test_path, test_content.as_bytes().to_vec()) + .await + .context("Failed to write data to homeserver")?; + println!("✓ Data written to path: {}", test_path); + println!(" Content: \"{}\"", test_content); + + // Read data back + println!("\n📖 Reading data from homeserver..."); + let response = session + .storage() + .get(test_path) + .await + .context("Failed to read data from homeserver")?; + + let read_data = response + .bytes() + .await + .context("Failed to extract bytes from response")? + .to_vec(); + + let read_content = String::from_utf8_lossy(&read_data); + println!("✓ Data read from path: {}", test_path); + println!(" Content: \"{}\"", read_content); + println!(" Size: {} bytes", read_data.len()); + + // Verify data matches + if read_content == test_content { + println!("\n✅ SUCCESS: Read data matches written data!"); + } else { + println!("\n❌ ERROR: Read data does not match written data!"); + println!(" Expected: \"{}\"", test_content); + println!(" Got: \"{}\"", read_content); + } + + println!("\n🎉 Example complete!"); + + Ok(()) +} diff --git a/publar/package.json b/publar/package.json new file mode 100644 index 0000000..f01d59c --- /dev/null +++ b/publar/package.json @@ -0,0 +1,21 @@ +{ + "name": "publar", + "version": "0.1.0", + "main": "index.js", + "scripts": { + "build:css": "tailwindcss --input ./assets/input.css --output ./assets/output.css", + "watch:css": "tailwindcss --input ./assets/input.css --output ./assets/output.css --watch", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": ["pubky", "testnet", "visualization", "dioxus"], + "author": "Kevin Karsopawiro ", + "license": "MIT", + "description": "A desktop application for visualizing, testing, and debugging Pubky network topologies", + "repository": { + "type": "git", + "url": "https://github.com/pubky/publar" + }, + "devDependencies": { + "tailwindcss": "^3.4.18" + } +} diff --git a/publar/src/api.rs b/publar/src/api.rs new file mode 100644 index 0000000..4eca9d3 --- /dev/null +++ b/publar/src/api.rs @@ -0,0 +1,43 @@ +use axum::{ + extract::State, + response::Json, + routing::get, + Router, +}; +use serde::Serialize; +use std::sync::{Arc, Mutex}; +use tower_http::cors::CorsLayer; + +#[derive(Clone)] +pub struct ApiState { + pub homeserver_urls: Arc>>, +} + +#[derive(Serialize)] +pub struct HomeserversResponse { + pub homeservers: Vec, +} + +async fn get_homeservers(State(state): State) -> Json { + let urls = state.homeserver_urls.lock().unwrap().clone(); + Json(HomeserversResponse { homeservers: urls }) +} + +pub fn create_router(state: ApiState) -> Router { + Router::new() + .route("/homeservers", get(get_homeservers)) + .layer(CorsLayer::permissive()) + .with_state(state) +} + +pub async fn start_api_server(state: ApiState, port: u16) -> anyhow::Result<()> { + let app = create_router(state); + let addr = format!("127.0.0.1:{}", port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + println!("API server running on http://{}", addr); + println!(" GET http://{}/homeservers - List all homeserver URLs", addr); + + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/publar/src/components/context_sidebar.rs b/publar/src/components/context_sidebar.rs new file mode 100644 index 0000000..02a1e83 --- /dev/null +++ b/publar/src/components/context_sidebar.rs @@ -0,0 +1,581 @@ +use dioxus::prelude::*; +use super::network_visualization::{Node, NodeStatus, ConnectivityStatus}; + +fn format_bytes(bytes: usize) -> String { + const KB: usize = 1024; + const MB: usize = KB * 1024; + const GB: usize = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +#[derive(Clone, PartialEq, Debug)] +pub struct EventLogEntry { + pub id: usize, + pub timestamp: String, + pub message: String, + pub event_type: EventType, +} + +#[derive(Clone, PartialEq, Debug)] +pub enum EventType { + Success, + Error, + Info, +} + +#[derive(Props, Clone, PartialEq)] +pub struct ContextSidebarProps { + pub selected_node: Option, + pub all_nodes: Vec, + pub event_log: Vec, + pub is_writing: bool, + pub is_reading: bool, + pub sidebar_width: i32, + pub event_log_height: i32, + pub on_stop_node: EventHandler, + pub on_start_node: EventHandler, + pub on_remove_node: EventHandler, + pub on_test_connectivity: EventHandler, + pub on_connect_client: EventHandler<(String, String)>, // (client_id, homeserver_id) + pub on_write_data: EventHandler<(String, String, String)>, // (client_id, path, content) + pub on_read_data: EventHandler<(String, String)>, // (client_id, path) + pub on_resize_sidebar: EventHandler, + pub on_resize_eventlog: EventHandler, +} + +#[component] +pub fn ContextSidebar(props: ContextSidebarProps) -> Element { + rsx! { + div { + class: "bg-black border-l border-zinc-800 flex flex-col relative", + style: "width: {props.sidebar_width}px;", + + // Horizontal resize handle (left edge) + div { + class: "absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-green-500/50 transition-colors z-10", + onmousedown: move |evt| { + evt.stop_propagation(); + props.on_resize_sidebar.call(-1); // Signal to start resizing (negative = start) + }, + } + + // Top section: Node details (scrollable) + div { + class: "flex-1 overflow-auto p-4", + + if let Some(node) = &props.selected_node { + div { + // Header + div { + class: "mb-6 pb-4 border-b border-zinc-800", + h2 { + class: "text-base font-semibold text-white mb-1", + "{node.name()}" + } + p { + class: "text-xs text-zinc-500", + match node { + Node::Homeserver(_) => "Homeserver Details", + Node::Client(_) => "Client Details", + } + } + } + + // Status section + div { + class: "mb-6", + h3 { + class: "text-xs font-medium text-zinc-400 mb-2", + "Status" + } + div { + class: "flex items-center gap-2", + div { + class: match node.status() { + NodeStatus::Running => "w-2 h-2 rounded-full bg-green-500", + NodeStatus::Starting => "w-2 h-2 rounded-full bg-yellow-500 animate-pulse", + NodeStatus::Stopped => "w-2 h-2 rounded-full bg-zinc-600", + NodeStatus::Error => "w-2 h-2 rounded-full bg-red-500", + } + } + span { + class: "text-sm text-zinc-300", + match node.status() { + NodeStatus::Running => "Running", + NodeStatus::Starting => "Starting...", + NodeStatus::Stopped => "Stopped", + NodeStatus::Error => "Error", + } + } + } + } + + // Node-specific content + match node { + Node::Homeserver(homeserver) => rsx! { + // Port info + div { + class: "mb-6", + h3 { + class: "text-xs font-medium text-zinc-400 mb-2", + "Port" + } + p { + class: "text-sm font-mono text-zinc-300", + "{homeserver.port}" + } + } + + // Public key + if let Some(public_key) = &homeserver.public_key { + div { + class: "mb-6", + h3 { + class: "text-xs font-medium text-zinc-400 mb-2", + "Public Key" + } + p { + class: "text-xs font-mono text-zinc-300 break-all bg-zinc-900 p-2 rounded border border-zinc-800", + "{public_key}" + } + } + } + + // Connectivity section + div { + class: "mb-6", + h3 { + class: "text-xs font-medium text-zinc-400 mb-2", + "Connectivity" + } + div { + class: "flex items-center gap-2 mb-2", + div { + class: match &homeserver.connectivity_status { + ConnectivityStatus::Connected => "w-2 h-2 rounded-full bg-green-500", + ConnectivityStatus::Testing => "w-2 h-2 rounded-full bg-yellow-500 animate-pulse", + ConnectivityStatus::Failed => "w-2 h-2 rounded-full bg-red-500", + ConnectivityStatus::Unknown => "w-2 h-2 rounded-full bg-zinc-600", + } + } + span { + class: "text-sm text-zinc-300", + match &homeserver.connectivity_status { + ConnectivityStatus::Connected => "Connected", + ConnectivityStatus::Testing => "Testing...", + ConnectivityStatus::Failed => "Failed", + ConnectivityStatus::Unknown => "Unknown", + } + } + } + + // HTTP URL section + if let Some(url) = &homeserver.http_url { + div { + class: "mb-4", + div { + class: "text-xs font-medium text-zinc-400 mb-1", + "HTTP URL" + } + div { + class: "p-2 bg-zinc-900/50 rounded border border-zinc-800 font-mono text-xs text-zinc-300 break-all", + "{url}" + } + } + } + + { + let homeserver_id = homeserver.id.clone(); + rsx! { + button { + class: "w-full px-3 py-1.5 rounded-md bg-zinc-900 hover:bg-zinc-800 text-white text-xs font-medium transition-all border border-zinc-800", + onclick: move |_| props.on_test_connectivity.call(homeserver_id.clone()), + "Test Connectivity" + } + } + } + } + + // Storage stats + if let Some(stats) = &homeserver.storage_stats { + div { + class: "mb-6 p-3 bg-zinc-900/50 rounded-lg border border-zinc-800", + h3 { + class: "text-xs font-medium text-zinc-400 mb-3", + "Storage Statistics" + } + + div { + class: "space-y-2", + div { + class: "flex justify-between items-center", + span { + class: "text-xs text-zinc-500", + "Total Keys" + } + span { + class: "text-xs font-mono text-zinc-300", + "{stats.total_keys}" + } + } + div { + class: "flex justify-between items-center", + span { + class: "text-xs text-zinc-500", + "Total Size" + } + span { + class: "text-xs font-mono text-zinc-300", + "{format_bytes(stats.total_size_bytes)}" + } + } + } + } + } + }, + Node::Client(client) => { + let mut selected_homeserver = use_signal(|| Option::::None); + let mut write_path = use_signal(|| String::from("/pub/publar/test.txt")); + let mut write_content = use_signal(|| String::from("Hello, Pubky!")); + let mut read_path = use_signal(|| String::from("/pub/publar/test.txt")); + + // Get available homeservers + let homeservers: Vec<_> = props.all_nodes.iter() + .filter_map(|n| { + if let Node::Homeserver(h) = n { + Some(h) + } else { + None + } + }) + .collect(); + + rsx! { + // Public key + div { + class: "mb-6", + h3 { + class: "text-xs font-medium text-zinc-400 mb-2", + "Public Key" + } + p { + class: "text-xs font-mono text-zinc-300 break-all bg-zinc-900 p-2 rounded border border-zinc-800", + "{client.public_key}" + } + } + + // Connected homeserver + div { + class: "mb-6", + h3 { + class: "text-xs font-medium text-zinc-400 mb-2", + "Connected Homeserver" + } + if let Some(homeserver_id) = &client.connected_homeserver { + p { + class: "text-sm text-zinc-300", + "{homeserver_id}" + } + } else { + p { + class: "text-sm text-zinc-500 italic", + "Not connected" + } + } + } + + // Connection controls (only show if not connected) + if client.connected_homeserver.is_none() && !homeservers.is_empty() { + div { + class: "mb-6", + h3 { + class: "text-xs font-medium text-zinc-400 mb-2", + "Connect to Homeserver" + } + + // Homeserver dropdown + select { + class: "w-full px-3 py-2 mb-2 rounded-md bg-zinc-900 text-zinc-300 text-xs border border-zinc-800 focus:outline-none focus:border-zinc-600", + onchange: move |evt| { + selected_homeserver.set(Some(evt.value())); + }, + + option { + value: "", + selected: selected_homeserver().is_none(), + "Select a homeserver..." + } + + for homeserver in homeservers.iter() { + option { + value: "{homeserver.id}", + "{homeserver.name} (port {homeserver.port})" + } + } + } + + // Connect button + { + let client_id = client.id.clone(); + let selected_hs = selected_homeserver(); + rsx! { + button { + class: "w-full px-3 py-1.5 rounded-md bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 text-xs font-medium transition-all border border-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed", + disabled: selected_hs.is_none(), + onclick: move |_| { + if let Some(hs_id) = selected_homeserver() { + props.on_connect_client.call((client_id.clone(), hs_id)); + } + }, + "Connect" + } + } + } + } + } + + // Read/Write controls (only show if connected) + if client.connected_homeserver.is_some() { + div { + class: "mb-6 p-3 bg-zinc-900/50 rounded-lg border border-zinc-800", + h3 { + class: "text-xs font-medium text-zinc-400 mb-3", + "Test Read/Write" + } + + // Write section + div { + class: "mb-4", + label { + class: "block text-xs text-zinc-500 mb-1", + "Write Path" + } + input { + class: "w-full px-2 py-1.5 mb-2 rounded-md bg-zinc-900 text-zinc-300 text-xs border border-zinc-800 focus:outline-none focus:border-zinc-600 font-mono", + r#type: "text", + value: "{write_path}", + oninput: move |evt| write_path.set(evt.value()), + placeholder: "/pub/publar/example.txt" + } + + label { + class: "block text-xs text-zinc-500 mb-1", + "Content" + } + textarea { + class: "w-full px-2 py-1.5 mb-2 rounded-md bg-zinc-900 text-zinc-300 text-xs border border-zinc-800 focus:outline-none focus:border-zinc-600 font-mono", + rows: "3", + value: "{write_content}", + oninput: move |evt| write_content.set(evt.value()), + placeholder: "Enter content to write..." + } + + { + let client_id = client.id.clone(); + let path = write_path(); + let content = write_content(); + rsx! { + button { + class: if props.is_writing { + "w-full px-3 py-1.5 rounded-md text-xs font-medium cursor-wait flex items-center justify-center gap-2" + } else { + "w-full px-3 py-1.5 rounded-md text-xs font-medium transition-all" + }, + style: "background-color: rgba(199, 255, 0, 0.1); color: #c7ff00; border: 1px solid rgba(199, 255, 0, 0.2);", + disabled: props.is_writing, + onclick: move |_| { + props.on_write_data.call((client_id.clone(), path.clone(), content.clone())); + }, + if props.is_writing { + div { + class: "w-3 h-3 rounded-full", + style: "border: 2px solid rgba(199, 255, 0, 0.3); border-top-color: #c7ff00; animation: spin 0.8s linear infinite;", + } + } else { + "Write Data" + } + } + } + } + } + + // Read section + div { + label { + class: "block text-xs text-zinc-500 mb-1", + "Read Path" + } + input { + class: "w-full px-2 py-1.5 mb-2 rounded-md bg-zinc-900 text-zinc-300 text-xs border border-zinc-800 focus:outline-none focus:border-zinc-600 font-mono", + r#type: "text", + value: "{read_path}", + oninput: move |evt| read_path.set(evt.value()), + placeholder: "/pub/publar/example.txt" + } + + { + let client_id = client.id.clone(); + let path = read_path(); + rsx! { + button { + class: if props.is_reading { + "w-full px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 text-xs font-medium border border-blue-500/20 cursor-wait flex items-center justify-center gap-2" + } else { + "w-full px-3 py-1.5 rounded-md bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 text-xs font-medium transition-all border border-blue-500/20" + }, + disabled: props.is_reading, + onclick: move |_| { + props.on_read_data.call((client_id.clone(), path.clone())); + }, + if props.is_reading { + div { + class: "w-3 h-3 rounded-full", + style: "border: 2px solid rgba(96, 165, 250, 0.3); border-top-color: #60a5fa; animation: spin 0.8s linear infinite;", + } + } else { + "Read Data" + } + } + } + } + } + } + } + } + } + } + + // Actions + div { + class: "pt-4 border-t border-zinc-800 space-y-2", + { + let node_id = node.id().to_string(); + rsx! { + button { + class: "w-full px-3 py-1.5 rounded-md text-xs font-medium transition-all", + style: "background-color: rgba(255, 0, 0, 0.1); color: #ff0000; border: 1px solid rgba(255, 0, 0, 0.2);", + onclick: move |_| props.on_remove_node.call(node_id.clone()), + "Remove" + } + } + } + } + } + } else { + // Empty state + div { + class: "h-full flex items-center justify-center", + div { + class: "text-center max-w-xs", + div { + class: "w-12 h-12 mx-auto mb-3 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center", + svg { + class: "w-6 h-6 text-zinc-600", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" + } + } + } + h3 { + class: "text-xs font-medium text-zinc-400 mb-1", + "No node selected" + } + p { + class: "text-xs text-zinc-600", + "Select a node to view details" + } + } + } + } + } + + // Bottom section: Event log (resizable height, scrollable) + div { + class: "border-t border-zinc-800 flex flex-col relative", + style: "height: {props.event_log_height}px;", + + // Vertical resize handle (top edge) + div { + class: "absolute left-0 right-0 top-0 h-1 cursor-ns-resize hover:bg-green-500/50 transition-colors z-10", + onmousedown: move |evt| { + evt.stop_propagation(); + props.on_resize_eventlog.call(-1); // Signal to start resizing (negative = start) + }, + } + + // Header + div { + class: "px-4 py-2 bg-zinc-900/50 border-b border-zinc-800", + h3 { + class: "text-xs font-medium text-zinc-400", + "Event Log" + } + } + + // Event list (scrollable) + div { + class: "flex-1 overflow-y-auto px-4 py-2", + if props.event_log.is_empty() { + div { + class: "h-full flex items-center justify-center", + p { + class: "text-xs text-zinc-600 italic", + "No events yet" + } + } + } else { + div { + class: "space-y-2", + for entry in props.event_log.iter().rev() { + div { + key: "{entry.id}", + class: "text-xs", + div { + class: "flex items-start gap-2", + div { + class: match entry.event_type { + EventType::Success => "w-1.5 h-1.5 rounded-full bg-green-500 mt-1 flex-shrink-0", + EventType::Error => "w-1.5 h-1.5 rounded-full bg-red-500 mt-1 flex-shrink-0", + EventType::Info => "w-1.5 h-1.5 rounded-full bg-blue-500 mt-1 flex-shrink-0", + } + } + div { + class: "flex-1", + div { + class: "text-zinc-500 font-mono text-[10px] mb-0.5", + "{entry.timestamp}" + } + div { + style: match entry.event_type { + EventType::Success => "color: #c7ff00;", + EventType::Error => "color: #ff0000;", + EventType::Info => "color: #60a5fa;", + }, + "{entry.message}" + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/publar/src/components/mod.rs b/publar/src/components/mod.rs new file mode 100644 index 0000000..089da18 --- /dev/null +++ b/publar/src/components/mod.rs @@ -0,0 +1,7 @@ +pub mod topbar; +pub mod network_visualization; +pub mod context_sidebar; + +pub use topbar::Topbar; +pub use network_visualization::NetworkVisualization; +pub use context_sidebar::{ContextSidebar, EventLogEntry, EventType}; diff --git a/publar/src/components/network_visualization.rs b/publar/src/components/network_visualization.rs new file mode 100644 index 0000000..239c158 --- /dev/null +++ b/publar/src/components/network_visualization.rs @@ -0,0 +1,534 @@ +use dioxus::prelude::*; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; + +// Convert client coordinates to SVG user units using the CTM +#[cfg(target_arch = "wasm32")] +fn client_to_svg(el: &web_sys::SvgGraphicsElement, client_x: f64, client_y: f64) -> (f64, f64) { + // CTM maps SVG user units -> CSS pixels. We need its inverse. + let ctm = el.get_screen_ctm().expect("ctm").inverse().expect("inv"); + // DOMPoint is convenient for matrix multiplication + let point = web_sys::DomPoint::new_with_x_and_y(client_x, client_y); + let p = ctm.multiply_point(&point); + (p.x(), p.y()) +} + +// Get mouse position in SVG user units +#[cfg(target_arch = "wasm32")] +macro_rules! mouse_svg { + ($g:expr, $evt:expr) => {{ + if let Some(g) = $g.read().as_ref() { + let coords = $evt.client_coordinates(); + client_to_svg(g, coords.x, coords.y) + } else { + let coords = $evt.client_coordinates(); + (coords.x, coords.y) + } + }}; +} + +#[cfg(not(target_arch = "wasm32"))] +macro_rules! mouse_svg { + ($g:expr, $evt:expr) => {{ + let coords = $evt.client_coordinates(); + (coords.x, coords.y) + }}; +} + +#[derive(Clone, PartialEq, Debug)] +pub enum Node { + Homeserver(Homeserver), + Client(Client), +} + +impl Node { + pub fn id(&self) -> &str { + match self { + Node::Homeserver(h) => &h.id, + Node::Client(c) => &c.id, + } + } + + pub fn name(&self) -> &str { + match self { + Node::Homeserver(h) => &h.name, + Node::Client(c) => &c.name, + } + } + + pub fn status(&self) -> &NodeStatus { + match self { + Node::Homeserver(h) => &h.status, + Node::Client(c) => &c.status, + } + } + + #[allow(dead_code)] + pub fn public_key(&self) -> Option<&str> { + match self { + Node::Homeserver(h) => h.public_key.as_deref(), + Node::Client(c) => Some(&c.public_key), + } + } + + pub fn position(&self) -> (f64, f64) { + match self { + Node::Homeserver(h) => (h.x, h.y), + Node::Client(c) => (c.x, c.y), + } + } + + pub fn set_position(&mut self, x: f64, y: f64) { + match self { + Node::Homeserver(h) => { + h.x = x; + h.y = y; + } + Node::Client(c) => { + c.x = x; + c.y = y; + } + } + } +} + +#[derive(Clone, PartialEq, Debug)] +pub struct Homeserver { + pub id: String, + pub name: String, + pub port: u16, + pub http_url: Option, + pub status: NodeStatus, + pub public_key: Option, + pub connectivity_status: ConnectivityStatus, + pub storage_stats: Option, + pub x: f64, + pub y: f64, +} + +#[derive(Clone, Debug)] +pub struct Client { + pub id: String, + pub name: String, + pub public_key: String, + pub status: NodeStatus, + pub connected_homeserver: Option, + pub x: f64, + pub y: f64, +} + +// Manual PartialEq implementation (keypair can't be compared) +impl PartialEq for Client { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.name == other.name + && self.public_key == other.public_key + && self.status == other.status + && self.connected_homeserver == other.connected_homeserver + && self.x == other.x + && self.y == other.y + } +} + +#[derive(Clone, PartialEq, Debug)] +pub struct Edge { + pub from: String, // node id + pub to: String, // node id + pub edge_type: EdgeType, +} + +#[derive(Clone, PartialEq, Debug)] +#[allow(dead_code)] +pub enum EdgeType { + Connection, // Client connected to Homeserver +} + +#[derive(Clone, PartialEq, Debug)] +#[allow(dead_code)] +pub enum ConnectivityStatus { + Unknown, + Testing, + Connected, + Failed, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct StorageStats { + pub total_keys: usize, + pub total_size_bytes: usize, +} + +#[derive(Clone, PartialEq, Debug)] +#[allow(dead_code)] +pub enum NodeStatus { + Starting, + Running, + Stopped, + Error, +} + +#[derive(Props, Clone, PartialEq)] +pub struct NetworkVisualizationProps { + pub nodes: Vec, + pub edges: Vec, + pub selected_id: Option, + pub on_select: EventHandler, + pub on_node_move: EventHandler<(String, f64, f64)>, + pub is_loading_scenario: bool, +} + +#[component] +pub fn NetworkVisualization(props: NetworkVisualizationProps) -> Element { + // Store: (node_id, offset_x, offset_y) - offset from mouse to node center in SVG coords + let mut dragging = use_signal(|| Option::<(String, f64, f64)>::None); + let mut panning = use_signal(|| Option::<(f64, f64)>::None); + let mut pan_offset = use_signal(|| (0.0, 0.0)); + let mut zoom = use_signal(|| 1.0); + + // Reference to the transformed element for coordinate conversion + #[cfg(target_arch = "wasm32")] + let viewport_g = use_signal(|| Option::::None); + + #[cfg(not(target_arch = "wasm32"))] + let _viewport_g = use_signal(|| Option::<()>::None); + + let on_mouse_move = { + let on_node_move = props.on_node_move.clone(); + + move |evt: MouseEvent| { + let (sx, sy) = mouse_svg!(viewport_g, evt); + let (cur_pan_x, cur_pan_y) = pan_offset(); + + // Handle panning: deltas in SVG units + if let Some((start_x, start_y)) = panning() { + let dx = sx - start_x; + let dy = sy - start_y; + pan_offset.set((cur_pan_x + dx, cur_pan_y + dy)); + panning.set(Some((sx, sy))); // advance in SVG units + return; + } + + // Handle node dragging: all in SVG units + if let Some((ref node_id, offset_x, offset_y)) = dragging() { + let new_svg_x = sx + offset_x; + let new_svg_y = sy + offset_y; + on_node_move.call((node_id.clone(), new_svg_x, new_svg_y)); + } + } + }; + + let on_mouse_up = move |_evt: MouseEvent| { + dragging.set(None); + panning.set(None); + }; + + let on_wheel = move |evt: WheelEvent| { + evt.prevent_default(); + + // Get mouse position in SVG user units + let (px_svg, py_svg) = mouse_svg!(viewport_g, evt); + + let (pan_x, pan_y) = pan_offset(); + let z = zoom(); + + let delta = evt.delta(); + let zoom_factor = if delta.strip_units().y < 0.0 { 1.1_f64 } else { 0.9_f64 }; + let z_new = (z * zoom_factor).max(0.1_f64).min(5.0_f64); + + // Keep pivot stable: pan' = pan + (z - z_new) * pivot_svg + let pan_x_new = pan_x + (z - z_new) * px_svg; + let pan_y_new = pan_y + (z - z_new) * py_svg; + + zoom.set(z_new); + pan_offset.set((pan_x_new, pan_y_new)); + }; + + let on_canvas_mouse_down = move |evt: MouseEvent| { + // Start panning with middle mouse button + let buttons = evt.held_buttons(); + if buttons.contains(dioxus::html::input_data::MouseButton::Auxiliary) { + let (sx, sy) = mouse_svg!(viewport_g, evt); + panning.set(Some((sx, sy))); // SVG units + } + }; + + let (pan_x, pan_y) = pan_offset(); + let current_zoom = zoom(); + let transform = format!("translate({} {}) scale({})", pan_x, pan_y, current_zoom); + + rsx! { + div { + class: "flex-1 bg-black relative overflow-hidden select-none", + style: "user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; cursor: default; will-change: transform;", + onmousemove: on_mouse_move, + onmouseup: on_mouse_up, + onmousedown: on_canvas_mouse_down, + onwheel: on_wheel, + + if props.nodes.is_empty() { + // Empty state + div { + class: "absolute inset-0 flex items-center justify-center pointer-events-none", + div { + class: "text-center max-w-md", + if props.is_loading_scenario { + // Loading spinner with pulsing animation + div { + class: "w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center", + style: "border: 3px solid #18181b; border-top-color: #c7ff00; animation: spin 1s linear infinite;", + } + h3 { + class: "text-sm font-medium mb-1", + style: "color: #c7ff00;", + "Loading scenario..." + } + p { + class: "text-xs text-zinc-600", + "Setting up network topology" + } + } else { + div { + class: "w-16 h-16 mx-auto mb-4 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center", + svg { + class: "w-8 h-8 text-zinc-600", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" + } + } + } + h3 { + class: "text-sm font-medium text-zinc-400 mb-1", + "No nodes in network" + } + p { + class: "text-xs text-zinc-600", + "Add homeservers or clients to visualize the network" + } + } + } + } + } else { + // SVG Canvas + svg { + class: "w-full h-full", + view_box: "0 0 1200 800", + style: "shape-rendering: geometricPrecision;", + + // Add a rect to catch mouse events and prevent artifacts + rect { + x: "0", + y: "0", + width: "1200", + height: "800", + fill: "transparent", + pointer_events: "all" + } + + // Group with transform for zoom and pan + g { + transform: "{transform}", + style: "will-change: transform;", + onmounted: move |_evt| { + #[cfg(target_arch = "wasm32")] + if let Ok(ge) = _evt.data().dyn_into::() { + viewport_g.set(Some(ge)); + } + }, + + // Draw edges first (so they appear behind nodes) + for edge in props.edges.iter() { + { + let from_node = props.nodes.iter().find(|n| n.id() == edge.from); + let to_node = props.nodes.iter().find(|n| n.id() == edge.to); + + if let (Some(from), Some(to)) = (from_node, to_node) { + let (x1, y1) = from.position(); + let (x2, y2) = to.position(); + + rsx! { + line { + key: "{edge.from}-{edge.to}", + x1: "{x1}", + y1: "{y1}", + x2: "{x2}", + y2: "{y2}", + stroke: "#c7ff00", + stroke_width: "3", + opacity: "0.6" + } + } + } else { + rsx! { line {} } + } + } + } + + // Draw nodes + for node in props.nodes.iter() { + { + let node_id_str = node.id(); + let is_selected = props.selected_id.as_ref().map(|s| s.as_str()) == Some(node_id_str); + let node_id = node_id_str.to_string(); + let node_id_for_drag = node_id.clone(); + let node_id_for_select = node_id.clone(); + let (x, y) = node.position(); + + let (fill_color, stroke_color) = match node.status() { + NodeStatus::Running => ("#18181b", "#c7ff00"), + NodeStatus::Starting => ("#18181b", "#eab308"), + NodeStatus::Stopped => ("#18181b", "#52525b"), + NodeStatus::Error => ("#18181b", "#ef4444"), + }; + + let stroke_width = if is_selected { "3" } else { "2" }; + + rsx! { + g { + key: "{node.id()}", + cursor: "pointer", + onclick: move |_| props.on_select.call(node_id.clone()), + + // Node circle + circle { + cx: "{x}", + cy: "{y}", + r: "30", + fill: fill_color, + stroke: stroke_color, + stroke_width: stroke_width, + onmousedown: move |evt| { + // Select the node when starting to drag + props.on_select.call(node_id_for_select.clone()); + + // Compute everything in SVG units + let (mx_svg, my_svg) = mouse_svg!(viewport_g, evt); + + // Offset in SVG units + let offset_x = x - mx_svg; + let offset_y = y - my_svg; + + dragging.set(Some((node_id_for_drag.clone(), offset_x, offset_y))); + }, + } + + // Node icon + match node { + Node::Homeserver(_) => rsx! { + // Server icon + rect { + x: "{x - 10.0}", + y: "{y - 8.0}", + width: "20", + height: "6", + fill: "#a1a1aa", + rx: "1" + } + rect { + x: "{x - 10.0}", + y: "{y + 2.0}", + width: "20", + height: "6", + fill: "#a1a1aa", + rx: "1" + } + circle { + cx: "{x + 7.0}", + cy: "{y - 5.0}", + r: "1.5", + fill: "#c7ff00" + } + }, + Node::Client(_) => rsx! { + // User icon + circle { + cx: "{x}", + cy: "{y - 6.0}", + r: "6", + fill: "none", + stroke: "#a1a1aa", + stroke_width: "2" + } + path { + d: "M {x - 10.0} {y + 10.0} Q {x} {y} {x + 10.0} {y + 10.0}", + fill: "none", + stroke: "#a1a1aa", + stroke_width: "2", + stroke_linecap: "round" + } + } + } + + // Node label + text { + x: "{x}", + y: "{y + 45.0}", + text_anchor: "middle", + fill: "#a1a1aa", + font_size: "12", + font_family: "system-ui, -apple-system, sans-serif", + pointer_events: "none", + style: "user-select: none; -webkit-user-select: none;", + "{node.name()}" + } + } + } + } + } + } + } + } + + // Zoom controls overlay + div { + class: "absolute bottom-4 right-4 flex flex-col gap-2", + + button { + class: "w-10 h-10 rounded-md bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all", + onclick: move |_| { + let new_zoom = (zoom() * 1.2).min(5.0); + zoom.set(new_zoom); + }, + "+" + } + + div { + class: "w-10 h-10 rounded-md bg-zinc-900 text-white border border-zinc-800 flex items-center justify-center text-xs", + "{(current_zoom * 100.0) as i32}%" + } + + button { + class: "w-10 h-10 rounded-md bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all", + onclick: move |_| { + let new_zoom = (zoom() / 1.2).max(0.1); + zoom.set(new_zoom); + }, + "−" + } + + button { + class: "w-10 h-10 rounded-md bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all text-xs", + onclick: move |_| { + zoom.set(1.0); + pan_offset.set((0.0, 0.0)); + }, + "Reset" + } + } + + // Instructions overlay + div { + class: "absolute top-4 left-4 bg-zinc-900/90 border border-zinc-800 rounded-md px-3 py-2 text-xs text-zinc-400", + div { "Scroll to zoom" } + div { "Middle-click + drag to pan" } + div { "Drag nodes to move" } + } + } + } +} diff --git a/publar/src/components/topbar.rs b/publar/src/components/topbar.rs new file mode 100644 index 0000000..4749cdb --- /dev/null +++ b/publar/src/components/topbar.rs @@ -0,0 +1,249 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct TopbarProps { + pub is_running: bool, + pub is_creating_homeserver: bool, + pub is_creating_client: bool, + pub scenarios: Vec, + pub selected_scenario: Option, + pub is_playing_scenario: bool, + pub on_toggle_network: EventHandler<()>, + pub on_add_homeserver: EventHandler<()>, + pub on_add_client: EventHandler<()>, + pub on_scenario_select: EventHandler, + pub on_play_scenario: EventHandler<()>, + pub on_reset: EventHandler<()>, + pub on_import_scenario: EventHandler<()>, + pub on_export_scenario: EventHandler<()>, +} + +#[component] +pub fn Topbar(props: TopbarProps) -> Element { + rsx! { + div { + class: "h-12 bg-black border-b border-zinc-800 flex items-center px-4", + + // Left side - Logo and status + div { + class: "flex items-center gap-3 flex-1", + + // Logo/Title + h1 { + class: "text-base font-semibold text-white", + "Publar" + } + + // Start/Stop button + button { + class: if props.is_running { + "h-8 px-3 rounded-md bg-zinc-900 hover:bg-zinc-800 text-xs font-medium transition-all border border-zinc-800 hover:border-zinc-700" + } else { + "h-8 px-3 rounded-md text-black text-xs font-medium transition-all" + }, + style: if props.is_running { + "color: #ff0000;" + } else { + "background-color: #c7ff00;" + }, + onclick: move |_| props.on_toggle_network.call(()), + if props.is_running { + "Stop" + } else { + "Start Network" + } + } + } + + // Center - Scenario controls + if props.is_running { + div { + class: "flex items-center gap-2 flex-1 justify-center", + + // Scenario selector + select { + class: "h-8 px-3 rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-300 text-xs border border-zinc-800 cursor-pointer transition-all", + onchange: move |evt| { + if let Ok(idx) = evt.value().parse::() { + props.on_scenario_select.call(idx); + } + }, + option { + value: "", + "Select Scenario" + } + for (idx, scenario_name) in props.scenarios.iter().enumerate() { + option { + value: "{idx}", + "{scenario_name}" + } + } + } + + // Play scenario button (icon only) + if props.is_playing_scenario { + button { + class: "h-8 w-8 flex items-center justify-center rounded-md cursor-not-allowed", + style: "background-color: #c7ff00;", + // Spinning loader + div { + class: "w-3.5 h-3.5 rounded-full", + style: "border: 2px solid #000; border-top-color: transparent; animation: spin 0.8s linear infinite;", + } + } + } else if props.selected_scenario.is_none() { + button { + class: "h-8 w-8 flex items-center justify-center rounded-md bg-zinc-900 text-zinc-600 border border-zinc-800 cursor-not-allowed", + // Play icon (disabled) + svg { + class: "w-3.5 h-3.5", + fill: "currentColor", + view_box: "0 0 24 24", + path { + d: "M8 5v14l11-7z" + } + } + } + } else { + button { + class: "h-8 w-8 flex items-center justify-center rounded-md text-black transition-all", + style: "background-color: #c7ff00;", + onclick: move |_| props.on_play_scenario.call(()), + onmouseenter: move |_| {}, + onmouseleave: move |_| {}, + // Play icon + svg { + class: "w-3.5 h-3.5", + fill: "currentColor", + view_box: "0 0 24 24", + path { + d: "M8 5v14l11-7z" + } + } + } + } + + // Import button + button { + class: "h-8 w-8 flex items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-800 transition-all", + onclick: move |_| props.on_import_scenario.call(()), + title: "Import Scenario", + // Import/download icon + svg { + class: "w-3.5 h-3.5", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" + } + } + } + + // Export button + button { + class: "h-8 w-8 flex items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-800 transition-all", + onclick: move |_| props.on_export_scenario.call(()), + title: "Export Scenario", + // Export/upload icon + svg { + class: "w-3.5 h-3.5", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" + } + } + } + } + } + + // Right side - Reset, Add homeserver and client buttons + div { + class: "flex items-center gap-2 flex-1 justify-end", + + // Reset button (outline style) + if props.is_running { + button { + class: "h-8 w-8 flex items-center justify-center rounded-md bg-transparent transition-all", + style: "color: #ff0000; border: 1px solid #ff0000;", + onclick: move |_| props.on_reset.call(()), + title: "Reset Network", + // Reset/refresh icon + svg { + class: "w-3.5 h-3.5", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" + } + } + } + } + + button { + class: if props.is_running && !props.is_creating_homeserver { + "h-8 px-3 rounded-md text-black text-xs font-medium transition-all flex items-center gap-1.5" + } else { + "h-8 px-3 rounded-md bg-zinc-900 text-zinc-600 text-xs font-medium flex items-center gap-1.5 cursor-not-allowed border border-zinc-800" + }, + style: if props.is_running && !props.is_creating_homeserver { "background-color: #c7ff00;" } else { "" }, + onclick: move |_| props.on_add_homeserver.call(()), + + // Plus icon + svg { + class: "w-3.5 h-3.5", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M12 4v16m8-8H4" + } + } + + "Add Homeserver" + } + + button { + class: if props.is_running && !props.is_creating_client { + "h-8 px-3 rounded-md text-black text-xs font-medium transition-all flex items-center gap-1.5" + } else { + "h-8 px-3 rounded-md bg-zinc-900 text-zinc-600 text-xs font-medium flex items-center gap-1.5 cursor-not-allowed border border-zinc-800" + }, + style: if props.is_running && !props.is_creating_client { "background-color: #c7ff00;" } else { "" }, + onclick: move |_| props.on_add_client.call(()), + + // Plus icon + svg { + class: "w-3.5 h-3.5", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M12 4v16m8-8H4" + } + } + + "Add Client" + } + } + } + } +} diff --git a/publar/src/force_layout.rs b/publar/src/force_layout.rs new file mode 100644 index 0000000..859741e --- /dev/null +++ b/publar/src/force_layout.rs @@ -0,0 +1,158 @@ +/// Force-directed graph layout algorithm +/// Based on Fruchterman-Reingold algorithm, similar to D3's force simulation + +use crate::components::network_visualization::Node; + +const REPULSION_STRENGTH: f64 = 5000.0; // Repulsion between nodes +const ATTRACTION_STRENGTH: f64 = 0.05; // Attraction along edges +const DAMPING: f64 = 0.85; // Velocity damping (0-1) +const MIN_DISTANCE: f64 = 50.0; // Minimum distance between nodes +const IDEAL_EDGE_LENGTH: f64 = 150.0; // Target distance for connected nodes + +#[derive(Clone, Debug)] +pub struct ForceNode { + pub id: String, + pub x: f64, + pub y: f64, + pub vx: f64, // Velocity X + pub vy: f64, // Velocity Y +} + +pub struct ForceLayout { + pub nodes: Vec, + pub edges: Vec<(String, String)>, // (from_id, to_id) +} + +impl ForceLayout { + #[allow(dead_code)] + pub fn new() -> Self { + Self { + nodes: Vec::new(), + edges: Vec::new(), + } + } + + /// Initialize from existing nodes + pub fn from_nodes(nodes: &[Node], edges: &[(String, String)]) -> Self { + let force_nodes = nodes + .iter() + .map(|node| { + let (x, y) = node.position(); + ForceNode { + id: node.id().to_string(), + x, + y, + vx: 0.0, + vy: 0.0, + } + }) + .collect(); + + Self { + nodes: force_nodes, + edges: edges.to_vec(), + } + } + + /// Run one iteration of the force simulation + pub fn tick(&mut self) { + // Calculate repulsion forces (all nodes repel each other) + for i in 0..self.nodes.len() { + for j in (i + 1)..self.nodes.len() { + let dx = self.nodes[j].x - self.nodes[i].x; + let dy = self.nodes[j].y - self.nodes[i].y; + let distance = (dx * dx + dy * dy).sqrt().max(MIN_DISTANCE); + + // Repulsion force: F = k^2 / distance + let force = REPULSION_STRENGTH / (distance * distance); + let fx = (dx / distance) * force; + let fy = (dy / distance) * force; + + self.nodes[i].vx -= fx; + self.nodes[i].vy -= fy; + self.nodes[j].vx += fx; + self.nodes[j].vy += fy; + } + } + + // Calculate attraction forces (connected nodes attract each other) + // Uses spring force: pulls nodes together if too far, pushes apart if too close + for (from_id, to_id) in &self.edges { + if let (Some(from_idx), Some(to_idx)) = ( + self.nodes.iter().position(|n| &n.id == from_id), + self.nodes.iter().position(|n| &n.id == to_id), + ) { + let dx = self.nodes[to_idx].x - self.nodes[from_idx].x; + let dy = self.nodes[to_idx].y - self.nodes[from_idx].y; + let distance = (dx * dx + dy * dy).sqrt().max(1.0); // Avoid division by zero + + // Spring force: F = (distance - ideal_length) * k + // This creates attraction if too far, repulsion if too close + let displacement = distance - IDEAL_EDGE_LENGTH; + let force = displacement * ATTRACTION_STRENGTH; + let fx = (dx / distance) * force; + let fy = (dy / distance) * force; + + self.nodes[from_idx].vx += fx; + self.nodes[from_idx].vy += fy; + self.nodes[to_idx].vx -= fx; + self.nodes[to_idx].vy -= fy; + } + } + + // Apply velocity with damping and update positions + for node in &mut self.nodes { + node.vx *= DAMPING; + node.vy *= DAMPING; + node.x += node.vx; + node.y += node.vy; + + // Keep nodes within reasonable bounds + node.x = node.x.max(100.0).min(1100.0); + node.y = node.y.max(100.0).min(700.0); + } + } + + /// Run multiple iterations to stabilize the layout + #[allow(dead_code)] + pub fn run(&mut self, iterations: usize) { + for _ in 0..iterations { + self.tick(); + } + } + + /// Get the final positions + pub fn get_positions(&self) -> Vec<(String, f64, f64)> { + self.nodes + .iter() + .map(|node| (node.id.clone(), node.x, node.y)) + .collect() + } +} + +/// Calculate initial position for a new node using force-directed principles +/// This provides a good starting position before the layout algorithm runs +pub fn calculate_initial_position( + existing_nodes: &[Node], + connected_to: Option<&str>, +) -> (f64, f64) { + // If connected to a specific node, position near it + if let Some(target_id) = connected_to { + if let Some(target) = existing_nodes.iter().find(|n| n.id() == target_id) { + let (tx, ty) = target.position(); + // Position at a random angle around the target node + let angle = (existing_nodes.len() as f64) * 1.3; // Pseudo-random angle + let distance = 150.0; // Distance from target + return (tx + distance * angle.cos(), ty + distance * angle.sin()); + } + } + + // Default: position near the center with some randomness + let count = existing_nodes.len(); + let angle = (count as f64) * 2.4; // Pseudo-random angle + let radius = 100.0 + (count as f64 * 30.0).min(200.0); + ( + 600.0 + radius * angle.cos(), + 400.0 + radius * angle.sin(), + ) +} diff --git a/publar/src/main.rs b/publar/src/main.rs new file mode 100644 index 0000000..7d9e638 --- /dev/null +++ b/publar/src/main.rs @@ -0,0 +1,1204 @@ +mod components; +mod testnet; +mod api; +mod scenario; +mod force_layout; + +use dioxus::prelude::*; +use components::{Topbar, NetworkVisualization, ContextSidebar, EventLogEntry, EventType}; +use components::network_visualization::{Node, Homeserver, Client, Edge, NodeStatus, ConnectivityStatus, StorageStats, EdgeType}; +use testnet::TestnetManager; +use std::sync::{Arc, Mutex}; +use std::collections::HashMap; +use pubky::Keypair; +use chrono::Local; +use force_layout::calculate_initial_position; +fn main() { + // Initialize tracing + tracing_subscriber::fmt::init(); + + // Configure desktop window + let config = dioxus::desktop::Config::new() + .with_window( + dioxus::desktop::WindowBuilder::new() + .with_title("Publar") + .with_inner_size(dioxus::desktop::LogicalSize::new(800, 600)) + .with_always_on_top(false) + ); + + dioxus::LaunchBuilder::desktop() + .with_cfg(config) + .launch(App); +} + +#[component] +fn App() -> Element { + // State management + let mut is_network_running = use_signal(|| false); + let mut nodes = use_signal(|| Vec::::new()); + let mut edges = use_signal(|| Vec::::new()); + let mut selected_node_id = use_signal(|| Option::::None); + let mut is_creating_homeserver = use_signal(|| false); + let mut is_creating_client = use_signal(|| false); + + // Store client keypairs (can't be cloned/stored in Node struct) + let client_keypairs: Signal>>> = + use_signal(|| Arc::new(Mutex::new(HashMap::new()))); + + // Store client sessions for reuse + let client_sessions: Signal>>>> = + use_signal(|| Arc::new(Mutex::new(HashMap::new()))); + + // Store the testnet manager + let testnet_manager: Signal>> = + use_signal(|| Arc::new(Mutex::new(TestnetManager::new()))); + + // Store homeserver URLs for API + let homeserver_urls: Signal>>> = + use_signal(|| Arc::new(Mutex::new(Vec::new()))); + + // Scenario state + let scenarios = use_signal(|| scenario::Scenario::built_in_scenarios()); + let mut selected_scenario_idx = use_signal(|| Option::::None); + let mut is_playing_scenario = use_signal(|| false); + + // Event log state + let mut event_log = use_signal(|| Vec::::new()); + let event_counter = use_signal(|| 0_usize); + + // Loading states for write/read operations + let is_writing = use_signal(|| false); + let is_reading = use_signal(|| false); + + // Notification state + let mut notification_message = use_signal(|| Option::::None); + + // Auto-dismiss notification after 3 seconds + use_effect(move || { + if notification_message().is_some() { + spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + notification_message.set(None); + }); + } + }); + + // Resize state for sidebar and event log + let mut sidebar_width = use_signal(|| 320); // 20rem = 320px + let mut event_log_height = use_signal(|| 256); // 16rem = 256px + let mut is_resizing_sidebar = use_signal(|| false); + let mut is_resizing_eventlog = use_signal(|| false); + let mut resize_start_x = use_signal(|| 0.0); + let mut resize_start_y = use_signal(|| 0.0); + let mut resize_start_width = use_signal(|| 0); + let mut resize_start_height = use_signal(|| 0); + + // Computed: Get selected node + let selected_node = use_memo(move || { + let id = selected_node_id.read(); + let all_nodes = nodes.read(); + id.as_ref() + .and_then(|id| all_nodes.iter().find(|n| n.id() == id)) + .cloned() + }); + + // Force-directed layout simulation (runs periodically) + use_effect(move || { + if nodes.read().len() > 1 { + spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + // Build edge list from current edges + let edge_list: Vec<(String, String)> = edges + .read() + .iter() + .map(|e| (e.from.clone(), e.to.clone())) + .collect(); + + // Create layout simulation + let mut layout = force_layout::ForceLayout::from_nodes(&nodes.read(), &edge_list); + + // Run simulation step + layout.tick(); + + // Update node positions + let positions = layout.get_positions(); + for (node_id, new_x, new_y) in positions { + if let Some(node) = nodes.write().iter_mut().find(|n| n.id() == node_id) { + node.set_position(new_x, new_y); + } + } + }); + } + }); + + // Handler: Toggle network + let toggle_network = move |_| { + let running = is_network_running(); + let manager = testnet_manager(); + + if running { + // Stop network + is_network_running.set(false); + nodes.set(Vec::new()); + edges.set(Vec::new()); + selected_node_id.set(None); + + // Clear homeserver URLs + if let Ok(mut urls) = homeserver_urls().lock() { + urls.clear(); + } + + // Stop the testnet + spawn(async move { + if let Ok(mut mgr) = manager.lock() { + mgr.stop().await; + } + }); + } else { + // Start network (initialize DHT and relays) + let urls_for_api = homeserver_urls(); + spawn(async move { + if let Ok(mut mgr) = manager.lock() { + match mgr.start().await { + Ok(_) => { + println!("Testnet started successfully"); + } + Err(e) => { + eprintln!("Failed to start testnet: {}", e); + } + } + } + + // Start API server + let api_state = api::ApiState { + homeserver_urls: urls_for_api, + }; + + tokio::spawn(async move { + if let Err(e) = api::start_api_server(api_state, 3030).await { + eprintln!("API server error: {}", e); + } + }); + }); + is_network_running.set(true); + } + }; + + // Handler: Add homeserver + let add_homeserver = move |_| { + if !is_network_running() { + return; + } + + // Prevent concurrent homeserver creation + if is_creating_homeserver() { + return; + } + + // Mark as creating + is_creating_homeserver.set(true); + + // Count existing homeservers + let homeserver_count = nodes.read().iter().filter(|n| matches!(n, Node::Homeserver(_))).count(); + let id = format!("homeserver-{}", homeserver_count + 1); + let name = format!("Homeserver {}", homeserver_count + 1); + + // Calculate initial position using force-directed principles + let (x, y) = calculate_initial_position(&nodes.read(), None); + + // Add homeserver node to UI with Starting status + nodes.write().push(Node::Homeserver(Homeserver { + id: id.clone(), + name: name.clone(), + port: 0, // Will be set when homeserver starts + http_url: None, + status: NodeStatus::Starting, + public_key: None, + connectivity_status: ConnectivityStatus::Unknown, + storage_stats: None, + x, + y, + })); + + // Create the homeserver + let manager = testnet_manager(); + let id_clone = id.clone(); + let mut all_nodes = nodes.clone(); + let mut creating_flag = is_creating_homeserver.clone(); + let urls = homeserver_urls(); + + spawn(async move { + if let Ok(mut mgr) = manager.lock() { + match mgr.create_homeserver().await { + Ok(info) => { + // Add URL to shared state for API + if let Ok(mut urls_list) = urls.lock() { + urls_list.push(info.http_url.clone()); + } + + // Update the homeserver with actual info + let mut nodes_write = all_nodes.write(); + for node in nodes_write.iter_mut() { + if let Node::Homeserver(h) = node { + if h.id == id_clone { + h.port = info.port; + h.http_url = Some(info.http_url.clone()); + h.public_key = Some(info.public_key); + h.status = NodeStatus::Running; + break; + } + } + } + println!("Homeserver created: {} on port {}", info.http_url, info.port); + } + Err(e) => { + eprintln!("Failed to create homeserver: {}", e); + // Update status to Error + let mut nodes_write = all_nodes.write(); + for node in nodes_write.iter_mut() { + if let Node::Homeserver(h) = node { + if h.id == id_clone { + h.status = NodeStatus::Error; + break; + } + } + } + } + } + } + + // Clear the creating flag + creating_flag.set(false); + }); + }; + + // Handler: Add client + let add_client = move |_| { + if !is_network_running() { + return; + } + + // Prevent concurrent client creation + if is_creating_client() { + return; + } + + // Mark as creating + is_creating_client.set(true); + + // Count existing clients + let client_count = nodes.read().iter().filter(|n| matches!(n, Node::Client(_))).count(); + let id = format!("client-{}", client_count + 1); + let name = format!("Client {}", client_count + 1); + + // Calculate initial position using force-directed principles + let (x, y) = calculate_initial_position(&nodes.read(), None); + + // Add client node to UI with Starting status + nodes.write().push(Node::Client(Client { + id: id.clone(), + name: name.clone(), + public_key: String::new(), // Will be set when client is created + status: NodeStatus::Starting, + connected_homeserver: None, + x, + y, + })); + + // Create the client + let manager = testnet_manager(); + let id_clone = id.clone(); + let mut all_nodes = nodes.clone(); + let keypairs = client_keypairs(); + let mut creating_flag = is_creating_client.clone(); + + spawn(async move { + if let Ok(mut mgr) = manager.lock() { + match mgr.create_client().await { + Ok(info) => { + // Store the keypair + if let Ok(mut kp_map) = keypairs.lock() { + kp_map.insert(id_clone.clone(), info.keypair); + } + + // Update the client with actual info + let mut nodes_write = all_nodes.write(); + for node in nodes_write.iter_mut() { + if let Node::Client(c) = node { + if c.id == id_clone { + c.public_key = info.public_key; + c.status = NodeStatus::Running; + break; + } + } + } + println!("Client created"); + } + Err(e) => { + eprintln!("Failed to create client: {}", e); + // Update status to Error + let mut nodes_write = all_nodes.write(); + for node in nodes_write.iter_mut() { + if let Node::Client(c) = node { + if c.id == id_clone { + c.status = NodeStatus::Error; + break; + } + } + } + } + } + } + + // Clear the creating flag + creating_flag.set(false); + }); + }; + + // Handler: Select node + let select_node = move |id: String| { + selected_node_id.set(Some(id)); + }; + + // Handler: Move node + let move_node = move |(node_id, x, y): (String, f64, f64)| { + let mut all_nodes = nodes.write(); + for node in all_nodes.iter_mut() { + if node.id() == node_id { + node.set_position(x, y); + break; + } + } + }; + + // Handler: Stop node (not supported - nodes managed by testnet) + let stop_node = move |_id: String| { + println!("Stop node not yet implemented"); + }; + + // Handler: Start node (not supported - nodes managed by testnet) + let start_node = move |_id: String| { + println!("Start node not yet implemented"); + }; + + // Handler: Remove node (removes from UI only) + let remove_node = move |id: String| { + // Remove from UI + let mut all_nodes = nodes.write(); + all_nodes.retain(|n| n.id() != id); + drop(all_nodes); + + // Clear selection if removed node was selected + let current_selection = selected_node_id(); + if current_selection.as_ref() == Some(&id) { + selected_node_id.set(None); + } + }; + + // Handler: Test connectivity (for homeservers only) + let test_connectivity = move |id: String| { + let mut all_nodes = nodes.clone(); + let id_clone = id.clone(); + + // Set status to Testing + let mut nodes_write = all_nodes.write(); + for node in nodes_write.iter_mut() { + if let Node::Homeserver(h) = node { + if h.id == id { + h.connectivity_status = ConnectivityStatus::Testing; + h.storage_stats = Some(StorageStats { + total_keys: 42, // Mock data for now + total_size_bytes: 1024 * 256, // 256 KB mock + }); + break; + } + } + } + drop(nodes_write); + + // Simulate connectivity test + spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Update to Connected status (mock success) + let mut nodes_write = all_nodes.write(); + for node in nodes_write.iter_mut() { + if let Node::Homeserver(h) = node { + if h.id == id_clone { + h.connectivity_status = ConnectivityStatus::Connected; + break; + } + } + } + }); + }; + + // Handler: Write data to homeserver + let write_data = move |(client_id, path, content): (String, String, String)| { + let manager = testnet_manager(); + let sessions = client_sessions(); + let mut writing_flag = is_writing; + let mut log = event_log; + let mut counter = event_counter; + + spawn(async move { + // Set loading state + writing_flag.set(true); + + // Log the start of the operation + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp: timestamp.clone(), + message: format!("Writing data to {} at {}", client_id, path), + event_type: EventType::Info, + }); + + // Get the client's session + let session = { + if let Ok(sess_map) = sessions.lock() { + sess_map.get(&client_id).cloned() + } else { + None + } + }; + + if let Some(session) = session { + if let Ok(mgr) = manager.lock() { + match mgr.write_to_homeserver(&session, &path, content.as_bytes()).await { + Ok(_) => { + println!("Successfully wrote to path: {}", path); + + // Log success + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message: format!("✓ Wrote data: {} → {}", client_id, path), + event_type: EventType::Success, + }); + } + Err(e) => { + eprintln!("Failed to write data: {}", e); + + // Log error + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message: format!("✗ Write failed: {}", e), + event_type: EventType::Error, + }); + } + } + } + } else { + eprintln!("Client session not found - client must be connected first"); + + // Log error + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message: "✗ Client not connected".to_string(), + event_type: EventType::Error, + }); + } + + // Clear loading state + writing_flag.set(false); + }); + }; + + // Handler: Read data from homeserver + let read_data = move |(client_id, path): (String, String)| { + let manager = testnet_manager(); + let sessions = client_sessions(); + let mut reading_flag = is_reading; + let mut log = event_log; + let mut counter = event_counter; + + spawn(async move { + // Set loading state + reading_flag.set(true); + + // Log the start of the operation + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp: timestamp.clone(), + message: format!("Reading data from {} at {}", client_id, path), + event_type: EventType::Info, + }); + + // Get the client's session + let session = { + if let Ok(sess_map) = sessions.lock() { + sess_map.get(&client_id).cloned() + } else { + None + } + }; + + if let Some(session) = session { + if let Ok(mgr) = manager.lock() { + match mgr.read_from_homeserver(&session, &path).await { + Ok(data) => { + let content = String::from_utf8_lossy(&data); + println!("Successfully read {} bytes from path: {}", data.len(), path); + println!("Content: {}", content); + + // Log success + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message: format!("✓ Read data: {} ← {} ({} bytes)", client_id, path, data.len()), + event_type: EventType::Success, + }); + + // Log the actual content + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message: format!("Content: {}", content), + event_type: EventType::Info, + }); + } + Err(e) => { + eprintln!("Failed to read data: {}", e); + + // Log error + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message: format!("✗ Read failed: {}", e), + event_type: EventType::Error, + }); + } + } + } + } else { + eprintln!("Client session not found - client must be connected first"); + + // Log error + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message: "✗ Client not connected".to_string(), + event_type: EventType::Error, + }); + } + + // Clear loading state + reading_flag.set(false); + }); + }; + + // Handler: Connect client to homeserver + let connect_client = move |(client_id, homeserver_id): (String, String)| { + let manager = testnet_manager(); + let keypairs = client_keypairs(); + let sessions = client_sessions(); + let mut all_nodes = nodes.clone(); + let mut all_edges = edges.clone(); + let client_id_clone = client_id.clone(); + let homeserver_id_clone = homeserver_id.clone(); + + spawn(async move { + // Get the client's keypair + let keypair = { + if let Ok(kp_map) = keypairs.lock() { + kp_map.get(&client_id).cloned() + } else { + None + } + }; + + if let Some(keypair) = keypair { + // Get the homeserver's public key + let homeserver_pubkey = { + let nodes_read = all_nodes.read(); + nodes_read.iter() + .find_map(|n| { + if let Node::Homeserver(h) = n { + if h.id == homeserver_id { + h.public_key.clone() + } else { + None + } + } else { + None + } + }) + }; + + if let Some(pubkey) = homeserver_pubkey { + // Connect the client and get the session + if let Ok(mgr) = manager.lock() { + match mgr.connect_client(&keypair, &pubkey).await { + Ok(session) => { + println!("Client {} connected to homeserver {}", client_id_clone, homeserver_id_clone); + + // Store the session for reuse + if let Ok(mut sess_map) = sessions.lock() { + sess_map.insert(client_id_clone.clone(), session); + } + + // Update client's connected_homeserver field + let mut nodes_write = all_nodes.write(); + for node in nodes_write.iter_mut() { + if let Node::Client(c) = node { + if c.id == client_id_clone { + c.connected_homeserver = Some(homeserver_id_clone.clone()); + break; + } + } + } + drop(nodes_write); + + // Add an edge from client to homeserver + all_edges.write().push(Edge { + from: client_id_clone.clone(), + to: homeserver_id_clone.clone(), + edge_type: EdgeType::Connection, + }); + } + Err(e) => { + eprintln!("Failed to connect client to homeserver: {}", e); + } + } + } + } else { + eprintln!("Homeserver {} not found or has no public key", homeserver_id); + } + } else { + eprintln!("Client keypair not found for {}", client_id); + } + }); + }; + + // Handler: Select scenario + let on_scenario_select = move |idx: usize| { + selected_scenario_idx.set(Some(idx)); + }; + + // Handler: Play scenario + let on_play_scenario = move |_| { + if let Some(idx) = selected_scenario_idx() { + let scenario = scenarios()[idx].clone(); + is_playing_scenario.set(true); + + println!("Playing scenario: {}", scenario.name); + println!("Description: {}", scenario.description); + println!("Total operations: {}", scenario.operations.len()); + + // Clear existing network before starting scenario + nodes.set(Vec::new()); + edges.set(Vec::new()); + selected_node_id.set(None); + + // Clear homeserver URLs + if let Ok(mut urls) = homeserver_urls().lock() { + urls.clear(); + } + + // Clear keypairs and sessions + if let Ok(mut kp_map) = client_keypairs().lock() { + kp_map.clear(); + } + if let Ok(mut sess_map) = client_sessions().lock() { + sess_map.clear(); + } + + // Clone all necessary state for async execution + let manager = testnet_manager(); + let mut all_nodes = nodes.clone(); + let mut all_edges = edges.clone(); + let keypairs = client_keypairs.clone(); + let sessions = client_sessions.clone(); + let urls = homeserver_urls.clone(); + let mut playing_flag = is_playing_scenario.clone(); + let mut log = event_log.clone(); + let mut counter = event_counter.clone(); + + spawn(async move { + use std::time::Instant; + use scenario::Action; + + let start_time = Instant::now(); + + // Helper to log events + let mut log_event = |message: String, event_type: EventType| { + let id = counter(); + counter.set(id + 1); + let timestamp = Local::now().format("%H:%M:%S%.3f").to_string(); + log.write().push(EventLogEntry { + id, + timestamp, + message, + event_type, + }); + }; + + for op in scenario.operations { + // Wait until the scheduled time + let target_time = std::time::Duration::from_secs_f64(op.at_seconds); + let elapsed = start_time.elapsed(); + if target_time > elapsed { + tokio::time::sleep(target_time - elapsed).await; + } + + println!("[@{:.1}s] Executing: {:?}", op.at_seconds, op.action); + + match op.action { + Action::CreateHomeserver { id } => { + if let Ok(mut mgr) = manager.lock() { + match mgr.create_homeserver().await { + Ok(info) => { + // Add to homeserver URLs + if let Ok(mut urls_list) = urls.read().lock() { + urls_list.push(info.http_url.clone()); + } + + // Calculate position using force-directed principles + let (x, y) = calculate_initial_position(&all_nodes.read(), None); + + // Add node + all_nodes.write().push(Node::Homeserver(Homeserver { + id: id.clone(), + name: format!("Homeserver {}", id), + port: info.port, + http_url: Some(info.http_url), + status: NodeStatus::Running, + public_key: Some(info.public_key), + connectivity_status: ConnectivityStatus::Unknown, + storage_stats: None, + x, + y, + })); + println!(" ✓ Homeserver created: {}", id); + log_event(format!("Created homeserver: {}", id), EventType::Success); + } + Err(e) => { + eprintln!(" ✗ Failed to create homeserver: {}", e); + log_event(format!("Failed to create homeserver: {}", e), EventType::Error); + } + } + } + } + Action::CreateClient { id } => { + if let Ok(mut mgr) = manager.lock() { + match mgr.create_client().await { + Ok(info) => { + // Calculate position using force-directed principles + let (x, y) = calculate_initial_position(&all_nodes.read(), None); + + // Store keypair + if let Ok(mut kp_map) = keypairs.read().lock() { + kp_map.insert(id.clone(), info.keypair); + } + + // Add node + all_nodes.write().push(Node::Client(Client { + id: id.clone(), + name: format!("Client {}", id), + public_key: info.public_key, + status: NodeStatus::Running, + connected_homeserver: None, + x, + y, + })); + println!(" ✓ Client created: {}", id); + log_event(format!("Created client: {}", id), EventType::Success); + } + Err(e) => { + eprintln!(" ✗ Failed to create client: {}", e); + log_event(format!("Failed to create client: {}", e), EventType::Error); + } + } + } + } + Action::ConnectClient { client_id, homeserver_id } => { + // Find homeserver public key + let homeserver_pubkey = { + all_nodes.read().iter().find_map(|n| { + if let Node::Homeserver(h) = n { + if h.id == homeserver_id { + h.public_key.clone() + } else { + None + } + } else { + None + } + }) + }; + + if let Some(pubkey) = homeserver_pubkey { + // Get client keypair + let keypair = { + if let Ok(kp_map) = keypairs.read().lock() { + kp_map.get(&client_id).cloned() + } else { + None + } + }; + + if let Some(kp) = keypair { + if let Ok(mgr) = manager.lock() { + match mgr.connect_client(&kp, &pubkey).await { + Ok(session) => { + // Store session + if let Ok(mut sess_map) = sessions.read().lock() { + sess_map.insert(client_id.clone(), session); + } + + // Update client node + for node in all_nodes.write().iter_mut() { + if let Node::Client(c) = node { + if c.id == client_id { + c.connected_homeserver = Some(homeserver_id.clone()); + break; + } + } + } + + // Add edge + all_edges.write().push(Edge { + from: client_id.clone(), + to: homeserver_id.clone(), + edge_type: EdgeType::Connection, + }); + println!(" ✓ Connected {} to {}", client_id, homeserver_id); + log_event(format!("Connected {} → {}", client_id, homeserver_id), EventType::Success); + } + Err(e) => { + eprintln!(" ✗ Failed to connect: {}", e); + log_event(format!("Failed to connect client: {}", e), EventType::Error); + } + } + } + } + } + } + Action::WriteData { client_id, path, content } => { + let session = { + if let Ok(sess_map) = sessions.read().lock() { + sess_map.get(&client_id).cloned() + } else { + None + } + }; + + if let Some(sess) = session { + if let Ok(mgr) = manager.lock() { + match mgr.write_to_homeserver(&sess, &path, content.as_bytes()).await { + Ok(_) => { + println!(" ✓ Wrote to {}: {}", client_id, path); + log_event(format!("Wrote data: {} → {}", client_id, path), EventType::Success); + } + Err(e) => { + eprintln!(" ✗ Write failed: {}", e); + log_event(format!("Write failed: {}", e), EventType::Error); + } + } + } + } + } + Action::ReadData { client_id, path } => { + let session = { + if let Ok(sess_map) = sessions.read().lock() { + sess_map.get(&client_id).cloned() + } else { + None + } + }; + + if let Some(sess) = session { + if let Ok(mgr) = manager.lock() { + match mgr.read_from_homeserver(&sess, &path).await { + Ok(data) => { + let content = String::from_utf8_lossy(&data); + println!(" ✓ Read from {}: {} = {}", client_id, path, content); + log_event(format!("Read data: {} ← {}", client_id, path), EventType::Success); + } + Err(e) => { + eprintln!(" ✗ Read failed: {}", e); + log_event(format!("Read failed: {}", e), EventType::Error); + } + } + } + } + } + Action::WaitForHomeserver { homeserver_id, timeout_seconds } => { + // Find the homeserver's HTTP URL + let http_url = { + all_nodes.read().iter().find_map(|n| { + if let Node::Homeserver(h) = n { + if h.id == homeserver_id { + h.http_url.clone() + } else { + None + } + } else { + None + } + }) + }; + + if let Some(url) = http_url { + println!(" ⏳ Waiting for {} to be ready...", homeserver_id); + log_event(format!("Waiting for {} to be ready", homeserver_id), EventType::Info); + + let start_wait = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs_f64(timeout_seconds); + let mut ready = false; + + // Poll the homeserver until it's ready or timeout + while start_wait.elapsed() < timeout { + if let Ok(response) = reqwest::get(&url).await { + if response.status().is_success() || response.status().is_client_error() { + ready = true; + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + if ready { + println!(" ✓ {} is ready", homeserver_id); + log_event(format!("{} is ready", homeserver_id), EventType::Success); + } else { + eprintln!(" ✗ {} not ready after {:.1}s", homeserver_id, timeout_seconds); + log_event(format!("{} not ready (timeout)", homeserver_id), EventType::Error); + } + } else { + eprintln!(" ✗ Homeserver {} not found", homeserver_id); + log_event(format!("Homeserver {} not found", homeserver_id), EventType::Error); + } + } + } + } + + playing_flag.set(false); + println!("✓ Scenario '{}' complete!", scenario.name); + }); + } + }; + + // Handler: Reset visualization (clear nodes/clients but keep network running) + let on_reset = move |_| { + println!("Clearing visualization..."); + + // Clear all nodes and edges + nodes.set(Vec::new()); + edges.set(Vec::new()); + selected_node_id.set(None); + + // Clear homeserver URLs + if let Ok(mut urls) = homeserver_urls().lock() { + urls.clear(); + } + + // Clear keypairs and sessions + if let Ok(mut kp_map) = client_keypairs().lock() { + kp_map.clear(); + } + if let Ok(mut sess_map) = client_sessions().lock() { + sess_map.clear(); + } + + // Clear event log + event_log.set(Vec::new()); + + // Reset scenario playing flag + is_playing_scenario.set(false); + + println!("✓ Visualization cleared (network still running)"); + }; + + // Handler: Start resizing sidebar (stores initial state) + let on_resize_sidebar = move |mouse_x: i32| { + if mouse_x < 0 { + // Negative value signals start of resizing + is_resizing_sidebar.set(true); + resize_start_width.set(sidebar_width()); + // Mouse X will be set on first mouse move + } + }; + + // Handler: Start resizing event log (stores initial state) + let on_resize_eventlog = move |mouse_y: i32| { + if mouse_y < 0 { + // Negative value signals start of resizing + is_resizing_eventlog.set(true); + resize_start_height.set(event_log_height()); + // Mouse Y will be set on first mouse move + } + }; + + // Global mouse move handler for resizing + let on_global_mouse_move = move |evt: MouseEvent| { + let coords = evt.client_coordinates(); + + if is_resizing_sidebar() { + // Store initial mouse X if not set + if resize_start_x() == 0.0 { + resize_start_x.set(coords.x); + } + + // Calculate delta from start position + let delta_x = coords.x - resize_start_x(); + + // For horizontal resize on left edge: moving left increases width, moving right decreases + let new_width = (resize_start_width() as f64 - delta_x) as i32; + sidebar_width.set(new_width.max(200).min(800)); + } + + if is_resizing_eventlog() { + // Store initial mouse Y if not set + if resize_start_y() == 0.0 { + resize_start_y.set(coords.y); + } + + // Calculate delta from start position + let delta_y = coords.y - resize_start_y(); + + // For vertical resize on top edge: moving up increases height, moving down decreases + let new_height = (resize_start_height() as f64 - delta_y) as i32; + event_log_height.set(new_height.max(100).min(600)); + } + }; + + // Global mouse up handler to stop resizing + let on_global_mouse_up = move |_evt: MouseEvent| { + is_resizing_sidebar.set(false); + is_resizing_eventlog.set(false); + // Reset start positions for next resize + resize_start_x.set(0.0); + resize_start_y.set(0.0); + }; + + rsx! { + document::Link { rel: "stylesheet", href: asset!("./assets/tailwind.css") } + style { + dangerous_inner_html: r#" + @keyframes spin {{ + from {{ transform: rotate(0deg); }} + to {{ transform: rotate(360deg); }} + }} + @keyframes slideDown {{ + from {{ + transform: translateY(-100%); + opacity: 0; + }} + to {{ + transform: translateY(0); + opacity: 1; + }} + }} + "# + } + + div { + class: "h-screen flex flex-col bg-black", + onmousemove: on_global_mouse_move, + onmouseup: on_global_mouse_up, + + // Topbar + Topbar { + is_running: is_network_running(), + is_creating_homeserver: is_creating_homeserver(), + is_creating_client: is_creating_client(), + scenarios: scenarios().iter().map(|s| s.name.clone()).collect(), + selected_scenario: selected_scenario_idx(), + is_playing_scenario: is_playing_scenario(), + on_toggle_network: toggle_network, + on_add_homeserver: add_homeserver, + on_add_client: add_client, + on_scenario_select: on_scenario_select, + on_play_scenario: on_play_scenario, + on_reset: on_reset, + on_import_scenario: move |_| { + notification_message.set(Some("Not implemented".to_string())); + }, + on_export_scenario: move |_| { + notification_message.set(Some("Not implemented".to_string())); + }, + } + + // Main content area + div { + class: "flex-1 flex overflow-hidden", + + // Network visualization (left) + NetworkVisualization { + nodes: nodes(), + edges: edges(), + selected_id: selected_node_id(), + on_select: select_node, + on_node_move: move_node, + is_loading_scenario: is_playing_scenario() && nodes().is_empty(), + } + + // Context sidebar (right) + ContextSidebar { + selected_node: selected_node(), + all_nodes: nodes(), + event_log: event_log(), + is_writing: is_writing(), + is_reading: is_reading(), + sidebar_width: sidebar_width(), + event_log_height: event_log_height(), + on_stop_node: stop_node, + on_start_node: start_node, + on_remove_node: remove_node, + on_test_connectivity: test_connectivity, + on_connect_client: connect_client, + on_write_data: write_data, + on_read_data: read_data, + on_resize_sidebar: on_resize_sidebar, + on_resize_eventlog: on_resize_eventlog, + } + } + + // Notification popup (top center) + if let Some(message) = notification_message() { + div { + class: "fixed inset-0 z-50 flex items-start justify-center pt-16 pointer-events-none", + div { + class: "pointer-events-auto px-6 py-3 rounded-lg shadow-2xl text-sm font-medium cursor-pointer", + style: "background-color: #c7ff00; color: #000; animation: slideDown 0.3s ease-out;", + onclick: move |_| { + notification_message.set(None); + }, + "{message}" + } + } + } + } + } +} diff --git a/publar/src/scenario.rs b/publar/src/scenario.rs new file mode 100644 index 0000000..433084c --- /dev/null +++ b/publar/src/scenario.rs @@ -0,0 +1,371 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Scenario { + pub name: String, + pub description: String, + pub operations: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Operation { + /// Seconds from scenario start to execute this operation + pub at_seconds: f64, + #[serde(flatten)] + pub action: Action, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Action { + CreateHomeserver { + id: String, + }, + CreateClient { + id: String, + }, + ConnectClient { + client_id: String, + homeserver_id: String, + }, + WriteData { + client_id: String, + path: String, + content: String, + }, + ReadData { + client_id: String, + path: String, + }, + /// Wait for a homeserver to be ready to accept connections + WaitForHomeserver { + homeserver_id: String, + /// Maximum time to wait in seconds + timeout_seconds: f64, + }, +} + +impl Scenario { + /// Load built-in scenarios (now loads from scenarios/ directory) + pub fn built_in_scenarios() -> Vec { + // First try to load from scenarios directory + let mut scenarios = Self::load_from_directory(); + + // If no scenarios were loaded from files, fall back to hardcoded scenarios + if scenarios.is_empty() { + scenarios = vec![ + Scenario { + name: "Quick Demo".to_string(), + description: "Creates 2 homeservers, 2 clients, and demonstrates read/write".to_string(), + operations: vec![ + Operation { + at_seconds: 0.0, + action: Action::CreateHomeserver { + id: "homeserver-1".to_string(), + }, + }, + Operation { + at_seconds: 1.0, + action: Action::CreateHomeserver { + id: "homeserver-2".to_string(), + }, + }, + // Wait for homeservers to be ready + Operation { + at_seconds: 2.0, + action: Action::WaitForHomeserver { + homeserver_id: "homeserver-1".to_string(), + timeout_seconds: 5.0, + }, + }, + Operation { + at_seconds: 2.0, + action: Action::WaitForHomeserver { + homeserver_id: "homeserver-2".to_string(), + timeout_seconds: 5.0, + }, + }, + Operation { + at_seconds: 2.5, + action: Action::CreateClient { + id: "client-1".to_string(), + }, + }, + Operation { + at_seconds: 3.0, + action: Action::CreateClient { + id: "client-2".to_string(), + }, + }, + Operation { + at_seconds: 3.5, + action: Action::ConnectClient { + client_id: "client-1".to_string(), + homeserver_id: "homeserver-1".to_string(), + }, + }, + Operation { + at_seconds: 4.0, + action: Action::ConnectClient { + client_id: "client-2".to_string(), + homeserver_id: "homeserver-2".to_string(), + }, + }, + Operation { + at_seconds: 5.0, + action: Action::WriteData { + client_id: "client-1".to_string(), + path: "/pub/publar/demo.txt".to_string(), + content: "Hello from client 1!".to_string(), + }, + }, + Operation { + at_seconds: 6.0, + action: Action::WriteData { + client_id: "client-2".to_string(), + path: "/pub/publar/demo.txt".to_string(), + content: "Hello from client 2!".to_string(), + }, + }, + Operation { + at_seconds: 7.0, + action: Action::ReadData { + client_id: "client-1".to_string(), + path: "/pub/publar/demo.txt".to_string(), + }, + }, + ], + }, + Scenario { + name: "Indexer Stress Test".to_string(), + description: "Creates 5 homeservers with 3 clients each, all writing data".to_string(), + operations: { + let mut ops = Vec::new(); + let mut time = 0.0; + + // Create 5 homeservers + for i in 1..=5 { + ops.push(Operation { + at_seconds: time, + action: Action::CreateHomeserver { + id: format!("homeserver-{}", i), + }, + }); + time += 0.5; + } + + // Create 15 clients (3 per homeserver) + for i in 1..=15 { + ops.push(Operation { + at_seconds: time, + action: Action::CreateClient { + id: format!("client-{}", i), + }, + }); + time += 0.3; + } + + // Connect clients to homeservers + time += 1.0; + for i in 1..=15 { + let homeserver_idx = ((i - 1) / 3) + 1; + ops.push(Operation { + at_seconds: time, + action: Action::ConnectClient { + client_id: format!("client-{}", i), + homeserver_id: format!("homeserver-{}", homeserver_idx), + }, + }); + time += 0.5; + } + + // Write data from each client + time += 1.0; + for i in 1..=15 { + for j in 1..=3 { + ops.push(Operation { + at_seconds: time, + action: Action::WriteData { + client_id: format!("client-{}", i), + path: format!("/pub/publar/file_{}.txt", j), + content: format!("Data from client {} file {}", i, j), + }, + }); + time += 0.2; + } + } + + ops + }, + }, + Scenario { + name: "Rate Limiting".to_string(), + description: "Creates 1 homeserver with 5 clients, each writing 10 times rapidly to test rate limiting".to_string(), + operations: { + let mut ops = Vec::new(); + let mut time = 0.0; + + // Create 1 homeserver + ops.push(Operation { + at_seconds: time, + action: Action::CreateHomeserver { + id: "homeserver-1".to_string(), + }, + }); + time += 1.0; + + // Wait for homeserver to be ready + ops.push(Operation { + at_seconds: time, + action: Action::WaitForHomeserver { + homeserver_id: "homeserver-1".to_string(), + timeout_seconds: 5.0, + }, + }); + time += 1.0; + + // Create 5 clients + for i in 1..=5 { + ops.push(Operation { + at_seconds: time, + action: Action::CreateClient { + id: format!("client-{}", i), + }, + }); + time += 0.05; // Stagger client creation + } + time += 0.5; // Small pause after client creation + + // Connect all 5 clients to the homeserver rapidly (sequentially) + for i in 1..=5 { + ops.push(Operation { + at_seconds: time, + action: Action::ConnectClient { + client_id: format!("client-{}", i), + homeserver_id: "homeserver-1".to_string(), + }, + }); + time += 0.01; // Very small delay (10ms) between connections + } + time += 0.5; // Small pause after connections + + // Each client writes 10 times rapidly within 5 seconds + let write_start_time = time; + for i in 1..=5 { + for j in 1..=10 { + // Spread writes across 5 seconds, but with some overlap + let write_time = write_start_time + (j as f64 * 0.5) + (i as f64 * 0.02); + ops.push(Operation { + at_seconds: write_time, + action: Action::WriteData { + client_id: format!("client-{}", i), + path: format!("/pub/publar/rate_test_{}.txt", j), + content: format!("Rate test data from client {} write {}", i, j), + }, + }); + } + } + + ops + }, + }, + ]; + } + + scenarios + } + + /// Save scenario to JSON file + #[allow(dead_code)] + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Load scenario from JSON + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Load scenario from a JSON file + pub fn from_file>(path: P) -> Result> { + let json = fs::read_to_string(path)?; + let scenario = Self::from_json(&json)?; + Ok(scenario) + } + + /// Save scenario to a JSON file + #[allow(dead_code)] + pub fn to_file>(&self, path: P) -> Result<(), Box> { + let json = self.to_json()?; + fs::write(path, json)?; + Ok(()) + } + + /// Get the scenarios directory path (~/.publar/scenarios) + pub fn scenarios_dir() -> PathBuf { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + + PathBuf::from(home).join(".publar").join("scenarios") + } + + /// Load all scenarios from the scenarios directory + pub fn load_from_directory() -> Vec { + let mut scenarios = Vec::new(); + + let scenarios_dir = Self::scenarios_dir(); + if !scenarios_dir.exists() { + // Create the directory if it doesn't exist + if let Err(e) = fs::create_dir_all(&scenarios_dir) { + eprintln!("Failed to create scenarios directory: {}", e); + return scenarios; + } + println!("Created scenarios directory at: {:?}", scenarios_dir); + } + + match fs::read_dir(&scenarios_dir) { + Ok(entries) => { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + match Self::from_file(&path) { + Ok(scenario) => { + println!("Loaded scenario: {}", scenario.name); + scenarios.push(scenario); + } + Err(e) => { + eprintln!("Failed to load scenario from {:?}: {}", path, e); + } + } + } + } + } + Err(e) => { + eprintln!("Failed to read scenarios directory: {}", e); + } + } + + scenarios + } + + /// List available scenario files in the scenarios directory + #[allow(dead_code)] + pub fn list_scenario_files() -> Vec { + let mut files = Vec::new(); + let scenarios_dir = Self::scenarios_dir(); + + if let Ok(entries) = fs::read_dir(&scenarios_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + files.push(path); + } + } + } + + files + } +} diff --git a/publar/src/testnet.rs b/publar/src/testnet.rs new file mode 100644 index 0000000..bd7c489 --- /dev/null +++ b/publar/src/testnet.rs @@ -0,0 +1,212 @@ +use anyhow::{Context, Result}; +use pubky_testnet::Testnet; +use pubky::{Keypair, PublicKey, PubkySession}; +use std::sync::Arc; + +pub struct HomeserverInfo { + pub port: u16, + pub public_key: String, + pub http_url: String, +} + +pub struct ClientInfo { + pub public_key: String, + pub keypair: Keypair, +} + +/// Manager for pubky-testnet +pub struct TestnetManager { + testnet: Option, +} + +impl TestnetManager { + pub fn new() -> Self { + Self { + testnet: None, + } + } + + /// Initialize the testnet + pub async fn start(&mut self) -> Result<()> { + let testnet = Testnet::new().await + .context("Failed to create testnet")?; + + self.testnet = Some(testnet); + + Ok(()) + } + + /// Create a new homeserver + pub async fn create_homeserver(&mut self) -> Result { + let testnet = self + .testnet + .as_mut() + .ok_or_else(|| anyhow::anyhow!("Testnet not initialized. Call start() first."))?; + + let homeserver = testnet.create_homeserver().await + .context("Failed to create homeserver in testnet")?; + + // Extract port and URL + let url = homeserver.icann_http_url(); + let port = url.port().unwrap_or(80); + let http_url = url.to_string(); + + Ok(HomeserverInfo { + port, + public_key: homeserver.public_key().to_z32(), + http_url, + }) + } + + /// Create a new client + pub async fn create_client(&mut self) -> Result { + // Generate a new keypair for the client + let keypair = Keypair::random(); + let public_key = keypair.public_key().to_z32(); + + Ok(ClientInfo { + public_key, + keypair, + }) + } + + /// Connect a client to a homeserver and return the session + pub async fn connect_client( + &self, + client_keypair: &Keypair, + homeserver_pubkey: &str, + ) -> Result> { + // Get the testnet instance + let testnet = self + .testnet + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Testnet not initialized. Call start() first."))?; + + // Create Pubky SDK instance using THIS testnet's DHT configuration + let pubky = testnet.sdk() + .context("Failed to create Pubky SDK from testnet")?; + + // Parse homeserver public key + let homeserver_pk = PublicKey::try_from(homeserver_pubkey) + .context("Invalid homeserver public key")?; + + // Sign up to the homeserver (no token needed for testnet) + // This creates the account and returns a session that can be reused + let session = pubky.signer(client_keypair.clone()) + .signup(&homeserver_pk, None) + .await + .context("Failed to connect client to homeserver")?; + + Ok(Arc::new(session)) + } + + /// Write data to a homeserver using an existing session + pub async fn write_to_homeserver( + &self, + session: &PubkySession, + path: &str, + content: &[u8], + ) -> Result<()> { + // Use the existing session - no need to signup again! + session.storage() + .put(path, content.to_vec()) + .await + .context("Failed to write to homeserver")?; + + Ok(()) + } + + /// Read data from a homeserver using an existing session + pub async fn read_from_homeserver( + &self, + session: &PubkySession, + path: &str, + ) -> Result> { + // Use the existing session - no need to signup again! + let response = session.storage() + .get(path) + .await + .context("Failed to read from homeserver")?; + + let data = response.bytes() + .await + .context("Failed to extract bytes from response")? + .to_vec(); + + Ok(data) + } + + /// Stop the entire testnet + pub async fn stop(&mut self) { + self.testnet = None; + } + + /// Bulk create multiple homeservers + #[allow(dead_code)] + pub async fn create_homeservers_bulk(&mut self, count: usize) -> Result> { + let mut homeservers = Vec::with_capacity(count); + for _ in 0..count { + let homeserver = self.create_homeserver().await?; + homeservers.push(homeserver); + } + Ok(homeservers) + } + + /// Bulk create multiple clients + #[allow(dead_code)] + pub async fn create_clients_bulk(&mut self, count: usize) -> Result> { + let mut clients = Vec::with_capacity(count); + for _ in 0..count { + let client = self.create_client().await?; + clients.push(client); + } + Ok(clients) + } + + /// Simulate indexer test scenario: create clients, connect them to homeservers, and write test data + /// Returns: Vec<(client_pubkey, homeserver_pubkey, homeserver_url, session)> + #[allow(dead_code)] + pub async fn simulate_indexer_scenario( + &mut self, + num_homeservers: usize, + clients_per_homeserver: usize, + files_per_client: usize, + ) -> Result)>> { + let mut results = Vec::new(); + + // Create homeservers + let homeservers = self.create_homeservers_bulk(num_homeservers).await?; + + for homeserver in &homeservers { + // Create clients for this homeserver + let clients = self.create_clients_bulk(clients_per_homeserver).await?; + + for client in clients { + // Connect client to homeserver + let session = self.connect_client(&client.keypair, &homeserver.public_key).await?; + + // Write test files + for i in 0..files_per_client { + let path = format!("/pub/publar/test_file_{}.txt", i); + let content = format!("Test data from client {} file {}", client.public_key, i); + self.write_to_homeserver(&session, &path, content.as_bytes()).await?; + } + + results.push(( + client.public_key.clone(), + homeserver.public_key.clone(), + homeserver.http_url.clone(), + session, + )); + } + } + + Ok(results) + } + + /// Get all homeserver URLs for external indexer configuration + #[allow(dead_code)] + pub fn export_homeserver_urls(&self, homeservers: &[HomeserverInfo]) -> Vec { + homeservers.iter().map(|h| h.http_url.clone()).collect() + } +} diff --git a/publar/tailwind.config.js b/publar/tailwind.config.js new file mode 100644 index 0000000..7c65eb0 --- /dev/null +++ b/publar/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.rs", + ], + theme: { + extend: {}, + }, + plugins: [], +} + From 181c12d79fef861c908eecf2ab2ae0fd982ca734 Mon Sep 17 00:00:00 2001 From: Kevin Karsopawiro Date: Thu, 23 Oct 2025 15:14:37 +0200 Subject: [PATCH 2/7] feat: add GitHub Actions workflow and UI improvements for Publar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pre-release workflow for building Linux, Windows, and macOS packages - Use Dioxus CLI v0.7.0-rc.3 pre-built binaries for faster builds - Build in dev profile for faster pre-release compilation - Linux: Build standalone binary (avoids dx bundle dependency conflicts) - Windows: Download and install pre-built MSI - macOS: Download ARM64 binary with OpenSSL setup and ad-hoc signing UI Improvements: - Move zoom controls to horizontal layout in bottom-right - Move scroll-to-zoom instructions to bottom-left - Make zoom controls more compact - Update resize handle hover effect to use lime green (#c7ff00) - Rename "Simple Connection" scenario to "Simple Read/Write" - Remove hardcoded fallback scenarios (load from JSON only) Documentation: - Extract architecture details to ARCHITECTURE.md - Enhance SCENARIOS.md with introduction and benefits - Add Windows/Linux build instructions to README - Add macOS "damaged app" fix instructions - Add documentation links section - Clarify dx CLI prerequisite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/publar-pre-release.yml | 259 ++++++++++++++ publar/ARCHITECTURE.md | 113 ++++++ publar/README.md | 323 +++++++----------- publar/SCENARIOS.md | 27 +- publar/assets/input.css | 5 + publar/assets/tailwind.css | 2 +- publar/src/components/context_sidebar.rs | 4 +- .../src/components/network_visualization.rs | 24 +- publar/src/scenario.rs | 228 +------------ 9 files changed, 549 insertions(+), 436 deletions(-) create mode 100644 .github/workflows/publar-pre-release.yml create mode 100644 publar/ARCHITECTURE.md diff --git a/.github/workflows/publar-pre-release.yml b/.github/workflows/publar-pre-release.yml new file mode 100644 index 0000000..a0edd94 --- /dev/null +++ b/.github/workflows/publar-pre-release.yml @@ -0,0 +1,259 @@ +name: Publar + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-linux: + runs-on: ubuntu-latest + defaults: + run: + working-directory: publar + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install npm dependencies + run: npm install + + - name: Install Dioxus CLI (pre-built binary) + run: | + # Download pre-built binary for much faster setup (v0.7.0-rc.3 has dependency fixes) + curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.7.0-rc.3/dx-x86_64-unknown-linux-gnu.tar.gz -o dx.tar.gz + tar -xzf dx.tar.gz + chmod +x dx + sudo mv dx /usr/local/bin/dx + dx --version + + - name: Build Tailwind CSS + run: npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --minify + + - name: Cache cargo build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: publar-linux + workspaces: publar + + - name: Build Linux binary (debug mode for speed) + run: | + mkdir -p dist + # Build the binary directly with cargo (avoids dx bundle dependency issues) + cargo build --profile dev + + # Copy binary to dist + cp target/dev/publar dist/publar-linux-x86_64 + chmod +x dist/publar-linux-x86_64 + + ls -lh dist + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: publar-linux + path: publar/dist/* + + build-windows: + runs-on: windows-latest + defaults: + run: + working-directory: publar + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install npm dependencies + run: npm install + + - name: Install Dioxus CLI (pre-built binary) + run: | + # Download pre-built binary for much faster setup (v0.7.0-rc.3 has dependency fixes) + curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.7.0-rc.3/dx-x86_64-pc-windows-msvc.zip -o dx.zip + unzip dx.zip + New-Item -ItemType Directory -Force -Path "$HOME/.cargo/bin" + Move-Item -Force dx.exe "$HOME/.cargo/bin/dx.exe" + dx --version + + - name: Build Tailwind CSS + run: npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --minify + + - name: Cache cargo build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: publar-windows + workspaces: publar + + - name: Build Windows installer (debug mode for speed) + run: | + mkdir -p dist + # Build in dev profile for faster pre-release builds (no optimizations) + dx bundle --platform desktop --profile dev --package-types msi + + # Find and copy the built installer + Get-ChildItem -Path target/dx/publar/bundle -Filter "*.msi" -Recurse | Copy-Item -Destination dist/ + + Get-ChildItem dist + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: publar-windows + path: publar/dist/* + + build-macos: + runs-on: macos-latest + defaults: + run: + working-directory: publar + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install OpenSSL (required for dx binary) + run: | + brew install openssl@3 + # Create symlink so dx can find OpenSSL + sudo mkdir -p /usr/local/opt/openssl@3/lib + sudo ln -sf $(brew --prefix openssl@3)/lib/libssl.3.dylib /usr/local/opt/openssl@3/lib/libssl.3.dylib + sudo ln -sf $(brew --prefix openssl@3)/lib/libcrypto.3.dylib /usr/local/opt/openssl@3/lib/libcrypto.3.dylib + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install npm dependencies + run: npm install + + - name: Install Dioxus CLI (pre-built binary) + run: | + # Download pre-built binary for much faster setup (v0.7.0-rc.3 has dependency fixes) + # GitHub Actions macOS runners are ARM64 (Apple Silicon) + curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.7.0-rc.3/dx-aarch64-apple-darwin.tar.gz -o dx.tar.gz + tar -xzf dx.tar.gz + chmod +x dx + sudo mv dx /usr/local/bin/dx + dx --version + + - name: Build Tailwind CSS + run: npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --minify + + - name: Cache cargo build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: publar-macos + workspaces: publar + + - name: Build macOS packages (debug mode for speed) + run: | + mkdir -p dist + # Build in dev profile for faster pre-release builds (no optimizations) + dx bundle --platform desktop --profile dev --package-types macos + dx bundle --platform desktop --profile dev --package-types dmg + + # Find the built packages + find target/dx/publar/bundle -name "*.app" -exec cp -r {} dist/ \; + find target/dx/publar/bundle -name "*.dmg" -exec cp {} dist/ \; + + # Ad-hoc sign the .app bundle + for app in dist/*.app; do + if [ -d "$app" ]; then + echo "Ad-hoc signing $app" + # Remove quarantine attributes and sign + sudo xattr -cr "$app" + codesign --force --deep --sign - "$app" + fi + done + + # Also remove quarantine from DMG files + for dmg in dist/*.dmg; do + if [ -f "$dmg" ]; then + sudo xattr -cr "$dmg" + fi + done + + ls -lh dist + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: publar-macos + path: publar/dist/* + + publish-release: + needs: + - build-linux + - build-windows + - build-macos + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Prepare release files + run: | + mkdir -p release + # Move all artifacts to release directory + find dist -type f \( -name "*.deb" -o -name "*.AppImage" -o -name "*.msi" -o -name "*.dmg" -o -name "*.app" \) -exec mv {} release/ \; + + # Create archives for .app bundles if any + for app in release/*.app; do + if [ -d "$app" ]; then + zip -r "${app}.zip" "$app" + rm -rf "$app" + fi + done + + ls -lh release + + - name: Prepare release metadata + id: release_meta + run: | + tag="publar-pre-release-${GITHUB_RUN_NUMBER}" + name="Publar pre-release ${GITHUB_RUN_NUMBER}" + echo "tag_name=$tag" >> "$GITHUB_OUTPUT" + echo "release_name=$name" >> "$GITHUB_OUTPUT" + + - name: Publish pre-release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.release_meta.outputs.tag_name }} + name: ${{ steps.release_meta.outputs.release_name }} + prerelease: true + generate_release_notes: true + files: release/* diff --git a/publar/ARCHITECTURE.md b/publar/ARCHITECTURE.md new file mode 100644 index 0000000..13a8d98 --- /dev/null +++ b/publar/ARCHITECTURE.md @@ -0,0 +1,113 @@ +# Publar Architecture + +This document describes the technical architecture and implementation details of Publar. + +## High-Level Overview + +``` + ┌───────────────────┐ + │ Publar UI │ + │ (Dioxus) │ + └─────────┬─────────┘ + │ + ▼ + ┌───────────────────┐ + │ Testnet Manager │ + │ (pubky-testnet) │ + └─────────┬─────────┘ + │ + ┌───────────┼───────────┐ + ▼ ▼ ▼ +┌─────────┐ ┌─────────┐ ┌─────────┐ +│Homeserver│Homeserver│Homeserver│ +│ :50000 │ :50001 │ :50002 │ +└────▲────┘ └────▲────┘ └────▲────┘ + │ │ │ + ┌───┴──┐ ┌───┴──┐ ┌───┴──┐ + │Client│ │Client│ │Client│ + └──────┘ └──────┘ └──────┘ +``` + +## Components + +### Frontend (Dioxus 0.6) + +- **Topbar**: Controls for adding nodes, running scenarios, and resetting +- **Network Visualization**: Interactive SVG graph with force-directed layout (Fruchterman-Reingold algorithm) + - Homeservers: White circles with port labels + - Clients: Lime green (#c7ff00) circles with truncated public keys + - Connections: Lime green lines showing client-homeserver relationships +- **Context Sidebar**: Resizable panel with node details, actions, and event log + +### Backend (pubky-testnet + pubky) + +- **Testnet Manager**: Manages multiple homeserver processes via pubky-testnet +- **Session Management**: Maintains client sessions with homeservers +- **Scenario Engine**: Executes timed operations (create, connect, write, read) + +### State Management + +- Dioxus signals for reactive UI updates +- Shared Arc> for cross-task state access + +## Data Flow + +``` +User Action → Dioxus Event Handler → Testnet Manager → Homeserver HTTP API + ↓ + Update Signals + ↓ + UI Re-renders + ↓ + Log Event Entry +``` + +## Key Algorithms + +### Force-Directed Layout + +- **Repulsion**: All nodes push away from each other (prevents overlap) +- **Spring Forces**: Connected nodes maintain ideal distance (~150px) +- **Damping**: Velocity decay creates smooth stabilization +- Runs continuously every 50ms for dynamic repositioning + +### Scenario Execution + +- Operations grouped by timestamp +- Sequential execution with precise timing +- Async/await for non-blocking UI + +## File Structure + +``` +publar/ +├── src/ +│ ├── main.rs # App entry, state, event handlers +│ ├── components/ +│ │ ├── topbar.rs # Top control bar +│ │ ├── network_visualization.rs # SVG graph with force layout +│ │ └── context_sidebar.rs # Right panel (details + log) +│ ├── testnet.rs # Wrapper around pubky-testnet +│ ├── scenario.rs # Scenario definitions and operations +│ ├── force_layout.rs # Fruchterman-Reingold algorithm +│ └── api.rs # REST API (future) +├── examples/ +│ └── testnet_write_read.rs # External connection example +├── assets/ +│ ├── input.css # Tailwind source +│ └── tailwind.css # Generated CSS +├── build.rs # Compiles Tailwind on build +├── tailwind.config.js # Tailwind configuration +├── package.json # npm dependencies +├── Cargo.toml # Rust dependencies +├── Dioxus.toml # Dioxus bundling configuration +└── SCENARIOS.md # JSON scenario documentation +``` + +## Key Technologies + +- **[Dioxus 0.6](https://dioxuslabs.com/)**: Cross-platform UI framework (desktop, web, mobile) +- **[Tailwind CSS v3](https://tailwindcss.com/)**: Utility-first styling +- **[Tokio](https://tokio.rs/)**: Async runtime +- **[pubky-testnet 0.6.0-rc.6](https://github.com/pubky/pubky)**: Manages local homeserver processes +- **[pubky 0.6.0-rc.6](https://github.com/pubky/pubky)**: Client library for Pubky protocol diff --git a/publar/README.md b/publar/README.md index d6b15b8..db91887 100644 --- a/publar/README.md +++ b/publar/README.md @@ -26,6 +26,11 @@ Developing distributed systems is hard. Publar makes it easier by: If you're building on Pubky, Publar helps you move faster and catch issues early. +## Documentation + +- **[ARCHITECTURE.md](ARCHITECTURE.md)**: Technical architecture, component details, algorithms, and data flow +- **[SCENARIOS.md](SCENARIOS.md)**: Complete guide to creating and using automated test scenarios + ## Setup & Run ### Prerequisites @@ -40,13 +45,15 @@ If you're building on Pubky, Publar helps you move faster and catch issues early git clone https://github.com/yourusername/publar.git cd publar -# Install npm dependencies for Tailwind CSS +# IMPORTANT: Install npm dependencies FIRST (required for CSS compilation) npm install -# Build and run (Tailwind CSS compiles automatically) +# Build CSS and run cargo run ``` +**Note**: You must run `npm install` before `cargo run`. The build process uses Tailwind CSS which requires Node.js dependencies to be installed first. + The application window will open automatically. Start by adding a homeserver or running a pre-built scenario. ### Running Examples @@ -67,245 +74,193 @@ Example: cargo run --example testnet_write_read http://127.0.0.1:50000/ z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK ``` -## Usage - -### Manual Network Management - -1. **Add nodes**: Click "Add Homeserver" or "Add Client" to create new nodes -2. **Select nodes**: Click any node to view details and available actions in the right sidebar -3. **Connect clients**: Select a client, choose a homeserver from the dropdown, and click "Connect to Homeserver" -4. **Write data**: Select a connected client, enter a path (e.g., `/pub/publar/test.txt`) and content, then click "Write" -5. **Read data**: Select a connected client, enter a path, and click "Read" -6. **Interact with visualization**: Drag nodes to reposition them, resize panels by dragging edges - -### Automated Scenarios +## Building for Distribution -Select a scenario from the dropdown and click "Play Scenario": +### Prerequisites -- **Simple Connection**: 1 homeserver + 1 client with a write/read operation -- **Multi Client**: 1 homeserver + 3 clients, each writing data independently -- **Rate Limiting**: 1 homeserver + 5 clients writing rapidly to test concurrent operations +Install the Dioxus CLI (`dx`): -Scenarios run automatically with timed operations, perfect for regression testing. +```bash +cargo install dioxus-cli +``` -### Reset +Verify installation: +```bash +dx --version +``` -Click "Reset" to clear all nodes and connections while keeping the testnet running. +### Building a macOS .app Bundle -## Architecture +To create a distributable macOS application: -### High-Level Overview +```bash +dx bundle --platform desktop --package-types "macos" +``` +The `.app` bundle will be created at: ``` -┌─────────────────────────────────────────────────────────────┐ -│ Publar UI (Dioxus) │ -│ ┌─────────────┐ ┌──────────────────┐ ┌───────────────┐ │ -│ │ Topbar │ │ Visualization │ │ Sidebar │ │ -│ │ Controls │ │ Force-Directed │ │ Node Details │ │ -│ │ │ │ Graph │ │ Event Log │ │ -│ └─────────────┘ └──────────────────┘ └───────────────┘ │ -└────────────────────────┬────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────┐ - │ Testnet Manager │ - │ (pubky-testnet) │ - └──────────┬───────────┘ - │ - ┌────────────────┼────────────────┐ - ▼ ▼ ▼ - ┌─────────┐ ┌─────────┐ ┌─────────┐ - │Homeserver│ │Homeserver│ │Homeserver│ - │ :50000 │ │ :50001 │ │ :50002 │ - └─────────┘ └─────────┘ └─────────┘ - ▲ ▲ ▲ - │ │ │ - ┌────┴───┐ ┌───┴────┐ ┌───┴────┐ - │Client 1│ │Client 2│ │Client 3│ - └────────┘ └────────┘ └────────┘ +target/dx/publar/bundle/macos/bundle/macos/Publar.app ``` -### Components +You can then: +- Copy it to `/Applications/` or anywhere else +- Distribute it to users (no Rust installation required) +- Double-click to run like any native macOS app + +**Size**: ~23MB + +**Requirements**: macOS 10.15+ (ARM64 for Apple Silicon, x86_64 for Intel) -**Frontend (Dioxus 0.6)** +#### macOS "Damaged" App Fix -- **Topbar**: Controls for adding nodes, running scenarios, and resetting -- **Network Visualization**: Interactive SVG graph with force-directed layout (Fruchterman-Reingold algorithm) - - Homeservers: White circles with port labels - - Clients: Lime green (#c7ff00) circles with truncated public keys - - Connections: Lime green lines showing client-homeserver relationships -- **Context Sidebar**: Resizable panel with node details, actions, and event log +If you download a pre-release build from GitHub and macOS says the app is "damaged", run this command to remove the quarantine attribute: -**Backend (pubky-testnet + pubky)** +```bash +# For .app bundle +xattr -cr /path/to/Publar.app -- **Testnet Manager**: Manages multiple homeserver processes via pubky-testnet -- **Session Management**: Maintains client sessions with homeservers -- **Scenario Engine**: Executes timed operations (create, connect, write, read) +# For .dmg file +xattr -cr /path/to/Publar.dmg +``` -**State Management** +Then right-click the app and select "Open" (or go to System Preferences → Security & Privacy and click "Open Anyway"). -- Dioxus signals for reactive UI updates -- Shared Arc> for cross-task state access +This is only needed for unsigned pre-release builds. Official releases will be properly code-signed and notarized. -### Data Flow +### Distribution Checklist -``` -User Action → Dioxus Event Handler → Testnet Manager → Homeserver HTTP API - ↓ - Update Signals - ↓ - UI Re-renders - ↓ - Log Event Entry -``` +For official distribution, you should: -### Key Algorithms +1. **Code Signing** (macOS): + ```bash + codesign --force --deep --sign "Developer ID Application: Your Name" Publar.app + ``` -**Force-Directed Layout** +2. **Notarization** (macOS 10.15+): + - Submit to Apple for notarization + - Required for users to run the app without security warnings -- **Repulsion**: All nodes push away from each other (prevents overlap) -- **Spring Forces**: Connected nodes maintain ideal distance (~150px) -- **Damping**: Velocity decay creates smooth stabilization -- Runs continuously every 50ms for dynamic repositioning +3. **Create DMG** (optional): + ```bash + # Use tools like create-dmg or node-appdmg + create-dmg Publar.app + ``` -**Scenario Execution** +### Building for Windows -- Operations grouped by timestamp -- Sequential execution with precise timing -- Async/await for non-blocking UI +To create a Windows MSI installer (must be run on Windows): -## File Structure +```bash +dx bundle --platform desktop --package-types "msi" +``` +The installer will be created at: ``` -publar/ -├── src/ -│ ├── main.rs # App entry, state, event handlers -│ ├── components/ -│ │ ├── topbar.rs # Top control bar -│ │ ├── network_visualization.rs # SVG graph with force layout -│ │ └── context_sidebar.rs # Right panel (details + log) -│ ├── testnet.rs # Wrapper around pubky-testnet -│ ├── scenario.rs # Scenario definitions and operations -│ ├── force_layout.rs # Fruchterman-Reingold algorithm -│ └── api.rs # REST API (future) -├── examples/ -│ └── testnet_write_read.rs # External connection example -├── assets/ -│ ├── input.css # Tailwind source -│ └── tailwind.css # Generated CSS -├── build.rs # Compiles Tailwind on build -├── tailwind.config.js # Tailwind configuration -├── package.json # npm dependencies -├── Cargo.toml # Rust dependencies -├── Dioxus.toml # Dioxus bundling configuration -└── SCENARIOS.md # JSON scenario documentation +target/dx/publar/bundle/msi/Publar_0.1.0_x64_en-US.msi ``` -## Key Technologies +**Requirements**: Windows 10+ (x64) -- **[Dioxus 0.6](https://dioxuslabs.com/)**: Cross-platform UI framework (desktop, web, mobile) -- **[Tailwind CSS v3](https://tailwindcss.com/)**: Utility-first styling -- **[Tokio](https://tokio.rs/)**: Async runtime -- **[pubky-testnet 0.6.0-rc.6](https://github.com/pubky/pubky)**: Manages local homeserver processes -- **[pubky 0.6.0-rc.6](https://github.com/pubky/pubky)**: Client library for Pubky protocol +### Building for Linux -## Development +#### Debian/Ubuntu (.deb) -### Building +To create a Debian package (must be run on Linux): ```bash -# Development build (faster compilation, slower runtime) -cargo build +dx bundle --platform desktop --package-types "deb" +``` -# Release build (optimized) -cargo build --release +The package will be created at: +``` +target/dx/publar/bundle/deb/publar_0.1.0_amd64.deb +``` -# Run with logging -RUST_LOG=debug cargo run +Install with: +```bash +sudo dpkg -i publar_0.1.0_amd64.deb ``` -### CSS Changes +#### AppImage (Universal Linux) -Tailwind CSS recompiles automatically on `cargo build`. To manually rebuild: +To create a portable AppImage (must be run on Linux): ```bash -npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --watch +dx bundle --platform desktop --package-types "appimage" ``` -### Adding Scenarios +The AppImage will be created at: +``` +target/dx/publar/bundle/appimage/publar_0.1.0_amd64.AppImage +``` -Scenarios are stored in `~/.publar/scenarios/` as JSON files. See [SCENARIOS.md](SCENARIOS.md) for the complete JSON schema and examples. +Make executable and run: +```bash +chmod +x publar_0.1.0_amd64.AppImage +./publar_0.1.0_amd64.AppImage +``` -You can also add scenarios programmatically in `src/scenario.rs` by editing the `built_in_scenarios()` function. +**Requirements**: Most modern Linux distributions (glibc 2.31+) -## Building for Distribution +### Cross-Platform Notes -### Prerequisites +- **You can only bundle for your current platform** - cross-compilation is not supported +- All builds are approximately 20-30MB in size +- No external dependencies required for end users -Install the Dioxus CLI: +## Usage -```bash -cargo install dioxus-cli -``` +### Manual Network Management -### Building a macOS .app Bundle +1. **Add nodes**: Click "Add Homeserver" or "Add Client" to create new nodes +2. **Select nodes**: Click any node to view details and available actions in the right sidebar +3. **Connect clients**: Select a client, choose a homeserver from the dropdown, and click "Connect to Homeserver" +4. **Write data**: Select a connected client, enter a path (e.g., `/pub/publar/test.txt`) and content, then click "Write" +5. **Read data**: Select a connected client, enter a path, and click "Read" +6. **Interact with visualization**: Drag nodes to reposition them, resize panels by dragging edges -To create a distributable macOS application: +### Automated Scenarios -```bash -dx bundle --platform desktop --package-types "macos" -``` +Select a scenario from the dropdown and click "Play Scenario": -The `.app` bundle will be created at: -``` -target/dx/publar/bundle/macos/bundle/macos/Publar.app -``` +- **Simple Connection**: 1 homeserver + 1 client with a write/read operation +- **Multi Client**: 1 homeserver + 3 clients, each writing data independently +- **Rate Limiting**: 1 homeserver + 5 clients writing rapidly to test concurrent operations -You can then: -- Copy it to `/Applications/` or anywhere else -- Distribute it to users (no Rust installation required) -- Double-click to run like any native macOS app +Scenarios run automatically with timed operations, perfect for regression testing. -**Size**: ~23MB +### Reset -**Requirements**: macOS 10.15+ (ARM64 for Apple Silicon, x86_64 for Intel) +Click "Reset" to clear all nodes and connections while keeping the testnet running. -### Distribution Checklist +## Development -For official distribution, you should: +### Building -1. **Code Signing** (macOS): - ```bash - codesign --force --deep --sign "Developer ID Application: Your Name" Publar.app - ``` +```bash +# Development build (faster compilation, slower runtime) +cargo build -2. **Notarization** (macOS 10.15+): - - Submit to Apple for notarization - - Required for users to run the app without security warnings +# Release build (optimized) +cargo build --release -3. **Create DMG** (optional): - ```bash - # Use tools like create-dmg or node-appdmg - create-dmg Publar.app - ``` +# Run with logging +RUST_LOG=debug cargo run +``` -### Cross-Platform Builds +### CSS Changes -The `dx bundle` command supports multiple platforms: +Tailwind CSS recompiles automatically on `cargo build`. To manually rebuild: ```bash -# macOS (on macOS) -dx bundle --platform desktop --package-types "macos" +npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --watch +``` -# Windows (on Windows) -dx bundle --platform desktop --package-types "msi" +### Adding Scenarios -# Linux (on Linux) -dx bundle --platform desktop --package-types "deb" -dx bundle --platform desktop --package-types "appimage" -``` +Scenarios are stored in `~/.publar/scenarios/` as JSON files. See [SCENARIOS.md](SCENARIOS.md) for the complete JSON schema and examples. -**Note**: You can only bundle for your current platform. Cross-compilation is not supported. +You can also add scenarios programmatically in `src/scenario.rs` by editing the `built_in_scenarios()` function. ## Troubleshooting @@ -325,30 +280,10 @@ dx bundle --platform desktop --package-types "appimage" - **Solution**: Click "Reset" and recreate nodes with fewer initial connections -## Contributing - -Contributions are welcome! Areas that need help: - -- [ ] Add more pre-built scenarios -- [ ] Implement REST API for external control -- [ ] Add export/import for network topologies -- [ ] Improve force-directed layout performance -- [ ] Add search/filter for event log - -Please open an issue before starting work on major features. - ## License MIT License - see [LICENSE](LICENSE) file for details ## Related Projects -- **[Pubky](https://github.com/pubky/pubky)**: The core Pubky protocol and client library -- **[Polar](https://github.com/jamaljsr/polar)**: Similar tool for Bitcoin Lightning Network (inspiration for Publar) -- **[pubky-nexus](https://github.com/pubky/pubky-nexus)**: Social graph indexer for Pubky - -## Acknowledgments - -- Inspired by [Polar](https://github.com/jamaljsr/polar) for Lightning Network development -- Built on the excellent [Dioxus](https://dioxuslabs.com/) framework -- Thanks to the Pubky team for the testnet library +- **[Polar](https://github.com/jamaljsr/polar)**: Similar tool for Bitcoin Lightning Network that inspired Publar's design diff --git a/publar/SCENARIOS.md b/publar/SCENARIOS.md index b406be5..115902e 100644 --- a/publar/SCENARIOS.md +++ b/publar/SCENARIOS.md @@ -1,6 +1,31 @@ # Publar Scenarios -Scenarios are stored in `~/.publar/scenarios/` and define automated test sequences for Publar. +## What Are Scenarios? + +Scenarios are automated test sequences that allow you to programmatically create, configure, and test Pubky networks in Publar. Instead of manually clicking through the UI to add homeservers, create clients, connect them, and write/read data, scenarios define all these operations in a JSON file that can be executed with a single click. + +### Why Use Scenarios? + +- **Reproducible Testing**: Run the same test sequence consistently across development sessions +- **Regression Testing**: Quickly verify that your changes don't break existing functionality +- **Complex Setups**: Create multi-node networks with precise timing in seconds instead of minutes +- **Documentation**: Scenarios serve as executable documentation of network behavior +- **CI/CD Integration**: Automate Pubky network testing in your build pipeline (future feature) + +### How They Work + +Scenarios define a sequence of time-stamped operations: +1. **Create** homeservers and clients +2. **Wait** for services to become ready +3. **Connect** clients to homeservers +4. **Write** data to homeservers +5. **Read** data back to verify operations + +All operations execute automatically at their specified times, with results visible in the event log and network visualization. + +## Storage Location + +Scenarios are stored in `~/.publar/scenarios/` as JSON files and are automatically loaded when Publar starts. ## JSON Schema diff --git a/publar/assets/input.css b/publar/assets/input.css index 76de83f..8214830 100644 --- a/publar/assets/input.css +++ b/publar/assets/input.css @@ -38,4 +38,9 @@ *::-webkit-scrollbar-corner { background: #18181b; } + + /* Resize handle hover effect */ + .resize-handle-hover:hover { + background-color: rgba(199, 255, 0, 0.5); + } } diff --git a/publar/assets/tailwind.css b/publar/assets/tailwind.css index d2bfaf4..fc217a3 100644 --- a/publar/assets/tailwind.css +++ b/publar/assets/tailwind.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{scrollbar-width:thin;scrollbar-color:#3f3f46 #18181b}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:#18181b;border-radius:4px}::-webkit-scrollbar-thumb{background:#3f3f46;border-radius:4px;-webkit-transition:background .2s;transition:background .2s}::-webkit-scrollbar-thumb:hover{background:#52525b}::-webkit-scrollbar-thumb:active{background:#71717a}::-webkit-scrollbar-corner{background:#18181b}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.left-0{left:0}.left-4{left:1rem}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-4{top:1rem}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-1{margin-top:.25rem}.block{display:block}.flex{display:flex}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-ew-resize{cursor:ew-resize}.cursor-not-allowed{cursor:not-allowed}.cursor-ns-resize{cursor:ns-resize}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-blue-500\/20{border-color:rgba(59,130,246,.2)}.border-zinc-800{--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-zinc-600{--tw-bg-opacity:1;background-color:rgb(82 82 91/var(--tw-bg-opacity,1))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-900\/50{background-color:rgba(24,24,27,.5)}.bg-zinc-900\/90{background-color:rgba(24,24,27,.9)}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pt-16{padding-top:4rem}.pt-4{padding-top:1rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.italic{font-style:italic}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-zinc-700:hover{--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.hover\:bg-blue-500\/20:hover{background-color:rgba(59,130,246,.2)}.hover\:bg-green-500\/50:hover{background-color:rgba(34,197,94,.5)}.hover\:bg-zinc-800:hover{--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.focus\:border-zinc-600:focus{--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{scrollbar-width:thin;scrollbar-color:#3f3f46 #18181b}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:#18181b;border-radius:4px}::-webkit-scrollbar-thumb{background:#3f3f46;border-radius:4px;-webkit-transition:background .2s;transition:background .2s}::-webkit-scrollbar-thumb:hover{background:#52525b}::-webkit-scrollbar-thumb:active{background:#71717a}::-webkit-scrollbar-corner{background:#18181b}.resize-handle-hover:hover{background-color:rgba(199,255,0,.5)}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.left-0{left:0}.left-4{left:1rem}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-1{margin-top:.25rem}.block{display:block}.flex{display:flex}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-ew-resize{cursor:ew-resize}.cursor-not-allowed{cursor:not-allowed}.cursor-ns-resize{cursor:ns-resize}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-blue-500\/20{border-color:rgba(59,130,246,.2)}.border-zinc-800{--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-zinc-600{--tw-bg-opacity:1;background-color:rgb(82 82 91/var(--tw-bg-opacity,1))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-900\/50{background-color:rgba(24,24,27,.5)}.bg-zinc-900\/90{background-color:rgba(24,24,27,.9)}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pt-16{padding-top:4rem}.pt-4{padding-top:1rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.italic{font-style:italic}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-zinc-700:hover{--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.hover\:bg-blue-500\/20:hover{background-color:rgba(59,130,246,.2)}.hover\:bg-zinc-800:hover{--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.focus\:border-zinc-600:focus{--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5} \ No newline at end of file diff --git a/publar/src/components/context_sidebar.rs b/publar/src/components/context_sidebar.rs index 02a1e83..5aa14bb 100644 --- a/publar/src/components/context_sidebar.rs +++ b/publar/src/components/context_sidebar.rs @@ -61,7 +61,7 @@ pub fn ContextSidebar(props: ContextSidebarProps) -> Element { // Horizontal resize handle (left edge) div { - class: "absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-green-500/50 transition-colors z-10", + class: "absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize transition-colors z-10 resize-handle-hover", onmousedown: move |evt| { evt.stop_propagation(); props.on_resize_sidebar.call(-1); // Signal to start resizing (negative = start) @@ -511,7 +511,7 @@ pub fn ContextSidebar(props: ContextSidebarProps) -> Element { // Vertical resize handle (top edge) div { - class: "absolute left-0 right-0 top-0 h-1 cursor-ns-resize hover:bg-green-500/50 transition-colors z-10", + class: "absolute left-0 right-0 top-0 h-1 cursor-ns-resize transition-colors z-10 resize-handle-hover", onmousedown: move |evt| { evt.stop_propagation(); props.on_resize_eventlog.call(-1); // Signal to start resizing (negative = start) diff --git a/publar/src/components/network_visualization.rs b/publar/src/components/network_visualization.rs index 239c158..98aa556 100644 --- a/publar/src/components/network_visualization.rs +++ b/publar/src/components/network_visualization.rs @@ -485,35 +485,35 @@ pub fn NetworkVisualization(props: NetworkVisualizationProps) -> Element { } } - // Zoom controls overlay + // Zoom controls overlay (horizontal layout in bottom-right) div { - class: "absolute bottom-4 right-4 flex flex-col gap-2", + class: "absolute bottom-4 right-4 flex flex-row gap-1", button { - class: "w-10 h-10 rounded-md bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all", + class: "w-8 h-8 rounded bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all text-sm", onclick: move |_| { - let new_zoom = (zoom() * 1.2).min(5.0); + let new_zoom = (zoom() / 1.2).max(0.1); zoom.set(new_zoom); }, - "+" + "−" } div { - class: "w-10 h-10 rounded-md bg-zinc-900 text-white border border-zinc-800 flex items-center justify-center text-xs", + class: "w-12 h-8 rounded bg-zinc-900 text-white border border-zinc-800 flex items-center justify-center text-xs", "{(current_zoom * 100.0) as i32}%" } button { - class: "w-10 h-10 rounded-md bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all", + class: "w-8 h-8 rounded bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all text-sm", onclick: move |_| { - let new_zoom = (zoom() / 1.2).max(0.1); + let new_zoom = (zoom() * 1.2).min(5.0); zoom.set(new_zoom); }, - "−" + "+" } button { - class: "w-10 h-10 rounded-md bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all text-xs", + class: "w-12 h-8 rounded bg-zinc-900 hover:bg-zinc-800 text-white border border-zinc-800 flex items-center justify-center transition-all text-xs", onclick: move |_| { zoom.set(1.0); pan_offset.set((0.0, 0.0)); @@ -522,9 +522,9 @@ pub fn NetworkVisualization(props: NetworkVisualizationProps) -> Element { } } - // Instructions overlay + // Instructions overlay (bottom-left) div { - class: "absolute top-4 left-4 bg-zinc-900/90 border border-zinc-800 rounded-md px-3 py-2 text-xs text-zinc-400", + class: "absolute bottom-4 left-4 bg-zinc-900/90 border border-zinc-800 rounded-md px-3 py-2 text-xs text-zinc-400", div { "Scroll to zoom" } div { "Middle-click + drag to pan" } div { "Drag nodes to move" } diff --git a/publar/src/scenario.rs b/publar/src/scenario.rs index 433084c..e6b97c9 100644 --- a/publar/src/scenario.rs +++ b/publar/src/scenario.rs @@ -48,233 +48,9 @@ pub enum Action { } impl Scenario { - /// Load built-in scenarios (now loads from scenarios/ directory) + /// Load built-in scenarios (loads from scenarios/ directory) pub fn built_in_scenarios() -> Vec { - // First try to load from scenarios directory - let mut scenarios = Self::load_from_directory(); - - // If no scenarios were loaded from files, fall back to hardcoded scenarios - if scenarios.is_empty() { - scenarios = vec![ - Scenario { - name: "Quick Demo".to_string(), - description: "Creates 2 homeservers, 2 clients, and demonstrates read/write".to_string(), - operations: vec![ - Operation { - at_seconds: 0.0, - action: Action::CreateHomeserver { - id: "homeserver-1".to_string(), - }, - }, - Operation { - at_seconds: 1.0, - action: Action::CreateHomeserver { - id: "homeserver-2".to_string(), - }, - }, - // Wait for homeservers to be ready - Operation { - at_seconds: 2.0, - action: Action::WaitForHomeserver { - homeserver_id: "homeserver-1".to_string(), - timeout_seconds: 5.0, - }, - }, - Operation { - at_seconds: 2.0, - action: Action::WaitForHomeserver { - homeserver_id: "homeserver-2".to_string(), - timeout_seconds: 5.0, - }, - }, - Operation { - at_seconds: 2.5, - action: Action::CreateClient { - id: "client-1".to_string(), - }, - }, - Operation { - at_seconds: 3.0, - action: Action::CreateClient { - id: "client-2".to_string(), - }, - }, - Operation { - at_seconds: 3.5, - action: Action::ConnectClient { - client_id: "client-1".to_string(), - homeserver_id: "homeserver-1".to_string(), - }, - }, - Operation { - at_seconds: 4.0, - action: Action::ConnectClient { - client_id: "client-2".to_string(), - homeserver_id: "homeserver-2".to_string(), - }, - }, - Operation { - at_seconds: 5.0, - action: Action::WriteData { - client_id: "client-1".to_string(), - path: "/pub/publar/demo.txt".to_string(), - content: "Hello from client 1!".to_string(), - }, - }, - Operation { - at_seconds: 6.0, - action: Action::WriteData { - client_id: "client-2".to_string(), - path: "/pub/publar/demo.txt".to_string(), - content: "Hello from client 2!".to_string(), - }, - }, - Operation { - at_seconds: 7.0, - action: Action::ReadData { - client_id: "client-1".to_string(), - path: "/pub/publar/demo.txt".to_string(), - }, - }, - ], - }, - Scenario { - name: "Indexer Stress Test".to_string(), - description: "Creates 5 homeservers with 3 clients each, all writing data".to_string(), - operations: { - let mut ops = Vec::new(); - let mut time = 0.0; - - // Create 5 homeservers - for i in 1..=5 { - ops.push(Operation { - at_seconds: time, - action: Action::CreateHomeserver { - id: format!("homeserver-{}", i), - }, - }); - time += 0.5; - } - - // Create 15 clients (3 per homeserver) - for i in 1..=15 { - ops.push(Operation { - at_seconds: time, - action: Action::CreateClient { - id: format!("client-{}", i), - }, - }); - time += 0.3; - } - - // Connect clients to homeservers - time += 1.0; - for i in 1..=15 { - let homeserver_idx = ((i - 1) / 3) + 1; - ops.push(Operation { - at_seconds: time, - action: Action::ConnectClient { - client_id: format!("client-{}", i), - homeserver_id: format!("homeserver-{}", homeserver_idx), - }, - }); - time += 0.5; - } - - // Write data from each client - time += 1.0; - for i in 1..=15 { - for j in 1..=3 { - ops.push(Operation { - at_seconds: time, - action: Action::WriteData { - client_id: format!("client-{}", i), - path: format!("/pub/publar/file_{}.txt", j), - content: format!("Data from client {} file {}", i, j), - }, - }); - time += 0.2; - } - } - - ops - }, - }, - Scenario { - name: "Rate Limiting".to_string(), - description: "Creates 1 homeserver with 5 clients, each writing 10 times rapidly to test rate limiting".to_string(), - operations: { - let mut ops = Vec::new(); - let mut time = 0.0; - - // Create 1 homeserver - ops.push(Operation { - at_seconds: time, - action: Action::CreateHomeserver { - id: "homeserver-1".to_string(), - }, - }); - time += 1.0; - - // Wait for homeserver to be ready - ops.push(Operation { - at_seconds: time, - action: Action::WaitForHomeserver { - homeserver_id: "homeserver-1".to_string(), - timeout_seconds: 5.0, - }, - }); - time += 1.0; - - // Create 5 clients - for i in 1..=5 { - ops.push(Operation { - at_seconds: time, - action: Action::CreateClient { - id: format!("client-{}", i), - }, - }); - time += 0.05; // Stagger client creation - } - time += 0.5; // Small pause after client creation - - // Connect all 5 clients to the homeserver rapidly (sequentially) - for i in 1..=5 { - ops.push(Operation { - at_seconds: time, - action: Action::ConnectClient { - client_id: format!("client-{}", i), - homeserver_id: "homeserver-1".to_string(), - }, - }); - time += 0.01; // Very small delay (10ms) between connections - } - time += 0.5; // Small pause after connections - - // Each client writes 10 times rapidly within 5 seconds - let write_start_time = time; - for i in 1..=5 { - for j in 1..=10 { - // Spread writes across 5 seconds, but with some overlap - let write_time = write_start_time + (j as f64 * 0.5) + (i as f64 * 0.02); - ops.push(Operation { - at_seconds: write_time, - action: Action::WriteData { - client_id: format!("client-{}", i), - path: format!("/pub/publar/rate_test_{}.txt", j), - content: format!("Rate test data from client {} write {}", i, j), - }, - }); - } - } - - ops - }, - }, - ]; - } - - scenarios + Self::load_from_directory() } /// Save scenario to JSON file From 5c4b4f433c39ea19e294bd0c4126c27ad7f38a46 Mon Sep 17 00:00:00 2001 From: Kevin Karsopawiro Date: Thu, 23 Oct 2025 15:19:12 +0200 Subject: [PATCH 3/7] fix: workflow --- .github/workflows/publar-pre-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publar-pre-release.yml b/.github/workflows/publar-pre-release.yml index a0edd94..0475d14 100644 --- a/.github/workflows/publar-pre-release.yml +++ b/.github/workflows/publar-pre-release.yml @@ -181,8 +181,8 @@ jobs: run: | mkdir -p dist # Build in dev profile for faster pre-release builds (no optimizations) - dx bundle --platform desktop --profile dev --package-types macos - dx bundle --platform desktop --profile dev --package-types dmg + dx bundle --profile dev --package-types macos + dx bundle --profile dev --package-types dmg # Find the built packages find target/dx/publar/bundle -name "*.app" -exec cp -r {} dist/ \; From 1b2f963e174caaf0b0002e3321276ac6c4ed456a Mon Sep 17 00:00:00 2001 From: Kevin Karsopawiro Date: Thu, 23 Oct 2025 15:22:49 +0200 Subject: [PATCH 4/7] fix: workflow --- .github/workflows/publar-pre-release.yml | 81 ++---------------------- 1 file changed, 6 insertions(+), 75 deletions(-) diff --git a/.github/workflows/publar-pre-release.yml b/.github/workflows/publar-pre-release.yml index 0475d14..9c00acd 100644 --- a/.github/workflows/publar-pre-release.yml +++ b/.github/workflows/publar-pre-release.yml @@ -7,74 +7,6 @@ permissions: contents: write jobs: - build-linux: - runs-on: ubuntu-latest - defaults: - run: - working-directory: publar - steps: - - name: Check out sources - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - libgtk-3-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install npm dependencies - run: npm install - - - name: Install Dioxus CLI (pre-built binary) - run: | - # Download pre-built binary for much faster setup (v0.7.0-rc.3 has dependency fixes) - curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.7.0-rc.3/dx-x86_64-unknown-linux-gnu.tar.gz -o dx.tar.gz - tar -xzf dx.tar.gz - chmod +x dx - sudo mv dx /usr/local/bin/dx - dx --version - - - name: Build Tailwind CSS - run: npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --minify - - - name: Cache cargo build artifacts - uses: Swatinem/rust-cache@v2 - with: - shared-key: publar-linux - workspaces: publar - - - name: Build Linux binary (debug mode for speed) - run: | - mkdir -p dist - # Build the binary directly with cargo (avoids dx bundle dependency issues) - cargo build --profile dev - - # Copy binary to dist - cp target/dev/publar dist/publar-linux-x86_64 - chmod +x dist/publar-linux-x86_64 - - ls -lh dist - - - name: Upload Linux artifacts - uses: actions/upload-artifact@v4 - with: - name: publar-linux - path: publar/dist/* - build-windows: runs-on: windows-latest defaults: @@ -97,8 +29,8 @@ jobs: - name: Install Dioxus CLI (pre-built binary) run: | - # Download pre-built binary for much faster setup (v0.7.0-rc.3 has dependency fixes) - curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.7.0-rc.3/dx-x86_64-pc-windows-msvc.zip -o dx.zip + # Download pre-built binary for much faster setup (v0.6.3) + curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.6.3/dx-x86_64-pc-windows-msvc-v0.6.3.zip -o dx.zip unzip dx.zip New-Item -ItemType Directory -Force -Path "$HOME/.cargo/bin" Move-Item -Force dx.exe "$HOME/.cargo/bin/dx.exe" @@ -160,9 +92,9 @@ jobs: - name: Install Dioxus CLI (pre-built binary) run: | - # Download pre-built binary for much faster setup (v0.7.0-rc.3 has dependency fixes) + # Download pre-built binary for much faster setup (v0.6.3) # GitHub Actions macOS runners are ARM64 (Apple Silicon) - curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.7.0-rc.3/dx-aarch64-apple-darwin.tar.gz -o dx.tar.gz + curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.6.3/dx-aarch64-apple-darwin-v0.6.3.tar.gz -o dx.tar.gz tar -xzf dx.tar.gz chmod +x dx sudo mv dx /usr/local/bin/dx @@ -181,8 +113,8 @@ jobs: run: | mkdir -p dist # Build in dev profile for faster pre-release builds (no optimizations) - dx bundle --profile dev --package-types macos - dx bundle --profile dev --package-types dmg + dx bundle --platform desktop --profile dev --package-types macos + dx bundle --platform desktop --profile dev --package-types dmg # Find the built packages find target/dx/publar/bundle -name "*.app" -exec cp -r {} dist/ \; @@ -215,7 +147,6 @@ jobs: publish-release: needs: - - build-linux - build-windows - build-macos runs-on: ubuntu-latest From 5f621ee40eaf1869716c4fa231e9228e85d6b072 Mon Sep 17 00:00:00 2001 From: Kevin Karsopawiro Date: Thu, 23 Oct 2025 15:34:06 +0200 Subject: [PATCH 5/7] fix: workflow --- .github/workflows/publar-pre-release.yml | 56 ------------------------ 1 file changed, 56 deletions(-) diff --git a/.github/workflows/publar-pre-release.yml b/.github/workflows/publar-pre-release.yml index 9c00acd..064bc33 100644 --- a/.github/workflows/publar-pre-release.yml +++ b/.github/workflows/publar-pre-release.yml @@ -7,61 +7,6 @@ permissions: contents: write jobs: - build-windows: - runs-on: windows-latest - defaults: - run: - working-directory: publar - steps: - - name: Check out sources - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install npm dependencies - run: npm install - - - name: Install Dioxus CLI (pre-built binary) - run: | - # Download pre-built binary for much faster setup (v0.6.3) - curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.6.3/dx-x86_64-pc-windows-msvc-v0.6.3.zip -o dx.zip - unzip dx.zip - New-Item -ItemType Directory -Force -Path "$HOME/.cargo/bin" - Move-Item -Force dx.exe "$HOME/.cargo/bin/dx.exe" - dx --version - - - name: Build Tailwind CSS - run: npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --minify - - - name: Cache cargo build artifacts - uses: Swatinem/rust-cache@v2 - with: - shared-key: publar-windows - workspaces: publar - - - name: Build Windows installer (debug mode for speed) - run: | - mkdir -p dist - # Build in dev profile for faster pre-release builds (no optimizations) - dx bundle --platform desktop --profile dev --package-types msi - - # Find and copy the built installer - Get-ChildItem -Path target/dx/publar/bundle -Filter "*.msi" -Recurse | Copy-Item -Destination dist/ - - Get-ChildItem dist - - - name: Upload Windows artifacts - uses: actions/upload-artifact@v4 - with: - name: publar-windows - path: publar/dist/* - build-macos: runs-on: macos-latest defaults: @@ -147,7 +92,6 @@ jobs: publish-release: needs: - - build-windows - build-macos runs-on: ubuntu-latest steps: From 11bb6ab5cc7ef3420e8ca2092ed58d036f1bd622 Mon Sep 17 00:00:00 2001 From: Kevin Karsopawiro Date: Fri, 24 Oct 2025 22:25:35 +0200 Subject: [PATCH 6/7] fix: remove .github --- .github/workflows/publar-pre-release.yml | 134 ----------------------- 1 file changed, 134 deletions(-) delete mode 100644 .github/workflows/publar-pre-release.yml diff --git a/.github/workflows/publar-pre-release.yml b/.github/workflows/publar-pre-release.yml deleted file mode 100644 index 064bc33..0000000 --- a/.github/workflows/publar-pre-release.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Publar - -on: - workflow_dispatch: - -permissions: - contents: write - -jobs: - build-macos: - runs-on: macos-latest - defaults: - run: - working-directory: publar - steps: - - name: Check out sources - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install OpenSSL (required for dx binary) - run: | - brew install openssl@3 - # Create symlink so dx can find OpenSSL - sudo mkdir -p /usr/local/opt/openssl@3/lib - sudo ln -sf $(brew --prefix openssl@3)/lib/libssl.3.dylib /usr/local/opt/openssl@3/lib/libssl.3.dylib - sudo ln -sf $(brew --prefix openssl@3)/lib/libcrypto.3.dylib /usr/local/opt/openssl@3/lib/libcrypto.3.dylib - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install npm dependencies - run: npm install - - - name: Install Dioxus CLI (pre-built binary) - run: | - # Download pre-built binary for much faster setup (v0.6.3) - # GitHub Actions macOS runners are ARM64 (Apple Silicon) - curl -L https://github.com/DioxusLabs/dioxus/releases/download/v0.6.3/dx-aarch64-apple-darwin-v0.6.3.tar.gz -o dx.tar.gz - tar -xzf dx.tar.gz - chmod +x dx - sudo mv dx /usr/local/bin/dx - dx --version - - - name: Build Tailwind CSS - run: npx tailwindcss -i ./assets/input.css -o ./assets/tailwind.css --minify - - - name: Cache cargo build artifacts - uses: Swatinem/rust-cache@v2 - with: - shared-key: publar-macos - workspaces: publar - - - name: Build macOS packages (debug mode for speed) - run: | - mkdir -p dist - # Build in dev profile for faster pre-release builds (no optimizations) - dx bundle --platform desktop --profile dev --package-types macos - dx bundle --platform desktop --profile dev --package-types dmg - - # Find the built packages - find target/dx/publar/bundle -name "*.app" -exec cp -r {} dist/ \; - find target/dx/publar/bundle -name "*.dmg" -exec cp {} dist/ \; - - # Ad-hoc sign the .app bundle - for app in dist/*.app; do - if [ -d "$app" ]; then - echo "Ad-hoc signing $app" - # Remove quarantine attributes and sign - sudo xattr -cr "$app" - codesign --force --deep --sign - "$app" - fi - done - - # Also remove quarantine from DMG files - for dmg in dist/*.dmg; do - if [ -f "$dmg" ]; then - sudo xattr -cr "$dmg" - fi - done - - ls -lh dist - - - name: Upload macOS artifacts - uses: actions/upload-artifact@v4 - with: - name: publar-macos - path: publar/dist/* - - publish-release: - needs: - - build-macos - runs-on: ubuntu-latest - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: dist - - - name: Prepare release files - run: | - mkdir -p release - # Move all artifacts to release directory - find dist -type f \( -name "*.deb" -o -name "*.AppImage" -o -name "*.msi" -o -name "*.dmg" -o -name "*.app" \) -exec mv {} release/ \; - - # Create archives for .app bundles if any - for app in release/*.app; do - if [ -d "$app" ]; then - zip -r "${app}.zip" "$app" - rm -rf "$app" - fi - done - - ls -lh release - - - name: Prepare release metadata - id: release_meta - run: | - tag="publar-pre-release-${GITHUB_RUN_NUMBER}" - name="Publar pre-release ${GITHUB_RUN_NUMBER}" - echo "tag_name=$tag" >> "$GITHUB_OUTPUT" - echo "release_name=$name" >> "$GITHUB_OUTPUT" - - - name: Publish pre-release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.release_meta.outputs.tag_name }} - name: ${{ steps.release_meta.outputs.release_name }} - prerelease: true - generate_release_notes: true - files: release/* From 39d93e8444a8ee379a8a545f6dc2446550e21fe7 Mon Sep 17 00:00:00 2001 From: Gabriel Comte Date: Mon, 26 Jan 2026 09:36:49 +0100 Subject: [PATCH 7/7] Fix: resolve async-std/tokio feature conflict in rfd --- publar/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/publar/Cargo.toml b/publar/Cargo.toml index 3b70169..35fa030 100644 --- a/publar/Cargo.toml +++ b/publar/Cargo.toml @@ -21,7 +21,7 @@ axum = "0.7" tower-http = { version = "0.5", features = ["cors"] } chrono = "0.4" reqwest = "0.11" -rfd = "0.14" +rfd = { version = "0.14", default-features = false, features = ["xdg-portal", "tokio"] } [profile]