From 8ebdebedc8ee86ca9c0db180377b8c9b5f221f7a Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 7 Jul 2026 11:53:16 +0700 Subject: [PATCH] chore: V2 planning docs + template discovery --- CHANGELOG.md | 25 + docs/v2/API.md | 1303 +++++++++++++++++++++ docs/v2/ARCHITECTURE.md | 459 ++++++++ docs/v2/BUILD_SYSTEM.md | 564 +++++++++ docs/v2/DATA_MODELS.md | 391 +++++++ docs/v2/PERFORMANCE.md | 709 +++++++++++ docs/v2/PROGRESS.md | 339 ++++++ docs/v2/SPRINT_PLAN.md | 529 +++++++++ docs/v2/TIMELINE.md | 316 +++++ docs/v2/UI_COMPONENTS.md | 1142 ++++++++++++++++++ docs/v2/UI_TERMIUS_REFERENCE.md | 558 +++++++++ template/.env.example | 9 + template/.gitignore | 8 + template/AGENTS.md | 75 ++ template/README.md | 20 + template/index.html | 17 + template/metadata.json | 6 + template/package.json | 35 + template/src/App.tsx | 261 +++++ template/src/components/BriefOverlay.tsx | 165 +++ template/src/components/DashboardView.tsx | 467 ++++++++ template/src/components/KeychainView.tsx | 359 ++++++ template/src/components/SettingsView.tsx | 249 ++++ template/src/components/SftpView.tsx | 389 ++++++ template/src/components/SnippetsView.tsx | 350 ++++++ template/src/components/TerminalView.tsx | 304 +++++ template/src/index.css | 136 +++ template/src/main.tsx | 10 + template/src/types.ts | 61 + template/tsconfig.json | 26 + template/vite.config.ts | 22 + 31 files changed, 9304 insertions(+) create mode 100644 docs/v2/API.md create mode 100644 docs/v2/ARCHITECTURE.md create mode 100644 docs/v2/BUILD_SYSTEM.md create mode 100644 docs/v2/DATA_MODELS.md create mode 100644 docs/v2/PERFORMANCE.md create mode 100644 docs/v2/PROGRESS.md create mode 100644 docs/v2/SPRINT_PLAN.md create mode 100644 docs/v2/TIMELINE.md create mode 100644 docs/v2/UI_COMPONENTS.md create mode 100644 docs/v2/UI_TERMIUS_REFERENCE.md create mode 100644 template/.env.example create mode 100644 template/.gitignore create mode 100644 template/AGENTS.md create mode 100644 template/README.md create mode 100644 template/index.html create mode 100644 template/metadata.json create mode 100644 template/package.json create mode 100644 template/src/App.tsx create mode 100644 template/src/components/BriefOverlay.tsx create mode 100644 template/src/components/DashboardView.tsx create mode 100644 template/src/components/KeychainView.tsx create mode 100644 template/src/components/SettingsView.tsx create mode 100644 template/src/components/SftpView.tsx create mode 100644 template/src/components/SnippetsView.tsx create mode 100644 template/src/components/TerminalView.tsx create mode 100644 template/src/index.css create mode 100644 template/src/main.tsx create mode 100644 template/src/types.ts create mode 100644 template/tsconfig.json create mode 100644 template/vite.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1759f..22394f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [Unreleased] — V2 Planning Complete (2026-06-29) + +### Added (V2 Planning Documents) +- `docs/v2/PROGRESS.md` — Status tracker for V2 development +- `docs/v2/ARCHITECTURE.md` — System architecture (GoFiber + React + Electron) +- `docs/v2/API.md` — REST API + WebSocket specification +- `docs/v2/DATA_MODELS.md` — Extended data models (Groups, PortForward, Workspace) +- `docs/v2/UI_COMPONENTS.md` — React component tree + state management +- `docs/v2/UI_TERMIUS_REFERENCE.md` — Termius visual reference +- `docs/v2/SPRINT_PLAN.md` — Sprint-by-sprint task breakdown +- `docs/v2/BUILD_SYSTEM.md` — Cross-platform build pipeline +- `docs/v2/PERFORMANCE.md` — Performance & stability guide + +### Architecture Decision +- Backend: GoFiber v2 (fasthttp-based) replacing chi/net/http +- WebSocket: gofiber/contrib/websocket (fasthttp/websocket) replacing gorilla/websocket +- Frontend: React 19 + TypeScript + xterm.js + Zustand +- Desktop: Electron (electron-builder) +- Mobile: gomobile + WebView + +### V1 Status (FROZEN) +- All V1 code (pkg/, internal/, cmd/, test/) is complete and frozen +- V1 CLI/TUI remains functional alongside V2 GUI +- 105 tests passing, zero race conditions + ## [Unreleased] — 2025-01-31 ### Fixed diff --git a/docs/v2/API.md b/docs/v2/API.md new file mode 100644 index 0000000..ba1d048 --- /dev/null +++ b/docs/v2/API.md @@ -0,0 +1,1303 @@ +# Hostkeeper V2 — REST API + WebSocket Specification + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 +> **Framework**: GoFiber v2 (fasthttp) + +--- + +## 1. Base URL + +``` +http://localhost:{random_port} +``` + +The Go backend starts on a random available port. The port is communicated to Electron via stdout: + +``` +HOSTKEEPER_PORT=54321 +``` + +--- + +## 2. Common Response Formats + +### Success + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Production Server", + "hostname": "192.168.1.100", + "port": 22, + "username": "admin" +} +``` + +### List Response + +```json +{ + "items": [...], + "total": 42, + "page": 1, + "per_page": 50 +} +``` + +### Error Response + +```json +{ + "error": "Host not found", + "code": "HOST_NOT_FOUND", + "details": "No host with ID 'abc123' exists" +} +``` + +### HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 204 | No Content (delete success) | +| 400 | Bad Request (validation error) | +| 401 | Unauthorized (vault locked) | +| 404 | Not Found | +| 409 | Conflict (duplicate name) | +| 500 | Internal Server Error | + +--- + +## 3. Endpoints + +### 3.1 Health Check + +``` +GET /api/health +``` + +**Response** (200): +```json +{ + "status": "ok", + "version": "2.0.0", + "vault_locked": false +} +``` + +--- + +### 3.2 Hosts + +#### List Hosts + +``` +GET /api/hosts +``` + +**Query Parameters**: +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `group` | string | — | Filter by group ID | +| `tag` | string | — | Filter by tag | +| `search` | string | — | Search by name/hostname | +| `favorite` | boolean | — | Filter favorites only | +| `sort` | string | `name` | Sort field (name, hostname, created_at, last_used_at) | +| `order` | string | `asc` | Sort order (asc, desc) | +| `page` | int | 1 | Page number | +| `per_page` | int | 50 | Items per page | + +**Response** (200): +```json +{ + "items": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Production Server", + "hostname": "192.168.1.100", + "port": 22, + "username": "admin", + "auth": { + "type": "key", + "key_id": "key-uuid" + }, + "group_id": "group-uuid", + "tags": ["production", "linux"], + "is_favorite": false, + "color": "#e78a4e", + "order": 0, + "notes": "Main production server", + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z", + "last_used_at": "2024-06-22T15:30:00Z" + } + ], + "total": 1, + "page": 1, + "per_page": 50 +} +``` + +#### Create Host + +``` +POST /api/hosts +``` + +**Request Body**: +```json +{ + "name": "Production Server", + "hostname": "192.168.1.100", + "port": 22, + "username": "admin", + "auth": { + "type": "key", + "key_id": "key-uuid", + "password": "" + }, + "group_id": "group-uuid", + "tags": ["production", "linux"], + "is_favorite": false, + "color": "#e78a4e", + "notes": "Main production server" +} +``` + +**Response** (201): Full host object with generated `id`, `created_at`, `updated_at`. + +**Validation**: +- `name`: required, max 100 chars +- `hostname`: required, valid hostname or IP +- `port`: required, 1-65535 +- `username`: required, max 100 chars +- `auth.type`: required, one of "password", "key", "agent" + +#### Get Host + +``` +GET /api/hosts/:id +``` + +**Response** (200): Full host object. + +**Error** (404): +```json +{ + "error": "Host not found", + "code": "HOST_NOT_FOUND" +} +``` + +#### Update Host + +``` +PUT /api/hosts/:id +``` + +**Request Body**: Same as create (all fields optional for partial update). + +**Response** (200): Updated host object. + +#### Delete Host + +``` +DELETE /api/hosts/:id +``` + +**Response** (204): No content. + +#### Toggle Favorite + +``` +PATCH /api/hosts/:id/favorite +``` + +**Response** (200): +```json +{ + "id": "host-uuid", + "is_favorite": true +} +``` + +--- + +### 3.3 Groups + +#### List Groups (Tree) + +``` +GET /api/groups +``` + +**Response** (200): +```json +{ + "items": [ + { + "id": "group-uuid", + "name": "Production", + "parent_id": null, + "color": "#e78a4e", + "icon": "server", + "host_count": 5, + "children": [ + { + "id": "child-group-uuid", + "name": "Web Servers", + "parent_id": "group-uuid", + "color": "#a9b665", + "icon": "globe", + "host_count": 3, + "children": [] + } + ] + } + ] +} +``` + +#### Create Group + +``` +POST /api/groups +``` + +**Request Body**: +```json +{ + "name": "Production", + "parent_id": null, + "color": "#e78a4e", + "icon": "server" +} +``` + +**Response** (201): Group object with generated `id`. + +#### Update Group + +``` +PUT /api/groups/:id +``` + +**Request Body**: Same as create (all fields optional). + +**Response** (200): Updated group object. + +#### Delete Group + +``` +DELETE /api/groups/:id +``` + +**Behavior**: Hosts in this group are moved to parent group (or ungrouped). + +**Response** (204): No content. + +#### Move Group + +``` +PUT /api/groups/:id/move +``` + +**Request Body**: +```json +{ + "parent_id": "new-parent-uuid" +} +``` + +**Response** (200): Updated group with new parent. + +#### Move Host to Group + +``` +PUT /api/hosts/:id/move +``` + +**Request Body**: +```json +{ + "group_id": "target-group-uuid" +} +``` + +**Response** (200): Updated host with new group. + +--- + +### 3.4 Keys + +#### List Keys + +``` +GET /api/keys +``` + +**Response** (200): +```json +{ + "items": [ + { + "id": "key-uuid", + "name": "My SSH Key", + "type": "ed25519", + "public_key": "ssh-ed25519 AAAA...", + "fingerprint": "SHA256:abc123...", + "has_passphrase": true, + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z" + } + ] +} +``` + +**Note**: Private key is NEVER returned in list or get responses. + +#### Create/Import Key + +``` +POST /api/keys +``` + +**Request Body** (import): +```json +{ + "name": "My SSH Key", + "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...", + "passphrase": "optional-passphrase" +} +``` + +**Response** (201): Key object (without private key). + +#### Generate Key + +``` +POST /api/keys/generate +``` + +**Request Body**: +```json +{ + "name": "New Key", + "type": "ed25519", + "passphrase": "optional-passphrase" +} +``` + +**Response** (201): +```json +{ + "id": "key-uuid", + "name": "New Key", + "type": "ed25519", + "public_key": "ssh-ed25519 AAAA...", + "fingerprint": "SHA256:abc123...", + "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n..." +} +``` + +**Note**: Private key is ONLY returned in generate response (one time). + +#### Delete Key + +``` +DELETE /api/keys/:id +``` + +**Response** (204): No content. + +--- + +### 3.5 Snippets + +#### List Snippets + +``` +GET /api/snippets +``` + +**Query Parameters**: +| Param | Type | Description | +|-------|------|-------------| +| `tag` | string | Filter by tag | +| `search` | string | Search by name/command | + +**Response** (200): +```json +{ + "items": [ + { + "id": "snippet-uuid", + "name": "Update packages", + "command": "sudo apt update && sudo apt upgrade -y", + "description": "Update all packages on Debian/Ubuntu", + "tags": ["apt", "update"], + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z" + } + ] +} +``` + +#### Create Snippet + +``` +POST /api/snippets +``` + +**Request Body**: +```json +{ + "name": "Update packages", + "command": "sudo apt update && sudo apt upgrade -y", + "description": "Update all packages on Debian/Ubuntu", + "tags": ["apt", "update"] +} +``` + +**Response** (201): Snippet object with generated `id`. + +#### Update Snippet + +``` +PUT /api/snippets/:id +``` + +**Request Body**: Same as create (all fields optional). + +**Response** (200): Updated snippet object. + +#### Delete Snippet + +``` +DELETE /api/snippets/:id +``` + +**Response** (204): No content. + +#### Run Snippet on Host + +``` +POST /api/snippets/:id/run +``` + +**Request Body**: +```json +{ + "host_id": "host-uuid" +} +``` + +**Response** (200): +```json +{ + "output": "Hit:1 http://archive.ubuntu.com/ubuntu jammy InRelease\n...", + "exit_code": 0, + "duration_ms": 1523 +} +``` + +--- + +### 3.6 Port Forwarding + +#### List Forwards + +``` +GET /api/forwards +``` + +**Response** (200): +```json +{ + "items": [ + { + "id": "forward-uuid", + "name": "Local Redis", + "host_id": "host-uuid", + "type": "local", + "local_addr": "127.0.0.1:6379", + "remote_addr": "localhost:6379", + "status": "running", + "auto_start": false, + "created_at": "2024-06-22T10:00:00Z" + } + ] +} +``` + +#### Create Forward + +``` +POST /api/forwards +``` + +**Request Body**: +```json +{ + "name": "Local Redis", + "host_id": "host-uuid", + "type": "local", + "local_addr": "127.0.0.1:6379", + "remote_addr": "localhost:6379", + "auto_start": false +} +``` + +**Response** (201): Forward object. + +#### Delete Forward + +``` +DELETE /api/forwards/:id +``` + +**Response** (204): No content. + +#### Start Forward + +``` +POST /api/forwards/:id/start +``` + +**Response** (200): +```json +{ + "status": "running" +} +``` + +#### Stop Forward + +``` +POST /api/forwards/:id/stop +``` + +**Response** (200): +```json +{ + "status": "stopped" +} +``` + +--- + +### 3.7 SFTP + +#### List Directory + +``` +GET /api/sftp/ls +``` + +**Query Parameters**: +| Param | Type | Description | +|-------|------|-------------| +| `host_id` | string | Required. Host to connect to | +| `path` | string | Directory path (default: "/") | + +**Response** (200): +```json +{ + "path": "/home/admin", + "items": [ + { + "name": "documents", + "path": "/home/admin/documents", + "is_dir": true, + "size": 4096, + "mode": "drwxr-xr-x", + "mod_time": "2024-06-22T10:00:00Z" + }, + { + "name": "file.txt", + "path": "/home/admin/file.txt", + "is_dir": false, + "size": 1234, + "mode": "-rw-r--r--", + "mod_time": "2024-06-22T10:00:00Z" + } + ] +} +``` + +#### Upload File + +``` +POST /api/sftp/upload +``` + +**Request**: `multipart/form-data` +| Field | Type | Description | +|-------|------|-------------| +| `host_id` | string | Required. Host to upload to | +| `path` | string | Required. Remote directory path | +| `file` | file | Required. File to upload | + +**Response** (200): +```json +{ + "success": true, + "path": "/home/admin/file.txt", + "size": 1234 +} +``` + +#### Download File + +``` +GET /api/sftp/download +``` + +**Query Parameters**: +| Param | Type | Description | +|-------|------|-------------| +| `host_id` | string | Required. Host to download from | +| `path` | string | Required. Remote file path | + +**Response** (200): Binary file stream. +``` +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="file.txt" +Content-Length: 1234 +``` + +#### Create Directory + +``` +POST /api/sftp/mkdir +``` + +**Request Body**: +```json +{ + "host_id": "host-uuid", + "path": "/home/admin/new-folder" +} +``` + +**Response** (201): +```json +{ + "success": true, + "path": "/home/admin/new-folder" +} +``` + +#### Delete File/Directory + +``` +POST /api/sftp/rm +``` + +**Request Body**: +```json +{ + "host_id": "host-uuid", + "path": "/home/admin/file.txt", + "recursive": false +} +``` + +**Response** (204): No content. + +#### Rename/Move + +``` +POST /api/sftp/rename +``` + +**Request Body**: +```json +{ + "host_id": "host-uuid", + "old_path": "/home/admin/old-name.txt", + "new_path": "/home/admin/new-name.txt" +} +``` + +**Response** (200): +```json +{ + "success": true, + "path": "/home/admin/new-name.txt" +} +``` + +#### Change Permissions + +``` +POST /api/sftp/chmod +``` + +**Request Body**: +```json +{ + "host_id": "host-uuid", + "path": "/home/admin/script.sh", + "mode": "0755" +} +``` + +**Response** (200): +```json +{ + "success": true, + "mode": "0755" +} +``` + +--- + +### 3.8 Config + +#### Get Config + +``` +GET /api/config +``` + +**Response** (200): +```json +{ + "version": "2.0.0", + "default_port": 22, + "connection_timeout": 30, + "keepalive_interval": 60, + "theme": "dark", + "editor": "vim", + "auto_sync": false, + "encryption_enabled": true, + "known_hosts_file": "~/.hostkeeper/known_hosts", + "active_profile": "default" +} +``` + +#### Update Config + +``` +PUT /api/config +``` + +**Request Body**: Partial config object (only fields to update). + +**Response** (200): Updated config object. + +#### Unlock Vault + +``` +POST /api/config/unlock +``` + +**Request Body**: +```json +{ + "password": "user-password" +} +``` + +**Response** (200): +```json +{ + "success": true, + "message": "Vault unlocked" +} +``` + +**Error** (401): +```json +{ + "error": "Invalid password", + "code": "INVALID_PASSWORD" +} +``` + +#### Lock Vault + +``` +POST /api/config/lock +``` + +**Response** (200): +```json +{ + "success": true, + "message": "Vault locked" +} +``` + +#### Vault Status + +``` +GET /api/config/status +``` + +**Response** (200): +```json +{ + "locked": false, + "encryption_enabled": true, + "auto_lock_enabled": true, + "auto_lock_minutes": 5 +} +``` + +--- + +### 3.9 Export/Import + +#### Export Data + +``` +GET /api/export +``` + +**Query Parameters**: +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `format` | string | `json` | Export format (json) | +| `include_keys` | boolean | false | Include private keys | +| `password` | string | — | Encrypt export with password | + +**Response** (200): JSON file download. +``` +Content-Type: application/json +Content-Disposition: attachment; filename="hostkeeper-export-2024-06-22.json" +``` + +**Export Structure**: +```json +{ + "version": "2.0.0", + "exported_at": "2024-06-22T16:00:00Z", + "encrypted": false, + "data": { + "hosts": [...], + "groups": [...], + "keys": [...], + "snippets": [...], + "forwards": [...], + "config": {} + } +} +``` + +#### Import Data + +``` +POST /api/import +``` + +**Request**: `multipart/form-data` +| Field | Type | Description | +|-------|------|-------------| +| `file` | file | Required. JSON export file | +| `strategy` | string | "merge" or "replace" (default: "merge") | +| `password` | string | Password if file is encrypted | + +**Response** (200): +```json +{ + "success": true, + "imported": { + "hosts": 5, + "groups": 2, + "keys": 3, + "snippets": 10, + "forwards": 1 + }, + "skipped": { + "hosts": 1 + } +} +``` + +#### Import SSH Config + +``` +POST /api/import/ssh-config +``` + +**Request**: `multipart/form-data` +| Field | Type | Description | +|-------|------|-------------| +| `file` | file | Optional. SSH config file (default: ~/.ssh/config) | + +**Response** (200): +```json +{ + "success": true, + "imported": 3, + "hosts": [ + { "name": "myserver", "hostname": "192.168.1.100", "port": 22 } + ] +} +``` + +#### Import CSV + +``` +POST /api/import/csv +``` + +**Request**: `multipart/form-data` +| Field | Type | Description | +|-------|------|-------------| +| `file` | file | Required. CSV file | +| `strategy` | string | "merge" or "replace" | + +**CSV Columns**: +``` +name,hostname,port,username,auth_type,group,tags,notes +Production Server,192.168.1.100,22,admin,key,Production,"production,linux",Main server +``` + +**Response** (200): +```json +{ + "success": true, + "imported": 2 +} +``` + +--- + +### 3.10 Profiles + +#### List Profiles + +``` +GET /api/profiles +``` + +**Response** (200): +```json +{ + "items": [ + { + "name": "default", + "theme": "dark", + "default_group": null, + "default_auth": "key", + "editor": "vim" + } + ], + "active": "default" +} +``` + +#### Create Profile + +``` +POST /api/profiles +``` + +**Request Body**: +```json +{ + "name": "work", + "theme": "light", + "default_group": "work-servers", + "default_auth": "key", + "editor": "nano" +} +``` + +**Response** (201): Profile object. + +#### Switch Active Profile + +``` +PUT /api/profiles/active +``` + +**Request Body**: +```json +{ + "name": "work" +} +``` + +**Response** (200): +```json +{ + "active": "work" +} +``` + +#### Delete Profile + +``` +DELETE /api/profiles/:name +``` + +**Response** (204): No content. + +--- + +## 4. WebSocket Endpoints + +### 4.1 Terminal Connection + +``` +WS /api/terminal/connect?host_id=xxx&cols=80&rows=24 +``` + +**Connection Parameters** (query string): +| Param | Type | Description | +|-------|------|-------------| +| `host_id` | string | Required. Host to connect to | +| `cols` | int | Terminal columns (default: 80) | +| `rows` | int | Terminal rows (default: 24) | + +**Connection Flow**: +1. Client opens WebSocket connection +2. Server validates host_id exists +3. Server creates SSH connection to host (or reuses pool) +4. Server requests PTY with xterm-256color +5. Server sends `{ "type": "status", "state": "connected" }` +6. Bidirectional streaming begins + +**Message Protocol** (binary frames): + +| Direction | Content | Description | +|-----------|---------|-------------| +| Client → Server | Raw bytes | Keyboard input | +| Server → Client | Raw bytes | Terminal output | +| Client → Server | JSON resize | `{"type":"resize","cols":120,"rows":40}` | +| Server → Client | JSON status | `{"type":"status","state":"connected"}` | +| Server → Client | JSON error | `{"type":"error","message":"Connection refused"}` | + +**Keepalive**: +- Client sends WebSocket ping every 30 seconds +- Server responds with pong +- If no pong received for 60 seconds, connection is closed + +**Disconnect**: +- Client sends close frame (1000 normal, 1001 going away) +- Server closes SSH session and responds with close frame + +--- + +### 4.2 SFTP Stream (Future) + +``` +WS /api/sftp/stream?host_id=xxx +``` + +**Note**: Initial version uses REST for SFTP. WebSocket streaming may be added later for large file transfers with progress. + +--- + +## 5. Middleware + +### CORS + +```go +app.Use(cors.New(cors.Config{ + AllowOrigins: "http://localhost:5173,http://localhost:*", + AllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS", + AllowHeaders: "Origin,Content-Type,Authorization", +})) +``` + +### Request Logging + +```go +app.Use(logger.New(logger.Config{ + Format: "${time} ${method} ${path} ${status} ${latency}\n", +})) +``` + +### Panic Recovery + +```go +app.Use(recover.New()) +``` + +### Compression + +```go +app.Use(compress.New(compress.Config{ + Level: compress.LevelBestSpeed, +})) +``` + +### Error Handler + +```go +app.Use(func(c *fiber.Ctx) error { + err := c.Next() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "code": "INTERNAL_ERROR", + }) + } + return nil +}) +``` + +--- + +## 6. Rate Limiting + +| Endpoint | Limit | Window | +|----------|-------|--------| +| `/api/config/unlock` | 5 attempts | 5 minutes | +| `/api/terminal/connect` | 10 connections | 1 minute | +| `/api/sftp/upload` | 100 MB | per request | + +--- + +## 7. GoFiber Handler Examples + +### REST Handler + +```go +package bridge + +import ( + "github.com/gofiber/fiber/v2" + "git.tukangketik.id/swanadiva/hostkeeper/internal/models" +) + +func ListHosts(c *fiber.Ctx) error { + // Parse query params + search := c.Query("search", "") + group := c.Query("group", "") + + // Call storage + hosts, err := storage.ListHosts(c.Context()) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to list hosts", + "code": "STORAGE_ERROR", + }) + } + + // Filter + if search != "" { + hosts = filterBySearch(hosts, search) + } + + return c.JSON(fiber.Map{ + "items": hosts, + "total": len(hosts), + }) +} + +func CreateHost(c *fiber.Ctx) error { + var host models.Host + if err := c.BodyParser(&host); err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": "Invalid request body", + "code": "INVALID_BODY", + }) + } + + // Validate + if host.Name == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "Name is required", + "code": "VALIDATION_ERROR", + }) + } + + // Save + if err := storage.SaveHost(c.Context(), &host); err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to save host", + "code": "STORAGE_ERROR", + }) + } + + return c.Status(201).JSON(host) +} +``` + +### WebSocket Handler + +```go +package ws + +import ( + "github.com/gofiber/contrib/websocket" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh" +) + +func HandleTerminal(c *websocket.Conn) { + hostID := c.Query("host_id") + cols := c.QueryInt("cols", 80) + rows := c.QueryInt("rows", 24) + + // Lookup host + host, err := storage.GetHost(c.Context(), hostID) + if err != nil { + c.WriteJSON(fiber.Map{"type": "error", "message": "Host not found"}) + return + } + + // Create SSH client + client := ssh.NewClient(host, 30*time.Second) + if err := client.Connect(c.Context()); err != nil { + c.WriteJSON(fiber.Map{"type": "error", "message": err.Error()}) + return + } + defer client.Close() + + // Request PTY + session, err := client.GetClient().NewSession() + if err != nil { + c.WriteJSON(fiber.Map{"type": "error", "message": "Failed to create session"}) + return + } + defer session.Close() + + modes := ssh.TerminalModes{ssh.ECHO: 1} + session.RequestPty("xterm-256color", rows, cols, modes) + + // Pipe I/O + stdin, _ := session.StdinPipe() + stdout, _ := session.StdoutPipe() + + session.Shell() + + // Read from WebSocket -> write to SSH + go func() { + for { + _, msg, err := c.ReadMessage() + if err != nil { break } + + // Check if resize message + if len(msg) > 0 && msg[0] == '{' { + var resize struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` + } + if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" { + session.WindowChange(resize.Rows, resize.Cols) + continue + } + } + + stdin.Write(msg) + } + }() + + // Read from SSH -> write to WebSocket + buf := make([]byte, 4096) + for { + n, err := stdout.Read(buf) + if err != nil { break } + c.WriteMessage(websocket.BinaryMessage, buf[:n]) + } +} +``` diff --git a/docs/v2/ARCHITECTURE.md b/docs/v2/ARCHITECTURE.md new file mode 100644 index 0000000..d85d9dd --- /dev/null +++ b/docs/v2/ARCHITECTURE.md @@ -0,0 +1,459 @@ +# Hostkeeper V2 — Architecture + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 +> **V1 (existing)**: CLI/TUI SSH/SFTP manager — frozen, no changes. + +--- + +## 1. System Overview + +Hostkeeper V2 is a Termius-like cross-platform SSH/SFTP management GUI. The architecture follows a **Go backend + Web UI + Electron/WebView** pattern — the same approach used by Termius, VS Code, Discord, Slack, and Figma. + +``` +┌──────────────────────────────────────────────────────┐ +│ ELECTRON / WEBVIEW │ +│ Native window, menu bar, tray icon, auto-updater │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ WEB UI (React + TypeScript) │ │ +│ │ Host List │ Terminal │ SFTP │ Keys │ Settings │ │ +│ │ ┌───────────────────────────────────────────┐ │ │ +│ │ │ xterm.js (terminal) │ │ │ +│ │ └───────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ HTTP + WebSocket │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ GO BACKEND (GoFiber v2) │ │ +│ │ REST API │ WebSocket │ SSH │ SFTP │ │ +│ │ ┌───────────────────────────────────────────┐ │ │ +│ │ │ pkg/ssh │ pkg/sftp │ pkg/crypto │ ... │ │ │ +│ │ └───────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ + │ Distributed as: + ├── .dmg (macOS) + ├── .exe (Windows) + ├── .AppImage (Linux) + └── .aab / .ipa (Android / iOS) +``` + +### Communication Flow + +``` +User interaction (keyboard, mouse) + │ + ▼ +React component (event handler) + │ + ▼ +API client (fetch / WebSocket) + │ + ▼ +GoFiber HTTP server (localhost:randomPort) + │ + ├── REST handlers → pkg/storage → JSON files + └── WebSocket handlers → pkg/ssh → remote server +``` + +--- + +## 2. Technology Stack + +### Backend (Go) + +| Component | Library | Purpose | +|-----------|---------|---------| +| HTTP server | `gofiber/fiber/v2` | REST API, static file serving | +| WebSocket | `gofiber/contrib/websocket` | Terminal streaming, real-time updates | +| WebSocket engine | `fasthttp/websocket` | Underlying WS protocol (fork of gorilla) | +| SSH client | `golang.org/x/crypto/ssh` | Remote SSH connections (reuse V1 pkg/ssh) | +| SFTP client | `github.com/pkg/sftp` | File transfer (reuse V1 pkg/sftp) | +| Crypto | `crypto/aes`, `crypto/cipher` | AES-256-GCM encryption (reuse V1 pkg/crypto) | +| Storage | Standard `os` + `encoding/json` | JSON file persistence (reuse V1 pkg/storage) | +| Config | `github.com/spf13/viper` | App configuration (reuse V1 pkg/config) | +| UUID | `github.com/google/uuid` | Unique IDs for entities | +| Routers | `github.com/gofiber/fiber/v2` | HTTP routing | +| Middleware | GoFiber built-in | CORS, compress, recover, logger | + +### Frontend (React/TypeScript) + +| Component | Library | Purpose | +|-----------|---------|---------| +| Framework | React 19 + TypeScript | UI components | +| Build tool | Vite | Fast development + production build | +| Terminal | xterm.js + addons | Terminal emulation (fit, web-links, search) | +| State | Zustand | Lightweight state management | +| Routing | React Router v7 | Screen navigation | +| Styling | TailwindCSS + CSS variables | Theming (dark/light/contrast) | +| Icons | Lucide React | Consistent icon set | +| Notifications | Sonner | Toast notifications | + +### Desktop (Electron) + +| Component | Library | Purpose | +|-----------|---------|---------| +| Shell | Electron 33 | Native desktop wrapper | +| Packaging | electron-builder | Build .dmg, .exe, .AppImage | +| Auto-update | electron-updater | In-app updates | +| Keychain | Node.js safeStorage | OS keychain integration | +| IPC | Electron IPC | Go backend <-> Electron communication | + +### Mobile + +| Component | Library | Purpose | +|-----------|---------|---------| +| Go library | gomobile bind | Compile Go to .aar/.xcframework | +| UI shell | React Native WebView | Display web UI on mobile | +| Native code | Java (Android) / Swift (iOS) | Minimal WebView wrapper | + +--- + +## 3. Directory Structure + +``` +hostkeeper/ +├── cmd/ # V1 CLI (FROZEN) +│ └── hostkeeper/ +├── pkg/ # V1 shared packages (FROZEN) +│ ├── ssh/ +│ ├── sftp/ +│ ├── crypto/ +│ ├── storage/ +│ ├── config/ +│ ├── knownhosts/ +│ └── tui/ +├── internal/ # V1 internal packages (FROZEN) +│ ├── models/ +│ └── errors/ +├── test/ # V1 tests (FROZEN) +│ +├── app/ # V2 GUI (NEW) +│ ├── backend/ # Go HTTP server +│ │ ├── main.go # Entry point, fiber server setup +│ │ ├── go.mod # Go module for backend +│ │ ├── bridge/ # REST API handlers +│ │ │ ├── hosts.go +│ │ │ ├── keys.go +│ │ │ ├── snippets.go +│ │ │ ├── groups.go +│ │ │ ├── portforward.go +│ │ │ ├── sftp.go +│ │ │ ├── config.go +│ │ │ ├── vault.go +│ │ │ └── export.go +│ │ ├── ws/ # WebSocket handlers +│ │ │ ├── terminal.go # SSH terminal streaming +│ │ │ └── session.go # Session management +│ │ └── middleware/ # Custom middleware +│ │ └── middleware.go # (optional overrides) +│ │ +│ ├── frontend/ # React web UI +│ │ ├── src/ +│ │ │ ├── components/ # Reusable UI components +│ │ │ ├── screens/ # Page-level components +│ │ │ ├── hooks/ # Custom React hooks +│ │ │ ├── stores/ # Zustand state stores +│ │ │ ├── api/ # API client functions +│ │ │ └── styles/ # CSS, themes, variables +│ │ ├── index.html +│ │ ├── vite.config.ts +│ │ ├── tailwind.config.js +│ │ ├── tsconfig.json +│ │ └── package.json +│ │ +│ └── electron/ # Electron shell +│ ├── main.ts # Main process +│ ├── preload.ts # Preload script (IPC bridge) +│ ├── build.ts # Build configuration +│ ├── package.json +│ └── electron-builder.yml +│ +├── mobile/ # V2 Mobile (NEW) +│ ├── android/ # Android WebView app +│ │ ├── app/ +│ │ ├── build.gradle +│ │ └── ... +│ ├── ios/ # iOS WKWebView app +│ │ ├── Hostkeeper/ +│ │ ├── Hostkeeper.xcodeproj +│ │ └── ... +│ └── gomobile/ # Go mobile compilation +│ ├── build.sh +│ └── ... +│ +├── scripts/ # Build scripts +│ ├── build.sh # Full build (Go + FE + Electron) +│ ├── build-go.sh # Go cross-compile +│ ├── build-fe.sh # Frontend build +│ ├── build-electron.sh # Electron package +│ └── build-mobile.sh # Mobile build +│ +├── docs/ # Documentation +│ ├── v2/ # V2 planning docs +│ │ ├── PROGRESS.md # Status tracker (read this first) +│ │ ├── ARCHITECTURE.md # This file +│ │ ├── API.md # REST API + WebSocket spec +│ │ ├── DATA_MODELS.md # Extended data models +│ │ ├── UI_COMPONENTS.md # React component tree +│ │ ├── UI_TERMIUS_REFERENCE.md # Termius visual reference +│ │ ├── SPRINT_PLAN.md # Sprint-by-sprint task breakdown +│ │ ├── BUILD_SYSTEM.md # Build pipeline +│ │ └── PERFORMANCE.md # Performance & stability +│ ├── v1/ # V1 docs (FROZEN, reference only) +│ │ ├── ARCHITECTURE.md +│ │ ├── TEST_PLAN.md +│ │ ├── INSTALLATION.md +│ │ └── USAGE.md +│ └── plans/ # V1 design docs (FROZEN) +│ +├── go.mod # V1 Go module +├── go.sum +├── Makefile +├── build.sh +├── CHANGELOG.md +├── PROJECT_STATE.md # V1 project state +└── README.md +``` + +--- + +## 4. Process Model + +### Startup Sequence + +``` +1. User double-clicks Hostkeeper.app (Electron) +2. Electron main process starts +3. Electron spawns Go binary as child process +4. Go binary starts GoFiber server on localhost:randomPort +5. Go serves frontend dist (embedded in Electron) +6. Electron loads web UI from localhost:PORT +7. Web UI renders vault unlock screen +8. User enters password -> Go unlocks vault (decrypts data) +9. User sees host list screen -> ready to use +``` + +### Shutdown Sequence + +``` +1. User closes window / Cmd+Q +2. Electron sends SIGTERM to Go child process +3. Go receives signal -> starts graceful shutdown +4. Go disconnects all active SSH sessions +5. Go saves any pending state +6. Go closes fiber server +7. Go exits +8. Electron exits +``` + +### Development Mode + +``` +1. Terminal: cd app/backend && go run . +2. Terminal: cd app/frontend && npm run dev +3. Go server at localhost:8080 +4. Frontend dev server at localhost:5173 (Vite HMR) +5. Frontend proxies API requests to localhost:8080 +6. Open browser at localhost:5173 +``` + +--- + +## 5. Data Flow + +### REST API Request Flow + +``` +React component + → useQuery/useMutation (React Query or manual fetch) + → API client (fetch wrapper) + → HTTP request to GoFiber (localhost:PORT/api/...) + → GoFiber router -> handler function + → Handler calls pkg/storage (read/write JSON) + → Response sent back through the chain +``` + +### Terminal WebSocket Flow + +``` +User opens terminal tab + → React: XTermWrapper mounts + → XTermWrapper opens WebSocket to ws://localhost:PORT/ws/terminal?host_id=xxx&cols=80&rows=24 + → GoFiber WebSocket handler accepts connection + → Go: SSH client connects to remote host (reuse pkg/ssh) + → SSH session created, PTY requested + → SSH stdout goroutine -> reads SSH output -> c.WriteMessage(BinaryMessage, data) + -> xterm.js receives data -> renders in terminal + -> xterm.js onKey -> c.WriteMessage(BinaryMessage, input) + -> Go receives input -> session.Stdin.Write(input) -> SSH remote +``` + +### SFTP File Transfer Flow + +``` +User drags file to remote pane + → React: FileList onDrop handler + → API client: POST /api/sftp/upload (multipart/form-data) + → Go: bridge/sftp.go receives upload + → Go: pkg/sftp client writes to remote + → Response: { "success": true } + → React: refresh remote file list +``` + +--- + +## 6. Security Architecture + +### Encryption Model + +| Layer | Method | Implementation | +|-------|--------|----------------| +| Data at rest | AES-256-GCM | `pkg/crypto/crypto.go` (reuse V1) | +| Key derivation | PBKDF2 (100k iterations) | `pkg/crypto/crypto.go` (reuse V1) | +| Password storage | SHA-256 hash | `pkg/config/config.go` (reuse V1) | +| Known hosts | TOFU verification | `pkg/knownhosts/knownhosts.go` (reuse V1) | +| Transport | localhost only | No TLS needed (same machine) | +| Export | AES-256-GCM with password | `pkg/storage/json_storage.go` (reuse V1) | + +### Vault Flow + +``` +App start -> vault locked -> prompt password + -> PBKDF2 derive key from password + -> Try decrypt hosts.json + -> Success -> vault unlocked, data accessible + -> Fail -> wrong password, show error + -> Vault locked -> no data accessible + -> Auto-lock after inactivity timer +``` + +### OS Keychain Integration + +| Platform | Library | Purpose | +|----------|---------|---------| +| macOS | `security` CLI or `go-keychain` | Store vault master key | +| Windows | `go-credential` | Store vault master key | +| Linux | `dbus` Secret Service | Store vault master key | +| Electron | `safeStorage` API | Store vault master key | + +--- + +## 7. WebSocket Protocol + +### Terminal WebSocket + +**Connection**: `ws://localhost:PORT/ws/terminal?host_id=xxx&cols=80&rows=24` + +**Message types** (binary frames): + +| Direction | Type | Content | +|-----------|------|---------| +| Client → Server | Input | Raw keystroke bytes | +| Server → Client | Output | Terminal output bytes | +| Client → Server | Resize | JSON: `{ "type": "resize", "cols": 120, "rows": 40 }` | +| Server → Client | Status | JSON: `{ "type": "status", "state": "connected" }` | +| Server → Client | Error | JSON: `{ "type": "error", "message": "..." }` | + +**Keepalive**: Client sends ping every 30s, server responds with pong. + +--- + +## 8. Build System + +### Development + +```bash +# Terminal 1: Go backend +cd app/backend +go run . + +# Terminal 2: Frontend with hot reload +cd app/frontend +npm run dev + +# Browser opens at http://localhost:5173 +``` + +### Production Build + +```bash +# Full build +./scripts/build.sh + +# Output: +# dist/hostkeeper-mac-arm64.dmg +# dist/hostkeeper-win-x64.exe +# dist/hostkeeper-linux-x64.AppImage +``` + +### Cross-Compilation Matrix + +| Platform | GOOS | GOARCH | Output | +|----------|------|--------|--------| +| macOS ARM64 | darwin | arm64 | hostkeeper-mac-arm64 | +| macOS x64 | darwin | amd64 | hostkeeper-mac-x64 | +| Windows x64 | windows | amd64 | hostkeeper-win-x64.exe | +| Linux x64 | linux | amd64 | hostkeeper-linux-x64 | +| Linux ARM64 | linux | arm64 | hostkeeper-linux-arm64 | +| Android ARM64 | android | arm64 | hostkeeper-android.aar | +| iOS ARM64 | ios | arm64 | hostkeeper-ios.xcframework | + +--- + +## 9. Error Handling + +### Go Backend Errors + +```go +// All API errors follow this format: +{ + "error": "Human-readable error message", + "code": "MACHINE_READABLE_CODE", + "details": "Optional technical details" +} + +// HTTP status codes: +// 200 - Success +// 201 - Created +// 400 - Bad Request (validation error) +// 401 - Unauthorized (vault locked) +// 404 - Not Found +// 409 - Conflict (duplicate) +// 500 - Internal Server Error +``` + +### Frontend Error Handling + +- Network errors: automatic retry with exponential backoff (3 retries) +- API errors: toast notification with error message +- WebSocket errors: reconnect button in terminal, auto-reconnect 3 times +- Validation errors: inline field-level error messages + +--- + +## 10. Testing Strategy + +| Layer | Tool | Coverage Target | +|-------|------|----------------| +| Go unit tests | `go test` | 80%+ | +| Go integration tests | `go test -tags=integration` | Key flows | +| React unit tests | Vitest + React Testing Library | 70%+ | +| React E2E tests | Playwright | Critical flows | +| Manual testing | All platforms | Every sprint | + +### Test Commands + +```bash +# Go backend tests +cd app/backend && go test ./... + +# Frontend tests +cd app/frontend && npm test + +# Full test suite +make test + +# Race condition check +go test -race ./app/backend/... +``` diff --git a/docs/v2/BUILD_SYSTEM.md b/docs/v2/BUILD_SYSTEM.md new file mode 100644 index 0000000..02d1805 --- /dev/null +++ b/docs/v2/BUILD_SYSTEM.md @@ -0,0 +1,564 @@ +# Hostkeeper V2 — Build System + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 + +--- + +## 1. Build Overview + +Hostkeeper V2 produces **4 platform outputs** from a single codebase: + +| Platform | Output | Wrapper | Backend | +|----------|--------|---------|---------| +| macOS (ARM64) | `.dmg` | Electron | Go binary (embedded) | +| macOS (x64) | `.dmg` | Electron | Go binary (embedded) | +| Windows (x64) | `.exe` installer | Electron | Go binary (embedded) | +| Linux (x64) | `.AppImage` | Electron | Go binary (embedded) | +| Android (ARM64) | `.aab` / `.apk` | WebView | Go library (.aar) | +| iOS (ARM64) | `.ipa` | WKWebView | Go framework (.xcframework) | + +--- + +## 2. Build Pipeline + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Go Build │ │ Frontend │ │ Electron │ │ Platform │ +│ (backend) │ → │ Build │ → │ Package │ → │ Sign │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ +``` + +### Step 1: Go Backend Cross-Compile + +```bash +# macOS ARM64 +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ + go build -ldflags="-s -w" -o dist/hostkeeper-server . + +# macOS x64 +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 \ + go build -ldflags="-s -w" -o dist/hostkeeper-server . + +# Windows x64 +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ + go build -ldflags="-s -w" -o dist/hostkeeper-server.exe . + +# Linux x64 +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w" -o dist/hostkeeper-server . + +# Linux ARM64 +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ + go build -ldflags="-s -w" -o dist/hostkeeper-server . +``` + +**Notes**: +- `CGO_ENABLED=0` — pure Go, no C dependencies +- `-ldflags="-s -w"` — strip debug info, reduce binary size +- Go binary size: ~15-20 MB (compressed) + +### Step 2: Frontend Build + +```bash +cd app/frontend +npm ci +npm run build +# Output: app/frontend/dist/ +``` + +**Vite config** (`app/frontend/vite.config.ts`): +```typescript +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + sourcemap: false, + minify: 'terser', + rollupOptions: { + output: { + manualChunks: { + xterm: ['@xterm/xterm', '@xterm/addon-fit'], + react: ['react', 'react-dom'], + }, + }, + }, + }, + server: { + proxy: { + '/api': 'http://localhost:8080', + '/ws': { + target: 'ws://localhost:8080', + ws: true, + }, + }, + }, +}); +``` + +### Step 3: Electron Package + +```yaml +# app/electron/electron-builder.yml +appId: com.hostkeeper.app +productName: Hostkeeper +copyright: Copyright © 2026 + +directories: + output: ../../dist + +files: + - "**/*" + - "!**/node_modules/*/{CHANGELOG.md,README.md,readme.md,LICENSE}" + +extraResources: + - from: "../backend/hostkeeper-server" + to: "hostkeeper-server" + - from: "../frontend/dist" + to: "frontend" + +mac: + category: public.app-category.developer-tools + icon: assets/icon.icns + target: + - dmg + - zip + hardenedRuntime: true + notarize: true + +win: + icon: assets/icon.ico + target: + - nsis + certificateFile: env.WIN_CERTIFICATE_FILE + +linux: + icon: assets/icon.png + target: + - AppImage + - deb + category: Development + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true +``` + +--- + +## 3. Mobile Build + +### Android + +```bash +# Prerequisites: +# - Android SDK installed +# - Go 1.26+ with gomobile +# - Java 17+ + +# Install gomobile +go install golang.org/x/mobile/cmd/gomobile@latest +gomobile init + +# Build Go library +cd mobile/gomobile +gomobile bind -target=android -o=../android/app/libs/hostkeeper.aar \ + ./go/ + +# Build Android app +cd ../android +./gradlew assembleRelease +# Output: mobile/android/app/build/outputs/apk/release/app-release.apk +``` + +### iOS + +```bash +# Prerequisites: +# - Xcode 15+ +# - Go 1.26+ with gomobile + +# Build Go framework +cd mobile/gomobile +gomobile bind -target=ios -o=../ios/Hostkeeper/Hostkeeper.xcframework \ + ./go/ + +# Build iOS app +cd ../ios +xcodebuild -project Hostkeeper.xcodeproj \ + -scheme Hostkeeper \ + -sdk iphoneos \ + -configuration Release +# Output: mobile/ios/build/Release-iphoneos/Hostkeeper.app +``` + +--- + +## 4. Build Scripts + +### `scripts/build.sh` (Full Build) + +```bash +#!/bin/bash +set -e + +VERSION=${1:-"dev"} +PLATFORM=${2:-"all"} + +echo "=== Building Hostkeeper V2 v${VERSION} ===" + +# Step 1: Build Go backend +echo "Step 1: Building Go backend..." +cd app/backend + +case $PLATFORM in + macos|all) + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION}" -o ../electron/hostkeeper-server . + ;; + windows|all) + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o ../electron/hostkeeper-server.exe . + ;; + linux|all) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o ../electron/hostkeeper-server . + ;; +esac + +cd ../.. + +# Step 2: Build frontend +echo "Step 2: Building frontend..." +cd app/frontend +npm ci +npm run build +cd ../.. + +# Step 3: Package Electron +echo "Step 3: Packaging Electron..." +cd app/electron + +case $PLATFORM in + macos) + npx electron-builder --mac --arm64 + ;; + windows) + npx electron-builder --win --x64 + ;; + linux) + npx electron-builder --linux --x64 + ;; + all) + npx electron-builder --mac --win --linux + ;; +esac + +cd ../.. + +# Step 4: Generate checksums +echo "Step 4: Generating checksums..." +cd dist +shasum -a 256 *.dmg *.exe *.AppImage 2>/dev/null > checksums.txt + +echo "=== Build complete ===" +echo "Output: dist/" +ls -la dist/ +``` + +### `scripts/build-mobile.sh` + +```bash +#!/bin/bash +set -e + +PLATFORM=${1:-"android"} + +echo "=== Building Hostkeeper Mobile (${PLATFORM}) ===" + +case $PLATFORM in + android) + cd mobile/gomobile + gomobile bind -target=android -o=../android/app/libs/hostkeeper.aar ./go/ + cd ../android + ./gradlew assembleRelease + echo "APK: mobile/android/app/build/outputs/apk/release/" + ;; + ios) + cd mobile/gomobile + gomobile bind -target=ios -o=../ios/Hostkeeper/Hostkeeper.xcframework ./go/ + cd ../ios + xcodebuild -project Hostkeeper.xcodeproj -scheme Hostkeeper -sdk iphoneos -configuration Release + echo "IPA: mobile/ios/build/Release-iphoneos/" + ;; +esac + +echo "=== Mobile build complete ===" +``` + +--- + +## 5. Development Workflow + +### Quick Start + +```bash +# Terminal 1: Go backend +cd app/backend +go run . + +# Terminal 2: Frontend (with hot reload) +cd app/frontend +npm run dev + +# Open browser: http://localhost:5173 +``` + +### With Electron + +```bash +# Terminal 1: Go backend +cd app/backend +go run . + +# Terminal 2: Frontend +cd app/frontend +npm run dev + +# Terminal 3: Electron +cd app/electron +npm run dev +# Electron opens with hot reload +``` + +### VS Code Launch Config + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Go Backend", + "type": "go", + "request": "launch", + "program": "${workspaceFolder}/app/backend", + "cwd": "${workspaceFolder}/app/backend" + }, + { + "name": "Electron", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/app/electron/node_modules/.bin/electron", + "args": ["."], + "cwd": "${workspaceFolder}/app/electron" + } + ] +} +``` + +--- + +## 6. CI/CD (GitHub Actions) + +```yaml +# .github/workflows/build.yml +name: Build + +on: + push: + tags: ['v*'] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - run: go test -race ./app/backend/... + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: cd app/frontend && npm ci && npm test + + build-macos: + needs: test + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: ./scripts/build.sh ${{ github.ref_name }} macos + - uses: actions/upload-artifact@v4 + with: + name: hostkeeper-macos + path: dist/*.dmg + + build-windows: + needs: test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: ./scripts/build.sh ${{ github.ref_name }} windows + - uses: actions/upload-artifact@v4 + with: + name: hostkeeper-windows + path: dist/*.exe + + build-linux: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: ./scripts/build.sh ${{ github.ref_name }} linux + - uses: actions/upload-artifact@v4 + with: + name: hostkeeper-linux + path: dist/*.AppImage +``` + +--- + +## 7. Binary Size Optimization + +| Technique | Impact | +|-----------|--------| +| `-ldflags="-s -w"` | -20% Go binary size | +| UPX compression (optional) | -60% Go binary size | +| Vite tree shaking | -40% JS bundle size | +| Code splitting (lazy load) | -30% initial load | +| Image optimization | -50% icon sizes | +| Terser minification | -30% JS size | +| CSS purging (Tailwind) | -80% CSS size | + +**Target sizes**: +- Go backend binary: ~15 MB (5 MB compressed) +- Frontend dist: ~2 MB (500 KB compressed) +- Electron package: ~200 MB total +- Mobile APK: ~50 MB +- Mobile IPA: ~60 MB + +--- + +## 8. Version Management + +### Version Format + +``` +vMAJOR.MINOR.PATCH +``` + +- **MAJOR**: Breaking changes (data format, API) +- **MINOR**: New features +- **PATCH**: Bug fixes + +### Version Injection + +```go +// In main.go +var version = "dev" + +func main() { + fmt.Printf("Hostkeeper v%s\n", version) + // ... +} +``` + +```bash +# Build with version +go build -ldflags="-X main.version=v2.0.0" -o hostkeeper-server . +``` + +### Changelog Format + +```markdown +## [v2.1.0] - 2026-07-15 + +### Added +- Port forwarding support (local, remote, dynamic) +- Import from ~/.ssh/config + +### Changed +- Improved terminal performance with WebGL renderer + +### Fixed +- Fixed SFTP upload progress not updating +- Fixed vault auto-lock not triggering on sleep +``` + +--- + +## 9. Code Signing + +### macOS + +```bash +# Requires Apple Developer account + certificates +export CSC_LINK="path/to/certificate.p12" +export CSC_KEY_PASSWORD="certificate-password" + +# electron-builder handles signing + notarization +npx electron-builder --mac --publish always +``` + +### Windows + +```bash +# Requires code signing certificate +export WIN_CERTIFICATE_FILE="path/to/certificate.pfx" +export WIN_CERTIFICATE_PASSWORD="certificate-password" + +npx electron-builder --win --publish always +``` + +### Linux + +No code signing required. AppImage is self-contained. + +--- + +## 10. Release Workflow + +```bash +# 1. Update version +npm version minor # or major, patch + +# 2. Update CHANGELOG.md + +# 3. Commit +git add . +git commit -m "chore: release v2.1.0" + +# 4. Tag +git tag -a v2.1.0 -m "Release v2.1.0" + +# 5. Push +git push origin main --tags + +# 6. GitHub Actions builds + publishes artifacts + +# 7. Create GitHub Release +gh release create v2.1.0 \ + --title "Hostkeeper v2.1.0" \ + --notes-file CHANGELOG.md \ + dist/*.dmg dist/*.exe dist/*.AppImage +``` diff --git a/docs/v2/DATA_MODELS.md b/docs/v2/DATA_MODELS.md new file mode 100644 index 0000000..bcab131 --- /dev/null +++ b/docs/v2/DATA_MODELS.md @@ -0,0 +1,391 @@ +# Hostkeeper V2 — Extended Data Models + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 +> **V1 models**: `internal/models/models.go` — frozen, no changes. + +--- + +## 1. Overview + +V2 extends V1 models with new entities for groups, vault, port forwarding, workspaces, and sessions. All V1 models remain unchanged. New models are added in `app/backend/models/` (or `internal/models/` with backward-compatible additions). + +--- + +## 2. V1 Models (FROZEN — do not modify) + +These exist in `internal/models/models.go` and are used as-is: + +- `Host` — SSH host connection configuration +- `AuthConfig` — Authentication configuration (password/key/agent) +- `KeyPair` — SSH key pair +- `Snippet` — Command snippet +- `Profile` — Named configuration profile +- `AppConfig` — Application configuration +- `KnownHost` — Verified host key + +--- + +## 3. V2 Models (NEW) + +### 3.1 HostGroup + +Nestable group for organizing hosts. Groups can contain other groups and hosts. + +```go +type HostGroup struct { + ID string `json:"id" bson:"_id,omitempty"` + Name string `json:"name"` + ParentID *string `json:"parent_id,omitempty"` // nil = root group + Color string `json:"color,omitempty"` // hex color e.g. "#e78a4e" + Icon string `json:"icon,omitempty"` // icon name e.g. "server", "globe" + Order int `json:"order"` // sort order within parent + Children []*HostGroup `json:"children,omitempty"` // populated at runtime, not stored + HostCount int `json:"host_count,omitempty"` // populated at runtime + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**Storage**: `groups.json` +```json +{ + "groups": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Production", + "parent_id": null, + "color": "#e78a4e", + "icon": "server", + "order": 0, + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z" + } + ] +} +``` + +**Rules**: +- Maximum nesting depth: 5 levels +- Root groups have `parent_id: null` +- Deleting a group moves its hosts to parent (or ungrouped) +- Group names must be unique within the same parent + +--- + +### 3.2 Extended Host + +New fields added to the existing `Host` struct: + +```go +// V2 additions to Host struct +type HostV2 struct { + // ... all V1 fields ... + + GroupID *string `json:"group_id,omitempty"` // reference to HostGroup.ID + IsFavorite bool `json:"is_favorite"` // quick access + Color string `json:"color,omitempty"` // override group color + Order int `json:"order"` // sort order within group + StartupSnippets []string `json:"startup_snippets,omitempty"` // snippet IDs to run on connect + Proxy *ProxyConfig `json:"proxy,omitempty"` // proxy configuration + JumpHost *string `json:"jump_host_id,omitempty"` // jump host ID + Keepalive int `json:"keepalive,omitempty"` // keepalive interval in seconds +} +``` + +**Note**: V1 Host fields are untouched. V2 fields are added with `omitempty` for backward compatibility. + +--- + +### 3.3 ProxyConfig + +Proxy configuration for SSH connections through proxy servers. + +```go +type ProxyConfig struct { + Type string `json:"type"` // "socks5", "http", "command" + Host string `json:"host"` // proxy host + Port int `json:"port"` // proxy port + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Command string `json:"command,omitempty"` // for "command" type (netcat-style) +} +``` + +--- + +### 3.4 PortForward + +SSH port forwarding configuration. + +```go +type PortForward struct { + ID string `json:"id" bson:"_id,omitempty"` + Name string `json:"name"` + HostID string `json:"host_id"` // reference to Host.ID + Type string `json:"type"` // "local", "remote", "dynamic" + LocalAddr string `json:"local_addr"` // e.g. "127.0.0.1:8080" + RemoteAddr string `json:"remote_addr"` // e.g. "localhost:3000" + Status string `json:"status"` // "stopped", "running", "error" + AutoStart bool `json:"auto_start"` // start tunnel on app launch + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**Storage**: `forwards.json` +```json +{ + "forwards": [ + { + "id": "forward-uuid", + "name": "Local Redis", + "host_id": "host-uuid", + "type": "local", + "local_addr": "127.0.0.1:6379", + "remote_addr": "localhost:6379", + "status": "stopped", + "auto_start": false, + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z" + } + ] +} +``` + +**Forward Types**: +- **Local**: `ssh -L localPort:remoteHost:remotePort` — forward local port to remote +- **Remote**: `ssh -R remotePort:localHost:localPort` — forward remote port to local +- **Dynamic**: `ssh -D localPort` — SOCKS5 proxy through SSH + +--- + +### 3.5 Workspace + +Group multiple terminal sessions in a layout. + +```go +type Workspace struct { + ID string `json:"id" bson:"_id,omitempty"` + Name string `json:"name"` + Layout string `json:"layout"` // "single", "split-h", "split-v", "grid-2x2" + Tabs []Tab `json:"tabs"` // terminal tabs in this workspace + IsActive bool `json:"is_active"` // currently active workspace + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Tab struct { + ID string `json:"id"` + HostID string `json:"host_id"` + Type string `json:"type"` // "terminal", "sftp" + Title string `json:"title"` // display title (host name) + Cols int `json:"cols"` + Rows int `json:"rows"` +} +``` + +**Storage**: `workspaces.json` + +**Layout Types**: +- `single` — one terminal, full width +- `split-h` — two terminals side by side (horizontal split) +- `split-v` — two terminals stacked (vertical split) +- `grid-2x2` — four terminals in a 2x2 grid + +--- + +### 3.6 Session (Runtime only, not persisted) + +Active terminal session tracking. + +```go +type Session struct { + ID string `json:"id"` + HostID string `json:"host_id"` + StartedAt time.Time `json:"started_at"` + LastActive time.Time `json:"last_active"` + Status string `json:"status"` // "connecting", "connected", "disconnected", "error" + Cols int `json:"cols"` + Rows int `json:"rows"` +} +``` + +**Note**: Sessions are in-memory only. Not stored to disk. + +--- + +### 3.7 VaultStatus (Runtime only) + +Vault lock/unlock state tracking. + +```go +type VaultStatus struct { + Locked bool `json:"locked"` + EncryptionEnabled bool `json:"encryption_enabled"` + AutoLockEnabled bool `json:"auto_lock_enabled"` + AutoLockMinutes int `json:"auto_lock_minutes"` + LastActivity time.Time `json:"last_activity"` + UnlockAttempts int `json:"unlock_attempts"` // failed attempts + LockedUntil *time.Time `json:"locked_until,omitempty"` // temporary lock after too many attempts +} +``` + +**Auto-lock rules**: +- Default: lock after 5 minutes of inactivity +- After 5 failed unlock attempts: temporary lock for 30 seconds +- Lock on system sleep/suspend +- Lock on window minimize (configurable) + +--- + +## 4. Storage Schema + +### File Structure + +``` +~/.hostkeeper/ +├── hosts.json # V1 + V2 host data +├── keys.json # V1 + V2 key pairs +├── snippets.json # V1 + V2 snippets +├── groups.json # V2: host groups +├── forwards.json # V2: port forwards +├── workspaces.json # V2: workspaces +├── config.json # V1 + V2 app config +├── known_hosts.json # V1 + V2 known hosts +├── profiles.json # V2: profiles (if separated from config) +└── sessions.json # V2: session history (optional) +``` + +### JSON Schema Examples + +#### hosts.json (extended) + +```json +{ + "hosts": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Production Server", + "hostname": "192.168.1.100", + "port": 22, + "username": "admin", + "auth": { + "type": "key", + "key_id": "key-uuid" + }, + "group_id": "group-uuid", + "tags": ["production", "linux"], + "is_favorite": true, + "color": "#e78a4e", + "order": 0, + "notes": "Main production server", + "startup_snippets": ["snippet-uuid-1"], + "proxy": { + "type": "socks5", + "host": "10.0.0.1", + "port": 1080 + }, + "jump_host_id": "jump-host-uuid", + "keepalive": 60, + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z", + "last_used_at": "2024-06-22T15:30:00Z" + } + ] +} +``` + +#### groups.json + +```json +{ + "groups": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Production", + "parent_id": null, + "color": "#e78a4e", + "icon": "server", + "order": 0, + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z" + }, + { + "id": "660e8400-e29b-41d4-a716-446655440001", + "name": "Web Servers", + "parent_id": "550e8400-e29b-41d4-a716-446655440000", + "color": "#a9b665", + "icon": "globe", + "order": 0, + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z" + } + ] +} +``` + +#### forwards.json + +```json +{ + "forwards": [ + { + "id": "forward-uuid", + "name": "Local Redis", + "host_id": "host-uuid", + "type": "local", + "local_addr": "127.0.0.1:6379", + "remote_addr": "localhost:6379", + "status": "stopped", + "auto_start": false, + "created_at": "2024-06-22T10:00:00Z", + "updated_at": "2024-06-22T10:00:00Z" + } + ] +} +``` + +--- + +## 5. Relationships + +``` +HostGroup (1) ──── (*) Host + │ │ + │ parent_id │ group_id + │ (self-ref) │ key_id + │ │ startup_snippets (*) + │ │ jump_host_id + │ │ +HostGroup (1) ──── (*) HostGroup (children) + │ + └── max depth: 5 + +Host (1) ──── (*) PortForward + │ + └── host_id + +Workspace (1) ──── (*) Tab + │ + └── tabs[].host_id + +Host (1) ──── (*) Snippet (via startup_snippets) + │ + └── startup_snippets[] = snippet IDs +``` + +--- + +## 6. Migration from V1 + +V2 data is backward-compatible with V1. New fields are optional (`omitempty`). + +**Migration steps** (automatic on first V2 launch): +1. Read existing `hosts.json` — V2 fields default to zero values +2. Create `groups.json` if not exists — empty groups +3. Create `forwards.json` if not exists — empty forwards +4. Create `workspaces.json` if not exists — empty workspaces +5. Update `config.json` — add V2 config fields + +**No data loss**: V1 data is preserved. V2 fields are additive. diff --git a/docs/v2/PERFORMANCE.md b/docs/v2/PERFORMANCE.md new file mode 100644 index 0000000..f1340da --- /dev/null +++ b/docs/v2/PERFORMANCE.md @@ -0,0 +1,709 @@ +# Hostkeeper V2 — Performance & Stability + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 +> **Goal**: "Tidak lemot dan bisa stabil digunakan" — fast, responsive, zero crashes. + +--- + +## 1. Performance Targets + +| Metric | Target | How to Measure | +|--------|--------|----------------| +| App startup | < 2 seconds | Electron ready → UI visible | +| API response | < 50ms | Time from request to response | +| Terminal latency | < 10ms | Keystroke → character appears | +| SFTP listing | < 500ms | Directory load time | +| Memory usage | < 200 MB | Steady state (10 tabs open) | +| CPU usage | < 5% | Idle state | +| Scroll (1000 hosts) | 60 fps | No jank during scroll | +| SSH reconnect | < 3 seconds | Auto-reconnect after disconnect | + +--- + +## 2. Go Backend Performance + +### 2.1 GoFiber Configuration + +```go +app := fiber.New(fiber.Config{ + // Timeouts (prevent hung connections) + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + + // Concurrency (limit goroutines) + Concurrency: 256, + + // Body limit (prevent large payload attacks) + BodyLimit: 10 * 1024 * 1024, // 10MB + + // Buffer sizes (optimize for typical requests) + ReadBufferSize: 4096, + WriteBufferSize: 4096, + + // Disable startup banner + DisableStartupMessage: false, + + // JSON encoder (fastest) + JSONEncoder: json.Marshal, + JSONDecoder: json.Unmarshal, +}) +``` + +### 2.2 Connection Pooling (SSH) + +```go +// app/backend/ws/pool.go +type SessionPool struct { + mu sync.RWMutex + sessions map[string]*pooledSession // key: hostID + maxIdle time.Duration + maxSize int +} + +type pooledSession struct { + client *ssh.Client + lastUsed time.Time + refCount int +} + +func NewSessionPool(maxSize int, maxIdle time.Duration) *SessionPool { + pool := &SessionPool{ + sessions: make(map[string]*pooledSession), + maxIdle: maxIdle, + maxSize: maxSize, + } + + // Cleanup goroutine: close idle connections + go func() { + ticker := time.NewTicker(30 * time.Second) + for range ticker.C { + pool.cleanup() + } + }() + + return pool +} + +func (p *SessionPool) Get(hostID string) (*pooledSession, error) { + p.mu.RLock() + sess, exists := p.sessions[hostID] + p.mu.RUnlock() + + if exists { + sess.lastUsed = time.Now() + sess.refCount++ + return sess, nil + } + + // Create new connection + p.mu.Lock() + defer p.mu.Unlock() + + // Double-check after acquiring write lock + if sess, exists := p.sessions[hostID]; exists { + sess.lastUsed = time.Now() + sess.refCount++ + return sess, nil + } + + // Evict if at capacity + if len(p.sessions) >= p.maxSize { + p.evictOldest() + } + + // Create new session + host, err := storage.GetHost(context.Background(), hostID) + if err != nil { + return nil, err + } + + client := ssh.NewClient(host, 30*time.Second) + if err := client.Connect(context.Background()); err != nil { + return nil, err + } + + sess = &pooledSession{ + client: client, + lastUsed: time.Now(), + refCount: 1, + } + p.sessions[hostID] = sess + + return sess, nil +} + +func (p *SessionPool) Put(hostID string) { + p.mu.Lock() + defer p.mu.Unlock() + + if sess, exists := p.sessions[hostID]; exists { + sess.refCount-- + if sess.refCount <= 0 { + // Don't close immediately — keep for reuse + sess.refCount = 0 + } + } +} + +func (p *SessionPool) cleanup() { + p.mu.Lock() + defer p.mu.Unlock() + + for key, sess := range p.sessions { + if sess.refCount == 0 && time.Since(sess.lastUsed) > p.maxIdle { + sess.client.Close() + delete(p.sessions, key) + } + } +} +``` + +### 2.3 Buffer Pooling + +```go +// app/backend/ws/buffers.go +var bufferPool = sync.Pool{ + New: func() interface{} { + buf := make([]byte, 4096) + return &buf + }, +} + +func GetBuffer() *[]byte { + return bufferPool.Get().(*[]byte) +} + +func PutBuffer(buf *[]byte) { + // Reset buffer + (*buf) = (*buf)[:0] + bufferPool.Put(buf) +} +``` + +### 2.4 Goroutine Management + +```go +// Limit concurrent SSH sessions +var sshSemaphore = make(chan struct{}, 50) // max 50 concurrent sessions + +func handleTerminal(c *websocket.Conn) { + // Acquire semaphore + select { + case sshSemaphore <- struct{}{}: + defer func() { <-sshSemaphore }() + default: + c.WriteJSON(fiber.Map{ + "type": "error", + "message": "Too many connections", + }) + return + } + + // ... handle session +} +``` + +--- + +## 3. Frontend Performance + +### 3.1 Code Splitting (Lazy Loading) + +```typescript +// app/frontend/src/router.tsx +import { lazy, Suspense } from 'react'; + +const HostListScreen = lazy(() => import('./screens/HostListScreen')); +const TerminalScreen = lazy(() => import('./screens/TerminalScreen')); +const SFTPScreen = lazy(() => import('./screens/SFTPScreen')); +const SettingsScreen = lazy(() => import('./screens/SettingsScreen')); + +const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { + index: true, + element: ( + }> + + + ), + }, + // ... other routes + ], + }, +]); +``` + +### 3.2 Virtual Scrolling (Host List) + +```typescript +// app/frontend/src/components/VirtualList.tsx +import { useVirtualizer } from '@tanstack/react-virtual'; + +interface VirtualListProps { + items: T[]; + renderItem: (item: T) => React.ReactNode; + estimateSize?: number; +} + +function VirtualList({ items, renderItem, estimateSize = 48 }: VirtualListProps) { + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => estimateSize, + overscan: 10, // Render 10 items outside viewport + }); + + return ( +
+
+ {virtualizer.getVirtualItems().map(virtualRow => ( +
+ {renderItem(items[virtualRow.index])} +
+ ))} +
+
+ ); +} +``` + +### 3.3 React.memo for Expensive Components + +```typescript +// Prevent re-render when props haven't changed +const HostItem = React.memo(function HostItem({ host, isActive, onClick }: HostItemProps) { + return ( +
+ {/* ... */} +
+ ); +}, (prev, next) => { + return prev.host.id === next.host.id + && prev.isActive === next.isActive; +}); +``` + +### 3.4 useMemo for Expensive Computations + +```typescript +function HostList({ hosts, filter }: { hosts: Host[]; filter: string }) { + const filteredHosts = useMemo(() => { + if (!filter) return hosts; + return hosts.filter(h => + h.name.toLowerCase().includes(filter.toLowerCase()) || + h.hostname.toLowerCase().includes(filter.toLowerCase()) + ); + }, [hosts, filter]); + + return ; +} +``` + +### 3.5 useCallback for Event Handlers + +```typescript +function HostItem({ host, onSelect }: { host: Host; onSelect: (id: string) => void }) { + const handleClick = useCallback(() => { + onSelect(host.id); + }, [host.id, onSelect]); + + return
{/* ... */}
; +} +``` + +### 3.6 Zustand Selector Optimization + +```typescript +// Bad: re-renders on ANY store change +const hosts = useHostStore(state => state.hosts); + +// Good: only re-renders when hosts change +const hosts = useHostStore(state => state.hosts); + +// Good: only re-renders when selectedHost changes +const selectedId = useHostStore(state => state.selectedHost); + +// Good: granular selector +const hostName = useHostStore(state => { + const host = state.hosts.find(h => h.id === state.selectedHost); + return host?.name; +}); +``` + +--- + +## 4. WebSocket Stability + +### 4.1 Keepalive + +```typescript +// Client side (React) +class TerminalWebSocket { + private ws: WebSocket; + private pingInterval: NodeJS.Timeout; + + connect(url: string) { + this.ws = new WebSocket(url); + + // Send ping every 30 seconds + this.pingInterval = setInterval(() => { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(new Uint8Array([0])); // ping frame + } + }, 30000); + } + + disconnect() { + clearInterval(this.pingInterval); + this.ws.close(1000, 'Client closing'); + } +} +``` + +```go +// Server side (Go) +func handleTerminal(c *websocket.Conn) { + // Set pong handler + c.SetPongHandler(func(string) error { + c.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + // Read loop (detects disconnect) + for { + _, _, err := c.ReadMessage() + if err != nil { + break + } + } +} +``` + +### 4.2 Auto-Reconnect + +```typescript +// app/frontend/src/hooks/useTerminalReconnect.ts +function useTerminalReconnect(ws: TerminalWebSocket) { + const [reconnectAttempts, setReconnectAttempts] = useState(0); + const maxReconnectAttempts = 5; + + useEffect(() => { + if (ws.status === 'disconnected' && reconnectAttempts < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); + + const timer = setTimeout(() => { + setReconnectAttempts(prev => prev + 1); + ws.reconnect(); + }, delay); + + return () => clearTimeout(timer); + } + }, [ws.status, reconnectAttempts]); + + return { reconnectAttempts, maxReconnectAttempts }; +} +``` + +### 4.3 Error Boundaries + +```typescript +// app/frontend/src/components/ErrorBoundary.tsx +class ErrorBoundary extends React.Component { + state = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error('Terminal error:', error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+ +

Something went wrong

+ +
+ ); + } + return this.props.children; + } +} +``` + +--- + +## 5. Memory Management + +### 5.1 Terminal Cleanup + +```typescript +function XTermWrapper({ tabId }: { tabId: string }) { + useEffect(() => { + const term = new Terminal({ + scrollback: 10000, // Limit scrollback buffer + }); + + return () => { + term.dispose(); // Free memory on unmount + }; + }, []); +} +``` + +### 5.2 WebSocket Cleanup + +```typescript +useEffect(() => { + const ws = new WebSocket(url); + + return () => { + ws.close(); // Close on unmount + }; +}, [hostId]); +``` + +### 5.3 Image/Icon Lazy Loading + +```typescript +// Only load icons when needed +const Icon = lazy(() => import('./Icon')); + +// Or use intersection observer +function LazyIcon({ name }: { name: string }) { + const [loaded, setLoaded] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + setLoaded(true); + observer.disconnect(); + } + }); + + if (ref.current) observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + + return
{loaded && }
; +} +``` + +--- + +## 6. Crash Recovery + +### 6.1 Go Panic Recovery + +```go +// Already built into GoFiber +app.Use(recover.New()) + +// Custom panic handler +app.Use(recover.New(recover.Config{ + Handler: func(c *fiber.Ctx, err error) { + log.Printf("PANIC: %v\n%s", err, debug.Stack()) + c.Status(500).JSON(fiber.Map{ + "error": "Internal server error", + "code": "PANIC", + }) + }, +})) +``` + +### 6.2 Electron Crash Reporter + +```typescript +// app/electron/main.ts +import { crashReporter } from 'electron'; + +crashReporter.start({ + productName: 'Hostkeeper', + submitURL: '', // No remote reporting (privacy) + uploadToServer: false, + compress: true, +}); +``` + +### 6.3 State Persistence + +```typescript +// Save critical state to localStorage +window.addEventListener('beforeunload', () => { + const state = { + sidebarWidth: useUIStore.getState().sidebarWidth, + activeTab: useTabStore.getState().activeTab, + theme: useUIStore.getState().theme, + }; + localStorage.setItem('hostkeeper-ui-state', JSON.stringify(state)); +}); + +// Restore on load +const savedState = localStorage.getItem('hostkeeper-ui-state'); +if (savedState) { + const state = JSON.parse(savedState); + useUIStore.getState().setSidebarWidth(state.sidebarWidth); + useUIStore.getState().setTheme(state.theme); +} +``` + +--- + +## 7. Logging + +### 7.1 Structured Logging (Go) + +```go +import "github.com/gofiber/fiber/v2/log" + +// Request logging (built into GoFiber) +app.Use(logger.New(logger.Config{ + Format: "${time} ${method} ${path} ${status} ${latency}\n", + Done: func(c *fiber.Ctx, log string) { + // Custom log processing + if c.Response().StatusCode() >= 500 { + log.Error(log) + } + }, +})) + +// Application logging +log.Info("Server started on port", port) +log.Warn("Connection pool full") +log.Error("SSH connection failed", err) +``` + +### 7.2 Frontend Logging + +```typescript +// Only log in development +const logger = { + info: (...args: any[]) => { + if (import.meta.env.DEV) console.log('[INFO]', ...args); + }, + warn: (...args: any[]) => { + if (import.meta.env.DEV) console.warn('[WARN]', ...args); + }, + error: (...args: any[]) => { + console.error('[ERROR]', ...args); // Always log errors + }, +}; +``` + +--- + +## 8. Monitoring + +### 8.1 Health Check Endpoint + +```go +app.Get("/api/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "version": version, + "uptime": time.Since(startTime).Seconds(), + "connections": sessionPool.ActiveCount(), + "memory": getMemoryUsage(), + }) +}) +``` + +### 8.2 Metrics (Optional) + +```go +// Simple metrics middleware +var ( + requestCount = prometheus.NewCounter("requests_total") + requestLatency = prometheus.NewHistogram("request_latency_seconds") +) + +app.Use(func(c *fiber.Ctx) error { + start := time.Now() + err := c.Next() + duration := time.Since(start).Seconds() + + requestCount.Inc() + requestLatency.Observe(duration) + + return err +}) +``` + +--- + +## 9. Testing for Performance + +### 9.1 Load Testing (Go) + +```bash +# Install hey (HTTP load testing) +go install github.com/rakyll/hey@latest + +# Test API endpoints +hey -n 1000 -c 50 http://localhost:8080/api/hosts +hey -n 1000 -c 50 http://localhost:8080/api/keys +hey -n 1000 -c 50 http://localhost:8080/api/snippets +``` + +### 9.2 Terminal Latency Test + +```typescript +// Measure keystroke to display latency +function measureTerminalLatency(term: Terminal): number { + const start = performance.now(); + term.write('x'); // Write character + // Measure time until character is rendered + return performance.now() - start; +} +``` + +### 9.3 Memory Profiling + +```go +import "runtime" + +// Log memory usage +var memStats runtime.MemStats +runtime.ReadMemStats(&memStats) +log.Printf("Memory: %d MB allocated, %d MB sys", + memStats.Alloc/1024/1024, + memStats.Sys/1024/1024, +) +``` + +--- + +## 10. Common Performance Pitfalls + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Memory leak in terminals | Memory grows over time | Dispose terminals on unmount | +| WebSocket not closing | Connections pile up | Close on component unmount | +| Large JSON responses | Slow API | Pagination, field filtering | +| No connection pooling | Slow SSH reconnect | Use SessionPool | +| Synchronous SFTP | UI freezes | Use goroutines + channels | +| Re-render storms | Jank, low FPS | React.memo, useMemo, Zustand selectors | +| No code splitting | Slow initial load | Lazy load screens | +| Oversized bundles | Slow startup | Tree shaking, purging | diff --git a/docs/v2/PROGRESS.md b/docs/v2/PROGRESS.md new file mode 100644 index 0000000..51bbad3 --- /dev/null +++ b/docs/v2/PROGRESS.md @@ -0,0 +1,339 @@ +# Hostkeeper V2 — Progress Tracker + +> **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions. +> **How to use**: Read this file first. Find the first unchecked `[ ]` item. That's where you continue. +> **Last Updated**: 2026-06-29 +> **Status**: Planning complete, implementation not started. + +--- + +## 1. Overview + +Hostkeeper V2 is a Termius-like cross-platform SSH/SFTP management GUI application. + +| Item | Value | +|------|-------| +| **Goal** | Build a free, open-source Termius alternative with full feature parity | +| **Desktop** | GoFiber backend + React/xterm.js UI + Electron wrapper | +| **Mobile** | Go mobile library + WebView wrapper (Android/iOS) | +| **Backend language** | Go 1.26 | +| **HTTP framework** | GoFiber v2 (fasthttp-based) | +| **Frontend** | React 19, TypeScript, xterm.js, Zustand, Vite | +| **Desktop wrapper** | Electron (electron-builder) | +| **Mobile wrapper** | gomobile + WebView | +| **V1 (existing)** | CLI/TUI SSH/SFTP manager — 105 tests, all passing. Frozen. | + +--- + +## 2. Current Status Summary + +| Item | Status | +|------|--------| +| Planning documents | DONE | +| Sprint 0 — Scaffolding | NOT STARTED | +| Sprint 1 — Foundation | NOT STARTED | +| Sprint 2 — Core Features | NOT STARTED | +| Sprint 3 — SFTP + Polish | NOT STARTED | +| Sprint 4 — Advanced | NOT STARTED | +| Sprint 5 — Platform Polish | NOT STARTED | + +--- + +## 3. Sprint Checklist + +### Sprint 0 — Project Scaffolding (Day 0) +- [ ] Create `app/backend/` directory with Go module (`go mod init`) +- [ ] Install GoFiber v2 + dependencies in `go.mod` +- [ ] Create `app/backend/main.go` — minimal fiber server (port 0 for random) +- [ ] Create `app/frontend/` with Vite + React + TypeScript +- [ ] Create `app/electron/` with Electron shell +- [ ] Electron spawns Go backend as child process on startup +- [ ] Electron loads web UI from localhost (Go serve frontend dist) +- [ ] Create `app/backend/middleware/` — CORS, logging, recover, compress +- [ ] Create `app/backend/bridge/` directory (placeholder handlers) +- [ ] Create `app/backend/ws/` directory (placeholder handlers) +- [ ] Build script: `scripts/build.sh` — Go build → frontend build → Electron package +- [ ] Verify: `npm run dev` opens Electron window with "Hostkeeper V2" title +- [ ] Verify: Go server responds at `localhost:PORT/api/health` + +### Sprint 1 — Foundation (Day 1-5) + +#### Day 1-2: Data API Layer +- [ ] `app/backend/bridge/hosts.go` — GET /api/hosts (list, filter, search) +- [ ] `app/backend/bridge/hosts.go` — POST /api/hosts (create) +- [ ] `app/backend/bridge/hosts.go` — GET /api/hosts/:id (detail) +- [ ] `app/backend/bridge/hosts.go` — PUT /api/hosts/:id (update) +- [ ] `app/backend/bridge/hosts.go` — DELETE /api/hosts/:id (delete) +- [ ] `app/backend/bridge/hosts.go` — PATCH /api/hosts/:id/favorite (toggle) +- [ ] `app/backend/bridge/keys.go` — GET /api/keys (list, no private key) +- [ ] `app/backend/bridge/keys.go` — POST /api/keys (import) +- [ ] `app/backend/bridge/keys.go` — POST /api/keys/generate (generate new) +- [ ] `app/backend/bridge/keys.go` — DELETE /api/keys/:id (delete) +- [ ] `app/backend/bridge/snippets.go` — GET /api/snippets (list) +- [ ] `app/backend/bridge/snippets.go` — POST /api/snippets (create) +- [ ] `app/backend/bridge/snippets.go` — PUT /api/snippets/:id (update) +- [ ] `app/backend/bridge/snippets.go` — DELETE /api/snippets/:id (delete) +- [ ] JSON error responses: `{ "error": "message" }` with proper HTTP codes +- [ ] Verify: `curl -X POST http://localhost:PORT/api/hosts` returns 201 + +#### Day 3: Groups + Config API +- [ ] `app/backend/bridge/groups.go` — GET /api/groups (tree structure) +- [ ] `app/backend/bridge/groups.go` — POST /api/groups (create) +- [ ] `app/backend/bridge/groups.go` — PUT /api/groups/:id (update) +- [ ] `app/backend/bridge/groups.go` — DELETE /api/groups/:id (delete) +- [ ] `app/backend/bridge/groups.go` — PUT /api/groups/:id/move (reparent) +- [ ] `app/backend/bridge/config.go` — GET /api/config (app config) +- [ ] `app/backend/bridge/config.go` — PUT /api/config (update config) +- [ ] `app/backend/bridge/config.go` — POST /api/config/unlock (vault unlock) +- [ ] `app/backend/bridge/config.go` — POST /api/config/lock (vault lock) +- [ ] `app/backend/bridge/config.go` — GET /api/config/status (vault status) +- [ ] Verify: Full CRUD cycle via curl for all endpoints + +#### Day 4: Web UI Scaffold +- [ ] React Router setup — routes for all screens (placeholder) +- [ ] Layout component — Sidebar + TabBar + MainContent + StatusBar +- [ ] Sidebar component — GroupTree (recursive), SearchBar, QuickActions +- [ ] Theme system — CSS variables (dark, light, high-contrast) +- [ ] API client layer — fetch wrapper + error handling + retry logic +- [ ] State management — Zustand stores (hosts, keys, snippets, ui, terminal) +- [ ] Keyboard shortcuts — Cmd+N new host, Cmd+K search, Cmd+D dark mode +- [ ] Verify: Navigation between screens works + +#### Day 5: Host List Screen +- [ ] HostList screen — HostCard components (grid + list view toggle) +- [ ] GroupTree — collapsible, recursive, drag-and-drop (host -> group) +- [ ] Search — filter by name, hostname, tag +- [ ] Tag filter bar — chips for each tag, click to filter +- [ ] Favorite toggle — star icon, filter favorites only +- [ ] Context menu — right-click: connect, edit, delete, duplicate +- [ ] Empty state — illustration + "Add your first host" CTA +- [ ] Loading skeleton states +- [ ] Verify: Host appears in list, can search, can filter, can favorite + +### Sprint 2 — Core Features (Day 6-10) + +#### Day 6-7: Host Form + CRUD UI +- [ ] HostForm screen — add mode + edit mode +- [ ] Basic info fields — name, hostname, port, username +- [ ] Auth section — type selector (password/key/agent) +- [ ] Password field — show/hide toggle, strength indicator +- [ ] Key picker — dropdown from stored keys +- [ ] TagsInput — type + Enter to add, backspace to remove, autocomplete +- [ ] GroupPicker — tree picker component +- [ ] Advanced settings — proxy, jump host, keepalive interval +- [ ] Validation — required fields, hostname regex, port range 1-65535 +- [ ] Error display — inline validation + toast notifications +- [ ] Keyboard shortcuts — Cmd+S save, Esc cancel, Tab next field +- [ ] Verify: Create host -> appears in list -> edit -> update -> delete + +#### Day 8-10: SSH Terminal (MOST CRITICAL) +- [ ] **Go: WebSocket handler** `app/backend/ws/terminal.go` + - [ ] Accept WS connection with host_id + cols + rows query params + - [ ] Lookup host from storage by ID + - [ ] Create `ssh.Client` using `pkg/ssh` (reuse existing) + - [ ] Request PTY with xterm-256color + - [ ] Stream SSH stdout -> WS binary frames (`c.WriteMessage`) + - [ ] Receive WS frames -> SSH stdin (`session.Stdin.Write`) + - [ ] Handle resize messages — JSON control frames + - [ ] Handle disconnect gracefully + - [ ] Buffer management (channel-based backpressure) + - [ ] Configurable idle timeout (default 300s) + - [ ] Connection pool (reuse SSH connections for same host) +- [ ] **React: XTermWrapper component** + - [ ] xterm.js instance initialization with addons (fit, web-links, search) + - [ ] WebSocket connection management + - [ ] Keyboard input -> WS send (binary frames) + - [ ] ResizeObserver -> WS send resize message + - [ ] Fit addon — auto fit to container + - [ ] Web links addon — clickable URLs + - [ ] Search addon — Ctrl+F search in terminal + - [ ] Copy/paste — Cmd+C/V, right-click menu + - [ ] Connection status indicator (connecting, connected, disconnected, error) + - [ ] Reconnect button on disconnect +- [ ] **React: TabBar + Tab management** + - [ ] Add tab -> connect to host + - [ ] Close tab -> disconnect SSH session + - [ ] Reorder tabs via drag + - [ ] Tab status dot (green=connected, red=error, gray=disconnected) + - [ ] Tab title = host name + - [ ] Max 20 tabs limit with warning +- [ ] **Snippet panel** (slide-in from right) + - [ ] List snippets for current host + - [ ] Click snippet -> inject command to terminal + - [ ] Edit/delete from panel +- [ ] Verify: Connect to host -> terminal works -> type commands -> disconnect -> reconnect + +### Sprint 3 — SFTP + Polish (Day 11-15) + +#### Day 11-12: SFTP Browser +- [ ] **Go: SFTP REST handlers** `app/backend/bridge/sftp.go` + - [ ] GET /api/sftp/ls — list directory (host_id, path) + - [ ] POST /api/sftp/upload — multipart upload with progress + - [ ] GET /api/sftp/download — file download stream + - [ ] POST /api/sftp/mkdir — create directory + - [ ] POST /api/sftp/rm — delete file/directory + - [ ] POST /api/sftp/rename — rename/move + - [ ] POST /api/sftp/chmod — change permissions +- [ ] **React: SFTPScreen** + - [ ] LocalPane + RemotePane (dual pane split view) + - [ ] File list table — name, size, date, permissions (sortable) + - [ ] Navigation — cd, back, forward, path breadcrumb + - [ ] Upload/download with progress bars + - [ ] Transfer queue — multiple files, cancel, retry + - [ ] Drag & drop from desktop to upload + - [ ] Context menu — download, delete, rename, chmod, mkdir + - [ ] Keyboard shortcuts — Delete, F2 rename, Ctrl+C/V copy/move +- [ ] Verify: Browse remote files -> upload -> download -> delete -> rename + +#### Day 13: Security + Vault +- [ ] Vault unlock screen — password prompt on app start +- [ ] Lock/unlock flow — auto-lock after inactivity (configurable: 1/5/15/30 min) +- [ ] Password change screen +- [ ] Known hosts dialog — first connect show fingerprint -> accept/reject +- [ ] Key passphrase prompt — when using encrypted private key +- [ ] Verify: Start app -> vault locked -> enter password -> unlock -> auto-lock + +#### Day 14: Settings Screen +- [ ] Appearance tab — theme switcher, font selection, font size, background opacity +- [ ] Terminal tab — scrollback (100-10000), cursor style, cursor blink, bell, copy-on-select +- [ ] Connection tab — default timeout, keepalive interval, auto-reconnect +- [ ] Vault tab — change password, auto-lock timer, lock on sleep +- [ ] General tab — data directory, export/import buttons, about/version +- [ ] Verify: Change settings -> restart -> settings persist + +#### Day 15: Mobile Setup +- [ ] React Native project with WebView +- [ ] Go mobile compilation script (gomobile bind) +- [ ] Android minimal app — WebView + gomobile .aar +- [ ] iOS minimal app — WKWebView + gomobile .xcframework +- [ ] Responsive CSS for mobile (touch-friendly targets) +- [ ] Verify: App runs on Android emulator + +### Sprint 4 — Advanced Features (Day 16-20) + +#### Day 16-17: Port Forwarding +- [ ] Model PortForward in Go + storage +- [ ] Go: Local forwarding — ssh.Listen -> local listener -> tunnel +- [ ] Go: Remote forwarding — ssh.Request remote forward +- [ ] Go: Dynamic forwarding — SOCKS5 proxy via SSH +- [ ] Status tracking — running/stopped/error +- [ ] React: PortForwardScreen — list, add form, start/stop toggles +- [ ] Verify: Set local forward -> `curl localhost:PORT` -> hits remote + +#### Day 18: Import/Export +- [ ] Go: Import from `~/.ssh/config` — parse host, hostname, port, user, identity +- [ ] Go: Import from CSV — columns: name, hostname, port, username, group, tags +- [ ] Go: Export all data — encrypted JSON with all entities +- [ ] React: Import/Export UI — drag & drop, progress, preview, confirm +- [ ] Verify: Export -> delete all -> import -> all data restored + +#### Day 19-20: Jump Hosts + Proxy +- [ ] Go: Jump host chain — SSH via intermediate hosts +- [ ] Go: Multi-hop support — host -> jump1 -> jump2 -> target +- [ ] Go: SOCKS5 proxy before SSH +- [ ] Go: HTTP CONNECT proxy +- [ ] React: Advanced connection settings — jump host selector, proxy config +- [ ] Verify: Connect via jump host -> terminal works + +### Sprint 5 — Platform Polish (Day 21-25) + +#### Day 21-22: Desktop Enhancements +- [ ] Electron native menu bar — File, Edit, View, Window, Help +- [ ] System tray icon — quick actions, recent hosts +- [ ] Global keyboard shortcuts — Cmd+` toggle, Cmd+N new host +- [ ] Window state persistence — position, size, maximized +- [ ] Auto-update via electron-updater +- [ ] OS Keychain integration — macOS Keychain, Windows Credential Manager, Linux Secret Service +- [ ] Verify: Install .dmg -> app appears -> open -> works + +#### Day 23: Performance Optimization +- [ ] Go: Connection pooling for SSH sessions +- [ ] Go: Memory optimization — buffer pools, goroutine limits +- [ ] WebSocket: permessage-deflate compression +- [ ] React: Virtual scrolling for host list (1000+ hosts) +- [ ] React: Lazy loading screens (code splitting) +- [ ] React: Memoization (React.memo, useMemo, useCallback) +- [ ] xterm.js: WebGL renderer (preferred) with Canvas fallback +- [ ] Verify: 1000 hosts in list -> smooth scrolling -> no lag + +#### Day 24-25: Mobile Polish +- [ ] Android: gomobile bind -> .aar +- [ ] Android: WebView app with touch-optimized UI +- [ ] iOS: gomobile bind -> .xcframework +- [ ] iOS: WKWebView app +- [ ] Mobile: Custom keyboard toolbar (Tab, Ctrl, Esc, arrows) +- [ ] Mobile: Gesture navigation (swipe to switch tabs) +- [ ] Mobile: Landscape/portrait handling +- [ ] Mobile: Bluetooth keyboard support +- [ ] Mobile: Background connection handling +- [ ] Verify: Install APK on Android -> connect SSH -> terminal works + +--- + +## 4. Known Issues + +| # | Issue | Severity | Status | +|---|-------|----------|--------| +| (none) | — | — | — | + +--- + +## 5. Last Session Notes + +| Date | What was done | Commits | +|------|---------------|---------| +| 2026-06-29 | V2 planning documents created (9 files) | — | + +--- + +## 6. How to Continue + +### For a new AI agent: + +1. **Read this file** (`docs/v2/PROGRESS.md`) — find the first `[ ]` item +2. **Read the relevant spec doc** — for the current task: + - REST API endpoints -> `docs/v2/API.md` + - UI components -> `docs/v2/UI_COMPONENTS.md` + - Data models -> `docs/v2/DATA_MODELS.md` + - Visual reference -> `docs/v2/UI_TERMIUS_REFERENCE.md` + - Performance notes -> `docs/v2/PERFORMANCE.md` + - Build pipeline -> `docs/v2/BUILD_SYSTEM.md` +3. **Read `docs/v2/ARCHITECTURE.md`** — understand the system +4. **Implement** in `app/backend/` (Go) or `app/frontend/` (React) +5. **Test** — run `go test ./app/backend/...` and `npm test` in `app/frontend/` +6. **Update this file** — mark `[x]` the completed task, update "Last Session Notes" + +### For a returning AI agent: + +1. **Read this file** — check "Last Session Notes" for context +2. **Check git log** — `git log --oneline -10` for recent commits +3. **Find the first `[ ]`** — that's where you continue +4. **Update this file** when done + +--- + +## 7. Reference Documents Index + +| Document | When to read | +|----------|-------------| +| `docs/v2/PROGRESS.md` | Always (first) — know where to continue | +| `docs/v2/ARCHITECTURE.md` | Understanding the system | +| `docs/v2/API.md` | Implementing REST endpoints or WebSocket handlers | +| `docs/v2/DATA_MODELS.md` | Creating/modifying Go structs or JSON schemas | +| `docs/v2/UI_COMPONENTS.md` | Building React components | +| `docs/v2/UI_TERMIUS_REFERENCE.md` | Designing UI layout, colors, typography | +| `docs/v2/SPRINT_PLAN.md` | Detailed file-by-file task instructions | +| `docs/v2/BUILD_SYSTEM.md` | Setting up build pipeline | +| `docs/v2/PERFORMANCE.md` | Tuning for speed and stability | + +--- + +## 8. Git History Reference + +| Commit | Description | +|--------|-------------| +| (V2 commits will be listed here) | — | + +--- + +**IMPORTANT**: This file MUST be updated after every development session to ensure continuity. diff --git a/docs/v2/SPRINT_PLAN.md b/docs/v2/SPRINT_PLAN.md new file mode 100644 index 0000000..f036152 --- /dev/null +++ b/docs/v2/SPRINT_PLAN.md @@ -0,0 +1,529 @@ +# Hostkeeper V2 — Sprint Plan (File-by-File Task Breakdown) + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 +> **Framework**: GoFiber v2 (backend), React/TypeScript (frontend), Electron (desktop) + +--- + +## Sprint 0 — Project Scaffolding + +**Goal**: Empty Electron app with GoFiber backend serving "Hello World". + +### Day 0: Setup (1 day) + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 0.1 | Go module for backend | `app/backend/go.mod` | `go mod init` runs clean | +| 0.2 | Install GoFiber + deps | `app/backend/go.mod` | `go get github.com/gofiber/fiber/v2` | +| 0.3 | Minimal fiber server | `app/backend/main.go` | Server starts on random port | +| 0.4 | Health endpoint | `app/backend/main.go` | `curl localhost:PORT/api/health` returns JSON | +| 0.5 | Middleware setup | `app/backend/middleware/middleware.go` | CORS, logger, recover, compress registered | +| 0.6 | Vite + React scaffold | `app/frontend/package.json`, `app/frontend/src/App.tsx` | `npm run dev` shows React app | +| 0.7 | Electron shell | `app/electron/main.ts`, `app/electron/preload.ts`, `app/electron/package.json` | `npx electron .` opens window | +| 0.8 | Electron spawns Go | `app/electron/main.ts` | Go process starts, API responds | +| 0.9 | Electron loads frontend | `app/electron/main.ts` | Window shows React app | +| 0.10 | Bridge placeholder dirs | `app/backend/bridge/`, `app/backend/ws/` | Directories exist | +| 0.11 | Build script | `scripts/build.sh` | Script runs without error | +| 0.12 | End-to-end verify | — | Electron window shows "Hostkeeper V2" title | + +#### Detailed Files + +**`app/backend/go.mod`**: +```go +module git.tukangketik.id/swanadiva/hostkeeper/app/backend + +go 1.26 + +require ( + github.com/gofiber/fiber/v2 v2.52.5 + github.com/gofiber/contrib/websocket v1.3.3 + github.com/fasthttp/websocket v1.5.12 +) +``` + +**`app/backend/main.go`**: +```go +package main + +import ( + "fmt" + "log" + "math/rand" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" +) + +func main() { + port := rand.Intn(60000) + 10000 // random port 10000-70000 + + app := fiber.New(fiber.Config{ + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + }) + + // Middleware + app.Use(recover.New()) + app.Use(logger.New()) + app.Use(compress.New(compress.Config{ + Level: compress.LevelBestSpeed, + })) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS", + AllowHeaders: "Origin,Content-Type,Authorization", + })) + + // Health check + app.Get("/api/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "version": "2.0.0", + }) + }) + + // Start server + go func() { + fmt.Printf("HOSTKEEPER_PORT=%d\n", port) + log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) + }() + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + app.Shutdown() +} +``` + +**`app/electron/main.ts`**: +```typescript +import { app, BrowserWindow } from 'electron'; +import { spawn, ChildProcess } from 'child_process'; +import * as path from 'path'; + +let mainWindow: BrowserWindow; +let goProcess: ChildProcess; + +function startGoBackend() { + const goBinary = path.join(__dirname, '../../backend/hostkeeper-server'); + goProcess = spawn(goBinary, [], { stdio: 'pipe' }); + + goProcess.stdout?.on('data', (data) => { + const output = data.toString(); + const match = output.match(/HOSTKEEPER_PORT=(\d+)/); + if (match) { + const port = match[1]; + mainWindow.loadURL(`http://localhost:${port}`); + } + }); +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + title: 'Hostkeeper V2', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + }, + }); + + // Load from Go backend (or dev server in development) + if (process.env.NODE_ENV === 'development') { + mainWindow.loadURL('http://localhost:5173'); + } else { + startGoBackend(); + } +} + +app.whenReady().then(createWindow); +app.on('window-all-closed', () => { + goProcess?.kill(); + app.quit(); +}); +``` + +**`scripts/build.sh`**: +```bash +#!/bin/bash +set -e + +echo "=== Building Hostkeeper V2 ===" + +# Build Go backend +echo "Building Go backend..." +cd app/backend +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o ../electron/hostkeeper-server . +cd ../.. + +# Build frontend +echo "Building frontend..." +cd app/frontend +npm run build +cd ../.. + +# Package Electron +echo "Packaging Electron..." +cd app/electron +npx electron-builder --mac --arm64 +cd ../.. + +echo "=== Build complete ===" +``` + +--- + +## Sprint 1 — Foundation (Day 1-5) + +**Goal**: Full CRUD for hosts/keys/snippets + web UI with host list. + +### Day 1-2: Data API Layer + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 1.1 | Host CRUD handlers | `app/backend/bridge/hosts.go` | All 6 endpoints work via curl | +| 1.2 | Key CRUD handlers | `app/backend/bridge/keys.go` | All 4 endpoints work via curl | +| 1.3 | Snippet CRUD handlers | `app/backend/bridge/snippets.go` | All 4 endpoints work via curl | +| 1.4 | Storage integration | Import `pkg/storage` | Read/write actual JSON files | +| 1.5 | Error responses | `app/backend/bridge/errors.go` | Consistent error format | +| 1.6 | Route registration | `app/backend/main.go` | All routes registered | + +**Example handler** (`bridge/hosts.go`): +```go +func ListHosts(c *fiber.Ctx) error { + hosts, err := storage.ListHosts(c.Context()) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"items": hosts, "total": len(hosts)}) +} +``` + +### Day 3: Groups + Config API + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 1.7 | Group CRUD handlers | `app/backend/bridge/groups.go` | Tree structure returned | +| 1.8 | Config handlers | `app/backend/bridge/config.go` | Get/set config works | +| 1.9 | Vault handlers | `app/backend/bridge/vault.go` | Lock/unlock/unlock status | +| 1.10 | Group tree builder | `app/backend/bridge/groups.go` | Recursive tree populated | + +### Day 4: Web UI Scaffold + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 1.11 | React Router | `app/frontend/src/router.tsx` | Navigate between screens | +| 1.12 | Layout component | `app/frontend/src/components/Layout.tsx` | Sidebar + tab bar + content | +| 1.13 | Sidebar component | `app/frontend/src/components/Sidebar.tsx` | Group tree + search | +| 1.14 | Theme system | `app/frontend/src/styles/themes.css` | CSS variables for dark/light | +| 1.15 | API client | `app/frontend/src/api/client.ts` | Fetch wrapper with errors | +| 1.16 | Zustand stores | `app/frontend/src/stores/*.ts` | Host, tab, vault stores | +| 1.17 | Keyboard shortcuts | `app/frontend/src/hooks/useKeyboard.ts` | Cmd+N, Cmd+K work | + +### Day 5: Host List Screen + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 1.18 | HostList screen | `app/frontend/src/screens/HostListScreen.tsx` | Hosts displayed | +| 1.19 | HostCard component | `app/frontend/src/components/HostCard.tsx` | Host info shown | +| 1.20 | GroupTree component | `app/frontend/src/components/GroupTree.tsx` | Collapsible tree | +| 1.21 | SearchBar component | `app/frontend/src/components/SearchBar.tsx` | Filter works | +| 1.22 | EmptyState component | `app/frontend/src/components/EmptyState.tsx` | Shown when no hosts | +| 1.23 | Context menu | `app/frontend/src/components/ContextMenu.tsx` | Right-click menu | + +--- + +## Sprint 2 — Core Features (Day 6-10) + +**Goal**: Host form + SSH terminal (the critical feature). + +### Day 6-7: Host Form + CRUD UI + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 2.1 | HostForm screen | `app/frontend/src/screens/HostFormScreen.tsx` | Add/edit host works | +| 2.2 | AuthSection component | `app/frontend/src/components/AuthSection.tsx` | Password/key selector | +| 2.3 | TagsInput component | `app/frontend/src/components/TagsInput.tsx` | Add/remove tags | +| 2.4 | GroupPicker component | `app/frontend/src/components/GroupPicker.tsx` | Tree picker | +| 2.5 | Form validation | `app/frontend/src/utils/validation.ts` | All field validation | +| 2.6 | Toast notifications | `app/frontend/src/components/Toast.tsx` | Success/error toasts | + +### Day 8-10: SSH Terminal (MOST CRITICAL) + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 2.7 | WebSocket handler | `app/backend/ws/terminal.go` | WS connection accepted | +| 2.8 | SSH session management | `app/backend/ws/session.go` | Connect, stream, disconnect | +| 2.9 | Terminal resize | `app/backend/ws/terminal.go` | Resize messages handled | +| 2.10 | XTermWrapper component | `app/frontend/src/components/XTermWrapper.tsx` | Terminal renders | +| 2.11 | TerminalScreen | `app/frontend/src/screens/TerminalScreen.tsx` | Full terminal view | +| 2.12 | TabBar component | `app/frontend/src/components/TabBar.tsx` | Multi-tab support | +| 2.13 | Tab management | `app/frontend/src/stores/tabStore.ts` | Add/close/switch tabs | +| 2.14 | SnippetPanel | `app/frontend/src/components/SnippetPanel.tsx` | Slide-in snippet list | +| 2.15 | Connection overlay | `app/frontend/src/components/ConnectionOverlay.tsx` | Status + reconnect | +| 2.16 | Terminal toolbar | `app/frontend/src/components/TerminalToolbar.tsx` | Host info + actions | + +**WebSocket handler** (`ws/terminal.go`): +```go +package ws + +import ( + "encoding/json" + "log" + "time" + + "github.com/gofiber/contrib/websocket" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh" +) + +func HandleTerminal(c *websocket.Conn) { + hostID := c.Query("host_id") + cols := c.QueryInt("cols", 80) + rows := c.QueryInt("rows", 24) + + // Get host from storage + host, err := storage.GetHost(c.Context(), hostID) + if err != nil { + c.WriteJSON(fiber.Map{"type": "error", "message": "Host not found"}) + return + } + + // Create SSH client + client := ssh.NewClient(host, 30*time.Second) + if err := client.Connect(c.Context()); err != nil { + c.WriteJSON(fiber.Map{"type": "error", "message": err.Error()}) + return + } + defer client.Close() + + // Create session + PTY + session, err := client.GetClient().NewSession() + if err != nil { + c.WriteJSON(fiber.Map{"type": "error", "message": "Session failed"}) + return + } + defer session.Close() + + modes := ssh.TerminalModes{ssh.ECHO: 1} + session.RequestPty("xterm-256color", rows, cols, modes) + + stdin, _ := session.StdinPipe() + stdout, _ := session.StdoutPipe() + + if err := session.Shell(); err != nil { + c.WriteJSON(fiber.Map{"type": "error", "message": "Shell failed"}) + return + } + + c.WriteJSON(fiber.Map{"type": "status", "state": "connected"}) + + // Goroutine: SSH stdout -> WebSocket + go func() { + buf := make([]byte, 4096) + for { + n, err := stdout.Read(buf) + if err != nil { + break + } + if err := c.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + break + } + } + }() + + // Main loop: WebSocket -> SSH stdin + for { + _, msg, err := c.ReadMessage() + if err != nil { + break + } + + // Check for resize message + if len(msg) > 0 && msg[0] == '{' { + var ctrl struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` + } + if json.Unmarshal(msg, &ctrl) == nil && ctrl.Type == "resize" { + session.WindowChange(ctrl.Rows, ctrl.Cols) + continue + } + } + + // Regular input + stdin.Write(msg) + } +} +``` + +--- + +## Sprint 3 — SFTP + Polish (Day 11-15) + +**Goal**: SFTP browser + security + settings. + +### Day 11-12: SFTP Browser + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 3.1 | SFTP REST handlers | `app/backend/bridge/sftp.go` | All 7 endpoints work | +| 3.2 | SFTPScreen | `app/frontend/src/screens/SFTPScreen.tsx` | Dual pane visible | +| 3.3 | FileList component | `app/frontend/src/components/FileList.tsx` | Files listed with columns | +| 3.4 | LocalPane component | `app/frontend/src/components/LocalPane.tsx` | Local files | +| 3.5 | RemotePane component | `app/frontend/src/components/RemotePane.tsx` | Remote files | +| 3.6 | TransferQueue | `app/frontend/src/components/TransferQueue.tsx` | Transfer progress | +| 3.7 | SFTPToolbar | `app/frontend/src/components/SFTPToolbar.tsx` | Upload/download buttons | +| 3.8 | Context menu | `app/frontend/src/components/SFTPContextMenu.tsx` | File operations | + +### Day 13: Security + Vault + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 3.9 | VaultScreen | `app/frontend/src/screens/VaultScreen.tsx` | Password prompt | +| 3.10 | KnownHostDialog | `app/frontend/src/components/KnownHostDialog.tsx` | Fingerprint verify | +| 3.11 | PasswordPrompt | `app/frontend/src/components/PasswordPrompt.tsx` | Reusable prompt | +| 3.12 | Auto-lock logic | `app/frontend/src/hooks/useAutoLock.ts` | Locks after inactivity | + +### Day 14: Settings Screen + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 3.13 | SettingsScreen | `app/frontend/src/screens/SettingsScreen.tsx` | Tabbed settings | +| 3.14 | AppearanceSettings | `app/frontend/src/components/settings/Appearance.tsx` | Theme, font | +| 3.15 | TerminalSettings | `app/frontend/src/components/settings/Terminal.tsx` | Scrollback, cursor | +| 3.16 | ConnectionSettings | `app/frontend/src/components/settings/Connection.tsx` | Timeout, keepalive | +| 3.17 | VaultSettings | `app/frontend/src/components/settings/Vault.tsx` | Password, auto-lock | +| 3.18 | GeneralSettings | `app/frontend/src/components/settings/General.tsx` | About, data dir | + +### Day 15: Mobile Setup + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 3.19 | gomobile build script | `mobile/gomobile/build.sh` | Compiles to .aar/.xcframework | +| 3.20 | Android WebView app | `mobile/android/` | App runs on emulator | +| 3.21 | iOS WebView app | `mobile/ios/` | App runs on simulator | +| 3.22 | Mobile responsive CSS | `app/frontend/src/styles/mobile.css` | Touch-friendly | + +--- + +## Sprint 4 — Advanced Features (Day 16-20) + +**Goal**: Port forwarding + import/export + jump hosts. + +### Day 16-17: Port Forwarding + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 4.1 | Forward model + storage | `app/backend/bridge/portforward.go` | CRUD works | +| 4.2 | Local forwarding | `app/backend/ws/portforward.go` | Local tunnel works | +| 4.3 | Remote forwarding | `app/backend/ws/portforward.go` | Remote tunnel works | +| 4.4 | Dynamic forwarding | `app/backend/ws/portforward.go` | SOCKS5 proxy works | +| 4.5 | PortForwardScreen | `app/frontend/src/screens/PortForwardScreen.tsx` | List + add form | +| 4.6 | ForwardCard component | `app/frontend/src/components/ForwardCard.tsx` | Status + controls | + +### Day 18: Import/Export + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 4.7 | SSH config parser | `app/backend/bridge/import.go` | Parses ~/.ssh/config | +| 4.8 | CSV import | `app/backend/bridge/import.go` | Parses CSV | +| 4.9 | Export handler | `app/backend/bridge/export.go` | Encrypted JSON export | +| 4.10 | ImportScreen | `app/frontend/src/screens/ImportScreen.tsx` | Drag & drop UI | + +### Day 19-20: Jump Hosts + Proxy + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 4.11 | Jump host chain | `app/backend/ws/jump.go` | Multi-hop SSH works | +| 4.12 | SOCKS5 proxy | `app/backend/ws/proxy.go` | Proxy before SSH | +| 4.13 | AdvancedHostForm | `app/frontend/src/components/AdvancedHostForm.tsx` | Jump host selector | + +--- + +## Sprint 5 — Platform Polish (Day 21-25) + +**Goal**: Desktop polish + performance + mobile. + +### Day 21-22: Desktop Enhancements + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 5.1 | Native menu bar | `app/electron/menu.ts` | File/Edit/View/Help | +| 5.2 | System tray | `app/electron/tray.ts` | Tray icon + menu | +| 5.3 | Global shortcuts | `app/electron/shortcuts.ts` | Cmd+`, Cmd+N | +| 5.4 | Window state | `app/electron/state.ts` | Position/size saved | +| 5.5 | Auto-update | `app/electron/updater.ts` | Update prompt | +| 5.6 | OS Keychain | `app/electron/keychain.ts` | Store vault key | + +### Day 23: Performance Optimization + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 5.7 | SSH connection pool | `app/backend/ws/pool.go` | Reuse connections | +| 5.8 | Buffer pools | `app/backend/ws/buffers.go` | sync.Pool for buffers | +| 5.9 | Virtual scrolling | `app/frontend/src/components/VirtualList.tsx` | 1000+ hosts smooth | +| 5.10 | Code splitting | `app/frontend/src/router.tsx` | Lazy load screens | +| 5.11 | React.memo | Various components | No unnecessary re-renders | +| 5.12 | WebGL renderer | `app/frontend/src/components/XTermWrapper.tsx` | Fast terminal render | + +### Day 24-25: Mobile Polish + +| # | Task | Files to Create | Verify | +|---|------|----------------|--------| +| 5.13 | Android gomobile | `mobile/android/` | .aar compiled | +| 5.14 | iOS gomobile | `mobile/ios/` | .xcframework compiled | +| 5.15 | Mobile keyboard | `app/frontend/src/components/MobileKeyboard.tsx` | Terminal toolbar | +| 5.16 | Gesture navigation | `app/frontend/src/hooks/useGesture.ts` | Swipe to switch tabs | +| 5.17 | Landscape handling | `app/frontend/src/styles/mobile.css` | Responsive layout | +| 5.18 | Bluetooth keyboard | `app/frontend/src/hooks/useKeyboard.ts` | External keyboard | + +--- + +## Dependency Graph + +``` +Sprint 0 (Scaffolding) + │ + ├── Sprint 1 (Foundation) + │ │ + │ ├── Sprint 2 (Core Features) + │ │ │ + │ │ ├── Sprint 3 (SFTP + Polish) + │ │ │ │ + │ │ │ └── Sprint 5 (Platform Polish) + │ │ │ + │ │ └── Sprint 4 (Advanced Features) + │ │ │ + │ │ └── Sprint 5 (Platform Polish) +``` + +**Critical path**: Sprint 0 → Sprint 1 → Sprint 2 → Sprint 5 + +**Parallel tracks**: +- Sprint 3 and Sprint 4 can be done in parallel +- Sprint 5 can start after Sprint 2 (desktop polish), Sprint 3 (SFTP mobile), Sprint 4 (advanced mobile) + +--- + +## File Creation Summary + +| Sprint | New Files | Directories | +|--------|-----------|-------------| +| Sprint 0 | 12 | 6 | +| Sprint 1 | 23 | 2 | +| Sprint 2 | 16 | 2 | +| Sprint 3 | 22 | 4 | +| Sprint 4 | 13 | 1 | +| Sprint 5 | 18 | 2 | +| **Total** | **104** | **17** | diff --git a/docs/v2/TIMELINE.md b/docs/v2/TIMELINE.md new file mode 100644 index 0000000..4a93cdc --- /dev/null +++ b/docs/v2/TIMELINE.md @@ -0,0 +1,316 @@ +# Hostkeeper V2 — Timeline & Milestones + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 +> **Start Date**: 2026-06-30 (estimated) +> **Target MVP**: 2026-08-07 (estimated) + +--- + +## 1. Timeline Overview + +Total estimated: **25-30 working days** (~6 minggu calendar, 1-2 minggu buffer) + +``` +Minggu 1 │ Sprint 0 ─ Sprint 1 ─ │──── Foundation +Minggu 2 │ Sprint 2 ─────────────│──── Core (Terminal) +Minggu 3 │ Sprint 3 ─────────────│──── SFTP + Polish +Minggu 4 │ Sprint 4 ─────────────│──── Advanced +Minggu 5 │ Sprint 5 ── Buffer ───│──── Polish + Release + └────────────────────────┘ + 26 Jun 10 Jul 24 Jul 7 Aug +``` + +--- + +## 2. Detailed Timeline + +### Phase 0: Foundation (Sprint 0 + Sprint 1) +**6 working days — 30 Jun s.d. 7 Jul** + +| Day | Date | Sprint | Deliverable | Milestone | +|-----|------|--------|-------------|-----------| +| 1 | 30 Jun | Sprint 0 | Go module, Electron shell, Vite scaffold | App starts, shows "Hostkeeper V2" | +| 2 | 1 Jul | Sprint 0 | Health endpoint, middleware, build script | `curl /api/health` returns OK | +| 3 | 2 Jul | Sprint 1 | Host CRUD API (6 endpoints) | `curl POST /api/hosts` returns 201 | +| 4 | 3 Jul | Sprint 1 | Key + Snippet CRUD API | All CRUD works via curl | +| 5 | 4 Jul | Sprint 1 | Groups + Config + Vault API | Group tree returned | +| 6 | 7 Jul | Sprint 1 | Web UI scaffold (layout, router, theme) | Navigation works, sidebar visible | + +**Milestone M1 — API Complete (7 Jul)** +- All REST API endpoints functional +- Storage integration with V1 `pkg/storage` +- Web UI layout renders + +--- + +### Phase 1: Core Features (Sprint 2) +**5 working days — 8 Jul s.d. 14 Jul** + +| Day | Date | Deliverable | Milestone | +|-----|------|-------------|-----------| +| 7 | 8 Jul | Host List screen + HostForm UI | Add/edit host from browser | +| 8 | 9 Jul | HostForm: auth, tags, group picker | All form fields work | +| 9 | 10 Jul | WebSocket handler (Go) | Terminal WS connects | +| 10 | 11 Jul | XTermWrapper + terminal UI | Terminal renders, keystrokes work | +| 11 | 14 Jul | TabBar + multi-tab + snippet panel | Multi-tab terminal with snippets | + +**Milestone M2 — Terminal Works (14 Jul)** +- SSH terminal via WebSocket +- Multi-tab support +- Snippet injection + +--- + +### Phase 2: SFTP + Security (Sprint 3) +**5 working days — 15 Jul s.d. 21 Jul** + +| Day | Date | Deliverable | Milestone | +|-----|------|-------------|-----------| +| 12 | 15 Jul | SFTP REST handlers (ls, upload, download) | SFTP API works via curl | +| 13 | 16 Jul | SFTP: mkdir, rm, rename, chmod | File operations work | +| 14 | 17 Jul | SFTPScreen + dual pane UI | SFTP browser functional | +| 15 | 18 Jul | Vault unlock + known hosts dialog | Security flow works | +| 16 | 21 Jul | Settings screen (all tabs) | Settings persist | + +**Milestone M3 — SFTP + Vault (21 Jul)** +- SFTP file browser with upload/download +- Vault lock/unlock flow +- Settings customization + +--- + +### Phase 3: Advanced Features (Sprint 4) +**5 working days — 22 Jul s.d. 28 Jul** + +| Day | Date | Deliverable | Milestone | +|-----|------|-------------|-----------| +| 17 | 22 Jul | PortForward CRUD + local tunnel | Local port forward works | +| 18 | 23 Jul | Remote + dynamic forwarding | All forward types work | +| 19 | 24 Jul | Import: SSH config + CSV parser | Bulk import works | +| 20 | 25 Jul | Export: all data encrypted | Export/import round-trip | +| 21 | 28 Jul | Jump host + proxy support | Multi-hop SSH works | + +**Milestone M4 — Advanced Complete (28 Jul)** +- Port forwarding (local/remote/dynamic) +- Import/export +- Jump hosts + proxy + +--- + +### Phase 4: Polish & Release (Sprint 5) +**5 working days — 29 Jul s.d. 4 Aug** + +| Day | Date | Deliverable | Milestone | +|-----|------|-------------|-----------| +| 22 | 29 Jul | Electron: menu, tray, shortcuts | Desktop polish complete | +| 23 | 30 Jul | OS Keychain + auto-update | Keychain integration | +| 24 | 31 Jul | Performance optimization | 1000 hosts smooth, memory < 200MB | +| 25 | 1 Aug | Android gomobile + WebView | Android app runs | +| 26 | 4 Aug | iOS gomobile + WKWebView | iOS app runs | + +**Milestone M5 — Release Candidate (4 Aug)** +- All features complete +- Desktop and mobile builds +- Performance targets met + +--- + +### Buffer: 3 days (5 Aug s.d. 7 Aug) + +| Day | Date | Activity | +|-----|------|----------| +| 27 | 5 Aug | Bug fixes from integration testing | +| 28 | 6 Aug | Documentation finalization | +| 29 | 7 Aug | Release v2.0.0 | + +**Milestone M6 — v2.0.0 Release (7 Aug)** +- All platforms built and signed +- Release notes complete +- Artifacts uploaded to GitHub + +--- + +## 3. Critical Path + +``` +Sprint 0 ── Sprint 1 ── Sprint 2 ── Sprint 5 ── Release + 1 day 5 days 5 days 5 days 3 days + ───────────────────────────────────────────────────────── + 19 days minimum (no parallel work) +``` + +### Parallel Work Opportunities + +| Sprint | Can run in parallel with | Notes | +|--------|-------------------------|-------| +| Sprint 1 | — | Must complete before Sprint 2 | +| Sprint 2 | — | Must complete before Sprint 3/4/5 | +| Sprint 3 | Sprint 4 | SFTP ≠ Port forwarding, different files | +| Sprint 4 | Sprint 3 | Independent features | +| Sprint 5 | Sprint 3, Sprint 4 | Desktop polish can start early | + +**Optimistic timeline** (with parallelism): 22 days +**Conservative timeline** (sequential): 29 days + +--- + +## 4. Dependency Graph + +``` +Sprint 0 (Scaffolding) + │ + ▼ +Sprint 1 (Foundation API) + │ + ▼ +Sprint 2 (SSH Terminal) ⭐ CRITICAL PATH + │ + ├────▶ Sprint 3 (SFTP + Security) ────▶ Sprint 5 (Desktop Polish) + │ │ + └────▶ Sprint 4 (Advanced) ────────────┘ + │ + ▼ + Release v2.0.0 +``` + +**Critical path**: Sprint 0 → Sprint 1 → Sprint 2 → Sprint 5 → Release + +**Fastest possible**: 22 hari (Sprint 3 + 4 done in parallel with Sprint 5) + +--- + +## 5. Milestone Summary + +| Milestone | Date | Deliverable | Dependency | +|-----------|------|-------------|------------| +| **M1** | 7 Jul | All REST API endpoints + Web UI scaffold | Sprint 0 + Sprint 1 | +| **M2** | 14 Jul | SSH terminal with multi-tab | M1 | +| **M3** | 21 Jul | SFTP browser + vault + settings | M2 | +| **M4** | 28 Jul | Port forwarding + import/export + jump hosts | M2 | +| **M5** | 4 Aug | Desktop polish + mobile apps | M2, M3, M4 | +| **M6** | 7 Aug | **v2.0.0 Release** | M5 | + +--- + +## 6. Weekly Breakdown + +### Week 1 (30 Jun - 4 Jul) — Foundation +``` +Mon 30: [Sprint 0] Go scaffold + Electron shell +Tue 1: [Sprint 0] Build script + middleware +Wed 2: [Sprint 1] Host CRUD API +Thu 3: [Sprint 1] Key + Snippet CRUD API +Fri 4: [Sprint 1] Groups + Config + Vault API +``` + +### Week 2 (7 Jul - 11 Jul) — API + UI Scaffold +``` +Mon 7: [Sprint 1] Web UI scaffold (layout, router, theme) ← M1 +Tue 8: [Sprint 2] Host List screen + HostForm +Wed 9: [Sprint 2] HostForm complete (auth, tags, groups) +Thu 10: [Sprint 2] WebSocket handler (Go) +Fri 11: [Sprint 2] XTermWrapper + terminal rendering +``` + +### Week 3 (14 Jul - 18 Jul) — Terminal + SFTP +``` +Mon 14: [Sprint 2] TabBar + multi-tab + snippet panel ← M2 +Tue 15: [Sprint 3] SFTP REST handlers +Wed 16: [Sprint 3] File operations (mkdir, rm, rename) +Thu 17: [Sprint 3] SFTPScreen + dual pane UI +Fri 18: [Sprint 3] Vault unlock + known hosts dialog +``` + +### Week 4 (21 Jul - 25 Jul) — SFTP Settings + Advanced +``` +Mon 21: [Sprint 3] Settings screen (all tabs) ← M3 +Tue 22: [Sprint 4] PortForward CRUD + local tunnel +Wed 23: [Sprint 4] Remote + dynamic forwarding +Thu 24: [Sprint 4] Import (SSH config, CSV) +Fri 25: [Sprint 4] Export + jump hosts +``` + +### Week 5 (28 Jul - 1 Aug) — Advanced + Polish +``` +Mon 28: [Sprint 4] Proxy support ← M4 +Tue 29: [Sprint 5] Electron: menu, tray, shortcuts +Wed 30: [Sprint 5] OS Keychain + auto-update +Thu 31: [Sprint 5] Performance optimization +Fri 1: [Sprint 5] Android gomobile + WebView +``` + +### Week 6 (4 Aug - 7 Aug) — Mobile + Release +``` +Mon 4: [Sprint 5] iOS gomobile + WKWebView ← M5 +Tue 5: [Buffer] Bug fixes +Wed 6: [Buffer] Documentation +Thu 7: [Release] v2.0.0 ← M6 +``` + +--- + +## 7. Risk Buffer + +| Risk | Impact | Probability | Mitigation | Buffer needed | +|------|--------|-------------|------------|---------------| +| Terminal WebSocket complexity | High | Medium | Use proven pattern from docs | +1 day | +| Mobile gomobile compilation | Medium | Medium | Start early, test on emulator | +1 day | +| Electron auto-update signing | Medium | Low | Prepare certificates early | — | +| Performance issues (1000 hosts) | Medium | Low | Virtual scrolling already planned | — | +| Unknown V1 package compatibility | Low | Low | V1 packages are framework-agnostic | — | +| UX polish takes longer | Medium | Medium | Prioritize function over beauty | +1 day | + +**Total buffer**: 3 days (included in timeline as 5-7 Aug) + +--- + +## 8. Effort by Technology + +| Technology | Days | % of total | Sprint | +|------------|------|------------|--------| +| Go (backend) | 10 | 34% | S0, S1, S2, S3, S4 | +| React/TypeScript (frontend) | 10 | 34% | S1, S2, S3, S4, S5 | +| Electron (desktop) | 3 | 10% | S0, S5 | +| Mobile (gomobile + WebView) | 2 | 7% | S3, S5 | +| DevOps (build, CI/CD) | 2 | 7% | S0, S5 | +| Testing + bug fixes | 2 | 7% | All | +| **Total** | **29** | **100%** | | + +--- + +## 9. What NOT in Scope (Post-MVP) + +| Feature | Reason | +|---------|--------| +| Cloud sync | Requires backend server, Phase 3 | +| Team vault | Requires multi-user auth, Phase 3 | +| Multiplayer terminal | Requires relay server, Phase 3 | +| AI command generation | LLM API integration, nice-to-have | +| Serial console | Niche use case, low priority | +| Mosh support | Requires additional library | +| Telnet support | Legacy protocol, low priority | + +--- + +## 10. Progress Tracking + +**How to update this timeline**: +1. At the end of each day, mark `[x]` for completed tasks +2. If a task takes longer, adjust subsequent dates +3. Update `docs/v2/PROGRESS.md` with actual completion dates + +```markdown +### Week 1 Progress +[x] Mon 30 Jun: [Sprint 0] Go scaffold + Electron shell +[ ] Tue 1 Jul: [Sprint 0] Build script + middleware +[ ] Wed 2 Jul: [Sprint 1] Host CRUD API +[ ] Thu 3 Jul: [Sprint 1] Key + Snippet CRUD API +[ ] Fri 4 Jul: [Sprint 1] Groups + Config + Vault API +``` + +--- + +**Target**: v2.0.0 Release by **7 August 2026** +**Best case**: 22 July (parallel work, no blockers) +**Worst case**: 14 August (1 week buffer used + delays) diff --git a/docs/v2/UI_COMPONENTS.md b/docs/v2/UI_COMPONENTS.md new file mode 100644 index 0000000..1b1dda3 --- /dev/null +++ b/docs/v2/UI_COMPONENTS.md @@ -0,0 +1,1142 @@ +# Hostkeeper V2 — React Component Tree + +> **Status**: V2 Planning Complete +> **Last Updated**: 2026-06-29 +> **Framework**: React 19, TypeScript, Zustand, TailwindCSS + +--- + +## 1. App Component Tree + +``` +App +├── # React Query (optional) +│ ├── # CSS variables theme provider +│ │ ├── # React Router +│ │ │ ├── # Route: / (if locked) +│ │ │ └── # Route: /* +│ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ │ ├── +│ │ │ │ │ │ └── +│ │ │ │ │ └── (recursive) +│ │ │ │ └── +│ │ │ ├── +│ │ │ │ ├── +│ │ │ │ └── +│ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ ├── +│ │ │ │ └── +│ │ │ └── +│ │ └── +│ │ ├── +│ │ ├── +│ │ ├── +│ │ ├── +│ │ └── +│ └── # Sonner toast notifications +``` + +--- + +## 2. Component Details + +### 2.1 App Root + +```typescript +// app/frontend/src/App.tsx +function App() { + const { isLocked } = useVaultStore(); + + if (isLocked) { + return ; + } + + return ( + + + + + ); +} +``` + +**State**: `useVaultStore()` — `isLocked: boolean` + +--- + +### 2.2 VaultScreen + +Password prompt shown on app start (when vault is locked). + +```typescript +interface VaultScreenProps { + onUnlock: (password: string) => Promise; +} + +function VaultScreen({ onUnlock }: VaultScreenProps) { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + // UI: centered card with password input, unlock button + // Enter key submits, Esc clears +} +``` + +**Layout**: Centered card on dark background. App logo + name above. Password input + Unlock button. Error message below input. + +--- + +### 2.3 MainLayout + +The main application shell after vault unlock. + +```typescript +function MainLayout() { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [sidebarWidth, setSidebarWidth] = useState(240); + const tabs = useTabStore(state => state.tabs); + + return ( +
+ +
+ + + +
+
+ ); +} +``` + +**Layout**: Horizontal split — sidebar (left) + main area (right). Main area splits vertically — tab bar (top) + content (middle) + status bar (bottom). + +--- + +### 2.4 Sidebar + +Left navigation panel with host tree and quick actions. + +```typescript +interface SidebarProps { + width: number; + onToggle: (open: boolean) => void; +} + +function Sidebar({ width, onToggle }: SidebarProps) { + const [searchQuery, setSearchQuery] = useState(''); + const groups = useHostStore(state => state.groups); + const hosts = useHostStore(state => state.hosts); + + return ( + + ); +} +``` + +**Width**: 240px default, resizable via drag handle (min 180px, max 400px). + +--- + +### 2.5 SearchBar + +```typescript +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +function SearchBar({ value, onChange, placeholder = "Search hosts..." }: SearchBarProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="w-full pl-8 pr-3 py-1.5 rounded-md bg-surface text-sm" + /> + {value && ( + + )} +
+ ); +} +``` + +--- + +### 2.6 GroupTree + +Recursive tree component for host groups. + +```typescript +interface GroupTreeProps { + groups: HostGroup[]; + hosts: Host[]; + filter: string; + parent_id?: string | null; + depth?: number; +} + +function GroupTree({ groups, hosts, filter, parent_id = null, depth = 0 }: GroupTreeProps) { + const childGroups = groups.filter(g => g.parent_id === parent_id); + const ungroupedHosts = hosts.filter(h => !h.group_id && !parent_id); + + return ( +
+ {childGroups.map(group => ( + + ))} + {depth === 0 && } +
+ ); +} + +interface GroupItemProps { + group: HostGroup; + groups: HostGroup[]; + hosts: Host[]; + filter: string; + depth: number; +} + +function GroupItem({ group, groups, hosts, filter, depth }: GroupItemProps) { + const [expanded, setExpanded] = useState(true); + const groupHosts = hosts.filter(h => h.group_id === group.id); + const color = group.color || '#888'; + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? : } +
+ {group.name} + + {groupHosts.length} + +
+ {expanded && ( + + )} +
+ ); +} +``` + +**Features**: +- Collapsible groups with chevron icon +- Color dot for group color +- Host count badge +- Drag-and-drop: drag host onto group to reassign +- Right-click: context menu (rename, delete, new sub-group) + +--- + +### 2.7 HostItem + +```typescript +interface HostItemProps { + host: Host; + isActive?: boolean; + onClick?: () => void; +} + +function HostItem({ host, isActive, onClick }: HostItemProps) { + const status = useTerminalStore(state => state.getSessionStatus(host.id)); + + return ( +
+ + + {host.name} + {host.is_favorite && ( + + )} +
+ ); +} + +function StatusDot({ status }: { status: string }) { + const color = { + connected: 'bg-green-500', + connecting: 'bg-yellow-500', + disconnected: 'bg-gray-500', + error: 'bg-red-500' + }[status] || 'bg-gray-500'; + + return
; +} +``` + +--- + +### 2.8 TabBar + +Horizontal tab bar for open terminals/SFTP sessions. + +```typescript +function TabBar() { + const { tabs, activeTab, addTab, closeTab, setActiveTab } = useTabStore(); + + return ( +
+ {tabs.map(tab => ( + setActiveTab(tab.id)} + onClose={() => closeTab(tab.id)} + /> + ))} + addTab()} /> +
+ ); +} + +interface TabProps { + tab: Tab; + isActive: boolean; + onClick: () => void; + onClose: () => void; +} + +function Tab({ tab, isActive, onClick, onClose }: TabProps) { + const status = useTerminalStore(state => state.getSessionStatus(tab.host_id)); + + return ( +
+ + {tab.title} + +
+ ); +} +``` + +**Features**: +- Status dot (green=connected, red=error, gray=disconnected) +- Tab title (host name) +- Close button (X) +- Drag to reorder +- Middle-click to close +- Max 20 tabs + +--- + +### 2.9 MainContent + +Routes to the appropriate screen based on active tab type. + +```typescript +function MainContent() { + const activeTab = useTabStore(state => state.getActiveTab()); + + if (!activeTab) { + return ; + } + + switch (activeTab.type) { + case 'terminal': + return ; + case 'sftp': + return ; + default: + return ; + } +} +``` + +--- + +### 2.10 HostListScreen + +Default screen when no tab is active. + +```typescript +function HostListScreen() { + const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); + const hosts = useHostStore(state => state.hosts); + const selectedHost = useHostStore(state => state.selectedHost); + + if (hosts.length === 0) { + return ; + } + + return ( +
+
+

Hosts

+
+ + + +
+
+ {viewMode === 'list' ? ( + + ) : ( + + )} +
+ ); +} + +function EmptyState() { + return ( +
+ +

No hosts yet

+

Add your first SSH host to get started

+ +
+ ); +} +``` + +--- + +### 2.11 HostForm (Add/Edit) + +```typescript +interface HostFormProps { + host?: Host; // undefined = add mode, defined = edit mode + onSave: (host: Host) => void; + onCancel: () => void; +} + +function HostForm({ host, onSave, onCancel }: HostFormProps) { + const [formData, setFormData] = useState({ + name: host?.name || '', + hostname: host?.hostname || '', + port: host?.port || 22, + username: host?.username || '', + auth_type: host?.auth?.type || 'key', + key_id: host?.auth?.key_id || '', + password: '', + group_id: host?.group_id || null, + tags: host?.tags || [], + is_favorite: host?.is_favorite || false, + notes: host?.notes || '', + }); + const [errors, setErrors] = useState>({}); + + return ( +
+

+ {host ? 'Edit Host' : 'New Host'} +

+ +
+ {/* Basic Info */} + setFormData({ ...formData, name: v })} + error={errors.name} + placeholder="My Server" + /> + setFormData({ ...formData, hostname: v })} + error={errors.hostname} + placeholder="192.168.1.100 or example.com" + /> +
+ setFormData({ ...formData, port: parseInt(v) })} + error={errors.port} + /> + setFormData({ ...formData, username: v })} + error={errors.username} + placeholder="root" + /> +
+ + {/* Auth */} + setFormData({ ...formData, auth_type: v })} + onKeyChange={v => setFormData({ ...formData, key_id: v })} + onPasswordChange={v => setFormData({ ...formData, password: v })} + /> + + {/* Tags */} + setFormData({ ...formData, tags })} + /> + + {/* Group */} + setFormData({ ...formData, group_id: id })} + /> + + {/* Notes */} +