commit f61e85d9a78995ebed2c6c2c49bb62046e8ad9bd Author: Sami Date: Thu Mar 5 02:26:12 2026 -0800 Initial commit: DashCaddy v1.0 Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5cf659 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Dependencies +node_modules/ + +# Runtime state/config files (generated, not source) +dashcaddy-api/credentials.json +dashcaddy-api/alert-config.json +dashcaddy-api/audit-log.json +dashcaddy-api/audit-log.json.lock +dashcaddy-api/backup-config.json +dashcaddy-api/backup-history.json +dashcaddy-api/container-stats.json +dashcaddy-api/health-config.json +dashcaddy-api/health-history.json +dashcaddy-api/update-config.json +dashcaddy-api/update-history.json +dashcaddy-api/dashcaddy-errors.log + +# Build output +dashcaddy-installer/build-output/ +dashcaddy-installer/dist/ +status/dist/ + +# Vendor / third-party +status/vendor/ + +# Backup files +*.backup.html +*.backup.*.html +*.recovered +backups/ + +# IDE / editor +.claude/ +.kiro/ +.vscode/ + +# Session-specific docs (not project docs) +DEPLOYMENT-SUCCESS.md +FINAL-DEPLOYMENT-REPORT.md +TEST-RESULTS.md +TESTING-GUIDE.md +DashCA-Plan.md +vhdx-cleanup-instructions.md + +# Utility scripts (local only) +check-e.ps1 +disk-scan.ps1 +disk-scan2.ps1 +fix-wsl-and-mount.ps1 +import-services.js + +# OS files +Thumbs.db +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cf49bc5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,230 @@ +# DashCaddy Project Guidelines for AI Assistants + +## HARD RULE: Docker Storage on E: Drive + +**ALL Docker container data, volumes, bind mounts, and app configs MUST use `E:/dockerdata/` via bind mounts or CIFS volumes. No exceptions.** + +- E: is a network share (`\\Sami-pc\e_share`) shared across all home network computers +- The ONLY thing allowed on C: is the Docker Desktop WSL engine VHD (`C:/dockerdata/DockerDesktopWSL/`) — this is the absolute bare minimum WSL2 requires (local NTFS). WSL2 cannot create VHDs on network shares. +- Keep C: Docker usage under 5GB +- When deploying new containers, always use `E:/dockerdata//` for bind mount paths +- For CIFS volumes in docker-compose, use `//Sami-pc/e_share/dockerdata/...` as the device path + +## CRITICAL: Production vs Development Paths + +### Production Files (LIVE - what actually runs) +``` +C:/caddy/ +├── Caddyfile # Active Caddy configuration +├── services.json # Services shown on dashboard +├── dns-credentials.json # DNS API credentials +├── config.json # DashCaddy configuration +└── sites/ + └── status/ # Dashboard frontend files + └── assets/ # Logos, fonts, icons +``` + +### Development Files (for editing/testing) +``` +e:/CaddyCerts/sites/ +├── caddy-api/ +│ ├── server.js # API server source code +│ ├── app-templates.js # Docker app templates (52+ apps) +│ ├── services.json # DEV ONLY - not used in production! +│ └── ... +└── status/ + └── index.html # Dashboard UI source +``` + +## Docker Container Mount Points + +The `caddy-api` container mounts production files: + +| Container Path | Host Path (Production) | +|----------------|------------------------| +| `/app/services.json` | `C:/caddy/services.json` | +| `/app/dns-credentials.json` | `C:/caddy/dns-credentials.json` | +| `/caddyfile` | `C:/caddy/Caddyfile` | +| `/app/assets` | `C:/caddy/sites/status/assets` | + +## When Making Changes + +### To add/remove services from dashboard: +Edit `C:/caddy/services.json` (NOT e:/CaddyCerts/sites/caddy-api/services.json) + +### To modify Caddy reverse proxy rules: +Edit `C:/caddy/Caddyfile`, then reload via: +```bash +curl -X POST http://localhost:2019/load -H "Content-Type: text/caddyfile" --data-binary @"C:/caddy/Caddyfile" +``` + +### To modify API server code: +Edit `e:/CaddyCerts/sites/caddy-api/server.js`, then: +1. Copy to production: `C:/caddy/sites/caddy-api/` +2. Restart container: `docker restart caddy-api` + +### To modify app templates: +Edit `e:/CaddyCerts/sites/caddy-api/app-templates.js` +(Templates are loaded at runtime, changes require container restart) + +### To modify dashboard UI: +Edit `e:/CaddyCerts/sites/status/index.html` +Copy to `C:/caddy/sites/status/` for production + +### To modify DashCA (CA certificate distribution): +Edit files in `e:/CaddyCerts/sites/ca/`, then: +1. Regenerate certificate formats: `cd e:/CaddyCerts/sites/ca/scripts && bash generate-all.sh` +2. Copy to production: `cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/` +3. Reload Caddy if Caddyfile changes were made + +## DashCA - Certificate Authority Distribution + +**Purpose**: Provides a one-click installation page for the root CA certificate, allowing users to easily trust *.sami domains on any device. + +**Access**: https://ca.sami (or https://ca.yourdomain for other installations) + +### File Locations + +**Development (for editing):** +``` +e:/CaddyCerts/sites/ca/ +├── index.html # Landing page +├── root.crt, root.der # Certificate formats +├── root.mobileconfig # Apple profile +├── intermediate.crt # Intermediate CA +├── cert-info.json # Certificate metadata +├── scripts/ +│ ├── install.ps1 # Windows installer +│ ├── install.sh # Linux/macOS installer +│ ├── generate-cert-info.js # Extract cert metadata +│ ├── generate-mobileconfig.js # Generate Apple profile +│ └── generate-all.sh # Regenerate all formats +└── assets/ # Icons, logos +``` + +**Production (served by Caddy):** +``` +C:/caddy/sites/ca/ +├── index.html +├── root.crt, root.der +├── root.mobileconfig +├── install.ps1, install.sh +└── assets/ +``` + +### Certificate Source + +Caddy's built-in PKI generates certificates at: +- **Root CA**: `C:/caddy/certs/pki/authorities/local/root.crt` +- **Intermediate CA**: `C:/caddy/certs/pki/authorities/local/intermediate.crt` + +**Certificate Info:** +- **CN**: Sami Home Network Root CA +- **Algorithm**: ECDSA P-256 with SHA-256 +- **Valid Until**: Dec 22, 2034 (~10 years) +- **Fingerprint**: `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E` + +### Deployment + +DashCA is a **static site** (not Docker-based), deployed via the app selector: +1. Navigate to App Selector in dashboard +2. Find "DashCA" in Security category +3. Click Deploy +4. System automatically: + - Creates `C:/caddy/sites/ca/` directory + - Copies files from development directory + - Generates certificate formats (DER, mobileconfig) + - Adds ca.sami block to Caddyfile + - Reloads Caddy configuration + - Registers service in `services.json` + +### Updating Certificates + +When Caddy's CA certificate is renewed (every ~10 years): + +```bash +# 1. Regenerate all certificate formats +cd e:/CaddyCerts/sites/ca/scripts +bash generate-all.sh + +# 2. Update fingerprint in installation scripts +# Edit install.ps1 - update $ExpectedFingerprint +# Edit install.sh - update EXPECTED_FP + +# 3. Copy to production +cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/ + +# 4. Notify users via dashboard or email +``` + +### API Endpoints + +- **GET /api/ca/info** - Returns certificate metadata (name, fingerprint, expiration, etc.) +- **GET /api/health/ca** - Returns CA expiration health status + - `healthy`: >90 days remaining + - `warning`: 30-90 days remaining + - `critical`: <30 days remaining + +### Caddyfile Configuration + +DashCA's Caddyfile block (auto-generated on deployment): +- **Root**: `C:/caddy/sites/ca` +- **TLS**: Internal (uses Caddy's local CA) +- **MIME Types**: Proper headers for .crt, .der, .mobileconfig, .ps1, .sh files +- **SPA Fallback**: Rewrites non-file requests to /index.html +- **Cache Control**: Certificates cached for 24h, HTML not cached + +### Supported Platforms + +- **Windows**: PowerShell installer (installs to LocalMachine\Root store) +- **macOS**: .mobileconfig profile or command-line installer +- **Linux**: Shell installer (Debian, RedHat, Arch) +- **iOS**: .mobileconfig profile (requires manual trust in Settings) +- **Android**: Direct .crt download (installs as user certificate) + +### Landing Page Features + +- Automatic OS detection +- QR code for mobile access +- Certificate info display (loaded from `/api/ca/info`) +- Platform-specific installation instructions +- Copy-to-clipboard for fingerprint and commands +- Download links for all certificate formats + +### Troubleshooting + +**Issue**: Certificate fingerprint mismatch during installation +**Cause**: CA certificate was renewed +**Solution**: Regenerate certificates and update fingerprints in install scripts + +**Issue**: *.sami sites still show warnings after CA install +**Cause**: Browser may have cached the untrusted state +**Solution**: Clear browser cache, restart browser, or visit site in incognito mode + +**Issue**: iOS doesn't trust certificate after profile install +**Cause**: iOS requires manual trust enablement +**Solution**: Settings → General → About → Certificate Trust Settings → Enable trust + +## Key Services + +| Service | Port | Description | +|---------|------|-------------| +| Caddy (HTTPS) | 443 | Reverse proxy | +| Caddy Admin | 2019 | Caddy API (note: NOT 2021) | +| DashCaddy API | 3001 | Dashboard backend | +| DNS2 (Primary) | 100.74.102.61:5380 | Technitium DNS | +| DNS1 (Secondary) | 192.168.254.204:5380 | Technitium DNS | + +## Common Mistakes to Avoid + +1. **Wrong services.json**: The API container reads from `C:/caddy/services.json`, not the development copy +2. **Caddy admin port**: It's 2019, not 2021 (check with `netstat` if unsure) +3. **DNS server**: DNS2 (100.74.102.61) is PRIMARY, DNS1 is secondary +4. **Caddyfile not reloaded**: After editing, must POST to /load endpoint or restart Caddy + +## Project Info + +- **Name**: DashCaddy +- **Version**: 1.0 +- **Purpose**: Unified management for Docker + Caddy + DNS +- **Local TLD**: .sami diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f61b25 --- /dev/null +++ b/README.md @@ -0,0 +1,370 @@ +# DashCaddy + +**Self-hosted dashboard for managing Docker apps with automatic SSL, DNS, and reverse proxy configuration.** + +![Version](https://img.shields.io/badge/version-1.0.0-blue) +![License](https://img.shields.io/badge/license-MIT-green) + +## What is DashCaddy? + +DashCaddy is an all-in-one solution for self-hosting Docker applications. It combines: +- 🎨 **Beautiful Dashboard** - Monitor all your services in one place +- 🐳 **Docker Management** - Deploy 50+ pre-configured apps with one click +- 🔒 **Automatic SSL** - Internal CA with automatic certificate generation +- 🌐 **DNS Integration** - Automatic DNS record creation (Technitium DNS) +- 🔄 **Reverse Proxy** - Caddy configuration managed automatically +- 🔐 **Tailscale Support** - Secure remote access built-in + +## Features + +### Authentication & Security +- Built-in TOTP two-factor authentication +- Fine-grained access control per service +- Secure session management +- Group-based permissions + +### Dashboard +- Real-time service health monitoring +- Response time tracking +- Status indicators with visual feedback +- Weather widget +- Multiple themes (dark/light/blue) +- Import/export configuration + +### App Deployment +- 50+ pre-configured app templates +- One-click deployment +- Automatic DNS + SSL + reverse proxy setup +- Container health checking +- Deployment status tracking +- SSL certificate generation monitoring + +### Service Management +- Add/edit/delete services +- Restart containers +- View logs +- Update configurations +- Silent deletions (no annoying popups) + +### Developer Tools +- Error log viewer +- API endpoints for automation +- Import/export for testing +- Comprehensive error logging + +## Quick Start + +### Prerequisites +- Docker & Docker Compose +- Caddy web server +- Technitium DNS (optional, for automatic DNS) +- Node.js 18+ (for API server) + +### Installation + +1. **Clone the repository** +```bash +git clone https://github.com/yourusername/dashcaddy.git +cd dashcaddy +``` + +2. **Install dependencies** +```bash +cd caddy-api +npm install +``` + +3. **Configure environment** +```bash +cp .env.example .env +# Edit .env with your settings +``` + +4. **Start the API server** +```bash +npm start +``` + +5. **Configure Caddy** +Add to your Caddyfile: +``` +status.yourdomain.com { + root * /path/to/dashcaddy/status + file_server + reverse_proxy /api/* localhost:3001 +} +``` + +6. **Access the dashboard** +Open `https://status.yourdomain.com` in your browser + +## Configuration + +### Environment Variables + +Create a `.env` file in the `caddy-api` directory: + +```env +# Caddy Configuration +CADDYFILE_PATH=/path/to/Caddyfile +CADDY_ADMIN_URL=http://localhost:2019 + +# DNS Configuration (optional) +DNS_SERVER=192.168.1.1 +DNS_TOKEN=your-dns-token + +# File Paths +SERVICES_FILE=/path/to/services.json +ERROR_LOG_FILE=/path/to/dashcaddy-errors.log +``` + +### DNS Integration + +DashCaddy works with Technitium DNS for automatic DNS record creation: + +1. Install Technitium DNS +2. Create an API token with DNS management permissions +3. Configure DNS credentials in dashboard (🔑 Tokens button) + +### Tailscale Integration + +For secure remote access: + +1. Install Tailscale on your server +2. Services can be restricted to Tailscale-only access +3. Configure in deployment settings + +## Usage + +### Deploying an App + +1. Click **"App Selector"** button +2. Choose an app from the template library +3. Configure: + - Subdomain (e.g., `jellyfin` → `jellyfin.yourdomain.com`) + - Port (auto-suggested) + - IP address (defaults to localhost) + - Tailscale-only access (optional) +4. Click **"Deploy"** +5. Wait for SSL certificate generation (30-60 seconds) +6. Access your app! + +### Managing Services + +- **View Status**: Cards show real-time health and response times +- **Open Service**: Click "Open" button +- **Restart**: Click restart button (for Docker containers) +- **Delete**: Click delete button (removes everything: container, DNS, Caddy config) +- **Edit**: Click settings button to modify configuration + +### Viewing Error Logs + +1. Click **"📋 Logs"** button in toolbar +2. View all errors with timestamps and context +3. Refresh to see latest errors +4. Clear logs when resolved + +### Backup & Restore + +**Export Configuration:** +1. Click **"📤 Export"** button +2. JSON file downloads with all your services +3. Save safely + +**Import Configuration:** +1. Click **"📥 Import"** button +2. Select your backup JSON file +3. Confirm import +4. Dashboard reloads with restored configuration + +**Note**: API tokens are not exported for security. Reconfigure after import. + +## App Templates + +DashCaddy includes 50+ pre-configured templates: + +### Media & Entertainment +- Plex, Jellyfin, Emby +- Navidrome, Airsonic +- Tautulli, Overseerr + +### Downloads +- Sonarr, Radarr, Lidarr, Readarr +- Prowlarr, Bazarr +- qBittorrent, Transmission +- SABnzbd, NZBGet + +### Productivity +- Nextcloud +- Paperless-ngx +- BookStack, Outline +- Standard Notes + +### Management +- Portainer +- Homepage, Homarr +- Uptime Kuma +- Grafana + +### Security & Authentication +- Vaultwarden (Password Manager) + +### Development +- Gitea +- VS Code Server +- Jenkins, Drone CI + +### And many more! + +## API Endpoints + +### Services +- `GET /api/services` - List all services +- `POST /api/services` - Add service +- `PUT /api/services` - Bulk import services +- `DELETE /api/services/:id` - Remove service + +### App Deployment +- `GET /api/apps/templates` - List app templates +- `POST /api/apps/deploy` - Deploy new app +- `DELETE /api/apps/:id` - Remove deployed app + +### Error Logs +- `GET /api/error-logs` - Get error logs +- `DELETE /api/error-logs` - Clear error logs + +### DNS Management +- `POST /api/dns/record` - Create DNS record +- `DELETE /api/dns/record` - Delete DNS record + +### Caddy Management +- `GET /api/caddy/config` - Get Caddyfile content +- `POST /api/caddy/reload` - Reload Caddy configuration + +## Troubleshooting + +### SSL Certificate Errors + +**Problem**: "Secure Connection Failed" when accessing new service + +**Solution**: +- Wait 30-60 seconds for certificate generation +- Check dashboard notification for SSL status +- Manually reload Caddy: `caddy reload --config /path/to/Caddyfile` +- Check error logs in dashboard + +### DNS Not Resolving + +**Problem**: Service URL doesn't resolve + +**Solution**: +- Verify DNS server is running +- Check DNS credentials in 🔑 Tokens menu +- Manually add DNS record in Technitium DNS +- Flush DNS cache: `ipconfig /flushdns` (Windows) or `sudo systemd-resolve --flush-caches` (Linux) + +### Container Won't Start + +**Problem**: Deployment succeeds but service is offline + +**Solution**: +- Check Docker logs: `docker logs [container-id]` +- Verify port isn't already in use +- Check container resource limits +- View error logs in dashboard + +### Import/Export Issues + +**Problem**: Import fails or data is incomplete + +**Solution**: +- Validate JSON format +- Check file has `version` and `services` fields +- Reconfigure API tokens after import +- Check error logs for details + +## Development + +### Project Structure + +``` +dashcaddy/ +├── status/ # Dashboard frontend +│ ├── index.html # Main dashboard +│ └── assets/ # Logos, icons, fonts +├── caddy-api/ # API backend +│ ├── server.js # Express server +│ ├── app-templates.js # App template definitions +│ └── package.json # Dependencies +├── dashcaddy-installer/ # Electron installer (WIP) +└── docs/ # Documentation +``` + +### Adding Custom App Templates + +Edit `caddy-api/app-templates.js`: + +```javascript +"myapp": { + name: "My App", + description: "Description of my app", + icon: "🚀", + logo: "https://cdn.example.com/logo.png", + category: "Productivity", + docker: { + image: "myapp/myapp:latest", + ports: ["{{PORT}}:8080"], + volumes: ["/opt/myapp:/data"], + environment: { + "APP_ENV": "production" + } + }, + subdomain: "myapp", + defaultPort: 8080, + healthCheck: "/health" +} +``` + +### Contributing + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request + +## Roadmap + +- [ ] Service groups/categories +- [ ] Container log viewer +- [ ] DNS management UI +- [ ] Backup automation +- [ ] Multi-user support +- [ ] Mobile app +- [ ] Analytics dashboard +- [ ] Template marketplace + +## License + +MIT License - see LICENSE file for details + +## Credits + +- **Dashboard Icons**: [walkxcode/dashboard-icons](https://github.com/walkxcode/dashboard-icons) (MIT License) +- **Caddy**: [caddyserver.com](https://caddyserver.com/) +- **Technitium DNS**: [technitium.com/dns](https://technitium.com/dns/) + +## Support + +- **Issues**: [GitHub Issues](https://github.com/yourusername/dashcaddy/issues) +- **Discussions**: [GitHub Discussions](https://github.com/yourusername/dashcaddy/discussions) +- **Documentation**: [Wiki](https://github.com/yourusername/dashcaddy/wiki) + +## Acknowledgments + +Built with ❤️ for the self-hosting community. + +--- + +**DashCaddy** - Making self-hosting beautiful and effortless. diff --git a/WHAT-IS-DASHCADDY.md b/WHAT-IS-DASHCADDY.md new file mode 100644 index 0000000..c8b220c --- /dev/null +++ b/WHAT-IS-DASHCADDY.md @@ -0,0 +1,242 @@ +# What is DashCaddy? + +DashCaddy is a self-hosted web dashboard that unifies Docker container management, Caddy reverse proxy configuration, DNS automation, and SSL certificate provisioning into a single interface. It is designed for homelabbers and self-hosters who want to deploy and manage services without manually editing config files, writing Docker Compose YAML, or configuring DNS records by hand. + +You open one page, click "Deploy", pick an app, and DashCaddy handles everything: pulls the Docker image, starts the container, creates a DNS record, adds a reverse proxy block with automatic HTTPS, and registers the service on your dashboard — all in about 30 seconds. + +## The Stack + +| Layer | Technology | Role | +|-------|-----------|------| +| Frontend | Vanilla JS SPA (~12,000 lines across 33 modules) | Dashboard UI, modals, wizards | +| Backend | Node.js / Express (~20,000 lines across 22 modules + 20 route files) | API server with 125+ endpoints | +| Reverse Proxy | Caddy | HTTPS termination, internal CA, automatic certificates | +| DNS | Technitium DNS Server | Automatic A-record creation for `*.sami` domains | +| Containers | Docker (via dockerode) | Application lifecycle management | +| Auth | TOTP (RFC 6238) + JWT | Two-factor authentication for dashboard access | +| Encryption | AES-256-GCM | Credential storage with OS keychain fallback | + +The API server runs inside a Docker container (`caddy-api`) on port 3001. Caddy sits in front of everything on port 443, terminating TLS with certificates signed by its own root CA. + +## What It Does + +### One-Click App Deployment + +55 pre-configured templates across 16 categories (Media, Downloads, Productivity, Development, Monitoring, DNS, Security, and more). Each template defines the Docker image, default port, environment variables, volume mounts, health check endpoint, and setup instructions. Deploying an app: + +1. Pulls the Docker image +2. Creates the container with the right env vars, ports, and volumes +3. Creates a DNS A-record on Technitium (e.g., `plex.sami`) +4. Adds a reverse proxy block to the Caddyfile with TLS +5. Reloads Caddy +6. Registers the service on the dashboard with health monitoring + +### Dashboard + +Real-time service cards showing status (up/slow/down), response time, uptime percentage, and container ID. Each card has controls to open the service, restart the container, view logs, edit settings, manage auto-login credentials, or delete the service. + +Special top-row cards for DNS servers, internet connectivity, TOTP auth status, and the certificate authority. + +### Smart Arr Connect + +A four-phase wizard that auto-detects Plex, Radarr, Sonarr, Overseerr/Jellyseerr, and Prowlarr, fetches their API keys, and wires them together automatically — connecting Overseerr to Plex, configuring Prowlarr with indexers for Radarr/Sonarr, etc. + +### Auto-Login SSO + +Per-service credential storage that authenticates users into services transparently via Caddy's `forward_auth` directive. Supports cookie-based auth, JWT-based auth (Open WebUI, Plex), IP-based auth (router), and Emby/Jellyfin token auth with separate device IDs to avoid token invalidation. + +### DashCA (Certificate Authority Distribution) + +A static site at `ca.sami` that auto-detects the visitor's OS and provides one-click installation of the root CA certificate. Supports Windows (PowerShell), macOS (.mobileconfig), Linux (shell script), iOS (profile), and Android (direct .crt download). + +### Monitoring and Operations + +- **Health Checker**: Periodic HTTP probes with configurable endpoints per service +- **Resource Monitor**: Per-container CPU, memory, disk I/O, and network stats +- **Update Manager**: Checks Docker Hub for newer image versions, one-click updates +- **Backup/Restore**: Export/import full dashboard configuration as JSON +- **Audit Logger**: Tracks all administrative actions +- **Error Log Viewer**: Aggregated error logs with severity filtering +- **Metrics**: Request counts, response times, error rates, business events (deploys, deletions, DNS records created) +- **Notifications**: Configurable alerts for health check failures and resource thresholds + +### Security + +- TOTP two-factor authentication with QR code setup +- CSRF token protection on all mutating endpoints +- Helmet security headers +- Rate limiting (general, strict, TOTP tiers) +- Input validation and sanitization (via `validator` library) +- AES-256-GCM credential encryption with OS keychain integration +- Docker security scanning +- API key management +- Non-root container execution with health checks + +### Other Features + +- Three themes (dark, light, blue) +- Keyboard shortcuts +- Customizable logo with position control +- Weather widget +- Setup wizard with three modes (Simple, Homelab, Public Server) +- Guided onboarding tour (Driver.js) +- Tailscale integration for access control +- Media folder browser for configuring volume mounts +- Interactive API documentation (OpenAPI/Swagger) + +--- + +## Architecture Diagram + +``` + Browser (index.html) + │ + ▼ + Caddy :443 ─── TLS (internal CA) ───┐ + │ │ + ├── /api/* → caddy-api :3001 │ + ├── *.sami → reverse proxy │ + │ to Docker containers │ + └── ca.sami → static DashCA site │ + │ + caddy-api container │ + ├── Express (server.js) │ + │ ├── 20 route modules │ + │ ├── State Manager (lock) │ + │ ├── Credential Manager │ + │ ├── Health Checker │ + │ ├── Resource Monitor │ + │ └── Metrics Collector │ + │ │ + ├──→ Docker Engine (dockerode) │ + ├──→ Caddy Admin API :2019 │ + ├──→ Technitium DNS :5380 │ + └──→ services.json (file-locked) │ +``` + +## Current State + +**Version**: 0.95 (1.0 = public release) + +The project is fully functional and in daily use. All core features work. The codebase has a test suite (17 test files under `__tests__/`) covering validators, crypto, health checks, state management, API endpoints, and integration scenarios. + +--- + +## Obstacles to v1.0 Release + +### 1. Windows-Only — No Cross-Platform Support + +DashCaddy was built on and for Windows. The entire deployment model assumes: +- `C:/caddy/` as the production path +- Windows-style path handling throughout (`C:\caddy\Caddyfile`, `host.docker.internal`) +- Docker Desktop for Windows +- Windows Task Scheduler for backups +- PowerShell for CA certificate installation + +A Linux or macOS user cannot run this without significant path rewiring. For a public release, either the documentation must clearly state "Windows only" or the path handling needs to be abstracted with platform-aware defaults. + +### 2. Hardcoded Infrastructure Assumptions + +The codebase has assumptions baked in that only apply to the author's setup: + +- **`.sami` TLD**: The local domain suffix is referenced throughout (Caddyfile templates, DNS record creation, documentation). A public user would need their own TLD — this needs to be a first-run configuration option, not a find-and-replace exercise. +- **Technitium DNS**: DNS automation assumes Technitium's REST API. Users running Pi-hole, CoreDNS, or no local DNS server have no path. The DNS layer needs to be pluggable or clearly documented as a hard requirement. +- **Docker Desktop**: Container operations assume Docker Desktop's `host.docker.internal` hostname. Native Docker on Linux uses `localhost` differently. +- **Caddy internal CA**: The TLS model assumes Caddy's built-in PKI. Users wanting Let's Encrypt or other CAs need a different onboarding flow (partially addressed by the "Public Server" setup wizard mode). + +### 3. Single-Page HTML Monolith + +The frontend is a ~12,000-line single HTML + 33 JS files architecture with no build step, no bundler, no framework, and no component system. While this means zero build tooling to configure, it creates obstacles: + +- No minification or tree-shaking — the full payload is served on every load +- No code splitting — all 33 modules load upfront +- IIFEs communicate through `window` globals — fragile, hard to test +- No TypeScript — no compile-time safety on a 12k-line frontend +- CSS is embedded in the HTML — no style extraction or scoping + +This works fine for a personal tool but makes contribution and maintenance harder at scale. + +### 4. No Automated Test Coverage for the Frontend + +The backend has 17 test files with unit and integration tests. The frontend has zero tests. The dashboard UI is the primary interface users interact with, and it has no test safety net — no unit tests, no E2E tests, no screenshot regression tests. + +### 5. No CI/CD Pipeline + +There is no GitHub Actions workflow, no pre-commit hooks, no automated linting, and no automated test runs. The deployment process is manual: + +1. Edit files in `e:/CaddyCerts/sites/dashcaddy-api/` +2. Copy JS files to `C:/caddy/sites/dashcaddy-api/` +3. Run `docker restart caddy-api` + +A public project needs at minimum: automated tests on push, a linter, and a documented release process. + +### 6. No Installation or Setup Documentation + +There is no README explaining how to install DashCaddy from scratch. The `CLAUDE.md` is an internal reference for AI assistants. A new user would need: + +- Prerequisites (Docker Desktop, Caddy, Technitium, Node.js) +- Step-by-step installation guide +- First-run configuration walkthrough +- Troubleshooting guide +- Architecture overview + +### 7. Single-User Only + +There is no concept of user accounts, roles, or permissions. TOTP protects access but there's one global session. For a household with multiple users, there's no way to give someone read-only access or restrict who can deploy/delete containers. + +### 8. No Container Orchestration Beyond Single-Host + +DashCaddy manages containers on one Docker host. There's no support for: +- Docker Compose stacks (multi-container apps like Nextcloud + MariaDB + Redis) +- Docker Swarm or Kubernetes +- Remote Docker hosts +- Container networking (custom networks, inter-container communication) + +Apps that need multiple containers (databases, caches, sidecars) must be set up manually. + +### 9. Credential and Secret Management Gaps + +While credentials are encrypted with AES-256-GCM, the encryption key management has limitations: +- The master key derivation and storage strategy isn't documented for end users +- Key rotation exists but there's no scheduled rotation or policy +- Backup exports include encrypted credentials but the key management for restoring on a different machine isn't clear +- No integration with external secret managers (Vault, 1Password, etc.) + +### 10. Incomplete Template Coverage + +55 templates is a strong start, but several popular self-hosted apps are missing, and the template system has constraints: +- No user-contributed templates or template marketplace +- No template versioning — if an image tag changes, templates need manual updates +- No Docker Compose support — templates are single-container only +- Environment variable templating is basic (`{{PORT}}`, `{{SUBDOMAIN}}`) with no conditional logic + +### 11. No Persistent Logging or Metrics Storage + +Metrics (request counts, response times, business events) are in-memory only — they reset on container restart. There's no time-series database, no Prometheus endpoint, no Grafana integration. For a monitoring-focused dashboard, losing all metrics on restart is a significant gap. + +### 12. The Development/Production File Split + +The two-directory development model (`e:/CaddyCerts/sites/` for editing, `C:/caddy/` for production) works for the author but would confuse contributors and can't work as-is for other users. A public release needs a single canonical source of truth with a proper build/deploy pipeline. + +--- + +## What's Strong + +Despite these obstacles, DashCaddy has substantial strengths that position it well for release: + +- **Feature-complete for its core use case**: Deploy apps, manage reverse proxy, automate DNS — it all works +- **Security-first design**: TOTP, CSRF, rate limiting, encryption, input validation, non-root containers +- **Polished UI**: Themes, keyboard shortcuts, onboarding tour, skeleton loaders, responsive design +- **Smart Arr Connect**: A genuinely useful automation that saves significant manual configuration +- **Auto-Login SSO**: Handles the messy reality of diverse auth mechanisms (cookies, JWT, IP-based, localStorage) +- **55 app templates**: Broad coverage of the self-hosting ecosystem +- **Thread-safe state management**: Proper file locking prevents corruption under concurrent access +- **In-memory metrics and monitoring**: Even without persistence, the real-time view is useful +- **Test suite exists**: 17 backend test files covering critical paths +- **Modular route architecture**: 20 route files keep the 125+ endpoints organized and maintainable + +## Summary + +DashCaddy is a mature, feature-rich self-hosting dashboard that solves a real problem — the tedium of manually configuring Docker + reverse proxy + DNS for every new service. It's daily-driver stable for a single Windows user with Caddy and Technitium DNS. + +The gap between "works great for me" and "anyone can install this" is the remaining 0.05 to v1.0. The biggest obstacles are cross-platform support, installation documentation, and removing the hardcoded infrastructure assumptions. The frontend architecture and CI/CD are secondary concerns that matter more for long-term maintainability than for a functional v1.0 release. diff --git a/ca/README.md b/ca/README.md new file mode 100644 index 0000000..aa09881 --- /dev/null +++ b/ca/README.md @@ -0,0 +1,281 @@ +# DashCA - Certificate Authority Distribution + +A self-hosted landing page for distributing your root CA certificate with one-click installation across all major platforms. + +## Quick Start + +### Regenerate All Certificate Formats + +```bash +cd scripts +bash generate-all.sh +``` + +This will: +1. Copy root.crt and intermediate.crt from Caddy PKI +2. Generate root.der (DER format for Windows) +3. Generate root.mobileconfig (Apple profile for iOS/macOS) +4. Extract certificate metadata to cert-info.json + +### Deploy to Production + +```bash +# Copy all files to production directory +cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/ +``` + +Or deploy via the dashboard app selector (preferred method). + +## File Structure + +``` +ca/ +├── index.html # Landing page with OS detection +├── root.crt # Root CA certificate (PEM format) +├── root.der # Root CA certificate (DER format) +├── root.mobileconfig # Apple configuration profile +├── intermediate.crt # Intermediate CA certificate +├── cert-info.json # Certificate metadata (auto-generated) +├── scripts/ +│ ├── install.ps1 # Windows PowerShell installer +│ ├── install.sh # Linux/macOS shell installer +│ ├── generate-cert-info.js # Extract certificate metadata +│ ├── generate-mobileconfig.js # Generate Apple profile +│ └── generate-all.sh # Wrapper script to regenerate all +└── assets/ + └── (icons, logos, etc.) +``` + +## Certificate Information + +**Source:** Caddy's built-in PKI at `C:/caddy/certs/pki/authorities/local/` + +- **Name:** Sami Home Network Root CA +- **Algorithm:** ECDSA P-256 with SHA-256 +- **Valid Until:** Dec 22, 2034 +- **Fingerprint:** `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E` + +## Installation Scripts + +### Windows (install.ps1) + +Features: +- Requires Administrator privileges +- Downloads certificate from ca.sami +- Verifies SHA-256 fingerprint +- Installs to LocalMachine\Root store +- Checks for existing installation + +**One-liner:** +```powershell +irm https://ca.sami/install.ps1 | iex +``` + +### Linux/macOS (install.sh) + +Features: +- Requires sudo/root +- Auto-detects OS (Debian, RedHat, Arch, macOS) +- Platform-specific installation commands +- Fingerprint verification with OpenSSL +- Checks for existing installation + +**One-liner:** +```bash +curl -fsSL https://ca.sami/install.sh | sudo bash +``` + +### Apple Devices (root.mobileconfig) + +Features: +- Works on both iOS and macOS +- XML configuration profile format +- Contains base64-encoded certificate +- Unique UUIDs per generation +- User must manually trust after installation (iOS) + +**Installation:** +1. Download root.mobileconfig +2. iOS: Settings prompts automatically +3. macOS: System Settings → Profiles → Install +4. iOS: Enable trust in Certificate Trust Settings + +## Landing Page Features + +The landing page (`index.html`) includes: + +- **OS Detection:** Automatically detects Windows, macOS, Linux, iOS, Android +- **Certificate Info Display:** Shows name, fingerprint, expiration, algorithm +- **QR Code:** For easy mobile access (powered by qrcodejs library) +- **Download Links:** All certificate formats and installation scripts +- **Platform Tabs:** Detailed instructions for each operating system +- **Copy-to-Clipboard:** For fingerprint and command-line scripts +- **DashCaddy Theme:** Dark mode with Sami Grotesk font + +**API Integration:** +- Loads certificate info from `/api/ca/info` endpoint +- Falls back to static info if API unavailable + +## Development Workflow + +1. **Edit Files:** Make changes in `e:/CaddyCerts/sites/ca/` +2. **Test Locally:** Open `index.html` in browser (file:// protocol works) +3. **Regenerate Certificates:** Run `scripts/generate-all.sh` if CA renewed +4. **Deploy:** Copy to production or use dashboard deployment +5. **Verify:** Visit https://ca.sami and test on target platforms + +## Updating After CA Renewal + +When Caddy regenerates its CA certificate (every ~10 years): + +### 1. Regenerate Certificate Formats + +```bash +cd e:/CaddyCerts/sites/ca/scripts +bash generate-all.sh +``` + +### 2. Update Fingerprints in Scripts + +The new fingerprint will be in `cert-info.json`. Update these files: + +**install.ps1** (line 17): +```powershell +$ExpectedFingerprint = "NEW:FIN:GER:PRINT:HERE" +``` + +**install.sh** (line 13): +```bash +EXPECTED_FP="NEW:FIN:GER:PRINT:HERE" +``` + +### 3. Deploy to Production + +```bash +cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/ +``` + +### 4. Notify Users + +- Add banner to dashboard +- Send notification via configured channels +- Update documentation with new expiration date + +## API Endpoints + +DashCA integrates with DashCaddy API: + +### GET /api/ca/info + +Returns certificate metadata: + +```json +{ + "success": true, + "certificate": { + "name": "Sami Home Network Root CA", + "fingerprint": "08:98:A5:...", + "validFrom": "Feb 12 07:44:51 2025 GMT", + "validUntil": "Dec 22 07:44:51 2034 GMT", + "daysUntilExpiration": 3235, + "algorithm": "ECDSA P-256 with SHA-256", + "serialNumber": "c1:dc:48:...", + "downloadUrl": "https://ca.sami/root.crt" + } +} +``` + +### GET /api/health/ca + +Returns CA expiration health status: + +```json +{ + "status": "healthy", + "message": "CA certificate valid for 3235 days", + "daysUntilExpiration": 3235, + "expiresAt": "Dec 22 07:44:51 2034 GMT" +} +``` + +**Status values:** +- `healthy`: >90 days remaining +- `warning`: 30-90 days +- `critical`: <30 days or expired +- `error`: Certificate not found or error reading + +## Troubleshooting + +### Certificate Not Found Error + +**Symptom:** Scripts fail with "certificate not found" +**Cause:** Caddy hasn't generated the local CA yet +**Solution:** Visit any *.sami domain to trigger CA generation + +### Fingerprint Mismatch + +**Symptom:** Install scripts reject certificate with fingerprint mismatch +**Cause:** CA was renewed but scripts not updated +**Solution:** Run `generate-all.sh` and update fingerprints in install scripts + +### iOS Profile Won't Install + +**Symptom:** .mobileconfig shows error when installing +**Cause:** Invalid XML or missing UUIDs +**Solution:** Regenerate with `node generate-mobileconfig.js` + +### Android Shows "Not Trusted" + +**Symptom:** Certificate installs but sites still show warnings +**Cause:** Android installs as "user" certificate; some apps don't trust user CAs +**Solution:** This is by design. System CA installation requires root access. + +### Landing Page Shows "Loading..." + +**Symptom:** Certificate info stuck on loading state +**Cause:** API endpoint not accessible +**Solution:** Check that dashcaddy-api server is running and `/api/ca/info` responds + +## Testing Checklist + +Before deploying to production: + +- [ ] All certificate formats generated successfully +- [ ] Landing page loads correctly in browser +- [ ] OS detection works (test multiple user agents) +- [ ] QR code renders and scans correctly +- [ ] Download links work for all file types +- [ ] API endpoint returns valid certificate info +- [ ] Copy-to-clipboard buttons work +- [ ] Platform instruction tabs function correctly +- [ ] Responsive design works on mobile viewport +- [ ] HTTPS access works after deployment + +## Security Notes + +- **Private Key:** NEVER serve the CA private key (`root.key`). Only public certificates are safe to distribute. +- **Fingerprint Verification:** Install scripts verify fingerprint to prevent MITM attacks +- **Access Control:** ca.sami should only be accessible on your Tailnet/internal network +- **HTTPS Enforcement:** The page itself uses HTTPS (via Caddy's internal CA) to protect the distribution +- **No Auto-Execution:** All installation methods require explicit user action + +## Contributing + +When adding features to DashCA: + +1. Test on multiple platforms before committing +2. Update this README with new features +3. Add relevant sections to troubleshooting guide +4. Update CLAUDE.md if deployment process changes +5. Ensure backward compatibility with existing certificates + +## Resources + +- **Caddy PKI Documentation:** https://caddyserver.com/docs/caddyfile/directives/tls#pki +- **mobileconfig Format:** https://developer.apple.com/documentation/devicemanagement +- **OpenSSL Certificate Commands:** https://www.openssl.org/docs/man1.1.1/man1/x509.html +- **QR Code Library:** https://github.com/davidshimjs/qrcodejs + +--- + +**Part of the DashCaddy project** - Unified management for Docker + Caddy + DNS diff --git a/ca/cert-info.json b/ca/cert-info.json new file mode 100644 index 0000000..80e6f12 --- /dev/null +++ b/ca/cert-info.json @@ -0,0 +1,11 @@ +{ + "name": "Sami Home Network Root CA", + "fingerprint": "08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E", + "validFrom": "Feb 12 07:44:51 2025 GMT", + "validUntil": "Dec 22 07:44:51 2034 GMT", + "daysUntilExpiration": 3235, + "algorithm": "ECDSA P-256 with SHA-256", + "issuer": "Sami Home Network Root CA", + "serialNumber": "C1DC482220B562C06853903A8956D052", + "generatedAt": "2026-02-11T10:43:32.863Z" +} \ No newline at end of file diff --git a/ca/index.html b/ca/index.html new file mode 100644 index 0000000..da70577 --- /dev/null +++ b/ca/index.html @@ -0,0 +1,1284 @@ + + + + + + DashCA - Certificate Authority + + + + + + + + +
+ +
+ +

DashCA

+

Certificate Authority Distribution

+
+ + +
+

+ 📜 + Certificate Information +

+
+
+
Certificate Name
+
+ Loading... +
+
+
+
Algorithm
+
+ Loading... +
+
+
+
Valid Until
+
+ Loading... +
+
+
+
Days Remaining
+
+ Loading... +
+
+
+
SHA-256 Fingerprint
+
+ Loading... + +
+
+
+
+ + +
+ +
+ Detecting your operating system... +
+ +
+ + +
+

📱 Quick Access for Mobile

+
+

Scan with your phone's camera to visit this page

+
+ + +
+

+ 🔐 + Service Certificates +

+

+ SSL certificates generated for services on your network +

+
+ +
+
+ Loading... + Checking +
+
Fetching certificate status...
+
+
+
+ + + + + +
+

+ 📖 + Installation Instructions +

+ +
+ + + + + +
+ + +
+
+
1
+
+

Run PowerShell as Administrator

+

Right-click the Start button and select "Windows PowerShell (Admin)" or "Terminal (Admin)"

+
+
+
+
2
+
+

Run the installation command

+

Copy and paste this command into PowerShell:

+
+ + +
+
+
+
+
3
+
+

Verify installation

+

Visit any *.sami domain (like https://status.sami) - you should see a secure connection with no warnings.

+
+
+
+ + +
+
+
1
+
+

Download the certificate profile

+

Click "Apple Profile" above to download root.mobileconfig

+
+
+
+
2
+
+

Install the profile

+

Open System Settings → Privacy & Security → Profiles. Click the downloaded profile and click "Install".

+
+
+
+
3
+
+

Alternative: Command line installation

+

Open Terminal and run:

+
+ + +
+
+
+
+ + +
+
+
1
+
+

Open Terminal

+

Open your terminal application (usually Ctrl+Alt+T)

+
+
+
+
2
+
+

Run the installation command

+

Copy and paste this command (will prompt for sudo password):

+
+ + +
+
+
+
+
3
+
+

Supported distributions

+

The installer supports Debian/Ubuntu, RedHat/CentOS/Fedora, and Arch Linux. It will automatically detect your distribution and use the appropriate commands.

+
+
+
+ + +
+
+
1
+
+

Scan QR code or visit this page

+

Use your iPhone's camera to scan the QR code above, or visit https://ca.sami directly in Safari

+
+
+
+
2
+
+

Download the profile

+

Tap "Apple Profile" to download root.mobileconfig. You'll see a notification that the profile was downloaded.

+
+
+
+
3
+
+

Install the profile

+

Go to Settings (you should see a notification), tap on the downloaded profile, and tap "Install". Enter your passcode if prompted.

+
+
+
+
4
+
+

Trust the certificate

+

Go to Settings → General → About → Certificate Trust Settings. Enable full trust for "Sami Home Network Root CA".

+
+
+
+ + +
+
+
1
+
+

Download the certificate

+

Tap "Root Certificate (.crt)" above to download the certificate to your device

+
+
+
+
2
+
+

Install the certificate

+

Open Settings → Security → Encryption & credentials → Install a certificate → CA certificate. Select the downloaded file.

+
+
+
+
3
+
+

Note about user certificates

+

Android installs this as a "user" certificate. Some apps may not trust user certificates for security reasons. For full trust, you would need to install it as a system certificate (requires root access).

+
+
+
+
+ + + +
+ + + + diff --git a/ca/intermediate.crt b/ca/intermediate.crt new file mode 100644 index 0000000..d992ac2 --- /dev/null +++ b/ca/intermediate.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/ca/root.crt b/ca/root.crt new file mode 100644 index 0000000..97a4ce4 --- /dev/null +++ b/ca/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/ca/root.der b/ca/root.der new file mode 100644 index 0000000..b22c573 Binary files /dev/null and b/ca/root.der differ diff --git a/ca/root.mobileconfig b/ca/root.mobileconfig new file mode 100644 index 0000000..611fd5e --- /dev/null +++ b/ca/root.mobileconfig @@ -0,0 +1,45 @@ + + + + + PayloadContent + + + PayloadCertificateFileName + root.crt + PayloadContent + + MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEiMCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3JrIFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgkwyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8Cyam9Y42t1K8Fx5q5iy+bs8w= + + PayloadDescription + Root CA certificate for Sami Home Network + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca.root-ca + PayloadType + com.apple.security.root + PayloadUUID + 059F6B88-E62A-4219-90D5-7FABBE83540A + PayloadVersion + 1 + + + PayloadDescription + Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains. + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca + PayloadOrganization + Sami Home Network + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + AF495D1C-16AF-44A7-8C6C-173CC8E82FC3 + PayloadVersion + 1 + + diff --git a/ca/scripts/generate-all.sh b/ca/scripts/generate-all.sh new file mode 100644 index 0000000..3746d80 --- /dev/null +++ b/ca/scripts/generate-all.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +# DashCA Certificate Generation Script +# This script generates all required certificate formats + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CA_DIR="$(dirname "$SCRIPT_DIR")" +CADDY_CERT_DIR="C:/caddy/certs/pki/authorities/local" + +echo "======================================" +echo "DashCA Certificate Format Generator" +echo "======================================" +echo "" + +# Step 1: Copy certificates from Caddy +echo "[1/4] Copying certificates from Caddy PKI..." +if [ ! -f "$CADDY_CERT_DIR/root.crt" ]; then + echo "ERROR: Root certificate not found at $CADDY_CERT_DIR/root.crt" + exit 1 +fi + +cp "$CADDY_CERT_DIR/root.crt" "$CA_DIR/" +cp "$CADDY_CERT_DIR/intermediate.crt" "$CA_DIR/" 2>/dev/null || echo " (Intermediate certificate not found, skipping)" +echo " ✓ Certificates copied" + +# Step 2: Generate DER format +echo "[2/4] Generating DER format..." +openssl x509 -in "$CA_DIR/root.crt" -outform DER -out "$CA_DIR/root.der" +echo " ✓ DER format generated: root.der" + +# Step 3: Generate certificate info JSON +echo "[3/4] Extracting certificate metadata..." +node "$SCRIPT_DIR/generate-cert-info.js" + +# Step 4: Generate Apple mobileconfig +echo "[4/4] Generating Apple mobile configuration profile..." +node "$SCRIPT_DIR/generate-mobileconfig.js" + +echo "" +echo "======================================" +echo "✓ All certificate formats generated!" +echo "======================================" +echo "" +echo "Files created in: $CA_DIR" +ls -lh "$CA_DIR"/*.{crt,der,mobileconfig,json} 2>/dev/null || echo "Files created successfully" +echo "" +echo "To deploy to production:" +echo " cp -r $CA_DIR/* C:/caddy/sites/ca/" +echo "" diff --git a/ca/scripts/generate-cert-info.js b/ca/scripts/generate-cert-info.js new file mode 100644 index 0000000..d3d22c9 --- /dev/null +++ b/ca/scripts/generate-cert-info.js @@ -0,0 +1,75 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const CERT_PATH = path.join(__dirname, '../root.crt'); +const OUTPUT_PATH = path.join(__dirname, '../cert-info.json'); + +function extractCertInfo() { + try { + console.log('Extracting certificate information from:', CERT_PATH); + + // Extract SHA-256 fingerprint + const fingerprint = execSync(`openssl x509 -in "${CERT_PATH}" -noout -fingerprint -sha256`) + .toString() + .trim() + .split('=')[1]; + + // Extract validity dates + const dates = execSync(`openssl x509 -in "${CERT_PATH}" -noout -dates`).toString(); + const notBefore = dates.match(/notBefore=(.*)/)[1].trim(); + const notAfter = dates.match(/notAfter=(.*)/)[1].trim(); + + // Extract subject + const subject = execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`) + .toString() + .trim() + .split('CN = ')[1] || execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`) + .toString() + .trim() + .split('CN=')[1]; + + // Extract serial number + const serialNumber = execSync(`openssl x509 -in "${CERT_PATH}" -noout -serial`) + .toString() + .trim() + .split('=')[1]; + + // Calculate days until expiration + const expirationDate = new Date(notAfter); + const today = new Date(); + const daysUntilExpiration = Math.floor((expirationDate - today) / (1000 * 60 * 60 * 24)); + + const certInfo = { + name: subject, + fingerprint: fingerprint, + validFrom: notBefore, + validUntil: notAfter, + daysUntilExpiration: daysUntilExpiration, + algorithm: 'ECDSA P-256 with SHA-256', + issuer: subject, // Self-signed root CA + serialNumber: serialNumber, + generatedAt: new Date().toISOString() + }; + + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(certInfo, null, 2)); + console.log('✓ Certificate information extracted successfully!'); + console.log(' Output:', OUTPUT_PATH); + console.log(' Name:', certInfo.name); + console.log(' Fingerprint:', certInfo.fingerprint); + console.log(' Valid until:', certInfo.validUntil); + console.log(' Days until expiration:', certInfo.daysUntilExpiration); + + return certInfo; + } catch (error) { + console.error('Error extracting certificate information:', error.message); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + extractCertInfo(); +} + +module.exports = { extractCertInfo }; diff --git a/ca/scripts/generate-mobileconfig.js b/ca/scripts/generate-mobileconfig.js new file mode 100644 index 0000000..77fe85e --- /dev/null +++ b/ca/scripts/generate-mobileconfig.js @@ -0,0 +1,105 @@ +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); + +const CERT_PATH = path.join(__dirname, '../root.crt'); +const OUTPUT_PATH = path.join(__dirname, '../root.mobileconfig'); + +function generateUUID() { + return crypto.randomUUID().toUpperCase(); +} + +function generateMobileConfig() { + try { + console.log('Generating Apple mobile configuration profile...'); + console.log('Reading certificate from:', CERT_PATH); + + // Read certificate + const certPem = fs.readFileSync(CERT_PATH, 'utf8'); + + // Extract base64 content (remove PEM headers and newlines) + const certBase64 = certPem + .replace('-----BEGIN CERTIFICATE-----', '') + .replace('-----END CERTIFICATE-----', '') + .replace(/\s/g, ''); + + // Generate UUIDs for profile and payload + const profileUUID = generateUUID(); + const payloadUUID = generateUUID(); + + const mobileconfig = ` + + + + PayloadContent + + + PayloadCertificateFileName + root.crt + PayloadContent + + ${certBase64} + + PayloadDescription + Root CA certificate for Sami Home Network + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca.root-ca + PayloadType + com.apple.security.root + PayloadUUID + ${payloadUUID} + PayloadVersion + 1 + + + PayloadDescription + Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains. + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca + PayloadOrganization + Sami Home Network + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + ${profileUUID} + PayloadVersion + 1 + + +`; + + fs.writeFileSync(OUTPUT_PATH, mobileconfig); + console.log('✓ Mobile configuration profile generated successfully!'); + console.log(' Output:', OUTPUT_PATH); + console.log(' Profile UUID:', profileUUID); + console.log(' Payload UUID:', payloadUUID); + console.log('\nTo install on iOS:'); + console.log(' 1. Download root.mobileconfig to your device'); + console.log(' 2. Open Settings app (it should prompt automatically)'); + console.log(' 3. Tap "Install Profile" and follow the prompts'); + console.log(' 4. Go to Settings > General > About > Certificate Trust Settings'); + console.log(' 5. Enable full trust for "Sami Home Network Root CA"'); + console.log('\nTo install on macOS:'); + console.log(' 1. Download root.mobileconfig'); + console.log(' 2. Open System Settings > Privacy & Security > Profiles'); + console.log(' 3. Click the profile and click Install'); + + return { profileUUID, payloadUUID }; + } catch (error) { + console.error('Error generating mobile configuration profile:', error.message); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + generateMobileConfig(); +} + +module.exports = { generateMobileConfig }; diff --git a/ca/scripts/install.ps1 b/ca/scripts/install.ps1 new file mode 100644 index 0000000..cc28a37 --- /dev/null +++ b/ca/scripts/install.ps1 @@ -0,0 +1,132 @@ +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Installs the Sami Home Network Root CA certificate to the Trusted Root Certification Authorities store. + +.DESCRIPTION + This script downloads the root CA certificate from ca.sami, verifies its fingerprint, + and installs it to the local machine's trusted root store. This allows all *.sami domains + to be trusted system-wide without browser warnings. + +.NOTES + Requires Administrator privileges. + For use with DashCA - https://ca.sami +#> + +$ErrorActionPreference = "Stop" + +# Configuration +$CertUrl = "https://ca.sami/root.crt" +$ExpectedFingerprint = "0898A563F5A1A2585F02D7A8A25487E6BC33969F9B5DB053622 07FAF9621290E" +$TempFile = "$env:TEMP\sami-root-ca.crt" + +# Colors +$Red = [System.ConsoleColor]::Red +$Green = [System.ConsoleColor]::Green +$Cyan = [System.ConsoleColor]::Cyan +$Yellow = [System.ConsoleColor]::Yellow + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host " DashCA Installer" -ForegroundColor $Cyan +Write-Host " Sami Home Network Root CA" -ForegroundColor $Cyan +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host "" + +# Step 1: Download certificate +Write-Host "[1/4] Downloading certificate from $CertUrl..." -ForegroundColor $Cyan +try { + $ProgressPreference = 'SilentlyContinue' # Disable progress bar for faster download + Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop + Write-Host " ✓ Certificate downloaded" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to download certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Write-Host "" + Write-Host "Troubleshooting:" -ForegroundColor $Yellow + Write-Host " - Ensure you are on the Tailnet/network where ca.sami is accessible" -ForegroundColor $Yellow + Write-Host " - Try accessing https://ca.sami in your browser first" -ForegroundColor $Yellow + exit 1 +} + +# Step 2: Verify fingerprint +Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan +try { + $Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile) + $Fingerprint = $Cert.Thumbprint + + $NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', '' + $NormalizedActual = $Fingerprint -replace '[:\s]', '' + + if ($NormalizedActual -ne $NormalizedExpected) { + Write-Host " ✗ Fingerprint mismatch!" -ForegroundColor $Red + Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow + Write-Host " Got: $Fingerprint" -ForegroundColor $Red + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red + Write-Host "This could indicate a man-in-the-middle attack or certificate renewal." -ForegroundColor $Red + Write-Host "Please verify with your network administrator before proceeding." -ForegroundColor $Red + exit 1 + } + + Write-Host " ✓ Fingerprint verified: $Fingerprint" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to verify fingerprint" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + exit 1 +} + +# Step 3: Check if already installed +Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan +$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint } +if ($ExistingCert) { + Write-Host " ℹ Certificate already installed" -ForegroundColor $Yellow + Write-Host " Subject: $($ExistingCert.Subject)" -ForegroundColor $Yellow + Write-Host " Not After: $($ExistingCert.NotAfter)" -ForegroundColor $Yellow + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "The Sami Home Network Root CA is already trusted on this system." -ForegroundColor $Green + Write-Host "No further action needed!" -ForegroundColor $Green + Write-Host "" + exit 0 +} +Write-Host " ✓ Certificate not yet installed, proceeding..." -ForegroundColor $Green + +# Step 4: Install certificate +Write-Host "[4/4] Installing certificate to Trusted Root store..." -ForegroundColor $Cyan +try { + $ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop + Write-Host " ✓ Certificate installed successfully" -ForegroundColor $Green + Write-Host " Subject: $($ImportedCert.Subject)" -ForegroundColor $Green + Write-Host " Thumbprint: $($ImportedCert.Thumbprint)" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to install certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + Write-Host "" + Write-Host "Installation failed. Please ensure you are running as Administrator." -ForegroundColor $Red + exit 1 +} + +# Cleanup +Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Green +Write-Host " SUCCESS!" -ForegroundColor $Green +Write-Host "========================================" -ForegroundColor $Green +Write-Host "" +Write-Host "The Sami Home Network Root CA has been installed to your Trusted Root store." -ForegroundColor $Green +Write-Host "" +Write-Host "What's next:" -ForegroundColor $Cyan +Write-Host " ✓ All *.sami domains will now be trusted system-wide" -ForegroundColor $Green +Write-Host " ✓ Browsers (Edge, Chrome, Firefox) will no longer show security warnings" -ForegroundColor $Green +Write-Host " ✓ Applications will trust HTTPS connections to your local services" -ForegroundColor $Green +Write-Host "" +Write-Host "Test it out:" -ForegroundColor $Cyan +Write-Host " Visit https://status.sami or any other *.sami service" -ForegroundColor $Yellow +Write-Host " The connection should show as secure with no warnings" -ForegroundColor $Yellow +Write-Host "" diff --git a/ca/scripts/install.sh b/ca/scripts/install.sh new file mode 100644 index 0000000..0fcb365 --- /dev/null +++ b/ca/scripts/install.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# +# DashCA Installer - Sami Home Network Root CA +# Installs the root CA certificate system-wide on Linux and macOS +# +# Usage: curl -fsSL https://ca.sami/install.sh | sudo bash +# +set -e + +# Configuration +CERT_URL="https://ca.sami/root.crt" +EXPECTED_FP="08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E" +CERT_NAME="Sami_Home_Network_Root_CA" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "" +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} DashCA Installer${NC}" +echo -e "${CYAN} Sami Home Network Root CA${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check for root/sudo +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}✗ This script requires root privileges${NC}" + echo "" + echo "Please run with sudo:" + echo -e " ${YELLOW}curl -fsSL https://ca.sami/install.sh | sudo bash${NC}" + echo "" + echo "Or download first, then run:" + echo -e " ${YELLOW}curl -o install.sh https://ca.sami/install.sh${NC}" + echo -e " ${YELLOW}sudo bash install.sh${NC}" + echo "" + exit 1 +fi + +# Detect OS +echo -e "${CYAN}[1/6] Detecting operating system...${NC}" +if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + OS_NAME="macOS" +elif [[ -f /etc/os-release ]]; then + . /etc/os-release + if [[ "$ID" == "debian" ]] || [[ "$ID" == "ubuntu" ]] || [[ "$ID_LIKE" == *"debian"* ]]; then + OS="debian" + OS_NAME="Debian/Ubuntu" + elif [[ "$ID" == "fedora" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "centos" ]] || [[ "$ID_LIKE" == *"fedora"* ]] || [[ "$ID_LIKE" == *"rhel"* ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS/Fedora" + elif [[ "$ID" == "arch" ]] || [[ "$ID_LIKE" == *"arch"* ]]; then + OS="arch" + OS_NAME="Arch Linux" + else + OS="unknown" + OS_NAME="Unknown Linux" + fi +elif [[ -f /etc/redhat-release ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS" +elif [[ -f /etc/arch-release ]]; then + OS="arch" + OS_NAME="Arch Linux" +else + OS="unknown" + OS_NAME="Unknown" +fi + +if [[ "$OS" == "unknown" ]]; then + echo -e "${RED} ✗ Unsupported operating system${NC}" + echo "" + echo "This script supports:" + echo " - Debian/Ubuntu" + echo " - RedHat/CentOS/Fedora" + echo " - Arch Linux" + echo " - macOS" + echo "" + echo "For manual installation, download the certificate:" + echo -e " ${YELLOW}curl -O $CERT_URL${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Detected: $OS_NAME${NC}" + +# Download certificate +echo -e "${CYAN}[2/6] Downloading certificate from $CERT_URL...${NC}" +TEMP_CERT=$(mktemp) +if ! curl -fsSL "$CERT_URL" -o "$TEMP_CERT"; then + echo -e "${RED} ✗ Failed to download certificate${NC}" + echo "" + echo -e "${YELLOW}Troubleshooting:${NC}" + echo " - Ensure you are on the Tailnet/network where ca.sami is accessible" + echo " - Try accessing https://ca.sami in your browser first" + echo " - Check your network connection" + rm -f "$TEMP_CERT" + exit 1 +fi +echo -e "${GREEN} ✓ Certificate downloaded${NC}" + +# Verify fingerprint +echo -e "${CYAN}[3/6] Verifying certificate fingerprint...${NC}" +if ! command -v openssl &> /dev/null; then + echo -e "${RED} ✗ OpenSSL not found${NC}" + echo "Please install OpenSSL to verify certificate fingerprint" + rm -f "$TEMP_CERT" + exit 1 +fi + +ACTUAL_FP=$(openssl x509 -in "$TEMP_CERT" -noout -fingerprint -sha256 | cut -d= -f2) + +if [[ "$ACTUAL_FP" != "$EXPECTED_FP" ]]; then + echo -e "${RED} ✗ Fingerprint mismatch!${NC}" + echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}" + echo -e "${RED} Got: $ACTUAL_FP${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${RED}SECURITY WARNING: The downloaded certificate does not match the expected fingerprint.${NC}" + echo -e "${RED}This could indicate a man-in-the-middle attack or certificate renewal.${NC}" + echo -e "${RED}Please verify with your network administrator before proceeding.${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Fingerprint verified${NC}" + +# Extract certificate details +echo -e "${CYAN}[4/6] Extracting certificate information...${NC}" +CERT_SUBJECT=$(openssl x509 -in "$TEMP_CERT" -noout -subject | sed 's/subject=//') +CERT_NOT_AFTER=$(openssl x509 -in "$TEMP_CERT" -noout -enddate | sed 's/notAfter=//') +echo -e "${GREEN} ✓ Subject: $CERT_SUBJECT${NC}" +echo -e "${GREEN} ✓ Valid until: $CERT_NOT_AFTER${NC}" + +# Check if already installed +echo -e "${CYAN}[5/6] Checking for existing installation...${NC}" +ALREADY_INSTALLED=false + +case "$OS" in + debian) + if [[ -f "/usr/local/share/ca-certificates/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + redhat) + if [[ -f "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + arch) + if [[ -f "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + macos) + if security find-certificate -a -c "$CERT_SUBJECT" /Library/Keychains/System.keychain &>/dev/null; then + ALREADY_INSTALLED=true + fi + ;; +esac + +if [[ "$ALREADY_INSTALLED" == "true" ]]; then + echo -e "${YELLOW} ℹ Certificate already installed${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${GREEN}The Sami Home Network Root CA is already trusted on this system.${NC}" + echo -e "${GREEN}No further action needed!${NC}" + echo "" + exit 0 +fi + +echo -e "${GREEN} ✓ Certificate not yet installed, proceeding...${NC}" + +# Install based on OS +echo -e "${CYAN}[6/6] Installing certificate...${NC}" +case "$OS" in + debian) + cp "$TEMP_CERT" "/usr/local/share/ca-certificates/${CERT_NAME}.crt" + update-ca-certificates + echo -e "${GREEN} ✓ Certificate installed via update-ca-certificates${NC}" + ;; + redhat) + cp "$TEMP_CERT" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" + update-ca-trust + echo -e "${GREEN} ✓ Certificate installed via update-ca-trust${NC}" + ;; + arch) + cp "$TEMP_CERT" "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" + trust extract-compat + echo -e "${GREEN} ✓ Certificate installed via trust extract-compat${NC}" + ;; + macos) + security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TEMP_CERT" + echo -e "${GREEN} ✓ Certificate installed to System Keychain${NC}" + ;; +esac + +# Cleanup +rm -f "$TEMP_CERT" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} SUCCESS!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "${GREEN}The Sami Home Network Root CA has been installed system-wide.${NC}" +echo "" +echo -e "${CYAN}What's next:${NC}" +echo -e " ${GREEN}✓${NC} All *.sami domains will now be trusted" +echo -e " ${GREEN}✓${NC} Browsers will no longer show security warnings" +echo -e " ${GREEN}✓${NC} Applications will trust HTTPS connections to your local services" +echo "" +echo -e "${CYAN}Test it out:${NC}" +echo -e " ${YELLOW}Visit https://status.sami or any other *.sami service${NC}" +echo -e " ${YELLOW}The connection should show as secure with no warnings${NC}" +echo "" diff --git a/dashcaddy-api/.dockerignore b/dashcaddy-api/.dockerignore new file mode 100644 index 0000000..3a4e192 --- /dev/null +++ b/dashcaddy-api/.dockerignore @@ -0,0 +1,10 @@ +node_modules/ +__tests__/ +jest.config.js +.env +.encryption-key +.gitignore +.dockerignore +*.log +*.md +docker-compose.yml diff --git a/dashcaddy-api/.license-counter b/dashcaddy-api/.license-counter new file mode 100644 index 0000000..56a6051 --- /dev/null +++ b/dashcaddy-api/.license-counter @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/dashcaddy-api/.license-secret b/dashcaddy-api/.license-secret new file mode 100644 index 0000000..7627bf7 --- /dev/null +++ b/dashcaddy-api/.license-secret @@ -0,0 +1 @@ +1d87da6ce9285898051ed2b120628d730d13ec4accad95908b7fc2c0ab33db48 \ No newline at end of file diff --git a/dashcaddy-api/BUFFER_AUDIT.md b/dashcaddy-api/BUFFER_AUDIT.md new file mode 100644 index 0000000..e69de29 diff --git a/dashcaddy-api/BUFFER_SECURITY.md b/dashcaddy-api/BUFFER_SECURITY.md new file mode 100644 index 0000000..e69de29 diff --git a/dashcaddy-api/DOMAIN_STRATEGY.md b/dashcaddy-api/DOMAIN_STRATEGY.md new file mode 100644 index 0000000..e69de29 diff --git a/dashcaddy-api/Dockerfile b/dashcaddy-api/Dockerfile new file mode 100644 index 0000000..ec6b9ea --- /dev/null +++ b/dashcaddy-api/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install OpenSSL for certificate generation +RUN apk add --no-cache openssl + +COPY package*.json ./ +RUN npm install --production + +COPY *.js ./ +COPY routes/ ./routes/ +COPY openapi.yaml ./ + +# Note: Running as root because container needs Docker socket access +# (which is root-equivalent anyway). Socket access required for container management. + +EXPOSE 3001 + +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))" + +CMD ["node", "server.js"] diff --git a/dashcaddy-api/__tests__/api-endpoints.test.js b/dashcaddy-api/__tests__/api-endpoints.test.js new file mode 100644 index 0000000..4a5598f --- /dev/null +++ b/dashcaddy-api/__tests__/api-endpoints.test.js @@ -0,0 +1,423 @@ +/** + * API Endpoint Tests + * + * Comprehensive tests for critical DashCaddy API endpoints + * Tests the migrated StateManager integration and core functionality + */ + +const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Create a test instance of the app +// Note: We need to mock the service file to avoid affecting production +const testServicesFile = path.join(os.tmpdir(), `test-services-${Date.now()}.json`); +const testConfigFile = path.join(os.tmpdir(), `test-config-${Date.now()}.json`); + +// Set test environment +process.env.SERVICES_FILE = testServicesFile; +process.env.CONFIG_FILE = testConfigFile; +process.env.CADDYFILE_PATH = path.join(os.tmpdir(), 'test-Caddyfile'); +process.env.CADDY_ADMIN_URL = 'http://localhost:2019'; +process.env.ENABLE_HEALTH_CHECKER = 'false'; // Disable to avoid background processes +process.env.NODE_ENV = 'test'; + +// Initialize test files +fs.writeFileSync(testServicesFile, '[]', 'utf8'); +fs.writeFileSync(testConfigFile, '{}', 'utf8'); +fs.writeFileSync(process.env.CADDYFILE_PATH, '# Test Caddyfile', 'utf8'); + +// Now require the app (after env setup) +const app = require('../server'); + +describe('API Endpoints', () => { + + // Clean up before each test + beforeEach(() => { + fs.writeFileSync(testServicesFile, '[]', 'utf8'); + }); + + // Clean up after all tests + afterAll(() => { + try { + fs.unlinkSync(testServicesFile); + fs.unlinkSync(testConfigFile); + fs.unlinkSync(process.env.CADDYFILE_PATH); + } catch (e) { + // Ignore cleanup errors + } + }); + + describe('GET /api/health', () => { + test('should return healthy status', async () => { + const res = await request(app).get('/api/health'); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('status', 'ok'); + expect(res.body).toHaveProperty('timestamp'); + }); + }); + + describe('GET /api/services', () => { + test('should return empty array initially', async () => { + const res = await request(app).get('/api/services'); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBe(0); + }); + + test('should return services after adding', async () => { + // Add a service first + await request(app) + .post('/api/services') + .send({ + id: 'test-service', + name: 'Test Service', + logo: '/assets/test.png', + ip: 'localhost', + tailscaleOnly: false + }); + + // Now get services + const res = await request(app).get('/api/services'); + + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(1); + expect(res.body[0]).toMatchObject({ + id: 'test-service', + name: 'Test Service' + }); + }); + + test('should use StateManager (thread-safe)', async () => { + // This test verifies StateManager is being used + // by checking that the file is read correctly + + // Manually write to file + const testData = [{ id: 'manual', name: 'Manual Service' }]; + fs.writeFileSync(testServicesFile, JSON.stringify(testData, null, 2)); + + const res = await request(app).get('/api/services'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(testData); + }); + }); + + describe('POST /api/services', () => { + test('should add a new service', async () => { + const newService = { + id: 'plex', + name: 'Plex', + logo: '/assets/plex.png', + ip: 'localhost', + tailscaleOnly: false + }; + + const res = await request(app) + .post('/api/services') + .send(newService); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('success', true); + + // Verify service was added + const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8')); + expect(services.length).toBe(1); + expect(services[0].id).toBe(newService.id); + expect(services[0].name).toBe(newService.name); + expect(services[0].logo).toBe(newService.logo); + }); + + test('should reject duplicate service IDs', async () => { + const service = { + id: 'duplicate', + name: 'Duplicate Service' + }; + + // Add first time + await request(app).post('/api/services').send(service); + + // Try to add again + const res = await request(app).post('/api/services').send(service); + + expect(res.statusCode).toBe(409); // Conflict is the correct status code + expect(res.body).toHaveProperty('success', false); + expect(res.body.error).toContain('already exists'); + }); + + test('should validate required fields', async () => { + const res = await request(app) + .post('/api/services') + .send({ + // Missing 'id' and 'name' + logo: '/assets/test.png' + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toHaveProperty('success', false); + }); + + test('should sanitize user input (XSS protection)', async () => { + const maliciousService = { + id: 'test', + name: '', + logo: '/assets/test.png' + }; + + const res = await request(app) + .post('/api/services') + .send(maliciousService); + + // Input should be sanitized or rejected + const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8')); + + // If the service was added, script tags should be removed or escaped + if (services.length > 0) { + expect(services[0].id).not.toContain(' + + +`); +}); + +app.get('/api/docs/spec', asyncHandler(async (req, res) => { + const specPath = path.join(__dirname, 'openapi.yaml'); + if (await exists(specPath)) { + const yaml = await fsp.readFile(specPath, 'utf8'); + res.type('text/yaml').send(yaml); + } else { + errorResponse(res, 404, 'OpenAPI spec not found'); + } +}, 'api-docs-spec')); + +// Global error handler for typed errors +app.use((err, req, res, next) => { + if (err instanceof AppError) { + return res.status(err.statusCode).json({ + success: false, + error: err.message, + code: err.code, + ...(err.details ? { details: err.details } : {}) + }); + } + if (err instanceof ValidationError) { + return res.status(err.statusCode || 400).json({ + success: false, + error: err.message, + errors: err.errors || undefined + }); + } + next(err); +}); + +// Export app for testing +module.exports = app; + +if (require.main === module) { +// Validate configuration and wait for async config loads before starting server +(async () => { +await Promise.all([_configsReady, _notificationsReady]); +await licenseManager.load(); +await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }); + +const server = app.listen(PORT, '0.0.0.0', () => { + log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE }); + if (BROWSE_ROOTS.length > 0) { + log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) }); + } + + // Start new feature modules + log.info('server', 'Starting DashCaddy feature modules'); + + // Clean up stale port locks + (async () => { + try { + await portLockManager.cleanupStaleLocks(); + log.info('server', 'Port lock cleanup completed'); + } catch (error) { + log.error('server', 'Port lock cleanup failed', { error: error.message }); + } + })(); + + try { + resourceMonitor.start(); + log.info('server', 'Resource monitoring started'); + } catch (error) { + log.error('server', 'Resource monitoring failed to start', { error: error.message }); + } + + try { + backupManager.start(); + log.info('server', 'Backup manager started'); + } catch (error) { + log.error('server', 'Backup manager failed to start', { error: error.message }); + } + + (async () => { + try { + // Auto-configure health checker from services.json + await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildDomain, APP }); + healthChecker.start(); + log.info('server', 'Health checker started'); + } catch (error) { + log.error('server', 'Health checker failed to start', { error: error.message }); + } + })(); + + try { + updateManager.start(); + log.info('server', 'Update manager started'); + } catch (error) { + log.error('server', 'Update manager failed to start', { error: error.message }); + } + + // Tailscale API sync (if OAuth configured) + if (tailscaleConfig.oauthConfigured) { + startTailscaleSyncTimer(); + // Run initial sync + syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message })); + } + + log.info('server', 'All feature modules initialized'); +}); + +// Graceful shutdown — drain connections before exiting +function shutdown(signal) { + log.info('shutdown', `${signal} received, draining connections...`); + resourceMonitor.stop(); + backupManager.stop(); + healthChecker.stop(); + updateManager.stop(); + stopTailscaleSyncTimer(); + server.close(() => { + log.info('shutdown', 'HTTP server closed'); + process.exit(0); + }); + // Force exit after 5s if connections don't drain + setTimeout(() => process.exit(0), 5000).unref(); +} +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); +})(); // end async startup +} // end if (require.main === module) + +// #2: Catch unhandled errors so the process doesn't crash silently +process.on('unhandledRejection', (reason) => { + logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))); +}); +process.on('uncaughtException', (error) => { + logError('uncaughtException', error); + // Give the error log time to flush, then exit + setTimeout(() => process.exit(1), 1000).unref(); +}); diff --git a/dashcaddy-api/startup-validator.js b/dashcaddy-api/startup-validator.js new file mode 100644 index 0000000..abb7555 --- /dev/null +++ b/dashcaddy-api/startup-validator.js @@ -0,0 +1,209 @@ +/** + * Startup Validation Module + * Extracts startup configuration validation and health checker sync from server.js + * (Phase 3 refactoring) + */ + +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const http = require('http'); +const https = require('https'); +const { exists, isAccessible } = require('./fs-helpers'); + +/** + * Validate startup configuration and environment. + * Fail fast with clear error messages if critical issues are found. + * + * @param {Object} deps + * @param {Function} deps.log - structured logger + * @param {string} deps.CADDYFILE_PATH + * @param {string} deps.SERVICES_FILE + * @param {string} deps.CONFIG_FILE + * @param {string} deps.CADDY_ADMIN_URL + * @param {number} deps.PORT + */ +async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }) { + const errors = []; + const warnings = []; + + log.info('startup', 'Validating startup configuration...'); + + // 1. Check if Caddyfile exists and is writable + try { + if (await exists(CADDYFILE_PATH)) { + if (!(await isAccessible(CADDYFILE_PATH, fs.constants.R_OK | fs.constants.W_OK))) { + errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`); + } else { + log.info('startup', 'Caddyfile is accessible', { path: CADDYFILE_PATH }); + } + } else { + warnings.push(`Caddyfile does not exist: ${CADDYFILE_PATH} (will be created if needed)`); + } + } catch (error) { + errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`); + } + + // 2. Check if services file exists or can be created + const servicesDir = path.dirname(SERVICES_FILE); + try { + if (await exists(SERVICES_FILE)) { + // Validate it's valid JSON + try { + const content = await fsp.readFile(SERVICES_FILE, 'utf8'); + JSON.parse(content); + log.info('startup', 'Services file is valid JSON', { path: SERVICES_FILE }); + } catch (parseError) { + errors.push(`Services file exists but contains invalid JSON: ${SERVICES_FILE}`); + } + } else { + // Check if parent directory exists and is writable + if (await exists(servicesDir)) { + if (!(await isAccessible(servicesDir, fs.constants.W_OK))) { + errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`); + } else { + log.info('startup', 'Services file directory is writable', { path: servicesDir }); + } + } else { + errors.push(`Services file directory does not exist: ${servicesDir}`); + } + } + } catch (error) { + errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`); + } + + // 3. Check if port is available + const net = require('net'); + const portCheckServer = net.createServer(); + try { + portCheckServer.listen(PORT, '0.0.0.0'); + portCheckServer.close(); + log.info('startup', `Port ${PORT} is available`); + } catch (error) { + errors.push(`Port ${PORT} is already in use or cannot be bound`); + } + + // 4. Check if config file is valid JSON (if exists) + try { + if (await exists(CONFIG_FILE)) { + const content = await fsp.readFile(CONFIG_FILE, 'utf8'); + JSON.parse(content); + log.info('startup', 'Config file is valid JSON', { path: CONFIG_FILE }); + } else { + log.warn('startup', 'Config file does not exist (will use defaults)', { path: CONFIG_FILE }); + } + } catch (error) { + errors.push(`Config file exists but contains invalid JSON: ${CONFIG_FILE}`); + } + + // 5. Check Caddy admin API reachability (warning only, not critical) + const checkCaddyAdmin = () => { + return new Promise((resolve) => { + const urlObj = new URL(CADDY_ADMIN_URL); + const client = urlObj.protocol === 'https:' ? https : http; + + const req = client.request({ + hostname: urlObj.hostname, + port: urlObj.port, + path: '/config/', + method: 'GET', + timeout: 2000 + }, (res) => { + resolve(res.statusCode >= 200 && res.statusCode < 500); + }); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); + }; + + // Run async Caddy check (don't block startup) + checkCaddyAdmin().then(isReachable => { + if (isReachable) { + log.info('startup', 'Caddy admin API is reachable', { url: CADDY_ADMIN_URL }); + } else { + log.warn('startup', 'Caddy admin API is not reachable (may start later)', { url: CADDY_ADMIN_URL }); + } + }); + + // Print warnings + if (warnings.length > 0) { + warnings.forEach(warning => log.warn('startup', warning)); + } + + // Fail fast if there are critical errors + if (errors.length > 0) { + errors.forEach(err => log.error('startup', err)); + log.error('startup', 'Cannot start server — fix the above errors and try again'); + process.exit(1); + } + + log.info('startup', 'Startup configuration validation passed'); +} + +/** + * Auto-configure health checker from services.json + top-card services. + * + * @param {Object} deps + * @param {Function} deps.log - structured logger + * @param {string} deps.SERVICES_FILE + * @param {Object} deps.servicesStateManager - StateManager instance + * @param {Object} deps.healthChecker - health checker module + * @param {Function} deps.buildDomain - builds domain from subdomain + * @param {Object} deps.APP - app constants (USER_AGENTS) + */ +async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildDomain, APP }) { + try { + const topCardServices = [ + { id: 'dns1', name: 'DNS1' }, + { id: 'dns2', name: 'DNS2' }, + { id: 'internet', name: 'Internet' }, + ]; + + let appServices = []; + if (await exists(SERVICES_FILE)) { + const data = await servicesStateManager.read(); + appServices = Array.isArray(data) ? data : data.services || []; + } + + const allServices = [...topCardServices, ...appServices]; + const configuredIds = new Set(Object.keys(healthChecker.config.services || {})); + let added = 0; + + for (const svc of allServices) { + const id = svc.id || svc.name?.toLowerCase(); + if (!id || configuredIds.has(id)) continue; + + let url; + if (id === 'internet') { + url = 'https://www.google.com'; + } else if (svc.isExternal && svc.externalUrl) { + url = svc.externalUrl; + } else { + url = `https://${buildDomain(id)}`; + } + + healthChecker.configureService(id, { + name: svc.name || id, + url, + method: 'HEAD', + timeout: 5000, + expectedStatusCodes: [200, 201, 204, 301, 302, 303, 307, 308, 401, 403], + headers: { 'User-Agent': APP.USER_AGENTS.HEALTH }, + }); + added++; + } + + if (added > 0) { + log.info('health', 'Auto-configured services from services.json', { count: added }); + } + } catch (error) { + log.error('health', 'Error syncing services', { error: error.message }); + } +} + +module.exports = { validateStartupConfig, syncHealthCheckerServices }; diff --git a/dashcaddy-api/state-manager.js b/dashcaddy-api/state-manager.js new file mode 100644 index 0000000..ed963ca --- /dev/null +++ b/dashcaddy-api/state-manager.js @@ -0,0 +1,237 @@ +/** + * State Manager - Thread-safe file operations with locking + * + * Prevents data corruption when multiple API requests modify state files concurrently. + * Uses file-based locking with automatic retry and timeout handling. + * + * @module state-manager + */ + +const lockfile = require('proper-lockfile'); +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); + +class StateManager { + /** + * Create a StateManager instance + * @param {string} filePath - Path to the state file (e.g., services.json) + * @param {Object} options - Configuration options + * @param {number} options.lockTimeout - Max time to wait for lock (ms) + * @param {number} options.lockRetries - Number of lock acquisition retries + * @param {number} options.lockRetryInterval - Time between retries (ms) + */ + constructor(filePath, options = {}) { + this.filePath = filePath; + this.lockOptions = { + retries: { + retries: options.lockRetries || 10, + minTimeout: options.lockRetryInterval || 100, + maxTimeout: (options.lockRetryInterval || 100) * 3 + }, + stale: options.lockTimeout || 30000 // 30 seconds + }; + + // Ensure file exists + this._ensureFileExists(); + } + + /** + * Ensure the state file exists, create with empty array if not + * @private + */ + _ensureFileExists() { + if (!fsSync.existsSync(this.filePath)) { + const dir = path.dirname(this.filePath); + if (!fsSync.existsSync(dir)) { + fsSync.mkdirSync(dir, { recursive: true }); + } + fsSync.writeFileSync(this.filePath, '[]', 'utf8'); + } + } + + /** + * Read the state file (no locking required for read-only operations) + * @returns {Promise} Parsed JSON data + * @throws {Error} If file doesn't exist or JSON is invalid + */ + async read() { + try { + const content = await fs.readFile(this.filePath, 'utf8'); + return JSON.parse(content); + } catch (error) { + if (error.code === 'ENOENT') { + // File doesn't exist — recreate without locking (no file to lock) + this._ensureFileExists(); + return []; + } + throw new Error(`Failed to read state file: ${error.message}`); + } + } + + /** + * Write data to the state file (with locking) + * @param {any} data - Data to write (will be JSON.stringify'd) + * @returns {Promise} + * @throws {Error} If lock cannot be acquired or write fails + */ + async write(data) { + let release; + try { + // Acquire lock + release = await lockfile.lock(this.filePath, this.lockOptions); + + // Write data with pretty formatting + await fs.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf8'); + } catch (error) { + if (error.code === 'ELOCKED') { + throw new Error('State file is locked by another process. Try again.'); + } + throw new Error(`Failed to write state file: ${error.message}`); + } finally { + // Always release lock + if (release) { + try { + await release(); + } catch (e) { + // Lock release failure (non-critical, lock will expire via stale timeout) + } + } + } + } + + /** + * Update the state file using a callback function (atomic operation) + * This is the recommended method for most operations. + * + * @param {Function} updateFn - Function that receives current data and returns updated data + * @returns {Promise} The updated data + * @throws {Error} If lock cannot be acquired or update fails + * + * @example + * // Add a new service + * await stateManager.update(services => { + * services.push({ id: 'new-service', name: 'New Service' }); + * return services; + * }); + * + * @example + * // Remove a service + * await stateManager.update(services => { + * return services.filter(s => s.id !== 'old-service'); + * }); + */ + async update(updateFn) { + let release; + try { + // Acquire lock + release = await lockfile.lock(this.filePath, this.lockOptions); + + // Read current data + const content = await fs.readFile(this.filePath, 'utf8'); + const currentData = JSON.parse(content); + + // Apply update function + const updatedData = await updateFn(currentData); + + // Write updated data + await fs.writeFile(this.filePath, JSON.stringify(updatedData, null, 2), 'utf8'); + + return updatedData; + } catch (error) { + if (error.code === 'ELOCKED') { + throw new Error('State file is locked by another process. Try again.'); + } + throw new Error(`Failed to update state file: ${error.message}`); + } finally { + // Always release lock + if (release) { + try { + await release(); + } catch (e) { + // Lock release failure (non-critical, lock will expire via stale timeout) + } + } + } + } + + /** + * Check if the state file is currently locked + * @returns {Promise} True if locked, false otherwise + */ + async isLocked() { + try { + return await lockfile.check(this.filePath); + } catch (error) { + return false; + } + } + + /** + * Forcefully unlock the state file (use with caution!) + * Only use this if a lock is stuck due to a crashed process. + * @returns {Promise} + */ + async forceUnlock() { + try { + await lockfile.unlock(this.filePath); + } catch (error) { + // Ignore errors if file wasn't locked + if (error.code !== 'ENOTACQUIRED') { + throw error; + } + } + } + + /** + * Add an item to the state array (convenience method) + * @param {any} item - Item to add + * @returns {Promise} Updated array + */ + async addItem(item) { + return await this.update(items => { + items.push(item); + return items; + }); + } + + /** + * Remove an item from the state array by ID (convenience method) + * @param {string} id - ID of item to remove + * @returns {Promise} Updated array + */ + async removeItem(id) { + return await this.update(items => { + return items.filter(item => item.id !== id); + }); + } + + /** + * Update an item in the state array by ID (convenience method) + * @param {string} id - ID of item to update + * @param {Object} updates - Properties to update + * @returns {Promise} Updated array + */ + async updateItem(id, updates) { + return await this.update(items => { + return items.map(item => { + if (item.id === id) { + return { ...item, ...updates }; + } + return item; + }); + }); + } + + /** + * Find an item in the state array by ID (convenience method) + * @param {string} id - ID of item to find + * @returns {Promise} Found item or null + */ + async findItem(id) { + const items = await this.read(); + return items.find(item => item.id === id) || null; + } +} + +module.exports = StateManager; diff --git a/dashcaddy-api/test-security-fixes.js b/dashcaddy-api/test-security-fixes.js new file mode 100644 index 0000000..3186b5d --- /dev/null +++ b/dashcaddy-api/test-security-fixes.js @@ -0,0 +1,386 @@ +#!/usr/bin/env node +/** + * Automated Testing Script for DashCaddy Security Fixes + * + * Tests all implemented security improvements: + * 1. Path traversal protection + * 2. Request size limits + * 3. Startup validation + * 4. Port locking + * 5. Session management (LRU cache) + * 6. Enhanced error logging + * 7. Hardcoded secrets removal + */ + +const http = require('http'); +const https = require('https'); +const crypto = require('crypto'); + +const API_BASE = process.env.API_BASE || 'http://localhost:3001'; +const TEST_RESULTS = []; + +// Color codes for terminal output +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m' +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function logTest(name) { + console.log(`\n${colors.cyan}━━━ Testing: ${name} ━━━${colors.reset}`); +} + +function logResult(passed, message) { + const icon = passed ? '✓' : '✗'; + const color = passed ? 'green' : 'red'; + log(` ${icon} ${message}`, color); + TEST_RESULTS.push({ passed, message }); +} + +async function makeRequest(path, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(path, API_BASE); + const isHttps = url.protocol === 'https:'; + const client = isHttps ? https : http; + + const requestOptions = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method: options.method || 'GET', + headers: options.headers || {}, + ...options + }; + + const req = client.request(requestOptions, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: data, + data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null + }); + }); + }); + + req.on('error', reject); + + if (options.body) { + req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body)); + } + + req.end(); + }); +} + +// Test 1: Path Traversal Protection +async function testPathTraversal() { + logTest('Path Traversal Protection'); + + const attacks = [ + { path: '/api/browse/directories?path=../../../../../../etc/passwd', desc: 'Unix path traversal' }, + { path: '/api/browse/directories?path=..\\..\\..\\Windows\\System32', desc: 'Windows path traversal' }, + { path: '/api/browse/directories?path=%2e%2e%2f%2e%2e%2fetc%2fpasswd', desc: 'URL-encoded traversal' }, + { path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' } + ]; + + for (const attack of attacks) { + try { + const response = await makeRequest(attack.path); + if (response.statusCode === 403 || response.statusCode === 400) { + logResult(true, `Blocked: ${attack.desc}`); + } else { + logResult(false, `NOT BLOCKED (${response.statusCode}): ${attack.desc}`); + } + } catch (error) { + logResult(false, `Error testing ${attack.desc}: ${error.message}`); + } + } +} + +// Test 2: Request Size Limits +async function testRequestSizeLimits() { + logTest('Request Size Limits'); + + // Test 1: Small payload (should work) + try { + const smallPayload = { data: 'a'.repeat(100) }; + const response = await makeRequest('/api/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(smallPayload) + }); + logResult(true, 'Small payload accepted (100 bytes)'); + } catch (error) { + logResult(false, `Small payload rejected: ${error.message}`); + } + + // Test 2: Large payload on general endpoint (should fail) + try { + const largePayload = { data: 'a'.repeat(2 * 1024 * 1024) }; // 2MB + const response = await makeRequest('/api/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(largePayload) + }); + if (response.statusCode === 413 || response.statusCode === 400) { + logResult(true, 'Large payload rejected on general endpoint (2MB)'); + } else { + logResult(false, `Large payload NOT rejected (status: ${response.statusCode})`); + } + } catch (error) { + if (error.message.includes('413') || error.message.includes('ECONNRESET')) { + logResult(true, 'Large payload rejected (connection reset)'); + } else { + logResult(false, `Unexpected error: ${error.message}`); + } + } + + // Test 3: Large payload on logo endpoint (should work) + try { + const largeImage = 'a'.repeat(5 * 1024 * 1024); // 5MB + const response = await makeRequest('/api/logo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ logo: largeImage }) + }); + if (response.statusCode !== 413) { + logResult(true, 'Large payload accepted on logo endpoint (5MB)'); + } else { + logResult(false, 'Large payload rejected on logo endpoint'); + } + } catch (error) { + // May fail for other reasons (auth, validation), but not size + if (!error.message.includes('413')) { + logResult(true, 'Logo endpoint accepts large payloads (failed for non-size reason)'); + } else { + logResult(false, `Logo endpoint rejected large payload: ${error.message}`); + } + } +} + +// Test 3: Startup Validation +async function testStartupValidation() { + logTest('Startup Validation'); + + // Check if server is running (implies validation passed) + try { + const response = await makeRequest('/health'); + if (response.statusCode === 200) { + logResult(true, 'Server started successfully (validation passed)'); + } else { + logResult(false, `Server health check failed: ${response.statusCode}`); + } + } catch (error) { + logResult(false, `Cannot reach server: ${error.message}`); + } + + // Check for validation logs (requires access to logs) + log(' → Check Docker logs for: "✓ Startup configuration validation passed"', 'yellow'); +} + +// Test 4: Enhanced Error Logging (Request ID) +async function testEnhancedLogging() { + logTest('Enhanced Error Logging'); + + try { + // Make a request that will be logged + const response = await makeRequest('/api/services'); + + // Check if X-Request-ID header is present + if (response.headers['x-request-id']) { + const requestId = response.headers['x-request-id']; + const isValidUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId); + + if (isValidUUID) { + logResult(true, `Request ID header present and valid: ${requestId.substring(0, 8)}...`); + } else { + logResult(false, `Request ID present but invalid format: ${requestId}`); + } + } else { + logResult(false, 'Request ID header not present'); + } + } catch (error) { + logResult(false, `Error testing logging: ${error.message}`); + } +} + +// Test 5: Session Management (LRU Cache) +async function testSessionManagement() { + logTest('Session Management (LRU Cache)'); + + log(' → This test requires code inspection (cannot test cache behavior externally)', 'yellow'); + log(' → Manual verification: Check server.js for LRUCache usage', 'yellow'); + + // We can test that sessions still work + try { + const response = await makeRequest('/api/totp/setup', { method: 'POST' }); + if (response.statusCode === 200 || response.statusCode === 401) { + logResult(true, 'Session-based endpoints still functional'); + } else { + logResult(false, `Unexpected response from session endpoint: ${response.statusCode}`); + } + } catch (error) { + logResult(false, `Error testing session endpoints: ${error.message}`); + } +} + +// Test 6: Hardcoded Secrets Removal +async function testSecretsRemoval() { + logTest('Hardcoded Secrets Removal'); + + try { + // Read app-templates.js and check for "changeme123" + const fs = require('fs'); + const templatesPath = require('path').join(__dirname, 'app-templates.js'); + const content = fs.readFileSync(templatesPath, 'utf8'); + + const matches = content.match(/changeme123/g); + if (!matches || matches.length === 0) { + logResult(true, 'No hardcoded "changeme123" passwords found'); + } else { + logResult(false, `Found ${matches.length} instances of "changeme123" still in templates`); + } + + // Check for secrets arrays + const secretsMatches = content.match(/secrets:\s*\[/g); + if (secretsMatches && secretsMatches.length >= 10) { + logResult(true, `Found ${secretsMatches.length} secrets configurations`); + } else { + logResult(false, `Only found ${secretsMatches?.length || 0} secrets configurations (expected 14+)`); + } + } catch (error) { + logResult(false, `Error reading templates: ${error.message}`); + } +} + +// Test 7: Port Locking Mechanism +async function testPortLocking() { + logTest('Port Locking Mechanism'); + + try { + // Check if .port-locks directory exists + const fs = require('fs'); + const path = require('path'); + const locksDir = path.join(__dirname, '.port-locks'); + + if (fs.existsSync(locksDir)) { + logResult(true, 'Port locks directory exists'); + + // Check if it's writable + try { + const testFile = path.join(locksDir, 'test-write'); + fs.writeFileSync(testFile, 'test'); + fs.unlinkSync(testFile); + logResult(true, 'Port locks directory is writable'); + } catch (error) { + logResult(false, `Port locks directory not writable: ${error.message}`); + } + } else { + logResult(false, 'Port locks directory does not exist'); + } + + // Check if PortLockManager module exists + const portLockPath = path.join(__dirname, 'port-lock-manager.js'); + if (fs.existsSync(portLockPath)) { + logResult(true, 'PortLockManager module exists'); + } else { + logResult(false, 'PortLockManager module not found'); + } + } catch (error) { + logResult(false, `Error testing port locking: ${error.message}`); + } +} + +// Test 8: Docker Security Module +async function testDockerSecurity() { + logTest('Docker Image Verification'); + + try { + const fs = require('fs'); + const path = require('path'); + + // Check if docker-security.js exists + const securityPath = path.join(__dirname, 'docker-security.js'); + if (fs.existsSync(securityPath)) { + logResult(true, 'DockerSecurity module exists'); + } else { + logResult(false, 'DockerSecurity module not found'); + } + + // Check if config file exists + const configPath = path.join(__dirname, 'docker-security-config.json'); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + logResult(true, `Security config exists (mode: ${config.verificationMode || 'not set'})`); + } else { + log(' → Security config will be created on first use', 'yellow'); + logResult(true, 'Config will be auto-created'); + } + } catch (error) { + logResult(false, `Error testing Docker security: ${error.message}`); + } +} + +// Main test runner +async function runTests() { + log('\n╔════════════════════════════════════════════════════╗', 'cyan'); + log('║ DashCaddy Security Fixes - Test Suite ║', 'cyan'); + log('╚════════════════════════════════════════════════════╝', 'cyan'); + + log(`\nAPI Base URL: ${API_BASE}`, 'blue'); + log('Starting tests...\n', 'blue'); + + // Run all tests + await testStartupValidation(); + await testPathTraversal(); + await testRequestSizeLimits(); + await testEnhancedLogging(); + await testSessionManagement(); + await testSecretsRemoval(); + await testPortLocking(); + await testDockerSecurity(); + + // Summary + log('\n╔════════════════════════════════════════════════════╗', 'cyan'); + log('║ Test Summary ║', 'cyan'); + log('╚════════════════════════════════════════════════════╝', 'cyan'); + + const passed = TEST_RESULTS.filter(r => r.passed).length; + const failed = TEST_RESULTS.filter(r => !r.passed).length; + const total = TEST_RESULTS.length; + + log(`\nTotal Tests: ${total}`, 'blue'); + log(`Passed: ${passed}`, 'green'); + log(`Failed: ${failed}`, failed > 0 ? 'red' : 'green'); + log(`Success Rate: ${((passed / total) * 100).toFixed(1)}%\n`, failed === 0 ? 'green' : 'yellow'); + + if (failed > 0) { + log('Failed tests:', 'red'); + TEST_RESULTS.filter(r => !r.passed).forEach(r => { + log(` ✗ ${r.message}`, 'red'); + }); + } + + process.exit(failed > 0 ? 1 : 0); +} + +// Run tests if executed directly +if (require.main === module) { + runTests().catch(error => { + log(`\nFatal error: ${error.message}`, 'red'); + console.error(error); + process.exit(1); + }); +} + +module.exports = { runTests }; diff --git a/dashcaddy-api/update-manager.js b/dashcaddy-api/update-manager.js new file mode 100644 index 0000000..d8ac7bd --- /dev/null +++ b/dashcaddy-api/update-manager.js @@ -0,0 +1,911 @@ +/** + * Update Management Module + * Checks for Docker image updates, manages update scheduling, + * and provides rollback capabilities + */ + +const Docker = require('dockerode'); +const EventEmitter = require('events'); +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const docker = new Docker(); + +const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(__dirname, 'update-config.json'); +const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(__dirname, 'update-history.json'); +const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour + +class UpdateManager extends EventEmitter { + constructor() { + super(); + this.config = this.loadConfig(); + this.history = this.loadHistory(); + this.availableUpdates = new Map(); + this.checking = false; + this.checkInterval = null; + } + + /** + * Start update checking + */ + start() { + if (this.checking) return; + + console.log('[UpdateManager] Starting update checks'); + this.checking = true; + + // Initial check + this.checkForUpdates(); + + // Schedule periodic checks + this.checkInterval = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL); + } + + /** + * Stop update checking + */ + stop() { + if (!this.checking) return; + + console.log('[UpdateManager] Stopping update checks'); + this.checking = false; + + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + } + + /** + * Check for updates for all containers + */ + async checkForUpdates() { + try { + const containers = await docker.listContainers({ all: true }); + + for (const containerInfo of containers) { + try { + const container = docker.getContainer(containerInfo.Id); + const inspect = await container.inspect(); + + const imageName = inspect.Config.Image; + const currentDigest = inspect.Image; + + // Check if update available + const latestDigest = await this.getLatestImageDigest(imageName); + + if (latestDigest && latestDigest !== currentDigest) { + this.availableUpdates.set(containerInfo.Id, { + containerId: containerInfo.Id, + containerName: containerInfo.Names[0].replace(/^\//, ''), + imageName, + currentDigest: currentDigest.substring(0, 12), + latestDigest: latestDigest.substring(0, 12), + currentTag: this.extractTag(imageName), + detectedAt: new Date().toISOString() + }); + + this.emit('update-available', this.availableUpdates.get(containerInfo.Id)); + } else { + this.availableUpdates.delete(containerInfo.Id); + } + } catch (error) { + console.error(`[UpdateManager] Error checking ${containerInfo.Names[0]}:`, error.message); + } + } + + console.log(`[UpdateManager] Found ${this.availableUpdates.size} updates available`); + } catch (error) { + console.error('[UpdateManager] Error checking for updates:', error.message); + } + } + + /** + * Get latest image digest from registry + */ + async getLatestImageDigest(imageName) { + try { + // Parse image name + const [repository, tag] = imageName.split(':'); + const imageTag = tag || 'latest'; + + // For Docker Hub images + if (!repository.includes('/') || repository.split('/').length === 2) { + return await this.getDockerHubDigest(repository, imageTag); + } + + // For other registries (would need authentication) + console.warn(`[UpdateManager] Custom registry not yet supported: ${repository}`); + return null; + } catch (error) { + console.error(`[UpdateManager] Error getting digest for ${imageName}:`, error.message); + return null; + } + } + + /** + * Get image digest from Docker Hub + */ + async getDockerHubDigest(repository, tag) { + return new Promise((resolve, reject) => { + // Normalize repository name + const repo = repository.includes('/') ? repository : `library/${repository}`; + + const options = { + hostname: 'registry-1.docker.io', + path: `/v2/${repo}/manifests/${tag}`, + method: 'GET', + headers: { + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json' + } + }; + + const req = https.request(options, (res) => { + if (res.statusCode === 401) { + // Need to authenticate + const authHeader = res.headers['www-authenticate']; + const authUrl = this.parseAuthHeader(authHeader); + + if (authUrl) { + this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject); + } else { + reject(new Error('Authentication required but no auth URL found')); + } + return; + } + + const digest = res.headers['docker-content-digest']; + resolve(digest || null); + }); + + req.on('error', reject); + req.end(); + }); + } + + /** + * Parse authentication header + */ + parseAuthHeader(header) { + if (!header) return null; + + const match = header.match(/Bearer realm="([^"]+)"/); + if (!match) return null; + + const url = new URL(match[1]); + const params = header.match(/service="([^"]+)"/); + if (params) url.searchParams.set('service', params[1]); + + const scope = header.match(/scope="([^"]+)"/); + if (scope) url.searchParams.set('scope', scope[1]); + + return url.toString(); + } + + /** + * Authenticate and get digest + */ + async authenticateAndGetDigest(authUrl, originalOptions) { + return new Promise((resolve, reject) => { + https.get(authUrl, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const auth = JSON.parse(data); + const token = auth.token || auth.access_token; + + if (!token) { + reject(new Error('No token in auth response')); + return; + } + + // Retry original request with token + const options = { + ...originalOptions, + headers: { + ...originalOptions.headers, + 'Authorization': `Bearer ${token}` + } + }; + + const req = https.request(options, (res) => { + const digest = res.headers['docker-content-digest']; + resolve(digest || null); + }); + + req.on('error', reject); + req.end(); + } catch (error) { + reject(error); + } + }); + }).on('error', reject); + }); + } + + /** + * Extract tag from image name + */ + extractTag(imageName) { + const parts = imageName.split(':'); + return parts.length > 1 ? parts[parts.length - 1] : 'latest'; + } + + /** + * Update a container + */ + async updateContainer(containerId, options = {}) { + const startTime = Date.now(); + + console.log(`[UpdateManager] Starting update for container ${containerId}`); + this.emit('update-start', { containerId, timestamp: new Date().toISOString() }); + + try { + const container = docker.getContainer(containerId); + const inspect = await container.inspect(); + + const imageName = inspect.Config.Image; + const containerName = inspect.Name.replace(/^\//, ''); + const oldImageId = inspect.Image; + + // Get old image digest for rollback + let oldImageDigest = null; + try { + const oldImage = docker.getImage(oldImageId); + const oldImageInspect = await oldImage.inspect(); + oldImageDigest = oldImageInspect.RepoDigests?.[0] || oldImageId; + console.log(`[UpdateManager] Stored old image digest: ${oldImageDigest.substring(0, 40)}...`); + } catch (error) { + console.warn(`[UpdateManager] Could not get old image digest: ${error.message}`); + } + + // Create backup of current state + const backup = { + containerId, + containerName, + imageName, + imageId: oldImageId, + imageDigest: oldImageDigest, + config: inspect.Config, + hostConfig: inspect.HostConfig, + networkSettings: inspect.NetworkSettings, + timestamp: new Date().toISOString() + }; + + // Pull latest image + console.log(`[UpdateManager] Pulling latest image: ${imageName}`); + await this.pullImage(imageName); + + // Stop container + console.log(`[UpdateManager] Stopping container: ${containerName}`); + await container.stop(); + + // Remove old container + console.log(`[UpdateManager] Removing old container: ${containerName}`); + await container.remove(); + + // Create new container with same configuration + console.log(`[UpdateManager] Creating new container: ${containerName}`); + const newContainer = await docker.createContainer({ + name: containerName, + Image: imageName, + ...backup.config, + HostConfig: backup.hostConfig + }); + + // Start new container + console.log(`[UpdateManager] Starting new container: ${containerName}`); + await newContainer.start(); + + // Extended verification with health checks and port accessibility + console.log(`[UpdateManager] Performing extended verification...`); + await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000); + + // Get new image ID + const newInspect = await newContainer.inspect(); + const newImageId = newInspect.Image; + + // Remove old image only after successful verification + if (oldImageId !== newImageId) { + try { + console.log(`[UpdateManager] Removing old image: ${oldImageId.substring(0, 12)}`); + const oldImage = docker.getImage(oldImageId); + await oldImage.remove({ force: false }); + console.log(`[UpdateManager] Old image removed successfully`); + } catch (error) { + console.warn(`[UpdateManager] Could not remove old image (may be in use): ${error.message}`); + } + } + + const duration = Date.now() - startTime; + + const historyEntry = { + containerId: newContainer.id, + containerName, + imageName, + oldImageId: oldImageId.substring(0, 12), + newImageId: newImageId.substring(0, 12), + timestamp: new Date().toISOString(), + duration, + status: 'success', + backup + }; + + this.addToHistory(historyEntry); + this.availableUpdates.delete(containerId); + + this.emit('update-complete', historyEntry); + console.log(`[UpdateManager] Update completed in ${duration}ms`); + + return historyEntry; + } catch (error) { + const duration = Date.now() - startTime; + + const historyEntry = { + containerId, + timestamp: new Date().toISOString(), + duration, + status: 'failed', + error: error.message + }; + + this.addToHistory(historyEntry); + this.emit('update-failed', historyEntry); + + // Attempt rollback + if (options.autoRollback !== false) { + console.log(`[UpdateManager] Attempting rollback for ${containerId}`); + try { + await this.rollbackUpdate(containerId); + } catch (rollbackError) { + console.error(`[UpdateManager] Rollback failed:`, rollbackError.message); + } + } + + throw error; + } + } + + /** + * Pull Docker image + */ + async pullImage(imageName) { + return new Promise((resolve, reject) => { + docker.pull(imageName, (err, stream) => { + if (err) { + reject(err); + return; + } + + docker.modem.followProgress(stream, (err, output) => { + if (err) { + reject(err); + } else { + resolve(output); + } + }); + }); + }); + } + + /** + * Verify container is running and healthy + */ + async verifyContainer(container, timeout = 30000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + try { + const inspect = await container.inspect(); + + if (inspect.State.Running) { + // Check health if health check is configured + if (inspect.State.Health) { + if (inspect.State.Health.Status === 'healthy') { + return true; + } + } else { + // No health check, just verify it's running + return true; + } + } + + // Wait before checking again + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + throw new Error(`Container verification failed: ${error.message}`); + } + } + + throw new Error('Container verification timeout'); + } + + /** + * Extended container verification with health checks and port accessibility + * @param {object} container - Docker container object + * @param {object} oldInspect - Old container inspect data for port comparison + * @param {number} timeout - Verification timeout in milliseconds (default: 60000) + */ + async verifyContainerExtended(container, oldInspect, timeout = 60000) { + const startTime = Date.now(); + const maxAttempts = Math.floor(timeout / 2000); // Check every 2 seconds + let lastError = null; + + console.log(`[UpdateManager] Extended verification with ${maxAttempts} attempts over ${timeout/1000}s`); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const inspect = await container.inspect(); + + // Step 1: Verify container is running + if (!inspect.State.Running) { + lastError = 'Container is not running'; + throw new Error(lastError); + } + + // Step 2: Check Docker health check if available + if (inspect.State.Health) { + if (inspect.State.Health.Status === 'healthy') { + console.log(`[UpdateManager] Container health check: healthy`); + return true; + } else if (inspect.State.Health.Status === 'unhealthy') { + lastError = 'Container health check failed (unhealthy)'; + throw new Error(lastError); + } + // Status is 'starting' - continue waiting + console.log(`[UpdateManager] Health check status: ${inspect.State.Health.Status} (attempt ${attempt + 1}/${maxAttempts})`); + } else { + // Step 3: No Docker health check - verify HTTP port accessibility + const ports = this.extractPorts(inspect); + + if (ports.length > 0) { + // Try to access the first HTTP port + const primaryPort = ports[0]; + const testUrl = `http://localhost:${primaryPort.hostPort}`; + + try { + const response = await fetch(testUrl, { + signal: AbortSignal.timeout(3000), + redirect: 'manual' + }); + + // Accept 2xx, 3xx, 4xx as "accessible" (server is responding) + if (response.status >= 200 && response.status < 500) { + console.log(`[UpdateManager] Port ${primaryPort.hostPort} is accessible (HTTP ${response.status})`); + + // Wait a bit more to ensure stability + if (attempt >= 2) { + console.log(`[UpdateManager] Container verified successfully`); + return true; + } + } + } catch (fetchError) { + lastError = `Port ${primaryPort.hostPort} not accessible: ${fetchError.message}`; + console.log(`[UpdateManager] ${lastError} (attempt ${attempt + 1}/${maxAttempts})`); + } + } else { + // No ports exposed - just verify it's running for a few cycles + if (attempt >= 5) { + console.log(`[UpdateManager] Container running without exposed ports (verified)`); + return true; + } + } + } + + // Wait before next attempt + if (attempt < maxAttempts - 1) { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } catch (error) { + lastError = error.message; + console.log(`[UpdateManager] Verification attempt ${attempt + 1} failed: ${lastError}`); + + if (attempt < maxAttempts - 1) { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + } + + // Verification failed + const duration = Date.now() - startTime; + throw new Error(`Extended verification failed after ${duration}ms: ${lastError || 'timeout'}`); + } + + /** + * Extract port mappings from container inspect data + * @param {object} inspect - Container inspect data + * @returns {Array} Array of port mappings + */ + extractPorts(inspect) { + const ports = []; + + if (inspect.NetworkSettings && inspect.NetworkSettings.Ports) { + for (const [containerPort, bindings] of Object.entries(inspect.NetworkSettings.Ports)) { + if (bindings && bindings.length > 0) { + for (const binding of bindings) { + if (binding.HostPort) { + ports.push({ + containerPort: containerPort.split('/')[0], + hostPort: binding.HostPort, + protocol: containerPort.split('/')[1] || 'tcp' + }); + } + } + } + } + } + + return ports; + } + + /** + * Rollback to previous version + */ + async rollbackUpdate(containerId) { + console.log(`[UpdateManager] Rolling back container ${containerId}`); + + // Find last successful update in history + const lastUpdate = this.history + .filter(h => h.containerId === containerId && h.status === 'success' && h.backup) + .pop(); + + if (!lastUpdate || !lastUpdate.backup) { + throw new Error('No backup found for rollback'); + } + + const backup = lastUpdate.backup; + + try { + // Stop and remove current container + try { + const container = docker.getContainer(containerId); + await container.stop(); + await container.remove(); + } catch (error) { + // Container might not exist, continue + } + + // Recreate container from backup + const newContainer = await docker.createContainer({ + name: backup.containerName, + Image: backup.imageName, + ...backup.config, + HostConfig: backup.hostConfig + }); + + await newContainer.start(); + + console.log(`[UpdateManager] Rollback completed for ${backup.containerName}`); + this.emit('rollback-complete', { containerId, containerName: backup.containerName }); + + return true; + } catch (error) { + console.error(`[UpdateManager] Rollback failed:`, error.message); + throw error; + } + } + + /** + * Schedule update for maintenance window + */ + scheduleUpdate(containerId, scheduledTime) { + const delay = new Date(scheduledTime).getTime() - Date.now(); + + if (delay < 0) { + throw new Error('Scheduled time must be in the future'); + } + + setTimeout(() => { + this.updateContainer(containerId).catch(error => { + console.error(`[UpdateManager] Scheduled update failed:`, error.message); + }); + }, delay); + + console.log(`[UpdateManager] Update scheduled for ${containerId} at ${scheduledTime}`); + } + + /** + * Get available updates + */ + getAvailableUpdates() { + return Array.from(this.availableUpdates.values()); + } + + /** + * Get update history + */ + getHistory(limit = 50) { + return this.history.slice(-limit).reverse(); + } + + /** + * Get changelog and release information from Docker Hub + * @param {string} imageName - Docker image name (e.g., "nginx:latest" or "linuxserver/plex") + * @returns {Object} Changelog information including tags, description, and URLs + */ + async getChangelog(imageName) { + try { + // Parse image name + const [fullRepo, tag] = imageName.split(':'); + const imageTag = tag || 'latest'; + + // Normalize repository name for Docker Hub API + let repo = fullRepo; + let namespace = 'library'; + + if (fullRepo.includes('/')) { + const parts = fullRepo.split('/'); + namespace = parts[0]; + repo = parts.slice(1).join('/'); + } + + const repoPath = namespace === 'library' ? repo : `${namespace}/${repo}`; + + // Fetch repository info from Docker Hub API + const repoInfo = await this.fetchDockerHubRepo(repoPath, namespace === 'library'); + + // Fetch available tags + const tags = await this.fetchDockerHubTags(repoPath, namespace === 'library'); + + // Build the Docker Hub URL + const hubUrl = namespace === 'library' + ? `https://hub.docker.com/_/${repo}` + : `https://hub.docker.com/r/${namespace}/${repo}`; + + return { + imageName, + currentTag: imageTag, + repository: { + name: repoPath, + description: repoInfo?.description || 'No description available', + shortDescription: repoInfo?.description?.substring(0, 200) || '', + starCount: repoInfo?.star_count || 0, + pullCount: repoInfo?.pull_count || 0, + lastUpdated: repoInfo?.last_updated || null + }, + tags: tags.slice(0, 10).map(t => ({ + name: t.name, + lastPushed: t.last_pushed || t.tag_last_pushed, + digest: t.digest?.substring(0, 12) || 'unknown', + size: t.full_size || t.size || 0 + })), + urls: { + dockerHub: hubUrl, + tags: `${hubUrl}/tags`, + dockerfile: repoInfo?.dockerfile_url || null + }, + changelog: this.formatChangelog(repoInfo, tags, imageTag) + }; + } catch (error) { + console.error(`[UpdateManager] Error fetching changelog for ${imageName}:`, error.message); + + // Return basic info even on error + const [fullRepo] = imageName.split(':'); + const repoPath = fullRepo.includes('/') ? fullRepo : `library/${fullRepo}`; + + return { + imageName, + error: error.message, + urls: { + dockerHub: `https://hub.docker.com/r/${repoPath.replace('library/', '_/')}`, + }, + changelog: 'Unable to fetch changelog. Visit Docker Hub for details.' + }; + } + } + + /** + * Fetch repository info from Docker Hub + */ + async fetchDockerHubRepo(repoPath, isLibrary) { + return new Promise((resolve, reject) => { + const apiPath = isLibrary + ? `/v2/repositories/library/${repoPath}` + : `/v2/repositories/${repoPath}`; + + const options = { + hostname: 'hub.docker.com', + path: apiPath, + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'DashCaddy/1.0' + } + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + if (res.statusCode === 200) { + resolve(JSON.parse(data)); + } else { + resolve(null); + } + } catch (e) { + resolve(null); + } + }); + }); + + req.on('error', () => resolve(null)); + req.setTimeout(10000, () => { + req.destroy(); + resolve(null); + }); + req.end(); + }); + } + + /** + * Fetch available tags from Docker Hub + */ + async fetchDockerHubTags(repoPath, isLibrary) { + return new Promise((resolve, reject) => { + const apiPath = isLibrary + ? `/v2/repositories/library/${repoPath}/tags?page_size=20&ordering=last_updated` + : `/v2/repositories/${repoPath}/tags?page_size=20&ordering=last_updated`; + + const options = { + hostname: 'hub.docker.com', + path: apiPath, + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'DashCaddy/1.0' + } + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + if (res.statusCode === 200) { + const parsed = JSON.parse(data); + resolve(parsed.results || []); + } else { + resolve([]); + } + } catch (e) { + resolve([]); + } + }); + }); + + req.on('error', () => resolve([])); + req.setTimeout(10000, () => { + req.destroy(); + resolve([]); + }); + req.end(); + }); + } + + /** + * Format changelog from repo info and tags + */ + formatChangelog(repoInfo, tags, currentTag) { + const lines = []; + + if (repoInfo?.description) { + lines.push(`**${repoInfo.description.split('\n')[0]}**`); + lines.push(''); + } + + // Find current and latest tags + const latestTag = tags.find(t => t.name === 'latest'); + const currentTagInfo = tags.find(t => t.name === currentTag); + + if (latestTag?.last_pushed || latestTag?.tag_last_pushed) { + const lastUpdated = new Date(latestTag.last_pushed || latestTag.tag_last_pushed); + lines.push(`Latest update: ${lastUpdated.toLocaleDateString()}`); + } + + if (tags.length > 0) { + lines.push(''); + lines.push('Recent tags:'); + tags.slice(0, 5).forEach(t => { + const date = t.last_pushed || t.tag_last_pushed; + const dateStr = date ? new Date(date).toLocaleDateString() : 'unknown'; + lines.push(` - ${t.name} (${dateStr})`); + }); + } + + if (repoInfo?.pull_count) { + lines.push(''); + lines.push(`Total pulls: ${repoInfo.pull_count.toLocaleString()}`); + } + + return lines.join('\n') || 'No changelog available'; + } + + /** + * Configure auto-update for a container + */ + configureAutoUpdate(containerId, config) { + if (!this.config.autoUpdate) { + this.config.autoUpdate = {}; + } + + this.config.autoUpdate[containerId] = { + enabled: config.enabled !== false, + schedule: config.schedule || 'weekly', + maintenanceWindow: config.maintenanceWindow, + autoRollback: config.autoRollback !== false, + securityOnly: config.securityOnly || false + }; + + this.saveConfig(); + } + + /** + * Add entry to history + */ + addToHistory(entry) { + this.history.push(entry); + + // Keep only last 100 entries + if (this.history.length > 100) { + this.history = this.history.slice(-100); + } + + this.saveHistory(); + } + + /** + * Load configuration + */ + loadConfig() { + try { + if (fs.existsSync(UPDATE_CONFIG_FILE)) { + return JSON.parse(fs.readFileSync(UPDATE_CONFIG_FILE, 'utf8')); + } + } catch (error) { + console.error('[UpdateManager] Error loading config:', error.message); + } + return { autoUpdate: {} }; + } + + /** + * Save configuration + */ + saveConfig() { + try { + fs.writeFileSync(UPDATE_CONFIG_FILE, JSON.stringify(this.config, null, 2)); + } catch (error) { + console.error('[UpdateManager] Error saving config:', error.message); + } + } + + /** + * Load history + */ + loadHistory() { + try { + if (fs.existsSync(UPDATE_HISTORY_FILE)) { + return JSON.parse(fs.readFileSync(UPDATE_HISTORY_FILE, 'utf8')); + } + } catch (error) { + console.error('[UpdateManager] Error loading history:', error.message); + } + return []; + } + + /** + * Save history + */ + saveHistory() { + try { + fs.writeFileSync(UPDATE_HISTORY_FILE, JSON.stringify(this.history, null, 2)); + } catch (error) { + console.error('[UpdateManager] Error saving history:', error.message); + } + } +} + +// Export singleton instance +module.exports = new UpdateManager(); diff --git a/dashcaddy-installer/BUILD_GUIDE.md b/dashcaddy-installer/BUILD_GUIDE.md new file mode 100644 index 0000000..8c45a0c --- /dev/null +++ b/dashcaddy-installer/BUILD_GUIDE.md @@ -0,0 +1,260 @@ +# DashCaddy Installer - Build Guide + +## Overview + +The DashCaddy Installer is a cross-platform Electron application that guides users through installing and configuring DashCaddy on their system. + +## Prerequisites + +- Node.js 18+ and npm +- Git +- Windows: No additional requirements +- macOS: Xcode Command Line Tools +- Linux: Standard build tools (gcc, make) + +## Installation + +```bash +# Clone the repository +cd dashcaddy-installer + +# Install dependencies +npm install +``` + +## Development + +### Run in Development Mode + +```bash +# Start the installer with DevTools +npm run dev + +# Or start normally +npm start +``` + +### Run Tests + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch +``` + +## Building + +### Build for Current Platform + +```bash +npm run build +``` + +### Build for Specific Platforms + +```bash +# Windows (creates portable .exe and installer) +npm run build:win + +# macOS (creates .dmg) +npm run build:mac + +# Linux (creates AppImage and .deb) +npm run build:linux +``` + +### Build Output + +Built applications are placed in the `dist/` directory: + +- **Windows**: `dist/win-unpacked/DashCaddy Installer.exe` (portable) +- **macOS**: `dist/DashCaddy Installer.dmg` +- **Linux**: `dist/DashCaddy Installer.AppImage` and `.deb` + +## Project Structure + +``` +dashcaddy-installer/ +├── src/ +│ ├── main/ # Electron main process +│ │ ├── index.js # Main entry point & IPC handlers +│ │ ├── dependency-checker.js # Check/install Docker & Caddy +│ │ ├── config-manager.js # Configuration persistence +│ │ ├── file-deployer.js # Copy dashboard & API files +│ │ ├── caddyfile-generator.js # Generate Caddyfile +│ │ ├── browser-launcher.js # Open URLs in browser +│ │ └── service-manager.js # Start/stop services +│ ├── renderer/ # UI layer +│ │ ├── index.html # Main HTML +│ │ ├── wizard.js # Wizard logic & state +│ │ └── styles.css # Styling +│ ├── preload/ # IPC bridge +│ │ └── index.js # Secure IPC exposure +│ └── shared/ # Shared utilities +│ ├── constants.js # App constants +│ └── platform-utils.js # Platform detection +├── templates/ # Configuration templates +│ ├── Caddyfile.template # Caddyfile template +│ └── docker-compose.template.yml +├── assets/ # Images & icons +│ └── icon.png # App icon +└── package.json # Dependencies & build config +``` + +## Key Features + +### 1. Platform Detection +Automatically detects Windows, macOS, or Linux and adjusts paths and commands accordingly. + +### 2. Dependency Management +- Checks for Docker and Caddy installation +- Provides installation instructions/automation +- Validates versions + +### 3. Guided Installation +5-step wizard: +1. **Welcome** - Introduction and platform detection +2. **Folders** - Select installation directories +3. **Dependencies** - Check and install requirements +4. **Install** - Deploy files and configure +5. **Complete** - Success screen with dashboard link + +### 4. File Deployment +- Copies dashboard files from `status/` directory +- Copies API server from `dashcaddy-api/` directory +- Installs npm dependencies for API +- Skips unnecessary files (node_modules, .git) + +### 5. Configuration Generation +- Creates Caddyfile for reverse proxy +- Generates docker-compose.yml for API container +- Saves installation configuration for upgrades + +### 6. Service Management +- Starts Caddy web server +- Launches Docker containers +- Opens dashboard in browser when ready + +## Configuration + +### Build Configuration + +Edit `package.json` under the `build` section: + +```json +{ + "build": { + "appId": "com.dashcaddy.installer", + "productName": "DashCaddy Installer", + "icon": "assets/icon.png", + "win": { + "target": ["portable", "dir"] + } + } +} +``` + +### Source Paths + +The installer copies files from these source directories (relative to project root): + +- Dashboard: `../status/` +- API Server: `../dashcaddy-api/` + +These paths are configured in `src/main/file-deployer.js`. + +## Troubleshooting + +### Build Fails + +1. **Missing dependencies**: Run `npm install` +2. **Electron download fails**: Check internet connection or use `npm config set electron_mirror https://npmmirror.com/mirrors/electron/` +3. **Icon errors**: Ensure `assets/icon.png` exists and is valid + +### Installer Doesn't Start + +1. **Check Node version**: Must be 18+ +2. **Reinstall dependencies**: `rm -rf node_modules && npm install` +3. **Check console**: Run with `npm run dev` to see errors + +### Files Not Deploying + +1. **Check source paths**: Verify `status/` and `dashcaddy-api/` directories exist +2. **Check permissions**: Ensure write access to installation directory +3. **Check disk space**: Ensure sufficient space for installation + +## Testing + +### Unit Tests + +Tests are located in `src/main/*.test.js`: + +```bash +npm test +``` + +### Property-Based Tests + +Some modules include property-based tests using fast-check: + +```bash +npm test -- --testNamePattern="property" +``` + +### Manual Testing + +1. Run installer: `npm start` +2. Go through all wizard steps +3. Verify files are copied correctly +4. Check that services start +5. Confirm dashboard opens in browser + +## Deployment + +### Creating a Release + +1. Update version in `package.json` +2. Build for all platforms: + ```bash + npm run build:win + npm run build:mac + npm run build:linux + ``` +3. Test each build on target platform +4. Create GitHub release with built artifacts + +### Distribution + +- **Windows**: Distribute `.exe` file (portable, no installation required) +- **macOS**: Distribute `.dmg` file +- **Linux**: Distribute `.AppImage` (universal) or `.deb` (Debian/Ubuntu) + +## Advanced + +### Custom Branding + +Replace `assets/icon.png` with your custom icon (512x512 PNG recommended). + +### Custom Templates + +Edit templates in `templates/` directory to customize generated configurations. + +### Adding New Steps + +1. Add step definition to `wizard.js` steps array +2. Create render function (e.g., `renderMyStep()`) +3. Add case to `renderCurrentStep()` switch +4. Update navigation logic if needed + +## Support + +For issues or questions: +- Check existing documentation +- Review console logs with `npm run dev` +- Check GitHub issues + +## License + +MIT diff --git a/dashcaddy-installer/QUICK_START.md b/dashcaddy-installer/QUICK_START.md new file mode 100644 index 0000000..341167e --- /dev/null +++ b/dashcaddy-installer/QUICK_START.md @@ -0,0 +1,184 @@ +# DashCaddy Installer - Quick Start + +## For Developers + +### Setup & Run + +```bash +# Install dependencies +npm install + +# Run in development mode +npm start + +# Build for Windows +npm run build:win +``` + +The built installer will be at: `dist/win-unpacked/DashCaddy Installer.exe` + +## For End Users + +### Running the Installer + +1. **Download** the DashCaddy Installer executable +2. **Run** the installer (no installation required - it's portable) +3. **Follow** the 5-step wizard: + +#### Step 1: Welcome +- Review features and system requirements +- Confirm detected platform + +#### Step 2: Choose Folders +- Select installation directory (default: `C:\DashCaddy`) +- Optionally customize Docker data and Caddy config paths +- Click **Next** + +#### Step 3: Check Dependencies +- Installer checks for Docker and Caddy +- If missing, click **Install** buttons to install them +- Wait for installation to complete +- Click **Install** when both are ready + +#### Step 4: Installation +- Watch progress as DashCaddy is installed +- Files are copied and configured automatically +- Takes 1-3 minutes depending on system + +#### Step 5: Complete +- Installation successful! +- Click **Open Dashboard** to launch DashCaddy +- Dashboard opens at `http://localhost:8080` + +### What Gets Installed + +The installer creates this structure: + +``` +C:\DashCaddy\ +├── dashboard\ # Web dashboard files +├── api\ # API server +├── docker-data\ # Docker volumes +├── caddy\ # Caddy configuration +├── Caddyfile # Reverse proxy config +├── docker-compose.yml # Container orchestration +└── .dashcaddy-config.json # Installation settings +``` + +### System Requirements + +- **OS**: Windows 10/11, macOS 10.15+, or Linux +- **RAM**: 4GB minimum, 8GB recommended +- **Disk**: 2GB free space +- **Docker**: Required (installer can install) +- **Caddy**: Required (installer can install) + +### Accessing DashCaddy + +After installation: + +- **Dashboard**: http://localhost:8080 +- **API**: http://localhost:3001 +- **Caddy Admin**: http://localhost:2021 + +### Troubleshooting + +#### Installer Won't Start +- Ensure you have administrator/sudo privileges +- Check antivirus isn't blocking the installer +- Try running as administrator (Windows) + +#### Dependencies Won't Install +- Check internet connection +- Manually install Docker from docker.com +- Manually install Caddy from caddyserver.com + +#### Dashboard Won't Open +- Check if Caddy is running: `caddy version` +- Check if Docker is running: `docker ps` +- Verify port 8080 isn't in use +- Check installation logs + +#### Services Not Starting +- Ensure Docker daemon is running +- Check firewall settings +- Verify installation paths are correct + +### Uninstalling + +To remove DashCaddy: + +1. Stop services: + ```bash + cd C:\DashCaddy + docker compose down + taskkill /IM caddy.exe /F + ``` + +2. Delete installation directory: + ```bash + rmdir /s C:\DashCaddy + ``` + +### Next Steps + +After installation: + +1. **Add Services**: Click "+" to deploy Docker containers +2. **Configure DNS**: Set up custom domains (optional) +3. **Import Existing**: Import services from other systems +4. **Customize**: Change themes, logos, and settings + +### Getting Help + +- **Documentation**: Check README.md in installation directory +- **Logs**: View container logs in dashboard +- **Support**: Open GitHub issue for bugs + +## Development Notes + +### Testing the Installer + +```bash +# Run with DevTools for debugging +npm run dev + +# Test specific functionality +npm test + +# Build and test portable exe +npm run build:win +./dist/win-unpacked/DashCaddy\ Installer.exe +``` + +### Modifying the Installer + +Key files to edit: + +- **UI**: `src/renderer/wizard.js` and `styles.css` +- **Logic**: `src/main/index.js` (IPC handlers) +- **Deployment**: `src/main/file-deployer.js` +- **Config**: `src/main/caddyfile-generator.js` + +### Adding Features + +1. Add IPC handler in `src/main/index.js` +2. Expose in `src/preload/index.js` +3. Call from `src/renderer/wizard.js` +4. Update UI in `styles.css` if needed + +## Tips + +- **Portable**: The Windows .exe is fully portable - no installation needed +- **Upgrades**: Installer detects existing installations and can upgrade +- **Backups**: Installation config saved in `.dashcaddy-config.json` +- **Logs**: Check console with DevTools (Ctrl+Shift+I in dev mode) + +## What's Next? + +After successful installation, explore: + +- **App Templates**: 50+ pre-configured applications +- **DNS Integration**: Set up local domains with Cloudflare +- **Monitoring**: Real-time service health checks +- **Backups**: Export/import dashboard configurations diff --git a/dashcaddy-installer/README.md b/dashcaddy-installer/README.md new file mode 100644 index 0000000..f4ffc23 --- /dev/null +++ b/dashcaddy-installer/README.md @@ -0,0 +1,230 @@ +# DashCaddy Installer + +A cross-platform Electron application that provides a guided installation wizard for DashCaddy - the unified management platform for Docker containers and Caddy reverse proxy. + +## ✨ Features + +- **🎯 Guided 5-Step Wizard** - Simple, intuitive installation process +- **🔍 Automatic Dependency Detection** - Checks for Docker and Caddy +- **📦 One-Click Dependency Installation** - Installs missing requirements +- **🖥️ Cross-Platform Support** - Windows, macOS, and Linux +- **📁 Smart File Deployment** - Copies and configures all necessary files +- **⚙️ Auto-Configuration** - Generates Caddyfile and docker-compose.yml +- **🚀 Service Management** - Starts services and opens dashboard +- **💾 Configuration Persistence** - Saves settings for upgrades +- **🎨 Modern UI** - Clean, responsive interface with progress tracking + +## 🚀 Quick Start + +### For End Users + +1. **Download** the installer for your platform +2. **Run** the executable (portable, no installation needed) +3. **Follow** the wizard steps +4. **Access** your dashboard at http://localhost:8080 + +See [QUICK_START.md](QUICK_START.md) for detailed user instructions. + +### For Developers + +```bash +# Install dependencies +npm install + +# Run in development mode with DevTools +npm run dev + +# Build for Windows +npm run build:win +``` + +See [BUILD_GUIDE.md](BUILD_GUIDE.md) for comprehensive build instructions. + +## 📋 System Requirements + +- **Operating System**: Windows 10/11, macOS 10.15+, or Linux +- **RAM**: 4GB minimum, 8GB recommended +- **Disk Space**: 2GB free space +- **Dependencies**: Docker and Caddy (installer can install these) + +## 🏗️ Project Structure + +``` +dashcaddy-installer/ +├── src/ +│ ├── main/ # Electron main process +│ │ ├── index.js # Main entry & IPC handlers +│ │ ├── dependency-checker.js # Docker/Caddy detection +│ │ ├── config-manager.js # Configuration management +│ │ ├── file-deployer.js # File copying & deployment +│ │ ├── caddyfile-generator.js # Config generation +│ │ ├── browser-launcher.js # Browser integration +│ │ └── service-manager.js # Service control +│ ├── renderer/ # UI layer +│ │ ├── index.html # Main HTML +│ │ ├── wizard.js # Wizard logic +│ │ └── styles.css # Styling +│ ├── preload/ # IPC bridge +│ │ └── index.js # Secure IPC exposure +│ └── shared/ # Shared utilities +│ ├── constants.js # App constants +│ └── platform-utils.js # Platform detection +├── templates/ # Configuration templates +│ ├── Caddyfile.template # Caddyfile template +│ ├── docker-compose.template.yml +│ └── README.md +├── assets/ # Images & icons +│ ├── icon.png # App icon +│ └── dashcaddy-logo.png +├── dist/ # Build output (generated) +├── BUILD_GUIDE.md # Comprehensive build guide +├── QUICK_START.md # User quick start guide +└── package.json # Dependencies & config +``` + +## 🛠️ Development + +### Setup + +```bash +# Clone the repository +git clone +cd dashcaddy-installer + +# Install dependencies +npm install +``` + +### Running + +```bash +# Development mode with DevTools +npm run dev + +# Production mode +npm start +``` + +### Testing + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch + +# Run property-based tests +npm test -- --testNamePattern="property" +``` + +### Building + +```bash +# Build for current platform +npm run build + +# Build for specific platforms +npm run build:win # Windows +npm run build:mac # macOS +npm run build:linux # Linux +``` + +**Build Output:** +- Windows: `dist/win-unpacked/DashCaddy Installer.exe` (168MB portable) +- macOS: `dist/DashCaddy Installer.dmg` +- Linux: `dist/DashCaddy Installer.AppImage` + +## 📖 Documentation + +- **[BUILD_GUIDE.md](BUILD_GUIDE.md)** - Complete build and development guide +- **[QUICK_START.md](QUICK_START.md)** - User installation guide +- **[templates/README.md](templates/README.md)** - Template documentation + +## 🎯 Installation Wizard Steps + +### 1. Welcome +- Introduction to DashCaddy +- Platform detection +- Feature overview + +### 2. Choose Folders +- Select installation directory +- Configure Docker data path +- Set Caddy config location + +### 3. Check Dependencies +- Detect Docker installation +- Detect Caddy installation +- Install missing dependencies + +### 4. Installation +- Deploy dashboard files +- Deploy API server +- Install npm dependencies +- Generate configurations +- Start services + +### 5. Complete +- Installation summary +- Dashboard URL +- Quick access button + +## 🔧 Configuration + +### Source Directories + +The installer copies files from: +- **Dashboard**: `../status/` → `{install}/dashboard/` +- **API Server**: `../dashcaddy-api/` → `{install}/api/` + +### Generated Files + +The installer creates: +- `Caddyfile` - Reverse proxy configuration +- `docker-compose.yml` - Container orchestration +- `.dashcaddy-config.json` - Installation settings + +### Default Ports + +- **Dashboard**: 8080 +- **API Server**: 3001 +- **Caddy Admin**: 2019 + +## 🐛 Troubleshooting + +### Build Issues +- Ensure Node.js 18+ is installed +- Delete `node_modules` and run `npm install` +- Check that source directories exist + +### Runtime Issues +- Run with `npm run dev` to see console errors +- Check that Docker daemon is running +- Verify Caddy is installed and accessible +- Ensure ports 8080, 3001, 2021 are available + +### Deployment Issues +- Verify source paths in `file-deployer.js` +- Check write permissions on installation directory +- Ensure sufficient disk space + +## 🤝 Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## 📝 License + +MIT + +## 🙏 Acknowledgments + +Built with: +- [Electron](https://www.electronjs.org/) - Cross-platform desktop apps +- [electron-builder](https://www.electron.build/) - Build and packaging +- [Jest](https://jestjs.io/) - Testing framework +- [fast-check](https://fast-check.dev/) - Property-based testing diff --git a/dashcaddy-installer/assets/DashCaddy logo dark.png b/dashcaddy-installer/assets/DashCaddy logo dark.png new file mode 100644 index 0000000..c3fcddc Binary files /dev/null and b/dashcaddy-installer/assets/DashCaddy logo dark.png differ diff --git a/dashcaddy-installer/assets/dashcaddy logo blue.png b/dashcaddy-installer/assets/dashcaddy logo blue.png new file mode 100644 index 0000000..5c185b5 Binary files /dev/null and b/dashcaddy-installer/assets/dashcaddy logo blue.png differ diff --git a/dashcaddy-installer/assets/dashcaddy logo icon.png b/dashcaddy-installer/assets/dashcaddy logo icon.png new file mode 100644 index 0000000..694749d Binary files /dev/null and b/dashcaddy-installer/assets/dashcaddy logo icon.png differ diff --git a/dashcaddy-installer/assets/dashcaddy logo light.png b/dashcaddy-installer/assets/dashcaddy logo light.png new file mode 100644 index 0000000..1ff97d5 Binary files /dev/null and b/dashcaddy-installer/assets/dashcaddy logo light.png differ diff --git a/dashcaddy-installer/assets/dashcaddy logo.png b/dashcaddy-installer/assets/dashcaddy logo.png new file mode 100644 index 0000000..a82a54f Binary files /dev/null and b/dashcaddy-installer/assets/dashcaddy logo.png differ diff --git a/dashcaddy-installer/assets/dashcaddy-logo.png b/dashcaddy-installer/assets/dashcaddy-logo.png new file mode 100644 index 0000000..a82a54f Binary files /dev/null and b/dashcaddy-installer/assets/dashcaddy-logo.png differ diff --git a/dashcaddy-installer/assets/favicon.ico b/dashcaddy-installer/assets/favicon.ico new file mode 100644 index 0000000..f199ce6 Binary files /dev/null and b/dashcaddy-installer/assets/favicon.ico differ diff --git a/dashcaddy-installer/assets/icon.ico b/dashcaddy-installer/assets/icon.ico new file mode 100644 index 0000000..f199ce6 Binary files /dev/null and b/dashcaddy-installer/assets/icon.ico differ diff --git a/dashcaddy-installer/install.sh b/dashcaddy-installer/install.sh new file mode 100644 index 0000000..7609d2a --- /dev/null +++ b/dashcaddy-installer/install.sh @@ -0,0 +1,960 @@ +#!/usr/bin/env bash +# ============================================================================ +# DashCaddy Linux Installer v1.0 +# +# Zero-effort install: +# curl -fsSL https://get.dashcaddy.net | bash +# +# Zero-typing install (local mode, instant access): +# curl -fsSL https://get.dashcaddy.net | bash -s -- quick +# +# With a domain (only thing you type is the domain): +# curl -fsSL https://get.dashcaddy.net | bash -s -- --domain my.example.com +# +# Designed for accessibility — all choices are single keystrokes. +# ============================================================================ + +set -euo pipefail + +# ---- Constants ------------------------------------------------------------- +readonly DASHCADDY_VERSION="1.0.0" +readonly DASHCADDY_DOWNLOAD="https://get.dashcaddy.net/release/latest.tar.gz" +readonly DASHCADDY_REPO="" # Set to a git URL to clone instead of downloading +readonly INSTALL_DIR="/etc/dashcaddy" +readonly DOCKER_DATA="/opt/dockerdata" +readonly SITES_DIR="${INSTALL_DIR}/sites" +readonly API_DIR="${SITES_DIR}/dashcaddy-api" +readonly DASHBOARD_DIR="${SITES_DIR}/status" +readonly CONTAINER_NAME="dashcaddy-api" +readonly CADDY_ADMIN_PORT=2019 + +# ---- Tunables (overridable via flags) -------------------------------------- +API_PORT=3001 +LOCAL_PORT=8080 + +# ---- Runtime state --------------------------------------------------------- +DOMAIN_MODE="" # public | custom-tld | local +DOMAIN="" +EMAIL="" +TLD="" +CA_NAME="DashCaddy Local CA" +SOURCE_PATH="" +GIT_BRANCH="main" +SKIP_DOCKER=false +SKIP_CADDY=false +UNINSTALL=false +KEEP_CONFIG=false +AUTO_YES=false +QUICK=false +DISTRO="" +DISTRO_FAMILY="" # debian | rhel | arch +PUBLIC_IP="" +LAN_IP="" +STEP=0 +TOTAL_STEPS=7 + +# ---- Colors ---------------------------------------------------------------- +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m' + BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m' + DIM='\033[2m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; BOLD=''; DIM=''; NC='' +fi + +# ============================================================================ +# Output Helpers +# ============================================================================ + +log() { echo -e "${CYAN}[DashCaddy]${NC} $*"; } +ok() { echo -e "${GREEN} ✓${NC} $*"; } +warn() { echo -e "${YELLOW} !${NC} $*"; } +err() { echo -e "${RED} ✗${NC} $*" >&2; } +fatal() { err "$*"; echo -e "${DIM} If this keeps failing, see: https://dashcaddy.net/docs/troubleshoot${NC}" >&2; exit 1; } + +step() { + STEP=$((STEP + 1)) + echo "" + echo -e "${BOLD}${BLUE} [${STEP}/${TOTAL_STEPS}] $*${NC}" + echo -e "${BLUE} ─────────────────────────────────────────${NC}" +} + +# Simple progress: run command in background, show dots +progress() { + local msg="$1"; shift + printf "${CYAN} ▸${NC} %s " "$msg" + + # Run the command, capture output for error reporting + local logfile="/tmp/dashcaddy-install-$$.log" + if "$@" >"$logfile" 2>&1; then + echo -e "${GREEN}done${NC}" + rm -f "$logfile" + return 0 + else + echo -e "${RED}failed${NC}" + echo -e "${DIM} Last output:${NC}" + tail -5 "$logfile" 2>/dev/null | sed 's/^/ /' + rm -f "$logfile" + return 1 + fi +} + +elapsed() { + local diff=$(( $(date +%s) - $1 )) + if (( diff < 60 )); then + echo "${diff}s" + else + printf '%dm%02ds' $((diff / 60)) $((diff % 60)) + fi +} + +# ============================================================================ +# System Detection +# ============================================================================ + +detect_system() { + # --- Root check --- + if [[ $EUID -ne 0 ]]; then + fatal "Must run as root. Try: sudo bash install.sh" + fi + + # --- OS detection --- + if [[ ! -f /etc/os-release ]]; then + fatal "Cannot detect OS — /etc/os-release not found." + fi + source /etc/os-release + + case "${ID,,}" in + ubuntu|debian|pop|linuxmint|elementary|zorin|raspbian) + DISTRO="${ID}"; DISTRO_FAMILY="debian" ;; + fedora|rhel|centos|rocky|almalinux|ol|amzn) + DISTRO="${ID}"; DISTRO_FAMILY="rhel" ;; + arch|manjaro|endeavouros) + DISTRO="${ID}"; DISTRO_FAMILY="arch" ;; + *) + DISTRO="${ID}"; DISTRO_FAMILY="debian" + warn "Unknown distro '${ID}' — using Debian-style commands" ;; + esac + + ok "OS: ${PRETTY_NAME:-$DISTRO}" + + # --- Architecture --- + local arch + arch=$(uname -m) + case "$arch" in + x86_64|amd64|aarch64|arm64) ok "Arch: ${arch}" ;; + *) warn "Arch '${arch}' may not be fully supported" ;; + esac + + # --- Network --- + LAN_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") + PUBLIC_IP=$(curl -fsSL --max-time 3 https://api.ipify.org 2>/dev/null || curl -fsSL --max-time 3 https://ifconfig.me 2>/dev/null || echo "unknown") + ok "LAN IP: ${LAN_IP}" + ok "Public IP: ${PUBLIC_IP}" + + # --- Existing install? --- + if [[ -f "${INSTALL_DIR}/config.json" ]]; then + warn "Existing DashCaddy installation detected" + warn "Re-running will upgrade in place (configs preserved)" + fi +} + +# ============================================================================ +# Interactive Setup — minimal typing, number keys only +# ============================================================================ + +interactive_setup() { + # Skip if mode already set via flags + if [[ -n "$DOMAIN_MODE" ]]; then return; fi + + # Detect environment to offer smart defaults + local is_vps=false + if [[ "$PUBLIC_IP" != "unknown" && "$PUBLIC_IP" != "$LAN_IP" ]] || \ + [[ "$LAN_IP" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.) ]]; then + : # Private IP — probably homelab or local + else + is_vps=true + fi + + echo "" + echo -e "${BOLD} How will you access DashCaddy?${NC}" + echo "" + + if $is_vps; then + echo -e " ${BOLD}1${NC}) Public domain ${DIM}— Let's Encrypt TLS (recommended for VPS)${NC}" + echo -e " ${BOLD}2${NC}) Quick start ${DIM}— http://${PUBLIC_IP}:${LOCAL_PORT} right now, add domain later${NC}" + echo -e " ${BOLD}3${NC}) Custom TLD ${DIM}— Internal CA for homelab (e.g., .home, .lab)${NC}" + else + echo -e " ${BOLD}1${NC}) Quick start ${DIM}— http://${LAN_IP}:${LOCAL_PORT} right now${NC}" + echo -e " ${BOLD}2${NC}) Custom TLD ${DIM}— Internal CA for homelab (e.g., .home, .lab)${NC}" + echo -e " ${BOLD}3${NC}) Public domain ${DIM}— Let's Encrypt TLS${NC}" + fi + + echo "" + local choice + read -rp "$(echo -e " ${YELLOW}Press 1, 2, or 3:${NC} ")" -n1 choice + echo "" + + if $is_vps; then + case "$choice" in + 1) setup_public_domain ;; + 2) DOMAIN_MODE="local" ;; + 3) setup_custom_tld ;; + *) DOMAIN_MODE="local"; warn "Defaulting to Quick Start" ;; + esac + else + case "$choice" in + 1) DOMAIN_MODE="local" ;; + 2) setup_custom_tld ;; + 3) setup_public_domain ;; + *) DOMAIN_MODE="local"; warn "Defaulting to Quick Start" ;; + esac + fi +} + +setup_public_domain() { + DOMAIN_MODE="public" + echo "" + read -rp "$(echo -e " ${YELLOW}Domain name:${NC} ")" DOMAIN + if [[ -z "$DOMAIN" ]]; then + fatal "Domain is required for public mode." + fi + + # Auto-check if domain points to this server + local resolved + resolved=$(dig +short "$DOMAIN" 2>/dev/null | head -1) + if [[ -n "$resolved" && "$resolved" == "$PUBLIC_IP" ]]; then + ok "${DOMAIN} correctly points to ${PUBLIC_IP}" + elif [[ -n "$resolved" ]]; then + warn "${DOMAIN} resolves to ${resolved}, but this server is ${PUBLIC_IP}" + echo -e " ${DIM}Let's Encrypt will fail if DNS doesn't point here.${NC}" + echo "" + read -rp "$(echo -e " ${YELLOW}Continue anyway? [y/N]:${NC} ")" -n1 cont + echo "" + [[ "$cont" =~ ^[Yy]$ ]] || fatal "Fix DNS first, then re-run the installer." + else + warn "Could not verify DNS for ${DOMAIN}" + echo -e " ${DIM}Make sure ${DOMAIN} points to ${PUBLIC_IP} for TLS to work.${NC}" + fi + + # Email — offer skip + echo "" + echo -e " ${DIM}Email for Let's Encrypt notifications (optional, press Enter to skip):${NC}" + read -rp " " EMAIL +} + +setup_custom_tld() { + DOMAIN_MODE="custom-tld" + echo "" + echo -e " ${DIM}Common choices: .home .local .lab .lan${NC}" + read -rp "$(echo -e " ${YELLOW}TLD (default .home):${NC} ")" TLD + [[ -z "$TLD" ]] && TLD=".home" + [[ "$TLD" != .* ]] && TLD=".$TLD" + + echo "" + read -rp "$(echo -e " ${YELLOW}CA name (default: DashCaddy Local CA):${NC} ")" input + [[ -n "$input" ]] && CA_NAME="$input" +} + +# ============================================================================ +# Dependency Installation +# ============================================================================ + +install_prereqs() { + # Only install what's missing + local needed=() + command -v curl &>/dev/null || needed+=(curl) + command -v jq &>/dev/null || needed+=(jq) + command -v dig &>/dev/null || needed+=(dnsutils) + # git only needed if using --source with a repo URL + [[ -n "$DASHCADDY_REPO" ]] && ! command -v git &>/dev/null && needed+=(git) + + if [[ ${#needed[@]} -eq 0 ]]; then + ok "Prerequisites already installed" + return + fi + + progress "Installing ${needed[*]}..." bash -c " + case '$DISTRO_FAMILY' in + debian) apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ${needed[*]} ;; + rhel) dnf install -y -q ${needed[*]} 2>/dev/null || yum install -y -q ${needed[*]} ;; + arch) pacman -Sy --noconfirm ${needed[*]} ;; + esac + " || warn "Some prerequisites may not have installed" +} + +install_docker() { + if $SKIP_DOCKER; then ok "Docker: skipped (--skip-docker)"; return; fi + + if command -v docker &>/dev/null && docker info &>/dev/null; then + ok "Docker: $(docker --version | grep -oP 'Docker version [\d.]+')" + return + fi + + progress "Installing Docker (this is the slowest step)..." bash -c " + case '$DISTRO_FAMILY' in + debian) + curl -fsSL https://get.docker.com | sh + ;; + rhel) + if command -v dnf &>/dev/null; then + dnf install -y -q dnf-plugins-core + dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + dnf install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin + else + yum install -y -q yum-utils + yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + yum install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin + fi + ;; + arch) + pacman -Sy --noconfirm docker docker-compose + ;; + esac + systemctl enable docker + systemctl start docker + " || fatal "Docker installation failed. Install manually: https://docs.docker.com/engine/install/" + + if docker info &>/dev/null; then + ok "Docker: $(docker --version | grep -oP 'Docker version [\d.]+')" + else + fatal "Docker installed but daemon not running. Try: systemctl start docker" + fi +} + +install_caddy() { + if $SKIP_CADDY; then ok "Caddy: skipped (--skip-caddy)"; return; fi + + if command -v caddy &>/dev/null; then + ok "Caddy: $(caddy version 2>/dev/null | head -1)" + return + fi + + progress "Installing Caddy..." bash -c " + case '$DISTRO_FAMILY' in + debian) + apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null + apt-get update -qq + apt-get install -y -qq caddy + ;; + rhel) + if command -v dnf &>/dev/null; then + dnf install -y -q 'dnf-command(copr)' + dnf copr enable -y @caddy/caddy + dnf install -y -q caddy + else + yum install -y -q yum-plugin-copr + yum copr enable -y @caddy/caddy + yum install -y -q caddy + fi + ;; + arch) + pacman -Sy --noconfirm caddy + ;; + esac + # Stop default Caddy — we configure it ourselves + systemctl stop caddy 2>/dev/null || true + " || fatal "Caddy installation failed. Install manually: https://caddyserver.com/docs/install" + + ok "Caddy: $(caddy version 2>/dev/null | head -1)" +} + +# ============================================================================ +# Fix known OS issues automatically +# ============================================================================ + +fix_system_issues() { + # systemd-resolved stub — breaks Docker DNS and Tailscale + if [[ -L /etc/resolv.conf ]] && readlink /etc/resolv.conf | grep -q stub; then + rm -f /etc/resolv.conf + cat > /etc/resolv.conf <<'EOF' +# Set by DashCaddy installer (replaced systemd-resolved stub) +nameserver 1.1.1.1 +nameserver 8.8.8.8 +nameserver 1.0.0.1 +EOF + ok "Fixed systemd-resolved stub (replaced with public DNS)" + fi + + # Ensure /etc/caddy exists + mkdir -p /etc/caddy +} + +# ============================================================================ +# Directory & File Setup +# ============================================================================ + +create_directories() { + mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets" + ok "Directories created" +} + +fetch_source() { + local tmp_src="/tmp/dashcaddy-src-$$" + + if [[ -n "$SOURCE_PATH" ]]; then + if [[ ! -d "$SOURCE_PATH" ]]; then + fatal "Source path not found: $SOURCE_PATH" + fi + tmp_src="$SOURCE_PATH" + ok "Using local source: $SOURCE_PATH" + elif [[ -n "$DASHCADDY_REPO" ]]; then + # Git clone mode (for development) + progress "Cloning DashCaddy..." \ + git clone --depth 1 --branch "$GIT_BRANCH" "$DASHCADDY_REPO" "$tmp_src" \ + || fatal "Failed to clone DashCaddy. Check network." + else + # Download release tarball (default — no git needed) + mkdir -p "$tmp_src" + progress "Downloading DashCaddy v${DASHCADDY_VERSION}..." bash -c " + curl -fsSL '${DASHCADDY_DOWNLOAD}' | tar xz -C '$tmp_src' --strip-components=1 + " || fatal "Failed to download DashCaddy. Check network or try --source with a local copy." + fi + + # Find API source (handle different repo layouts) + local api_src="" + for try in "${tmp_src}/dashcaddy-api" "${tmp_src}/sites/dashcaddy-api" "${tmp_src}/api"; do + [[ -d "$try" ]] && api_src="$try" && break + done + [[ -z "$api_src" ]] && fatal "Cannot find dashcaddy-api/ in source" + + # Find dashboard source + local dash_src="" + for try in "${tmp_src}/status" "${tmp_src}/sites/status" "${tmp_src}/dashboard"; do + [[ -d "$try" ]] && dash_src="$try" && break + done + [[ -z "$dash_src" ]] && fatal "Cannot find dashboard source in source" + + # Deploy API files + cp -f "${api_src}"/*.js "$API_DIR/" 2>/dev/null || true + cp -f "${api_src}/package.json" "$API_DIR/" + cp -f "${api_src}/package-lock.json" "$API_DIR/" 2>/dev/null || true + cp -f "${api_src}/Dockerfile" "$API_DIR/" + cp -f "${api_src}/openapi.yaml" "$API_DIR/" 2>/dev/null || true + [[ -d "${api_src}/routes" ]] && cp -rf "${api_src}/routes" "$API_DIR/" + ok "API files deployed" + + # Deploy dashboard files + cp -f "${dash_src}/index.html" "$DASHBOARD_DIR/" + cp -f "${dash_src}/sw.js" "$DASHBOARD_DIR/" 2>/dev/null || true + for dir in css js dist vendor assets; do + [[ -d "${dash_src}/${dir}" ]] && cp -rf "${dash_src}/${dir}" "$DASHBOARD_DIR/" + done + ok "Dashboard files deployed" + + # Cleanup + [[ -z "$SOURCE_PATH" ]] && rm -rf "$tmp_src" +} + +create_seed_configs() { + local config_type dashboard_host tz + + case "$DOMAIN_MODE" in + public) config_type="public"; dashboard_host="$DOMAIN" ;; + custom-tld) config_type="homelab"; dashboard_host="dashcaddy${TLD}" ;; + local) config_type="local"; dashboard_host="${LAN_IP}:${LOCAL_PORT}" ;; + esac + + tz=$(timedatectl show --property=Timezone --value 2>/dev/null || echo 'UTC') + + # Only create files that don't already exist (preserve on re-install) + [[ -f "${INSTALL_DIR}/services.json" ]] || echo '[]' > "${INSTALL_DIR}/services.json" + [[ -f "${INSTALL_DIR}/dns-credentials.json" ]] || echo '{}' > "${INSTALL_DIR}/dns-credentials.json" + [[ -f "${INSTALL_DIR}/credentials.json" ]] || echo '{}' > "${INSTALL_DIR}/credentials.json" + [[ -f "${INSTALL_DIR}/notifications.json" ]] || echo '[]' > "${INSTALL_DIR}/notifications.json" + + if [[ ! -f "${INSTALL_DIR}/.encryption-key" ]]; then + openssl rand -hex 32 > "${INSTALL_DIR}/.encryption-key" + chmod 600 "${INSTALL_DIR}/.encryption-key" + fi + + # config.json — always regenerate (contains dashboard URL which may change) + cat > "${INSTALL_DIR}/config.json" < "$cf" < "$cf" < "$cf" < "${API_DIR}/docker-compose.yml" </dev/null || true + + cd "$API_DIR" + + progress "Building container image (30-60s)..." \ + docker compose build --quiet \ + || progress "Building container image (fallback)..." \ + docker-compose build --quiet \ + || fatal "Docker build failed. Check: docker info" + + progress "Starting container..." \ + docker compose up -d \ + || docker-compose up -d 2>/dev/null \ + || fatal "Failed to start container" + + # Wait for healthy + local i=0 + printf "${CYAN} ▸${NC} Waiting for API health check " + while (( i < 30 )); do + if curl -fsSL --max-time 2 "http://localhost:${API_PORT}/health" &>/dev/null; then + echo -e "${GREEN}healthy${NC}" + return + fi + printf "." + sleep 1 + i=$((i + 1)) + done + echo -e "${YELLOW}timeout${NC}" + warn "API may still be starting. Check: docker logs ${CONTAINER_NAME}" +} + +start_caddy() { + # Symlink our Caddyfile to where Caddy's systemd unit expects it + if [[ -f /etc/caddy/Caddyfile && ! -L /etc/caddy/Caddyfile ]]; then + mv /etc/caddy/Caddyfile /etc/caddy/Caddyfile.original 2>/dev/null || true + fi + ln -sf "${INSTALL_DIR}/Caddyfile" /etc/caddy/Caddyfile + + systemctl enable caddy >/dev/null 2>&1 + systemctl restart caddy 2>/dev/null + + sleep 2 + if systemctl is-active --quiet caddy; then + ok "Caddy is running" + else + warn "Caddy may have issues. Check: systemctl status caddy" + fi +} + +# ============================================================================ +# Firewall +# ============================================================================ + +open_firewall() { + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then + case "$DOMAIN_MODE" in + public) + ufw allow 80/tcp >/dev/null 2>&1; ufw allow 443/tcp >/dev/null 2>&1 + ok "Firewall: opened 80, 443 (UFW)" ;; + local) + ufw allow "${LOCAL_PORT}"/tcp >/dev/null 2>&1 + ok "Firewall: opened ${LOCAL_PORT} (UFW)" ;; + esac + elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then + case "$DOMAIN_MODE" in + public) + firewall-cmd --permanent --add-service=http --add-service=https >/dev/null 2>&1 + firewall-cmd --reload >/dev/null 2>&1 + ok "Firewall: opened 80, 443 (firewalld)" ;; + local) + firewall-cmd --permanent --add-port="${LOCAL_PORT}"/tcp >/dev/null 2>&1 + firewall-cmd --reload >/dev/null 2>&1 + ok "Firewall: opened ${LOCAL_PORT} (firewalld)" ;; + esac + fi +} + +# ============================================================================ +# Uninstall +# ============================================================================ + +do_uninstall() { + echo -e "\n${BOLD} Uninstalling DashCaddy${NC}\n" + + echo " This will:" + echo " - Stop and remove the dashcaddy-api container" + echo " - Remove DashCaddy files from ${INSTALL_DIR}/" + if $KEEP_CONFIG; then + echo " - KEEP your config files (--keep-config)" + fi + echo " - NOT remove Docker or Caddy" + echo "" + + read -rp "$(echo -e " ${YELLOW}Continue? [y/N]:${NC} ")" -n1 answer + echo "" + [[ "$answer" =~ ^[Yy]$ ]] || { echo " Cancelled."; exit 0; } + + docker rm -f "$CONTAINER_NAME" 2>/dev/null && ok "Container removed" || true + + if [[ -L /etc/caddy/Caddyfile ]] && readlink /etc/caddy/Caddyfile | grep -q dashcaddy; then + rm -f /etc/caddy/Caddyfile + [[ -f /etc/caddy/Caddyfile.original ]] && mv /etc/caddy/Caddyfile.original /etc/caddy/Caddyfile + systemctl restart caddy 2>/dev/null || true + ok "Caddy config restored" + fi + + if $KEEP_CONFIG; then + rm -rf "$API_DIR" "$DASHBOARD_DIR" + ok "App files removed, config preserved in ${INSTALL_DIR}/" + else + rm -rf "$INSTALL_DIR" + ok "All files removed from ${INSTALL_DIR}/" + fi + + echo -e "\n${GREEN} DashCaddy uninstalled.${NC}\n" + exit 0 +} + +# ============================================================================ +# Argument Parsing +# ============================================================================ + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + quick) QUICK=true; DOMAIN_MODE="local"; shift ;; + --domain) DOMAIN_MODE="public"; DOMAIN="${2:-}"; shift; shift ;; + --email) EMAIL="${2:-}"; shift; shift ;; + --tld) DOMAIN_MODE="custom-tld"; TLD="${2:-}"; shift; shift ;; + --ca-name) CA_NAME="${2:-}"; shift; shift ;; + --local) DOMAIN_MODE="local"; shift ;; + --port) LOCAL_PORT="${2:-8080}"; shift; shift ;; + --api-port) API_PORT="${2:-3001}"; shift; shift ;; + --source) SOURCE_PATH="${2:-}"; shift; shift ;; + --branch) GIT_BRANCH="${2:-main}"; shift; shift ;; + --skip-docker) SKIP_DOCKER=true; shift ;; + --skip-caddy) SKIP_CADDY=true; shift ;; + --uninstall) UNINSTALL=true; shift ;; + --keep-config) KEEP_CONFIG=true; shift ;; + --yes|-y) AUTO_YES=true; shift ;; + --help|-h) print_help; exit 0 ;; + *) warn "Unknown option: $1 (ignored)"; shift ;; + esac + done + + # Normalize TLD + if [[ -n "$TLD" && "$TLD" != .* ]]; then TLD=".$TLD"; fi +} + +print_help() { + cat <<'HELP' + + DashCaddy Linux Installer + + QUICK INSTALL (zero typing): + curl -fsSL https://get.dashcaddy.net | bash -s -- quick + + WITH DOMAIN: + curl -fsSL https://get.dashcaddy.net | bash -s -- --domain my.example.com + + INTERACTIVE: + curl -fsSL https://get.dashcaddy.net | bash + + OPTIONS: + quick Instant local install, zero questions + --domain DOMAIN Public domain (Let's Encrypt TLS) + --email EMAIL Let's Encrypt email (optional) + --tld TLD Custom TLD, internal CA (e.g., .home) + --local Local mode (http://IP:port) + --port PORT Port for local mode (default: 8080) + --source PATH Use local source files + --skip-docker Already have Docker + --skip-caddy Already have Caddy + --uninstall Remove DashCaddy + --keep-config Keep configs during uninstall + --yes Skip confirmations + +HELP +} + +# ============================================================================ +# Banner & Success +# ============================================================================ + +print_banner() { + echo "" + echo -e "${BOLD}${BLUE} ╔══════════════════════════════════════════════╗${NC}" + echo -e "${BOLD}${BLUE} ║${NC}${BOLD} DashCaddy Installer v${DASHCADDY_VERSION} ${BLUE}║${NC}" + echo -e "${BOLD}${BLUE} ╚══════════════════════════════════════════════╝${NC}" + echo "" +} + +print_success() { + local total_time=$1 + local url + + case "$DOMAIN_MODE" in + public) url="https://${DOMAIN}" ;; + custom-tld) url="https://dashcaddy${TLD}" ;; + local) url="http://${PUBLIC_IP}:${LOCAL_PORT}" ;; + esac + + # Also show LAN URL for local mode + local lan_url="" + if [[ "$DOMAIN_MODE" == "local" && "$LAN_IP" != "$PUBLIC_IP" ]]; then + lan_url="http://${LAN_IP}:${LOCAL_PORT}" + fi + + echo "" + echo -e "${GREEN}${BOLD} ┌──────────────────────────────────────────────┐${NC}" + echo -e "${GREEN}${BOLD} │ Installation Complete! │${NC}" + echo -e "${GREEN}${BOLD} └──────────────────────────────────────────────┘${NC}" + echo "" + echo -e " ${BOLD}Open in browser:${NC} ${url}" + [[ -n "$lan_url" ]] && echo -e " ${BOLD}LAN access:${NC} ${lan_url}" + echo "" + echo -e " ${DIM}Config: ${INSTALL_DIR}/ | Logs: docker logs dashcaddy-api${NC}" + echo -e " ${DIM}Installed in: ${total_time}${NC}" + + if [[ "$DOMAIN_MODE" == "public" ]]; then + echo "" + echo -e " ${CYAN}TLS will auto-provision on first visit.${NC}" + echo -e " ${CYAN}Ensure DNS for ${DOMAIN} → ${PUBLIC_IP}${NC}" + fi + + echo "" + echo -e " ${BOLD}Everything is managed from the dashboard — no CLI needed.${NC}" + echo -e " ${DIM}Deploy apps, manage Docker containers, edit Caddy configs,${NC}" + echo -e " ${DIM}and monitor services all from the web UI.${NC}" + echo "" +} + +# ============================================================================ +# Main +# ============================================================================ + +main() { + local start_time + start_time=$(date +%s) + + parse_args "$@" + + # Handle uninstall + if $UNINSTALL; then do_uninstall; fi + + print_banner + + # ---- Step 1: System check ---- + step "Checking system" + detect_system + fix_system_issues + + # ---- Step 2: Configuration ---- + if $QUICK; then + ok "Quick mode: http://${PUBLIC_IP}:${LOCAL_PORT}" + else + step "Configuration" + interactive_setup + fi + + # Show what we're doing + echo "" + case "$DOMAIN_MODE" in + public) log "Installing with domain: ${DOMAIN}" ;; + custom-tld) log "Installing with TLD: ${TLD}" ;; + local) log "Installing in local mode (port ${LOCAL_PORT})" ;; + esac + + # ---- Step 3: Dependencies ---- + step "Installing dependencies" + install_prereqs + install_docker + install_caddy + + # ---- Step 4: Deploy files ---- + step "Deploying DashCaddy" + create_directories + fetch_source + create_seed_configs + + # ---- Step 5: Configuration ---- + step "Generating configuration" + generate_caddyfile + generate_docker_compose + open_firewall + + # ---- Step 6: Build & start ---- + step "Building & starting services" + build_and_start + + # ---- Step 7: Start Caddy ---- + step "Starting web server" + start_caddy + + print_success "$(elapsed "$start_time")" +} + +main "$@" diff --git a/dashcaddy-installer/package.json b/dashcaddy-installer/package.json new file mode 100644 index 0000000..6e317a9 --- /dev/null +++ b/dashcaddy-installer/package.json @@ -0,0 +1,87 @@ +{ + "name": "dashcaddy-installer", + "version": "1.0.0", + "description": "Cross-platform installer for DashCaddy platform", + "main": "src/main/index.js", + "scripts": { + "start": "electron .", + "dev": "electron . --dev", + "test": "jest", + "test:watch": "jest --watch", + "build": "electron-builder", + "build:win": "electron-builder --win", + "build:mac": "electron-builder --mac", + "build:linux": "electron-builder --linux" + }, + "keywords": [ + "dashcaddy", + "installer", + "docker", + "caddy" + ], + "author": { + "name": "DashCaddy Team", + "email": "dashcaddy@sami.cloud" + }, + "homepage": "https://github.com/dashcaddy/dashcaddy", + "license": "MIT", + "devDependencies": { + "electron": "^28.3.3", + "electron-builder": "^24.9.1", + "fast-check": "^3.15.0", + "jest": "^29.7.0" + }, + "build": { + "appId": "com.dashcaddy.installer", + "productName": "DashCaddy Installer", + "asar": true, + "directories": { + "output": "build-output" + }, + "files": [ + "src/**/*", + "assets/**/*", + "templates/**/*" + ], + "extraResources": [ + { + "from": "../status", + "to": "status", + "filter": ["**/*", "!node_modules/**", "!.git/**", "!**/*.test.js", "!**/*.spec.js"] + }, + { + "from": "../dashcaddy-api", + "to": "dashcaddy-api", + "filter": ["**/*", "!node_modules/**", "!.git/**", "!**/*.test.js", "!**/*.spec.js"] + } + ], + "icon": "assets/favicon.ico", + "win": { + "target": [ + "nsis", + "portable" + ], + "icon": "assets/favicon.ico", + "signAndEditExecutable": false + }, + "mac": { + "target": "dmg", + "icon": "assets/dashcaddy-logo.png" + }, + "linux": { + "target": [ + "AppImage", + "deb" + ], + "icon": "assets/dashcaddy-logo.png", + "category": "Utility" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "installerIcon": "assets/icon.ico", + "uninstallerIcon": "assets/icon.ico", + "installerHeaderIcon": "assets/icon.ico" + } + } +} diff --git a/dashcaddy-installer/src/main/browser-launcher.js b/dashcaddy-installer/src/main/browser-launcher.js new file mode 100644 index 0000000..911d29c --- /dev/null +++ b/dashcaddy-installer/src/main/browser-launcher.js @@ -0,0 +1,111 @@ +const { exec } = require('child_process'); +const { promisify } = require('util'); +const { getPlatformInfo } = require('../shared/platform-utils'); + +const execAsync = promisify(exec); + +/** + * BrowserLauncher - Opens URLs in the default browser + */ +class BrowserLauncher { + /** + * Open URL in default browser + * @param {string} url - URL to open + */ + async openURL(url) { + try { + const platform = getPlatformInfo(); + let command; + + switch (platform.os) { + case 'windows': + command = `start ${url}`; + break; + case 'macos': + command = `open ${url}`; + break; + case 'linux': + command = `xdg-open ${url}`; + break; + default: + throw new Error(`Unsupported platform: ${platform.os}`); + } + + await execAsync(command); + + return { + success: true, + url, + platform: platform.os + }; + } catch (error) { + return { + success: false, + error: error.message, + url + }; + } + } + + /** + * Open dashboard in browser + * @param {number} port - Port dashboard is running on + * @param {string} hostname - Hostname (default: localhost) + */ + async openDashboard(port = 8080, hostname = 'localhost') { + const url = `http://${hostname}:${port}`; + return this.openURL(url); + } + + /** + * Wait for service to be ready before opening + * @param {string} url - URL to check + * @param {number} maxAttempts - Maximum number of attempts + * @param {number} delayMs - Delay between attempts in milliseconds + */ + async waitForService(url, maxAttempts = 30, delayMs = 1000) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await fetch(url); + if (response.ok) { + return { success: true, attempts: attempt }; + } + } catch (error) { + // Service not ready yet, continue waiting + } + + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + return { + success: false, + error: `Service did not become ready after ${maxAttempts} attempts` + }; + } + + /** + * Open dashboard after waiting for it to be ready + * @param {number} port - Port dashboard is running on + * @param {string} hostname - Hostname (default: localhost) + */ + async openDashboardWhenReady(port = 8080, hostname = 'localhost') { + const url = `http://${hostname}:${port}`; + + // Wait for service to be ready + const waitResult = await this.waitForService(url); + + if (!waitResult.success) { + return { + success: false, + error: waitResult.error + }; + } + + // Open in browser + return this.openURL(url); + } +} + +module.exports = BrowserLauncher; diff --git a/dashcaddy-installer/src/main/caddyfile-generator.js b/dashcaddy-installer/src/main/caddyfile-generator.js new file mode 100644 index 0000000..551a946 --- /dev/null +++ b/dashcaddy-installer/src/main/caddyfile-generator.js @@ -0,0 +1,375 @@ +const fs = require('fs'); +const path = require('path'); +const { DEFAULT_PORTS } = require('../shared/constants'); + +/** + * CaddyfileGenerator - Creates Caddyfile and docker-compose.yml for DashCaddy + * + * Generates production-grade configs that match the patterns used by the + * running DashCaddy deployment (CORS snippets, admin origins, PKI, etc.) + */ +class CaddyfileGenerator { + /** + * Normalize a Windows path to forward slashes for Caddyfile/Docker Compose + */ + _p(s) { + return s.replace(/\\/g, '/'); + } + + /** + * Generate the CORS snippet block used by the API + */ + _corsSnippets() { + return `(cors-preflight) { + @preflight method OPTIONS + header @preflight { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" + Access-Control-Allow-Headers "*" + Access-Control-Max-Age "600" + } + respond @preflight 204 +} +(cors-allow) { + header { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" + Access-Control-Allow-Headers "*" + Access-Control-Max-Age "600" + } +} +`; + } + + /** + * Generate the dashcaddy_auth snippet for SSO + */ + _authSnippet(apiPort) { + return `# DashCaddy SSO auth snippet +(dashcaddy_auth) { + forward_auth localhost:${apiPort} { + uri /api/auth/gate/{args[0]} + copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token + } +} +`; + } + + /** + * Generate the dashboard site block content (shared across all modes) + */ + _dashboardBlock(dashboardPath, tier, apiPort) { + let block = ''; + + block += ` root * ${this._p(dashboardPath)}\n`; + block += ` encode gzip\n\n`; + + // API proxy for intermediate/advanced tiers + if (tier !== 'basic') { + block += ` # API proxy to Docker container\n`; + block += ` handle /api/* {\n`; + block += ` reverse_proxy localhost:${apiPort}\n`; + block += ` }\n\n`; + } + + // SPA fallback with file_server + block += ` # Static site + SPA fallback\n`; + block += ` handle {\n`; + block += ` @notFile not file {path}\n`; + block += ` rewrite @notFile /index.html\n`; + block += ` file_server\n`; + block += ` }\n`; + + return block; + } + + /** + * Generate Caddyfile for local mode (IP:Port, no TLS) + */ + generateCaddyfile(options) { + const { + dashboardPath, + port = 8080, + tier = 'basic', + apiPort = DEFAULT_PORTS.API + } = options; + + const adminPort = DEFAULT_PORTS.CADDY_ADMIN; + + let caddyfile = `# DashCaddy Caddyfile - Local Mode +# Generated by DashCaddy Installer + +{ + admin localhost:${adminPort} { + origins localhost localhost:${adminPort} host.docker.internal host.docker.internal:${adminPort} + } + auto_https off +} + +`; + + if (tier !== 'basic') { + caddyfile += this._corsSnippets() + '\n'; + } + + caddyfile += `:${port} {\n`; + caddyfile += this._dashboardBlock(dashboardPath, tier, apiPort); + caddyfile += `}\n`; + + return caddyfile; + } + + /** + * Generate Caddyfile for public domain mode (Let's Encrypt TLS) + */ + generateCaddyfileWithDomain(options) { + const { + dashboardPath, + domain, + email = '', + tier = 'basic', + apiPort = DEFAULT_PORTS.API + } = options; + + const adminPort = DEFAULT_PORTS.CADDY_ADMIN; + + let caddyfile = `# DashCaddy Caddyfile - Public Domain Mode +# Generated by DashCaddy Installer + +{ + admin localhost:${adminPort} { + origins localhost localhost:${adminPort} host.docker.internal host.docker.internal:${adminPort} + } +`; + if (email) { + caddyfile += ` email ${email}\n`; + } + caddyfile += `}\n\n`; + + if (tier !== 'basic') { + caddyfile += this._corsSnippets() + '\n'; + caddyfile += this._authSnippet(apiPort) + '\n'; + } + + caddyfile += `${domain} {\n`; + caddyfile += this._dashboardBlock(dashboardPath, tier, apiPort); + caddyfile += `}\n`; + + return caddyfile; + } + + /** + * Generate Caddyfile for custom TLD mode (internal CA) + */ + generateCaddyfileCustomTLD(options) { + const { + dashboardPath, + installPath, + tld = '.home', + caName = 'DashCaddy Local CA', + tier = 'basic', + apiPort = DEFAULT_PORTS.API + } = options; + + const adminPort = DEFAULT_PORTS.CADDY_ADMIN; + const dashboardDomain = `dashcaddy${tld}`; + const certsPath = installPath ? this._p(path.join(installPath, 'certs')) : './certs'; + + let caddyfile = `# DashCaddy Caddyfile - Custom TLD Mode (${tld}) +# Generated by DashCaddy Installer + +{ + storage file_system { + root ${certsPath} + } + + admin localhost:${adminPort} { + origins localhost localhost:${adminPort} host.docker.internal host.docker.internal:${adminPort} + } + + pki { + ca local { + name "${caName}" + root_cn "${caName} Root CA" + intermediate_cn "${caName} Intermediate CA" + } + } +} + +`; + + if (tier !== 'basic') { + caddyfile += this._corsSnippets() + '\n'; + caddyfile += this._authSnippet(apiPort) + '\n'; + } + + caddyfile += `# Dashboard - ${dashboardDomain}\n`; + caddyfile += `${dashboardDomain} {\n`; + caddyfile += ` tls internal\n\n`; + caddyfile += this._dashboardBlock(dashboardPath, tier, apiPort); + caddyfile += `}\n`; + + return caddyfile; + } + + /** + * Write Caddyfile to disk + */ + async writeCaddyfile(content, outputPath) { + try { + await fs.promises.writeFile(outputPath, content, 'utf8'); + return { success: true, path: outputPath }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Create complete Caddyfile setup + * @param {string} installPath - Installation directory + * @param {Object} options - Configuration options + */ + async createCaddyfileSetup(installPath, options = {}) { + try { + const dashboardPath = path.join(installPath, 'sites', 'status'); + const caddyfilePath = path.join(installPath, 'Caddyfile'); + + let content; + if (options.domainMode === 'public') { + content = this.generateCaddyfileWithDomain({ + dashboardPath, + domain: options.publicDomain, + email: options.email, + tier: options.tier, + apiPort: options.apiPort + }); + } else if (options.domainMode === 'custom-tld') { + content = this.generateCaddyfileCustomTLD({ + dashboardPath, + installPath, + tld: options.tld, + caName: options.caName || 'DashCaddy Local CA', + tier: options.tier, + apiPort: options.apiPort + }); + } else { + content = this.generateCaddyfile({ + dashboardPath, + ...options + }); + } + + const result = await this.writeCaddyfile(content, caddyfilePath); + + if (!result.success) { + throw new Error(result.error); + } + + return { + success: true, + path: caddyfilePath, + content + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } + + /** + * Generate docker-compose.yml for running the API server + * @param {string} installPath - Installation directory + * @param {Object} options - Configuration options + * @param {number} options.apiPort - API server port + * @param {string} options.lanIP - Host LAN IP address + * @param {string} options.tailscaleIP - Host Tailscale IP address + * @param {string} options.domainMode - Domain mode (local, public, custom-tld) + */ + generateDockerCompose(installPath, options = {}) { + const apiPort = options.apiPort || DEFAULT_PORTS.API; + const adminPort = DEFAULT_PORTS.CADDY_ADMIN; + const p = this._p.bind(this); + + // Core volume mounts + let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw + - ${p(installPath)}/services.json:/app/services.json:rw + - ${p(installPath)}/dns-credentials.json:/app/dns-credentials.json:rw + - ${p(installPath)}/config.json:/app/config.json:rw + - ${p(installPath)}/credentials.json:/app/credentials.json:rw + - ${p(installPath)}/.encryption-key:/app/.encryption-key:rw + - ${p(installPath)}/notifications.json:/app/notifications.json:rw + - ${p(installPath)}/sites/status/assets:/app/assets:rw + - /var/run/docker.sock:/var/run/docker.sock`; + + // Add CA cert mount for custom-tld mode + if (options.domainMode === 'custom-tld') { + volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`; + } + + // Environment variables + let envVars = ` - CADDYFILE_PATH=/caddyfile + - CADDY_ADMIN_URL=http://host.docker.internal:${adminPort} + - ASSETS_PATH=/app/assets + - CREDENTIALS_FILE=/app/credentials.json + - NODE_ENV=production`; + + if (options.domainMode === 'custom-tld') { + envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`; + } + + if (options.lanIP) { + envVars += `\n - HOST_LAN_IP=${options.lanIP}`; + } + if (options.tailscaleIP) { + envVars += `\n - HOST_TAILSCALE_IP=${options.tailscaleIP}`; + } + + const dockerCompose = `services: + dashcaddy-api: + build: . + container_name: dashcaddy-api + ports: + - "${apiPort}:${apiPort}" + volumes: +${volumes} + environment: +${envVars} + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped +`; + + return dockerCompose; + } + + /** + * Write docker-compose.yml to disk + * @param {string} installPath - Installation directory + * @param {Object} options - Configuration options + */ + async createDockerCompose(installPath, options = {}) { + try { + const content = this.generateDockerCompose(installPath, options); + const apiDir = path.join(installPath, 'sites', 'dashcaddy-api'); + await fs.promises.mkdir(apiDir, { recursive: true }); + const outputPath = path.join(apiDir, 'docker-compose.yml'); + + await fs.promises.writeFile(outputPath, content, 'utf8'); + + return { + success: true, + path: outputPath, + content + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } +} + +module.exports = CaddyfileGenerator; diff --git a/dashcaddy-installer/src/main/config-manager.js b/dashcaddy-installer/src/main/config-manager.js new file mode 100644 index 0000000..4ec80a0 --- /dev/null +++ b/dashcaddy-installer/src/main/config-manager.js @@ -0,0 +1,781 @@ +const fs = require('fs').promises; +const path = require('path'); +const { REQUIRED_DIRS } = require('../shared/constants'); + +// Import crypto utilities for secure credential storage +let cryptoUtils; +try { + // Try to load from dashcaddy-api if available + cryptoUtils = require('../../dashcaddy-api/crypto-utils'); +} catch { + // Fallback: create minimal crypto implementation + const crypto = require('crypto'); + const ALGORITHM = 'aes-256-gcm'; + const KEY_LENGTH = 32; + const IV_LENGTH = 16; + + let encryptionKey = null; + + function loadOrCreateKey() { + if (encryptionKey) return encryptionKey; + if (process.env.DASHCADDY_ENCRYPTION_KEY) { + encryptionKey = Buffer.from(process.env.DASHCADDY_ENCRYPTION_KEY, 'hex'); + return encryptionKey; + } + encryptionKey = crypto.randomBytes(KEY_LENGTH); + console.warn('[ConfigManager] Using ephemeral encryption key - credentials will not persist across restarts'); + return encryptionKey; + } + + cryptoUtils = { + encrypt: (data) => { + const key = loadOrCreateKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const plaintext = typeof data === 'object' ? JSON.stringify(data) : String(data); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(plaintext, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + const authTag = cipher.getAuthTag(); + return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; + }, + decrypt: (encryptedData) => { + const key = loadOrCreateKey(); + const parts = encryptedData.split(':'); + if (parts.length !== 3) throw new Error('Invalid encrypted data format'); + const iv = Buffer.from(parts[0], 'base64'); + const authTag = Buffer.from(parts[1], 'base64'); + const ciphertext = parts[2]; + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(ciphertext, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + }, + isEncrypted: (data) => { + if (typeof data !== 'string') return false; + const parts = data.split(':'); + return parts.length === 3; + } + }; +} + +class ConfigManager { + /** + * Saves installation configuration to disk + * @param {Object} config - Configuration object + * @param {string} installPath - Installation directory path + * @returns {Promise} { success: boolean, path: string } + */ + async saveConfig(config, installPath) { + try { + const configPath = path.join(installPath, 'config.json'); + + // Add metadata + const configWithMetadata = { + ...config, + version: config.version || '1.0.0', + lastModified: new Date().toISOString() + }; + + // Write config file + await fs.writeFile( + configPath, + JSON.stringify(configWithMetadata, null, 2), + 'utf8' + ); + + return { + success: true, + path: configPath + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } + + /** + * Loads installation configuration from disk + * @param {string} installPath - Installation directory path + * @returns {Promise} { config: object, exists: boolean } + */ + async loadConfig(installPath) { + try { + const configPath = path.join(installPath, 'config.json'); + + // Check if config exists + try { + await fs.access(configPath); + } catch { + return { + config: null, + exists: false + }; + } + + // Read and parse config + const configData = await fs.readFile(configPath, 'utf8'); + const config = JSON.parse(configData); + + return { + config: config, + exists: true + }; + } catch (error) { + return { + config: null, + exists: false, + error: error.message + }; + } + } + + /** + * Creates required directory structure + * @param {string} installPath - Installation directory path + * @returns {Promise} { success: boolean, paths: array } + */ + async createDirectories(installPath) { + try { + const createdPaths = []; + + // Create main installation directory + await fs.mkdir(installPath, { recursive: true }); + createdPaths.push(installPath); + + // Create required subdirectories + for (const dir of REQUIRED_DIRS) { + const dirPath = path.join(installPath, dir); + await fs.mkdir(dirPath, { recursive: true }); + createdPaths.push(dirPath); + } + + return { + success: true, + paths: createdPaths + }; + } catch (error) { + return { + success: false, + error: error.message, + paths: [] + }; + } + } + + /** + * Validates that a path is writable + * @param {string} testPath - Path to validate + * @returns {Promise} { valid: boolean, message: string } + */ + async validatePath(testPath) { + try { + // Try to create the directory if it doesn't exist + await fs.mkdir(testPath, { recursive: true }); + + // Try to write a test file + const testFile = path.join(testPath, '.dashcaddy-test'); + await fs.writeFile(testFile, 'test', 'utf8'); + + // Clean up test file + await fs.unlink(testFile); + + return { + valid: true, + message: 'Path is writable' + }; + } catch (error) { + let message = 'Path is not writable'; + + if (error.code === 'EACCES') { + message = 'Permission denied. Please choose a different location or run as administrator.'; + } else if (error.code === 'ENOENT') { + message = 'Path does not exist and cannot be created.'; + } else if (error.code === 'ENOSPC') { + message = 'Not enough disk space.'; + } + + return { + valid: false, + message: message, + error: error.message + }; + } + } + + /** + * Checks if an installation exists at the given path + * @param {string} installPath - Path to check + * @returns {Promise} True if installation exists + */ + async installationExists(installPath) { + try { + const configPath = path.join(installPath, 'config.json'); + await fs.access(configPath); + return true; + } catch { + return false; + } + } + + /** + * Gets disk space information for a path + * @param {string} testPath - Path to check + * @returns {Promise} Disk space info + */ + async getDiskSpace(testPath) { + // Note: This is a simplified version. In production, you'd use a library like 'check-disk-space' + try { + const stats = await fs.stat(testPath); + + return { + available: true, + path: testPath + }; + } catch (error) { + return { + available: false, + error: error.message + }; + } + } + + /** + * Saves DNS credentials securely with encryption + * @param {Object} credentials - DNS credentials + * @param {string} installPath - Installation directory path + * @returns {Promise} { success: boolean, path: string } + */ + async saveDNSCredentials(credentials, installPath) { + try { + const credPath = path.join(installPath, 'dns-credentials.json'); + + // Encrypt sensitive fields + const credentialsToSave = { + server: credentials.server, + username: credentials.username, + // Encrypt password and token + password: credentials.password ? cryptoUtils.encrypt(credentials.password) : null, + token: credentials.token ? cryptoUtils.encrypt(credentials.token) : null, + apiKey: credentials.apiKey ? cryptoUtils.encrypt(credentials.apiKey) : null, + _encrypted: true, + _encryptedFields: ['password', 'token', 'apiKey'], + savedAt: new Date().toISOString() + }; + + await fs.writeFile( + credPath, + JSON.stringify(credentialsToSave, null, 2), + 'utf8' + ); + + // Set restrictive permissions (Unix-like systems) + try { + await fs.chmod(credPath, 0o600); // Read/write for owner only + } catch { + // Windows doesn't support chmod, that's okay + } + + console.log('[ConfigManager] DNS credentials saved with encryption'); + + return { + success: true, + path: credPath + }; + } catch (error) { + console.error('[ConfigManager] Failed to save DNS credentials:', error.message); + return { + success: false, + error: error.message + }; + } + } + + /** + * Loads DNS credentials and decrypts them + * @param {string} installPath - Installation directory path + * @returns {Promise} { credentials: object, exists: boolean } + */ + async loadDNSCredentials(installPath) { + try { + const credPath = path.join(installPath, 'dns-credentials.json'); + + // Check if credentials exist + try { + await fs.access(credPath); + } catch { + return { + credentials: null, + exists: false + }; + } + + // Read and parse credentials + const credData = await fs.readFile(credPath, 'utf8'); + const credentials = JSON.parse(credData); + + // Decrypt sensitive fields if encrypted + if (credentials._encrypted) { + const decrypted = { + server: credentials.server, + username: credentials.username, + password: credentials.password && cryptoUtils.isEncrypted(credentials.password) + ? cryptoUtils.decrypt(credentials.password) + : credentials.password, + token: credentials.token && cryptoUtils.isEncrypted(credentials.token) + ? cryptoUtils.decrypt(credentials.token) + : credentials.token, + apiKey: credentials.apiKey && cryptoUtils.isEncrypted(credentials.apiKey) + ? cryptoUtils.decrypt(credentials.apiKey) + : credentials.apiKey, + savedAt: credentials.savedAt + }; + + console.log('[ConfigManager] DNS credentials loaded and decrypted'); + + return { + credentials: decrypted, + exists: true + }; + } + + // Legacy plaintext credentials - migrate on next save + console.warn('[ConfigManager] Found plaintext DNS credentials - will encrypt on next save'); + + return { + credentials: credentials, + exists: true, + needsMigration: true + }; + } catch (error) { + console.error('[ConfigManager] Failed to load DNS credentials:', error.message); + return { + credentials: null, + exists: false, + error: error.message + }; + } + } + + /** + * Files that constitute "user settings" — preserved on uninstall if requested. + * These are config/credential files the user would want to keep for a reinstall. + */ + static get USER_SETTINGS_FILES() { + return [ + 'config.json', + 'dns-credentials.json', + 'credentials.json', + 'branding.json', + 'services.json', + '.encryption-key', + 'notifications.json', + 'backup-config.json' + ]; + } + + /** + * CA certificate paths (relative to installPath) to preserve on uninstall. + * Caddy's built-in PKI stores root + intermediate certs and keys here. + * Regenerating these would invalidate every device that trusted the old root. + */ + static get CA_CERT_PATHS() { + return [ + 'certs/pki/authorities/local/root.crt', + 'certs/pki/authorities/local/root.key', + 'certs/pki/authorities/local/intermediate.crt', + 'certs/pki/authorities/local/intermediate.key' + ]; + } + + /** + * Copies a list of files (relative paths) from installPath into backupDir, + * recreating subdirectory structure as needed. + * @param {string} installPath - Source root + * @param {string} backupDir - Destination root + * @param {string[]} relativePaths - Files to copy + * @returns {Promise} List of files that were actually copied + */ + async _copyFilesToBackup(installPath, backupDir, relativePaths) { + const copied = []; + for (const relPath of relativePaths) { + const src = path.join(installPath, relPath); + const dest = path.join(backupDir, relPath); + try { + await fs.access(src); + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.copyFile(src, dest); + copied.push(relPath); + } catch { + // File doesn't exist, skip + } + } + return copied; + } + + /** + * Removes all installation files and directories + * @param {string} installPath - Installation directory path + * @param {Object} options - Uninstall options + * @param {boolean} options.preserveSettings - Keep user config/credentials for reinstall + * @param {boolean} options.preserveCA - Keep CA certificates (root + intermediate keys) + * @returns {Promise} { success: boolean, message: string, settingsPath?: string } + */ + async removeInstallation(installPath, options = {}) { + try { + // Verify this is a DashCaddy installation before removing + const exists = await this.installationExists(installPath); + + if (!exists) { + return { + success: false, + message: 'No DashCaddy installation found at this path' + }; + } + + const needsBackup = options.preserveSettings || options.preserveCA; + let settingsBackupPath = null; + + if (needsBackup) { + // Back up to a temp location outside the install dir so rm -rf doesn't eat it + const os = require('os'); + const tempBackupDir = path.join(os.tmpdir(), 'dashcaddy-settings-backup'); + // Clean any previous leftover + try { await fs.rm(tempBackupDir, { recursive: true, force: true }); } catch {} + await fs.mkdir(tempBackupDir, { recursive: true }); + + if (options.preserveSettings) { + await this._copyFilesToBackup(installPath, tempBackupDir, ConfigManager.USER_SETTINGS_FILES); + + // Also grab custom logos + const assetsDir = path.join(installPath, 'sites', 'status', 'assets'); + try { + const entries = await fs.readdir(assetsDir); + const customLogos = entries.filter(f => f.startsWith('custom-logo')); + await this._copyFilesToBackup( + installPath, + tempBackupDir, + customLogos.map(f => path.join('sites', 'status', 'assets', f)) + ); + } catch {} + } + + if (options.preserveCA) { + await this._copyFilesToBackup(installPath, tempBackupDir, ConfigManager.CA_CERT_PATHS); + } + + // Nuke the install directory + await fs.rm(installPath, { recursive: true, force: true }); + + // Recreate install dir and move preserved files back + const settingsDir = path.join(installPath, '.dashcaddy-settings'); + await fs.mkdir(settingsDir, { recursive: true }); + + // Recursively copy everything from temp back + await this._restoreFromTemp(tempBackupDir, settingsDir); + + // Clean up temp + await fs.rm(tempBackupDir, { recursive: true, force: true }); + settingsBackupPath = settingsDir; + } else { + // Remove everything + await fs.rm(installPath, { recursive: true, force: true }); + } + + const parts = []; + if (options.preserveSettings) parts.push('settings'); + if (options.preserveCA) parts.push('CA certificates'); + + return { + success: true, + message: parts.length > 0 + ? `Installation removed. Preserved: ${parts.join(' and ')}.` + : 'Installation removed successfully', + settingsPath: settingsBackupPath + }; + } catch (error) { + return { + success: false, + message: 'Failed to remove installation', + error: error.message + }; + } + } + + /** + * Recursively copies all files from src directory to dest directory + * @param {string} src - Source directory + * @param {string} dest - Destination directory + */ + async _restoreFromTemp(src, dest) { + const entries = await fs.readdir(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + await fs.mkdir(destPath, { recursive: true }); + await this._restoreFromTemp(srcPath, destPath); + } else { + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(srcPath, destPath); + } + } + } + + /** + * Restores user settings and CA certs from a previous uninstall. + * The backup in .dashcaddy-settings mirrors the original directory structure. + * @param {string} installPath - Installation directory path + * @returns {Promise} { success: boolean, restored: string[] } + */ + async restoreUserSettings(installPath) { + try { + const settingsDir = path.join(installPath, '.dashcaddy-settings'); + try { + await fs.access(settingsDir); + } catch { + return { success: false, restored: [], message: 'No saved settings found' }; + } + + const restored = []; + + // Recursively restore all files back to installPath + const restoreDir = async (srcDir, destDir) => { + const entries = await fs.readdir(srcDir, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(srcDir, entry.name); + const destPath = path.join(destDir, entry.name); + if (entry.isDirectory()) { + await fs.mkdir(destPath, { recursive: true }); + await restoreDir(srcPath, destPath); + } else { + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(srcPath, destPath); + restored.push(path.relative(installPath, destPath)); + } + } + }; + + await restoreDir(settingsDir, installPath); + + // Clean up the settings backup dir + await fs.rm(settingsDir, { recursive: true, force: true }); + + return { success: true, restored }; + } catch (error) { + return { success: false, restored: [], error: error.message }; + } + } + + /** + * Lists all files in the installation directory + * @param {string} installPath - Installation directory path + * @returns {Promise} List of files + */ + async listInstallationFiles(installPath) { + try { + const files = []; + + async function scanDirectory(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + await scanDirectory(fullPath); + } else { + files.push(fullPath); + } + } + } + + await scanDirectory(installPath); + return files; + } catch (error) { + return []; + } + } + + /** + * Saves branding configuration + * @param {Object} branding - Branding configuration + * @param {string} installPath - Installation directory path + * @returns {Promise} { success: boolean } + */ + async saveBranding(branding, installPath) { + try { + const brandingPath = path.join(installPath, 'branding.json'); + const assetsPath = path.join(installPath, 'sites', 'status', 'assets'); + + // Ensure directories exist + await fs.mkdir(path.dirname(brandingPath), { recursive: true }); + await fs.mkdir(assetsPath, { recursive: true }); + + // If a logo source path is provided, copy it to assets + if (branding.logoSourcePath) { + const logoExt = path.extname(branding.logoSourcePath); + const logoDestName = `custom-logo${logoExt}`; + const logoDestPath = path.join(assetsPath, logoDestName); + + await fs.copyFile(branding.logoSourcePath, logoDestPath); + branding.logoUrl = `/assets/${logoDestName}`; + delete branding.logoSourcePath; // Don't save source path + } + + // Default branding values + const brandingConfig = { + name: branding.name || 'DashCaddy', + title: branding.title || 'DashCaddy Dashboard', + logoUrl: branding.logoUrl || '/assets/dashcaddy-logo.png', + faviconUrl: branding.faviconUrl || '/assets/favicon.ico', + primaryColor: branding.primaryColor || '#6366f1', + showWatermark: branding.showWatermark !== false, + updatedAt: new Date().toISOString() + }; + + await fs.writeFile( + brandingPath, + JSON.stringify(brandingConfig, null, 2), + 'utf8' + ); + + return { + success: true, + path: brandingPath, + branding: brandingConfig + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } + + /** + * Loads branding configuration + * @param {string} installPath - Installation directory path + * @returns {Promise} { branding: object, exists: boolean } + */ + async loadBranding(installPath) { + try { + const brandingPath = path.join(installPath, 'branding.json'); + + try { + await fs.access(brandingPath); + } catch { + // Return default branding if file doesn't exist + return { + branding: { + name: 'DashCaddy', + title: 'DashCaddy Dashboard', + logoUrl: '/assets/dashcaddy-logo.png', + faviconUrl: '/assets/favicon.ico', + primaryColor: '#6366f1', + showWatermark: true + }, + exists: false + }; + } + + const brandingData = await fs.readFile(brandingPath, 'utf8'); + const branding = JSON.parse(brandingData); + + return { + branding, + exists: true + }; + } catch (error) { + return { + branding: null, + exists: false, + error: error.message + }; + } + } + + /** + * Creates seed JSON files that DashCaddy needs to start. + * Called after createDirectories() during installation. + * @param {string} installPath - Installation directory path + * @returns {Promise} { success: boolean, files: string[] } + */ + async seedFiles(installPath) { + try { + const crypto = require('crypto'); + const createdFiles = []; + + const seedData = { + 'services.json': '[]', + 'dns-credentials.json': '{}', + 'credentials.json': '{}', + 'notifications.json': '{}' + }; + + for (const [filename, content] of Object.entries(seedData)) { + const filePath = path.join(installPath, filename); + // Only create if doesn't already exist + try { + await fs.access(filePath); + } catch { + await fs.writeFile(filePath, content, 'utf8'); + createdFiles.push(filename); + } + } + + // Create .encryption-key (random 32-byte hex) if missing + const keyPath = path.join(installPath, '.encryption-key'); + try { + await fs.access(keyPath); + } catch { + const key = crypto.randomBytes(32).toString('hex'); + await fs.writeFile(keyPath, key, 'utf8'); + // Restrict permissions on key file + try { await fs.chmod(keyPath, 0o600); } catch {} + createdFiles.push('.encryption-key'); + } + + return { success: true, files: createdFiles }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Copies default DashCaddy logo to assets folder + * @param {string} installPath - Installation directory path + * @param {string} sourceLogo - Path to source logo file + * @returns {Promise} { success: boolean } + */ + async setupDefaultBranding(installPath, sourceLogo) { + try { + const assetsPath = path.join(installPath, 'sites', 'status', 'assets'); + await fs.mkdir(assetsPath, { recursive: true }); + + // If source logo provided, copy it + if (sourceLogo) { + const logoDestPath = path.join(assetsPath, 'dashcaddy-logo.png'); + await fs.copyFile(sourceLogo, logoDestPath); + } + + // Create default branding config + await this.saveBranding({ + name: 'DashCaddy', + title: 'DashCaddy Dashboard', + logoUrl: '/assets/dashcaddy-logo.png' + }, installPath); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } +} + +module.exports = ConfigManager; diff --git a/dashcaddy-installer/src/main/config-manager.property.test.js b/dashcaddy-installer/src/main/config-manager.property.test.js new file mode 100644 index 0000000..0d4b2f2 --- /dev/null +++ b/dashcaddy-installer/src/main/config-manager.property.test.js @@ -0,0 +1,837 @@ +const fc = require('fast-check'); +const ConfigManager = require('./config-manager'); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); + +/** + * Property-based tests for ConfigManager + * These tests validate universal properties across all valid inputs + */ +describe('ConfigManager Property Tests', () => { + let manager; + let testDir; + + beforeEach(async () => { + manager = new ConfigManager(); + testDir = path.join(os.tmpdir(), `dashcaddy-prop-test-${Date.now()}`); + }); + + afterEach(async () => { + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + /** + * Feature: dashcaddy-installer, Property 5: Configuration Persistence + * For any installation configuration, saving and then loading the configuration + * should produce an equivalent configuration object. + * Validates: Requirements 2.6, 10.1, 10.2 + */ + describe('Property 5: Configuration Persistence', () => { + // Generator for valid configuration objects + const configArbitrary = () => fc.record({ + installPath: fc.constant(testDir), + tier: fc.constantFrom('basic', 'intermediate', 'advanced'), + dashboardName: fc.string({ minLength: 1, maxLength: 50 }), + customTLD: fc.option(fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s)), + caddyAdminUrl: fc.constant('http://localhost:2021'), + apiPort: fc.integer({ min: 1024, max: 65535 }), + dnsEnabled: fc.boolean(), + tailscaleEnabled: fc.boolean() + }); + + test('configuration round-trips correctly', async () => { + await fc.assert( + fc.asyncProperty( + configArbitrary(), + async (config) => { + // Save configuration + const saveResult = await manager.saveConfig(config, testDir); + if (!saveResult.success) return true; // Skip if save failed + + // Load configuration + const loadResult = await manager.loadConfig(testDir); + if (!loadResult.exists) return false; + + // Verify all original fields are preserved + const loaded = loadResult.config; + return ( + loaded.installPath === config.installPath && + loaded.tier === config.tier && + loaded.dashboardName === config.dashboardName && + loaded.apiPort === config.apiPort && + loaded.dnsEnabled === config.dnsEnabled && + loaded.tailscaleEnabled === config.tailscaleEnabled + ); + } + ), + { numRuns: 100 } + ); + }); + + test('saved config always includes metadata', async () => { + await fc.assert( + fc.asyncProperty( + configArbitrary(), + async (config) => { + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; // Skip if load failed + + return ( + typeof result.config.version === 'string' && + typeof result.config.lastModified === 'string' + ); + } + ), + { numRuns: 100 } + ); + }); + + test('loading non-existent config always returns exists=false', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }), + async (randomPath) => { + const nonExistentPath = path.join(testDir, randomPath); + const result = await manager.loadConfig(nonExistentPath); + + return result.exists === false && result.config === null; + } + ), + { numRuns: 50 } + ); + }); + }); + + /** + * Feature: dashcaddy-installer, Property 3: Installation Path Validation + * For any selected folder path, the installer should correctly determine + * if the path is writable before proceeding with installation. + * Validates: Requirements 2.3, 2.4 + */ + describe('Property 3: Installation Path Validation', () => { + test('validatePath returns consistent structure', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 50 }), + async (pathSegment) => { + const testPath = path.join(testDir, pathSegment); + const result = await manager.validatePath(testPath); + + return ( + typeof result === 'object' && + typeof result.valid === 'boolean' && + typeof result.message === 'string' + ); + } + ), + { numRuns: 50 } + ); + }); + + test('valid paths are consistently validated', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }), + async (pathSegment) => { + const testPath = path.join(testDir, pathSegment); + + // Validate twice + const result1 = await manager.validatePath(testPath); + const result2 = await manager.validatePath(testPath); + + // Results should be consistent + return result1.valid === result2.valid; + } + ), + { numRuns: 30 } + ); + }); + }); + + /** + * Feature: dashcaddy-installer, Property 4: Directory Structure Creation + * For any valid installation path, the installer should create all required + * subdirectories (config, data, logs, caddyfile) and verify their existence. + * Validates: Requirements 2.5 + */ + describe('Property 4: Directory Structure Creation', () => { + test('createDirectories creates all required directories', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }), + async (pathSegment) => { + const installPath = path.join(testDir, pathSegment); + const result = await manager.createDirectories(installPath); + + if (!result.success) return true; // Skip if creation failed + + // Verify all required directories exist + const requiredDirs = ['config', 'data', 'logs', 'caddyfile']; + const checks = await Promise.all( + requiredDirs.map(async (dir) => { + const dirPath = path.join(installPath, dir); + try { + await fs.access(dirPath); + return true; + } catch { + return false; + } + }) + ); + + return checks.every(check => check === true); + } + ), + { numRuns: 50 } + ); + }); + + test('createDirectories is idempotent', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')), + async (pathSegment) => { + const installPath = path.join(testDir, pathSegment); + + // Create directories twice + const result1 = await manager.createDirectories(installPath); + const result2 = await manager.createDirectories(installPath); + + // Both should succeed (or both fail for invalid paths) + return result1.success === result2.success; + } + ), + { numRuns: 30 } + ); + }); + }); + + /** + * Feature: dashcaddy-installer, Property 8: DNS Credential Security + * For any DNS credentials, saving and then loading the credentials should + * preserve all fields, and the password should be encrypted in storage. + * Validates: Requirements 4.8, 10.3 + */ + describe('Property 8: DNS Credential Security', () => { + const credentialsArbitrary = () => fc.record({ + server: fc.ipV4(), + username: fc.string({ minLength: 1, maxLength: 20 }), + password: fc.string({ minLength: 8, maxLength: 50 }), + tld: fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s) + }); + + test('DNS credentials round-trip correctly', async () => { + await fc.assert( + fc.asyncProperty( + credentialsArbitrary(), + async (credentials) => { + // Save credentials + const saveResult = await manager.saveDNSCredentials(credentials, testDir); + if (!saveResult.success) return true; // Skip if save failed + + // Load credentials + const loadResult = await manager.loadDNSCredentials(testDir); + if (!loadResult.exists) return false; + + // Verify all fields are preserved + const loaded = loadResult.credentials; + return ( + loaded.server === credentials.server && + loaded.username === credentials.username && + loaded.password === credentials.password && + loaded.tld === credentials.tld + ); + } + ), + { numRuns: 100 } + ); + }); + + test('saved credentials always include timestamp', async () => { + await fc.assert( + fc.asyncProperty( + credentialsArbitrary(), + async (credentials) => { + await manager.saveDNSCredentials(credentials, testDir); + const result = await manager.loadDNSCredentials(testDir); + + if (!result.exists) return true; // Skip if load failed + + return typeof result.credentials.savedAt === 'string'; + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * Feature: dashcaddy-installer, Property 6: Disk Space Verification + * For any valid path, the installer should correctly determine available disk space + * and report consistent results across multiple checks. + * Validates: Requirements 2.4 + */ + describe('Property 6: Disk Space Verification', () => { + test('getDiskSpace returns consistent structure', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')), + async (pathSegment) => { + const testPath = path.join(testDir, pathSegment); + const result = await manager.getDiskSpace(testPath); + + return ( + typeof result === 'object' && + typeof result.available === 'boolean' && + (result.available ? typeof result.path === 'string' : typeof result.error === 'string') + ); + } + ), + { numRuns: 50 } + ); + }); + + test('getDiskSpace is idempotent for same path', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')), + async (pathSegment) => { + const testPath = path.join(testDir, pathSegment); + + // Check twice + const result1 = await manager.getDiskSpace(testPath); + const result2 = await manager.getDiskSpace(testPath); + + // Results should be consistent + return result1.available === result2.available; + } + ), + { numRuns: 30 } + ); + }); + + test('getDiskSpace returns available=true for existing writable paths', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 15 }).filter(s => /^[a-zA-Z0-9_-]+$/.test(s)), + async (pathSegment) => { + const testPath = path.join(testDir, pathSegment); + + // Create the directory first + await fs.mkdir(testPath, { recursive: true }); + + const result = await manager.getDiskSpace(testPath); + + return result.available === true; + } + ), + { numRuns: 30 } + ); + }); + }); + + /** + * Feature: dashcaddy-installer, Property 7: Tier Validation + * For any configuration, only valid tier values (basic, intermediate, advanced) + * should be accepted and persisted correctly. + * Validates: Requirements 3.1, 3.2, 3.3 + */ + describe('Property 7: Tier Validation', () => { + const validTiers = ['basic', 'intermediate', 'advanced']; + + test('valid tiers are preserved in config round-trip', async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom(...validTiers), + fc.string({ minLength: 1, maxLength: 20 }), + async (tier, dashboardName) => { + const config = { + installPath: testDir, + tier: tier, + dashboardName: dashboardName, + apiPort: 3001, + dnsEnabled: false, + tailscaleEnabled: false + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; // Skip if save/load failed + + return result.config.tier === tier; + } + ), + { numRuns: 50 } + ); + }); + + test('tier value is always one of valid options after load', async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom(...validTiers), + async (tier) => { + const config = { + installPath: testDir, + tier: tier, + dashboardName: 'Test', + apiPort: 3001, + dnsEnabled: false, + tailscaleEnabled: false + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + return validTiers.includes(result.config.tier); + } + ), + { numRuns: 30 } + ); + }); + + test('tier determines expected feature availability', async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom(...validTiers), + async (tier) => { + const config = { + installPath: testDir, + tier: tier, + dashboardName: 'Test', + apiPort: 3001, + dnsEnabled: tier === 'advanced', + tailscaleEnabled: tier === 'advanced' + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + // Advanced tier should support DNS and Tailscale + if (tier === 'advanced') { + return result.config.dnsEnabled === true && result.config.tailscaleEnabled === true; + } + + // Non-advanced tiers should have these disabled + return result.config.dnsEnabled === false && result.config.tailscaleEnabled === false; + } + ), + { numRuns: 30 } + ); + }); + }); + + /** + * Additional property tests for installation management + */ + describe('Installation Management Properties', () => { + // Generator for valid configuration objects (reused from above) + const configArbitrary = () => fc.record({ + installPath: fc.constant(testDir), + tier: fc.constantFrom('basic', 'intermediate', 'advanced'), + dashboardName: fc.string({ minLength: 1, maxLength: 50 }), + apiPort: fc.integer({ min: 1024, max: 65535 }), + dnsEnabled: fc.boolean(), + tailscaleEnabled: fc.boolean() + }); + + test('installationExists is consistent with config file presence', async () => { + await fc.assert( + fc.asyncProperty( + configArbitrary(), + async (config) => { + // Use a unique directory for this test + const uniqueDir = path.join(testDir, `test-${Date.now()}-${Math.random()}`); + config.installPath = uniqueDir; + + // Before saving, installation should not exist + const existsBefore = await manager.installationExists(uniqueDir); + + // Save config + await manager.saveConfig(config, uniqueDir); + + // After saving, installation should exist + const existsAfter = await manager.installationExists(uniqueDir); + + return !existsBefore && existsAfter; + } + ), + { numRuns: 50 } + ); + }); + + test('removeInstallation removes all files', async () => { + await fc.assert( + fc.asyncProperty( + configArbitrary(), + async (config) => { + // Create installation + await manager.createDirectories(testDir); + await manager.saveConfig(config, testDir); + + // Verify it exists + const existsBefore = await manager.installationExists(testDir); + if (!existsBefore) return true; // Skip if creation failed + + // Remove installation + const removeResult = await manager.removeInstallation(testDir); + if (!removeResult.success) return true; // Skip if removal failed + + // Verify it no longer exists + const existsAfter = await manager.installationExists(testDir); + + return !existsAfter; + } + ), + { numRuns: 30 } + ); + }); + + test('listInstallationFiles returns array', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }), + async (pathSegment) => { + const installPath = path.join(testDir, pathSegment); + const files = await manager.listInstallationFiles(installPath); + + return Array.isArray(files); + } + ), + { numRuns: 50 } + ); + }); + }); + + /** + * Configuration Edge Cases + * Tests for special characters, boundary conditions, and data integrity + */ + describe('Configuration Edge Cases', () => { + test('special characters in dashboardName are preserved', async () => { + // Generator for strings with special characters + const specialCharArbitrary = fc.string({ + unit: fc.constantFrom( + 'a', 'Z', '0', ' ', '-', '_', '.', '!', '@', '#', + 'é', 'ñ', '中', '日', '한', 'α', 'β' + ), + minLength: 1, + maxLength: 30 + }); + + await fc.assert( + fc.asyncProperty( + specialCharArbitrary, + async (dashboardName) => { + const config = { + installPath: testDir, + tier: 'basic', + dashboardName: dashboardName, + apiPort: 3001, + dnsEnabled: false, + tailscaleEnabled: false + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + return result.config.dashboardName === dashboardName; + } + ), + { numRuns: 100 } + ); + }); + + test('port values at boundaries are handled correctly', async () => { + const boundaryPorts = fc.constantFrom(1024, 1025, 3000, 3001, 8080, 49151, 65534, 65535); + + await fc.assert( + fc.asyncProperty( + boundaryPorts, + async (port) => { + const config = { + installPath: testDir, + tier: 'basic', + dashboardName: 'Test', + apiPort: port, + dnsEnabled: false, + tailscaleEnabled: false + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + return result.config.apiPort === port; + } + ), + { numRuns: 20 } + ); + }); + + test('customTLD with various formats preserved correctly', async () => { + const tldArbitrary = fc.oneof( + fc.constant(undefined), + fc.constant(null), + fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s.replace(/[^a-zA-Z]/g, 'x')), + fc.constantFrom('.local', '.home', '.lan', '.sami', '.test') + ); + + await fc.assert( + fc.asyncProperty( + tldArbitrary, + async (customTLD) => { + const config = { + installPath: testDir, + tier: 'basic', + dashboardName: 'Test', + customTLD: customTLD, + apiPort: 3001, + dnsEnabled: false, + tailscaleEnabled: false + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + // Both null/undefined should be equivalent after round-trip + if (customTLD === null || customTLD === undefined) { + return result.config.customTLD === null || + result.config.customTLD === undefined || + result.config.customTLD === customTLD; + } + + return result.config.customTLD === customTLD; + } + ), + { numRuns: 50 } + ); + }); + + test('boolean fields remain booleans after round-trip', async () => { + await fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.boolean(), + async (dnsEnabled, tailscaleEnabled) => { + const config = { + installPath: testDir, + tier: 'basic', + dashboardName: 'Test', + apiPort: 3001, + dnsEnabled: dnsEnabled, + tailscaleEnabled: tailscaleEnabled + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + return ( + typeof result.config.dnsEnabled === 'boolean' && + typeof result.config.tailscaleEnabled === 'boolean' && + result.config.dnsEnabled === dnsEnabled && + result.config.tailscaleEnabled === tailscaleEnabled + ); + } + ), + { numRuns: 50 } + ); + }); + + test('config version is valid semver format', async () => { + const semverRegex = /^\d+\.\d+\.\d+$/; + + await fc.assert( + fc.asyncProperty( + fc.constantFrom('basic', 'intermediate', 'advanced'), + async (tier) => { + const config = { + installPath: testDir, + tier: tier, + dashboardName: 'Test', + apiPort: 3001, + dnsEnabled: false, + tailscaleEnabled: false + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + return semverRegex.test(result.config.version); + } + ), + { numRuns: 30 } + ); + }); + + test('lastModified is valid ISO date string', async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom('basic', 'intermediate', 'advanced'), + async (tier) => { + const config = { + installPath: testDir, + tier: tier, + dashboardName: 'Test', + apiPort: 3001, + dnsEnabled: false, + tailscaleEnabled: false + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + // Should be parseable as a date and not NaN + const date = new Date(result.config.lastModified); + return !isNaN(date.getTime()); + } + ), + { numRuns: 30 } + ); + }); + + test('multiple rapid saves preserve final state', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.string({ minLength: 1, maxLength: 20 }), { minLength: 2, maxLength: 5 }), + async (dashboardNames) => { + // Rapidly save multiple configs + for (const name of dashboardNames) { + await manager.saveConfig({ + installPath: testDir, + tier: 'basic', + dashboardName: name, + apiPort: 3001, + dnsEnabled: false, + tailscaleEnabled: false + }, testDir); + } + + const result = await manager.loadConfig(testDir); + + if (!result.exists) return true; + + // Final saved name should be the last one + return result.config.dashboardName === dashboardNames[dashboardNames.length - 1]; + } + ), + { numRuns: 30 } + ); + }); + }); + + /** + * DNS Credential Edge Cases + */ + describe('DNS Credential Edge Cases', () => { + test('passwords with special characters are preserved', async () => { + const specialPasswordArbitrary = fc.string({ + unit: fc.constantFrom( + 'a', 'Z', '0', '!', '@', '#', '$', '%', '^', '&', '*', + '(', ')', '-', '_', '=', '+', '[', ']', '{', '}', '|', + ';', ':', "'", '"', '<', '>', ',', '.', '/', '?', '`', '~' + ), + minLength: 8, + maxLength: 50 + }); + + await fc.assert( + fc.asyncProperty( + specialPasswordArbitrary, + async (password) => { + const credentials = { + server: '192.168.1.1', + username: 'admin', + password: password, + tld: '.local' + }; + + await manager.saveDNSCredentials(credentials, testDir); + const result = await manager.loadDNSCredentials(testDir); + + if (!result.exists) return true; + + return result.credentials.password === password; + } + ), + { numRuns: 100 } + ); + }); + + test('various IP address formats in server field', async () => { + await fc.assert( + fc.asyncProperty( + fc.ipV4(), + async (server) => { + const credentials = { + server: server, + username: 'admin', + password: 'password123', + tld: '.local' + }; + + await manager.saveDNSCredentials(credentials, testDir); + const result = await manager.loadDNSCredentials(testDir); + + if (!result.exists) return true; + + return result.credentials.server === server; + } + ), + { numRuns: 50 } + ); + }); + + test('username with edge case lengths', async () => { + const usernameArbitrary = fc.string({ + unit: fc.constantFrom('a', 'b', 'c', '1', '2', '_', '-'), + minLength: 1, + maxLength: 100 + }); + + await fc.assert( + fc.asyncProperty( + usernameArbitrary, + async (username) => { + const credentials = { + server: '192.168.1.1', + username: username, + password: 'password123', + tld: '.local' + }; + + await manager.saveDNSCredentials(credentials, testDir); + const result = await manager.loadDNSCredentials(testDir); + + if (!result.exists) return true; + + return result.credentials.username === username; + } + ), + { numRuns: 50 } + ); + }); + }); +}); diff --git a/dashcaddy-installer/src/main/config-manager.test.js b/dashcaddy-installer/src/main/config-manager.test.js new file mode 100644 index 0000000..46df01b --- /dev/null +++ b/dashcaddy-installer/src/main/config-manager.test.js @@ -0,0 +1,288 @@ +const ConfigManager = require('./config-manager'); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); + +describe('ConfigManager', () => { + let manager; + let testDir; + + beforeEach(async () => { + manager = new ConfigManager(); + // Create a unique test directory for each test + testDir = path.join(os.tmpdir(), `dashcaddy-test-${Date.now()}`); + }); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe('saveConfig', () => { + test('saves configuration to disk', async () => { + const config = { + installPath: testDir, + tier: 'basic', + dashboardName: 'Test Dashboard' + }; + + const result = await manager.saveConfig(config, testDir); + + expect(result.success).toBe(true); + expect(result.path).toContain('config.json'); + + // Verify file was created + const configPath = path.join(testDir, 'config', 'config.json'); + const exists = await fs.access(configPath).then(() => true).catch(() => false); + expect(exists).toBe(true); + }); + + test('adds metadata to saved config', async () => { + const config = { + installPath: testDir, + tier: 'basic' + }; + + await manager.saveConfig(config, testDir); + + const loaded = await manager.loadConfig(testDir); + expect(loaded.config.version).toBeDefined(); + expect(loaded.config.lastModified).toBeDefined(); + }); + + test('handles save errors gracefully', async () => { + // Use a truly invalid path that will fail on all platforms + const config = { + installPath: '\0invalid' // Null character in path is invalid on all platforms + }; + + const result = await manager.saveConfig(config, '\0invalid'); + + // On some systems this might succeed with recursive mkdir, so we just verify structure + expect(result).toHaveProperty('success'); + if (!result.success) { + expect(result.error).toBeDefined(); + } + }); + }); + + describe('loadConfig', () => { + test('loads existing configuration', async () => { + const config = { + installPath: testDir, + tier: 'advanced', + dashboardName: 'My Dashboard' + }; + + await manager.saveConfig(config, testDir); + const result = await manager.loadConfig(testDir); + + expect(result.exists).toBe(true); + expect(result.config.tier).toBe('advanced'); + expect(result.config.dashboardName).toBe('My Dashboard'); + }); + + test('returns exists=false for non-existent config', async () => { + const result = await manager.loadConfig(testDir); + + expect(result.exists).toBe(false); + expect(result.config).toBeNull(); + }); + + test('handles corrupted config files', async () => { + // Create a corrupted config file + const configPath = path.join(testDir, 'config', 'config.json'); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, 'invalid json{', 'utf8'); + + const result = await manager.loadConfig(testDir); + + expect(result.exists).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('createDirectories', () => { + test('creates all required directories', async () => { + const result = await manager.createDirectories(testDir); + + expect(result.success).toBe(true); + expect(result.paths.length).toBeGreaterThan(0); + + // Verify directories were created + const configDir = path.join(testDir, 'config'); + const dataDir = path.join(testDir, 'data'); + const logsDir = path.join(testDir, 'logs'); + const caddyfileDir = path.join(testDir, 'caddyfile'); + + const configExists = await fs.access(configDir).then(() => true).catch(() => false); + const dataExists = await fs.access(dataDir).then(() => true).catch(() => false); + const logsExists = await fs.access(logsDir).then(() => true).catch(() => false); + const caddyfileExists = await fs.access(caddyfileDir).then(() => true).catch(() => false); + + expect(configExists).toBe(true); + expect(dataExists).toBe(true); + expect(logsExists).toBe(true); + expect(caddyfileExists).toBe(true); + }); + + test('handles directory creation errors', async () => { + // Use a truly invalid path + const result = await manager.createDirectories('\0invalid'); + + // On some systems this might succeed, so we just verify structure + expect(result).toHaveProperty('success'); + if (!result.success) { + expect(result.error).toBeDefined(); + } + }); + }); + + describe('validatePath', () => { + test('validates writable paths', async () => { + const result = await manager.validatePath(testDir); + + expect(result.valid).toBe(true); + expect(result.message).toContain('writable'); + }); + + test('rejects non-writable paths', async () => { + // Use a truly invalid path + const result = await manager.validatePath('\0invalid'); + + // On some systems this might succeed, so we just verify structure + expect(result).toHaveProperty('valid'); + expect(result).toHaveProperty('message'); + }); + + test('creates directory if it doesn\'t exist', async () => { + const newDir = path.join(testDir, 'new-directory'); + const result = await manager.validatePath(newDir); + + expect(result.valid).toBe(true); + + // Verify directory was created + const exists = await fs.access(newDir).then(() => true).catch(() => false); + expect(exists).toBe(true); + }); + }); + + describe('installationExists', () => { + test('returns true for existing installations', async () => { + const config = { installPath: testDir }; + await manager.saveConfig(config, testDir); + + const exists = await manager.installationExists(testDir); + + expect(exists).toBe(true); + }); + + test('returns false for non-existent installations', async () => { + const exists = await manager.installationExists(testDir); + + expect(exists).toBe(false); + }); + }); + + describe('saveDNSCredentials', () => { + test('saves DNS credentials', async () => { + const credentials = { + server: '192.168.1.1', + username: 'admin', + password: 'secret', + tld: '.sami' + }; + + const result = await manager.saveDNSCredentials(credentials, testDir); + + expect(result.success).toBe(true); + expect(result.path).toContain('dns-credentials.json'); + }); + + test('adds timestamp to saved credentials', async () => { + const credentials = { + server: '192.168.1.1', + username: 'admin', + password: 'secret' + }; + + await manager.saveDNSCredentials(credentials, testDir); + const loaded = await manager.loadDNSCredentials(testDir); + + expect(loaded.credentials.savedAt).toBeDefined(); + }); + }); + + describe('loadDNSCredentials', () => { + test('loads existing credentials', async () => { + const credentials = { + server: '192.168.1.1', + username: 'admin', + password: 'secret', + tld: '.sami' + }; + + await manager.saveDNSCredentials(credentials, testDir); + const result = await manager.loadDNSCredentials(testDir); + + expect(result.exists).toBe(true); + expect(result.credentials.server).toBe('192.168.1.1'); + expect(result.credentials.username).toBe('admin'); + }); + + test('returns exists=false for non-existent credentials', async () => { + const result = await manager.loadDNSCredentials(testDir); + + expect(result.exists).toBe(false); + expect(result.credentials).toBeNull(); + }); + }); + + describe('removeInstallation', () => { + test('removes existing installation', async () => { + // Create an installation + const config = { installPath: testDir }; + await manager.saveConfig(config, testDir); + + const result = await manager.removeInstallation(testDir); + + expect(result.success).toBe(true); + + // Verify directory was removed + const exists = await fs.access(testDir).then(() => true).catch(() => false); + expect(exists).toBe(false); + }); + + test('fails to remove non-existent installation', async () => { + const result = await manager.removeInstallation(testDir); + + expect(result.success).toBe(false); + expect(result.message).toContain('No DashCaddy installation found'); + }); + }); + + describe('listInstallationFiles', () => { + test('lists all files in installation', async () => { + // Create some files + await manager.createDirectories(testDir); + await manager.saveConfig({ installPath: testDir }, testDir); + + const files = await manager.listInstallationFiles(testDir); + + expect(Array.isArray(files)).toBe(true); + expect(files.length).toBeGreaterThan(0); + expect(files.some(f => f.includes('config.json'))).toBe(true); + }); + + test('returns empty array for non-existent directory', async () => { + const files = await manager.listInstallationFiles('/non/existent/path'); + + expect(Array.isArray(files)).toBe(true); + expect(files.length).toBe(0); + }); + }); +}); diff --git a/dashcaddy-installer/src/main/dependency-checker.js b/dashcaddy-installer/src/main/dependency-checker.js new file mode 100644 index 0000000..c6a779e --- /dev/null +++ b/dashcaddy-installer/src/main/dependency-checker.js @@ -0,0 +1,792 @@ +const { exec, spawn } = require('child_process'); +const { promisify } = require('util'); +const path = require('path'); +const platformUtils = require('../shared/platform-utils'); +const DownloadManager = require('./download-manager'); + +const execAsync = promisify(exec); + +class DependencyChecker { + /** + * Checks if Docker is installed and running + * @returns {Promise} { installed: boolean, running: boolean, version: string } + */ + async checkDocker() { + try { + // Check if Docker is installed + const versionResult = await this.executeCommand('docker --version'); + + if (!versionResult.success) { + return { + installed: false, + running: false, + version: null + }; + } + + // Extract version from output (e.g., "Docker version 24.0.7, build afdd53b") + const versionMatch = versionResult.stdout.match(/Docker version ([\d.]+)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + // Check if Docker daemon is running + const infoResult = await this.executeCommand('docker info'); + + return { + installed: true, + running: infoResult.success, + version: version + }; + } catch (error) { + return { + installed: false, + running: false, + version: null, + error: error.message + }; + } + } + + /** + * Checks if Caddy is installed + * @param {string[]} [additionalPaths] - Additional paths to search for caddy binary + * @returns {Promise} { installed: boolean, version: string, path: string } + */ + async checkCaddy(additionalPaths) { + try { + // First try caddy in PATH + const result = await this.executeCommand('caddy version'); + + if (result.success) { + const versionMatch = result.stdout.match(/v([\d.]+)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + // Try to get Caddy path + let caddyPath = null; + if (platformUtils.isWindows()) { + const whereResult = await this.executeCommand('where caddy'); + if (whereResult.success) { + caddyPath = whereResult.stdout.trim().split('\n')[0]; + } + } else { + const whichResult = await this.executeCommand('which caddy'); + if (whichResult.success) { + caddyPath = whichResult.stdout.trim(); + } + } + + return { installed: true, version, path: caddyPath }; + } + + // Not in PATH - search common locations + const searchResult = await this.findCaddyBinary(additionalPaths); + if (searchResult) { + return searchResult; + } + + return { installed: false, version: null, path: null }; + } catch (error) { + return { + installed: false, + version: null, + path: null, + error: error.message + }; + } + } + + /** + * Searches common locations for the Caddy binary + * @param {string[]} [additionalPaths] - Additional paths to search + * @returns {Promise} Caddy info if found, null otherwise + */ + async findCaddyBinary(additionalPaths) { + const fs = require('fs'); + const binaryName = platformUtils.isWindows() ? 'caddy.exe' : 'caddy'; + + // Build list of candidate paths + const searchPaths = []; + + if (platformUtils.isWindows()) { + searchPaths.push( + 'C:\\caddy', + 'C:\\Program Files\\Caddy', + 'C:\\Program Files (x86)\\Caddy', + path.join(process.env.LOCALAPPDATA || '', 'Caddy'), + path.join(process.env.USERPROFILE || '', 'caddy'), + 'C:\\DashCaddy\\caddy' + ); + } else { + searchPaths.push( + '/usr/local/bin', + '/usr/bin', + '/opt/caddy', + '/opt/dashcaddy/caddy', + path.join(process.env.HOME || '', '.local/bin') + ); + } + + // Add any user-specified paths + if (additionalPaths) { + searchPaths.push(...additionalPaths); + } + + for (const dir of searchPaths) { + if (!dir) continue; + const fullPath = path.join(dir, binaryName); + try { + fs.accessSync(fullPath, fs.constants.X_OK); + // Found the binary - verify it works + const result = await this.executeCommand(`"${fullPath}" version`); + if (result.success) { + const versionMatch = result.stdout.match(/v([\d.]+)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + return { installed: true, version, path: fullPath }; + } + } catch { + // Not found here, continue searching + } + } + + return null; + } + + /** + * Checks platform information + * @returns {Promise} Platform details + */ + async checkPlatform() { + return platformUtils.getPlatformInfo(); + } + + /** + * Provides installation instructions for Docker + * @param {string} platform - Platform identifier (windows, macos, linux) + * @returns {Object} Installation instructions + */ + getDockerInstallInstructions(platform) { + const instructions = { + windows: { + title: 'Install Docker Desktop for Windows', + steps: [ + 'Download Docker Desktop from https://www.docker.com/products/docker-desktop', + 'Run the installer and follow the setup wizard', + 'Restart your computer if prompted', + 'Launch Docker Desktop from the Start menu', + 'Wait for Docker to start (you\'ll see the whale icon in the system tray)', + 'Click "Retry" in the installer once Docker is running' + ], + url: 'https://docs.docker.com/desktop/install/windows-install/', + requiresWSL2: true, + wsl2Instructions: [ + 'Docker Desktop requires WSL 2', + 'The installer will guide you through enabling WSL 2 if needed', + 'You may need to enable virtualization in your BIOS' + ] + }, + macos: { + title: 'Install Docker Desktop for Mac', + steps: [ + 'Download Docker Desktop from https://www.docker.com/products/docker-desktop', + 'Open the .dmg file and drag Docker to Applications', + 'Launch Docker from Applications', + 'Grant necessary permissions when prompted', + 'Wait for Docker to start (you\'ll see the whale icon in the menu bar)', + 'Click "Retry" in the installer once Docker is running' + ], + url: 'https://docs.docker.com/desktop/install/mac-install/', + requiresWSL2: false + }, + linux: { + title: 'Install Docker Engine for Linux', + steps: [ + 'Open a terminal', + 'Run: curl -fsSL https://get.docker.com -o get-docker.sh', + 'Run: sudo sh get-docker.sh', + 'Run: sudo systemctl start docker', + 'Run: sudo systemctl enable docker', + 'Run: sudo usermod -aG docker $USER', + 'Log out and back in for group changes to take effect', + 'Click "Retry" in the installer' + ], + url: 'https://docs.docker.com/engine/install/', + requiresWSL2: false, + packageManagers: { + ubuntu: 'sudo apt-get update && sudo apt-get install docker.io', + fedora: 'sudo dnf install docker', + arch: 'sudo pacman -S docker' + } + } + }; + + return instructions[platform] || instructions.linux; + } + + /** + * Provides installation instructions for Caddy + * @param {string} platform - Platform identifier (windows, macos, linux) + * @returns {Object} Installation instructions + */ + getCaddyInstallInstructions(platform) { + const instructions = { + windows: { + title: 'Install Caddy for Windows', + steps: [ + 'Download Caddy from https://caddyserver.com/download', + 'Extract the caddy.exe file', + 'Move caddy.exe to C:\\Windows\\System32 (requires admin)', + 'Or add the Caddy folder to your PATH environment variable', + 'Open a new PowerShell window', + 'Run: caddy version', + 'Click "Retry" in the installer' + ], + url: 'https://caddyserver.com/docs/install#windows', + automated: false + }, + macos: { + title: 'Install Caddy for macOS', + steps: [ + 'Open Terminal', + 'Install Homebrew if not already installed: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', + 'Run: brew install caddy', + 'Run: caddy version', + 'Click "Retry" in the installer' + ], + url: 'https://caddyserver.com/docs/install#mac', + automated: true, + command: 'brew install caddy' + }, + linux: { + title: 'Install Caddy for Linux', + steps: [ + 'Open a terminal', + 'Run the appropriate command for your distribution (see below)', + 'Run: caddy version', + 'Click "Retry" in the installer' + ], + url: 'https://caddyserver.com/docs/install#debian-ubuntu-raspbian', + automated: true, + packageManagers: { + ubuntu: 'sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https && curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | sudo tee /etc/apt/sources.list.d/caddy-stable.list && sudo apt update && sudo apt install caddy', + fedora: 'dnf install "dnf-command(copr)" && dnf copr enable @caddy/caddy && dnf install caddy', + arch: 'pacman -S caddy' + } + } + }; + + return instructions[platform] || instructions.linux; + } + + /** + * Attempts automated installation of Docker (platform-specific) + * @param {string} platform - Platform identifier + * @param {Function} progressCallback - Progress callback + * @returns {Promise} Installation result + */ + async installDocker(platform, progressCallback) { + const downloadManager = new DownloadManager(); + const platformInfo = platformUtils.getPlatformInfo(); + + try { + // Download Docker Desktop + progressCallback?.({ status: 'downloading', message: 'Downloading Docker Desktop...' }); + + const downloadResult = await downloadManager.downloadDocker( + platform, + platformInfo.arch, + progressCallback + ); + + if (!downloadResult.success) { + // Fallback to instructions if download fails + return { + success: false, + automated: false, + message: downloadResult.message || 'Download failed', + instructions: this.getDockerInstallInstructions(platform) + }; + } + + // Run the installer + progressCallback?.({ status: 'installing', message: 'Running Docker installer...' }); + + if (platform === 'windows') { + // Run Docker Desktop installer silently + const installerPath = downloadResult.path; + + try { + // The Docker Desktop installer supports silent install + await this.runInstallerWindows(installerPath); + + // Wait for Docker to be ready + progressCallback?.({ status: 'waiting', message: 'Waiting for Docker to start...' }); + await this.waitForDocker(120000); // 2 minute timeout + + // Verify installation + const verification = await this.checkDocker(); + + if (verification.installed) { + await downloadManager.cleanup(); + return { + success: true, + automated: true, + message: 'Docker Desktop installed successfully', + version: verification.version + }; + } + } catch (err) { + // Installation might require user interaction or admin + return { + success: false, + automated: true, + message: 'Docker installer launched. Please complete the installation and restart.', + instructions: this.getDockerInstallInstructions(platform) + }; + } + } else if (platform === 'macos') { + // Mount and install DMG + const dmgPath = downloadResult.path; + + try { + await this.runInstallerMacOS(dmgPath); + + progressCallback?.({ status: 'waiting', message: 'Waiting for Docker to start...' }); + await this.waitForDocker(120000); + + const verification = await this.checkDocker(); + + if (verification.installed) { + await downloadManager.cleanup(); + return { + success: true, + automated: true, + message: 'Docker Desktop installed successfully', + version: verification.version + }; + } + } catch (err) { + return { + success: false, + automated: true, + message: 'Docker installation started. Please complete any prompts.', + instructions: this.getDockerInstallInstructions(platform) + }; + } + } else { + // Linux - use package manager + return await this.installDockerLinux(progressCallback); + } + + return { + success: false, + automated: false, + message: 'Docker installation requires manual completion', + instructions: this.getDockerInstallInstructions(platform) + }; + } catch (error) { + return { + success: false, + automated: false, + message: error.message, + instructions: this.getDockerInstallInstructions(platform) + }; + } + } + + /** + * Run Docker installer on Windows + */ + async runInstallerWindows(installerPath) { + return new Promise((resolve, reject) => { + // Start installer with install flag + const installer = spawn(installerPath, ['install', '--quiet', '--accept-license'], { + detached: true, + stdio: 'ignore' + }); + + installer.unref(); + + // Give the installer time to start + setTimeout(() => resolve(), 5000); + }); + } + + /** + * Run Docker installer on macOS + */ + async runInstallerMacOS(dmgPath) { + // Mount the DMG + await this.executeCommand(`hdiutil attach "${dmgPath}" -nobrowse`); + + // Copy to Applications + await this.executeCommand('cp -R "/Volumes/Docker/Docker.app" /Applications/'); + + // Unmount + await this.executeCommand('hdiutil detach "/Volumes/Docker"'); + + // Launch Docker + await this.executeCommand('open /Applications/Docker.app'); + } + + /** + * Install Docker on Linux using package manager + */ + async installDockerLinux(progressCallback) { + const distro = await this.detectLinuxDistro(); + + progressCallback?.({ status: 'installing', message: `Installing Docker for ${distro}...` }); + + try { + if (distro === 'ubuntu' || distro === 'debian') { + await this.executeCommand('curl -fsSL https://get.docker.com -o /tmp/get-docker.sh'); + await this.executeCommand('sudo sh /tmp/get-docker.sh'); + await this.executeCommand('sudo systemctl enable docker'); + await this.executeCommand('sudo systemctl start docker'); + } else if (distro === 'fedora') { + await this.executeCommand('sudo dnf install -y docker-ce docker-ce-cli containerd.io'); + await this.executeCommand('sudo systemctl enable docker'); + await this.executeCommand('sudo systemctl start docker'); + } else if (distro === 'arch') { + await this.executeCommand('sudo pacman -S --noconfirm docker'); + await this.executeCommand('sudo systemctl enable docker'); + await this.executeCommand('sudo systemctl start docker'); + } else { + return { + success: false, + automated: false, + message: 'Unsupported Linux distribution', + instructions: this.getDockerInstallInstructions('linux') + }; + } + + // Add user to docker group + await this.executeCommand('sudo usermod -aG docker $USER'); + + const verification = await this.checkDocker(); + + return { + success: verification.installed, + automated: true, + message: verification.installed ? + 'Docker installed successfully. You may need to log out and back in for group changes.' : + 'Docker installed but may require restart', + version: verification.version + }; + } catch (error) { + return { + success: false, + automated: true, + message: error.message, + instructions: this.getDockerInstallInstructions('linux') + }; + } + } + + /** + * Wait for Docker to become available + */ + async waitForDocker(timeout = 60000) { + const startTime = Date.now(); + const pollInterval = 3000; + + while (Date.now() - startTime < timeout) { + const status = await this.checkDocker(); + if (status.installed && status.running) { + return true; + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + throw new Error('Docker did not become available within timeout'); + } + + /** + * Attempts automated installation of Caddy (platform-specific) + * @param {string} platform - Platform identifier + * @param {Function} progressCallback - Progress callback + * @param {string} targetPath - Optional target path for Caddy binary + * @returns {Promise} Installation result + */ + async installCaddy(platform, progressCallback, targetPath) { + const downloadManager = new DownloadManager(); + const platformInfo = platformUtils.getPlatformInfo(); + const instructions = this.getCaddyInstallInstructions(platform); + + try { + // Download Caddy from GitHub + progressCallback?.({ status: 'downloading', message: 'Downloading Caddy...' }); + + const downloadResult = await downloadManager.downloadCaddy( + platform, + platformInfo.arch, + progressCallback + ); + + if (!downloadResult.success) { + // Try package manager as fallback + return await this.installCaddyWithPackageManager(platform, progressCallback); + } + + // Extract the archive + progressCallback?.({ status: 'extracting', message: 'Extracting Caddy...' }); + + // Determine target directory + let extractTarget; + if (targetPath) { + extractTarget = targetPath; + } else if (platform === 'windows') { + extractTarget = 'C:\\Program Files\\Caddy'; + } else { + extractTarget = '/usr/local/bin'; + } + + const extractResult = await downloadManager.extractCaddy( + downloadResult.path, + extractTarget, + platform + ); + + if (!extractResult.success) { + return { + success: false, + automated: true, + message: 'Failed to extract Caddy', + error: extractResult.error, + instructions + }; + } + + // On Windows, add to PATH if needed + if (platform === 'windows') { + progressCallback?.({ status: 'configuring', message: 'Adding Caddy to PATH...' }); + await this.addToWindowsPath(extractTarget); + } + + // Verify installation + progressCallback?.({ status: 'verifying', message: 'Verifying installation...' }); + + // Give a moment for PATH changes to take effect + await new Promise(resolve => setTimeout(resolve, 1000)); + + const verification = await this.checkCaddy([extractTarget]); + + await downloadManager.cleanup(); + + if (verification.installed) { + return { + success: true, + automated: true, + message: 'Caddy installed successfully', + version: downloadResult.version || verification.version, + path: verification.path || extractResult.binaryPath + }; + } else { + // Binary exists but not in PATH yet + return { + success: true, + automated: true, + message: 'Caddy installed. Restart terminal for PATH changes.', + version: downloadResult.version, + path: extractResult.binaryPath + }; + } + } catch (error) { + // Fallback to package manager + return await this.installCaddyWithPackageManager(platform, progressCallback); + } + } + + /** + * Install Caddy using system package manager + */ + async installCaddyWithPackageManager(platform, progressCallback) { + const instructions = this.getCaddyInstallInstructions(platform); + + try { + let command; + + if (platform === 'macos') { + // Check if Homebrew is available + const brewCheck = await this.executeCommand('which brew'); + if (brewCheck.success) { + progressCallback?.({ status: 'installing', message: 'Installing Caddy via Homebrew...' }); + command = 'brew install caddy'; + } else { + return { + success: false, + automated: false, + message: 'Homebrew not found. Please install Homebrew first.', + instructions + }; + } + } else if (platform === 'linux') { + const distro = await this.detectLinuxDistro(); + progressCallback?.({ status: 'installing', message: `Installing Caddy for ${distro}...` }); + + if (distro === 'ubuntu' || distro === 'debian') { + // Add Caddy repository and install + await this.executeCommand('sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https'); + await this.executeCommand('curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg'); + await this.executeCommand('curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | sudo tee /etc/apt/sources.list.d/caddy-stable.list'); + await this.executeCommand('sudo apt update'); + command = 'sudo apt install -y caddy'; + } else if (distro === 'fedora') { + await this.executeCommand('sudo dnf install -y "dnf-command(copr)"'); + await this.executeCommand('sudo dnf copr enable -y @caddy/caddy'); + command = 'sudo dnf install -y caddy'; + } else if (distro === 'arch') { + command = 'sudo pacman -S --noconfirm caddy'; + } else { + return { + success: false, + automated: false, + message: 'Unsupported Linux distribution', + instructions + }; + } + } else { + return { + success: false, + automated: false, + message: 'Automated installation not supported', + instructions + }; + } + + const result = await this.executeCommand(command); + + if (result.success) { + const verification = await this.checkCaddy(); + + return { + success: verification.installed, + automated: true, + message: verification.installed ? 'Caddy installed successfully' : 'Installation completed but verification failed', + version: verification.version + }; + } else { + return { + success: false, + automated: true, + message: 'Installation command failed', + error: result.stderr, + instructions + }; + } + } catch (error) { + return { + success: false, + automated: true, + message: 'Installation failed', + error: error.message, + instructions + }; + } + } + + /** + * Add directory to Windows PATH + */ + async addToWindowsPath(directory) { + try { + // Add to user PATH using PowerShell + const script = ` + $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if ($currentPath -notlike '*${directory.replace(/\\/g, '\\\\')}*') { + [Environment]::SetEnvironmentVariable('Path', "$currentPath;${directory.replace(/\\/g, '\\\\')}", 'User') + } + `; + + await this.executeCommand(`powershell -Command "${script}"`); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Detects Linux distribution + * @returns {Promise} Distribution name (ubuntu, fedora, arch, etc.) + */ + async detectLinuxDistro() { + try { + const result = await this.executeCommand('cat /etc/os-release'); + + if (result.success) { + const output = result.stdout.toLowerCase(); + + if (output.includes('ubuntu') || output.includes('debian')) { + return 'ubuntu'; + } else if (output.includes('fedora') || output.includes('rhel') || output.includes('centos')) { + return 'fedora'; + } else if (output.includes('arch')) { + return 'arch'; + } + } + + return 'unknown'; + } catch (error) { + return 'unknown'; + } + } + + /** + * Executes a shell command + * @param {string} command - Command to execute + * @returns {Promise} { success: boolean, stdout: string, stderr: string } + */ + async executeCommand(command) { + try { + const { stdout, stderr } = await execAsync(command, { + timeout: 30000, // 30 second timeout + maxBuffer: 1024 * 1024 // 1MB buffer + }); + + return { + success: true, + stdout: stdout, + stderr: stderr + }; + } catch (error) { + return { + success: false, + stdout: error.stdout || '', + stderr: error.stderr || error.message, + error: error.message + }; + } + } + + /** + * Checks if WSL2 is available on Windows + * @returns {Promise} WSL2 status + */ + async checkWSL2() { + if (!platformUtils.isWindows()) { + return { + available: false, + required: false, + message: 'WSL2 is only relevant on Windows' + }; + } + + try { + const result = await this.executeCommand('wsl --status'); + + return { + available: result.success, + required: true, + message: result.success ? 'WSL2 is available' : 'WSL2 is not installed', + details: result.stdout + }; + } catch (error) { + return { + available: false, + required: true, + message: 'WSL2 is not installed or not accessible', + error: error.message + }; + } + } +} + +module.exports = DependencyChecker; diff --git a/dashcaddy-installer/src/main/dependency-checker.property.test.js b/dashcaddy-installer/src/main/dependency-checker.property.test.js new file mode 100644 index 0000000..449c9e2 --- /dev/null +++ b/dashcaddy-installer/src/main/dependency-checker.property.test.js @@ -0,0 +1,269 @@ +const fc = require('fast-check'); +const DependencyChecker = require('./dependency-checker'); + +/** + * Feature: dashcaddy-installer, Property 2: Dependency Verification + * For any system state, the installer should accurately report whether Docker + * is installed and running, and whether Caddy is installed. + * Validates: Requirements 1.2, 1.3 + */ +describe('Property 2: Dependency Verification', () => { + let checker; + + beforeEach(() => { + checker = new DependencyChecker(); + }); + + test('checkDocker always returns valid structure', async () => { + // Skip if Docker commands are too slow + const quickCheck = await checker.checkDocker(); + if (!quickCheck.installed) { + // If Docker not installed, just verify the structure is correct + expect(quickCheck.installed).toBe(false); + expect(quickCheck.running).toBe(false); + return; + } + + await fc.assert( + fc.asyncProperty( + fc.constant(null), + async () => { + const result = await checker.checkDocker(); + + // Must have required fields + const hasRequiredFields = + typeof result.installed === 'boolean' && + typeof result.running === 'boolean' && + (result.version === null || typeof result.version === 'string'); + + // If installed, version should be present + const versionConsistent = !result.installed || result.version !== null; + + // If not installed, cannot be running + const runningConsistent = !result.running || result.installed; + + return hasRequiredFields && versionConsistent && runningConsistent; + } + ), + { numRuns: 5 } // Very few runs since Docker commands are slow + ); + }, 60000); // 60 second timeout + + test('checkCaddy always returns valid structure', async () => { + await fc.assert( + fc.asyncProperty( + fc.constant(null), + async () => { + const result = await checker.checkCaddy(); + + // Must have required fields + const hasRequiredFields = + typeof result.installed === 'boolean' && + (result.version === null || typeof result.version === 'string') && + (result.path === null || typeof result.path === 'string'); + + // If installed, version should be present + const versionConsistent = !result.installed || result.version !== null; + + return hasRequiredFields && versionConsistent; + } + ), + { numRuns: 50 } + ); + }); + + test('dependency check results are deterministic', async () => { + // Skip if Docker commands are too slow + const quickCheck = await checker.checkDocker(); + if (!quickCheck.installed) { + // If Docker not installed, just verify consistency + const check2 = await checker.checkDocker(); + expect(check2.installed).toBe(false); + return; + } + + await fc.assert( + fc.asyncProperty( + fc.constant(null), + async () => { + const result1 = await checker.checkDocker(); + const result2 = await checker.checkDocker(); + + // Same system state should return same results + return ( + result1.installed === result2.installed && + result1.running === result2.running && + result1.version === result2.version + ); + } + ), + { numRuns: 3 } // Very few runs since this is expensive + ); + }, 60000); // 60 second timeout + + test('getDockerInstallInstructions returns valid structure for any platform', () => { + fc.assert( + fc.property( + fc.constantFrom('windows', 'macos', 'linux', 'unknown'), + (platform) => { + const instructions = checker.getDockerInstallInstructions(platform); + + return ( + typeof instructions === 'object' && + typeof instructions.title === 'string' && + Array.isArray(instructions.steps) && + instructions.steps.length > 0 && + typeof instructions.url === 'string' && + typeof instructions.requiresWSL2 === 'boolean' + ); + } + ), + { numRuns: 100 } + ); + }); + + test('getCaddyInstallInstructions returns valid structure for any platform', () => { + fc.assert( + fc.property( + fc.constantFrom('windows', 'macos', 'linux', 'unknown'), + (platform) => { + const instructions = checker.getCaddyInstallInstructions(platform); + + return ( + typeof instructions === 'object' && + typeof instructions.title === 'string' && + Array.isArray(instructions.steps) && + instructions.steps.length > 0 && + typeof instructions.url === 'string' && + typeof instructions.automated === 'boolean' + ); + } + ), + { numRuns: 100 } + ); + }); + + test('Windows Docker instructions always mention WSL2', () => { + const instructions = checker.getDockerInstallInstructions('windows'); + + expect(instructions.requiresWSL2).toBe(true); + expect(instructions.wsl2Instructions).toBeDefined(); + expect(Array.isArray(instructions.wsl2Instructions)).toBe(true); + }); + + test('macOS Caddy instructions support automation', () => { + const instructions = checker.getCaddyInstallInstructions('macos'); + + expect(instructions.automated).toBe(true); + expect(instructions.command).toBeDefined(); + expect(instructions.command).toContain('brew'); + }); + + test('Linux instructions include package manager options', () => { + fc.assert( + fc.property( + fc.constantFrom('docker', 'caddy'), + (tool) => { + const instructions = tool === 'docker' + ? checker.getDockerInstallInstructions('linux') + : checker.getCaddyInstallInstructions('linux'); + + return ( + typeof instructions.packageManagers === 'object' && + Object.keys(instructions.packageManagers).length > 0 + ); + } + ), + { numRuns: 100 } + ); + }); + + test('executeCommand handles any command string', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 50 }), + async (command) => { + try { + const result = await checker.executeCommand(command); + + // Must return valid structure + return ( + typeof result === 'object' && + typeof result.success === 'boolean' && + typeof result.stdout === 'string' && + typeof result.stderr === 'string' + ); + } catch (error) { + // Some commands might fail, that's okay + return true; + } + } + ), + { numRuns: 50 } // Reduced since this executes actual commands + ); + }); + + test('detectLinuxDistro returns valid distro name', async () => { + await fc.assert( + fc.asyncProperty( + fc.constant(null), + async () => { + const distro = await checker.detectLinuxDistro(); + const validDistros = ['ubuntu', 'fedora', 'arch', 'unknown']; + + return ( + typeof distro === 'string' && + validDistros.includes(distro) + ); + } + ), + { numRuns: 50 } + ); + }); + + test('installDocker always returns result with instructions', async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom('windows', 'macos', 'linux'), + async (platform) => { + const result = await checker.installDocker(platform); + + return ( + typeof result === 'object' && + typeof result.success === 'boolean' && + typeof result.automated === 'boolean' && + typeof result.message === 'string' && + typeof result.instructions === 'object' + ); + } + ), + { numRuns: 100 } + ); + }); + + test('installCaddy returns appropriate result for platform', async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom('windows', 'macos', 'linux'), + async (platform) => { + const result = await checker.installCaddy(platform); + + const hasRequiredFields = + typeof result === 'object' && + typeof result.success === 'boolean' && + typeof result.automated === 'boolean' && + typeof result.message === 'string'; + + // Windows should not be automated + const windowsCorrect = platform !== 'windows' || result.automated === false; + + // macOS should attempt automation (Linux may or may not depending on distro detection) + const macCorrect = platform !== 'macos' || result.automated === true; + + return hasRequiredFields && windowsCorrect && macCorrect; + } + ), + { numRuns: 100 } + ); + }, 30000); // 30 second timeout +}); diff --git a/dashcaddy-installer/src/main/dependency-checker.test.js b/dashcaddy-installer/src/main/dependency-checker.test.js new file mode 100644 index 0000000..ba8de5c --- /dev/null +++ b/dashcaddy-installer/src/main/dependency-checker.test.js @@ -0,0 +1,263 @@ +const DependencyChecker = require('./dependency-checker'); +const { exec } = require('child_process'); + +// Mock child_process +jest.mock('child_process'); + +describe('DependencyChecker', () => { + let checker; + + beforeEach(() => { + checker = new DependencyChecker(); + jest.clearAllMocks(); + // Default: commands fail (prevents hanging when unmocked calls occur) + exec.mockImplementation((cmd, opts, callback) => { + callback(new Error('command not found'), { stdout: '', stderr: 'command not found' }); + }); + }); + + describe('checkDocker', () => { + test('detects Docker installed and running', async () => { + // Mock successful docker --version + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'Docker version 24.0.7, build afdd53b', stderr: '' }); + }); + + // Mock successful docker info + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'Server Version: 24.0.7', stderr: '' }); + }); + + const result = await checker.checkDocker(); + + expect(result.installed).toBe(true); + expect(result.running).toBe(true); + expect(result.version).toBe('24.0.7'); + }); + + test('detects Docker installed but not running', async () => { + // Mock successful docker --version + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'Docker version 24.0.7, build afdd53b', stderr: '' }); + }); + + // Mock failed docker info + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(new Error('Cannot connect to Docker daemon'), { stdout: '', stderr: 'Cannot connect' }); + }); + + const result = await checker.checkDocker(); + + expect(result.installed).toBe(true); + expect(result.running).toBe(false); + expect(result.version).toBe('24.0.7'); + }); + + test('detects Docker not installed', async () => { + // Mock failed docker --version + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(new Error('command not found'), { stdout: '', stderr: 'command not found' }); + }); + + const result = await checker.checkDocker(); + + expect(result.installed).toBe(false); + expect(result.running).toBe(false); + expect(result.version).toBeNull(); + }); + }); + + describe('checkCaddy', () => { + test('detects Caddy installed', async () => { + // Mock successful caddy version + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'v2.7.6 h1:w0NymbG2m9PcvKWsrXO6EEkY9Ru4FJK8uQbYcev1p3A=', stderr: '' }); + }); + + // Mock successful which/where caddy + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: '/usr/bin/caddy', stderr: '' }); + }); + + const result = await checker.checkCaddy(); + + expect(result.installed).toBe(true); + expect(result.version).toBe('2.7.6'); + expect(result.path).toBe('/usr/bin/caddy'); + }); + + test('detects Caddy not installed', async () => { + // Mock failed caddy version + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(new Error('command not found'), { stdout: '', stderr: 'command not found' }); + }); + + const result = await checker.checkCaddy(); + + expect(result.installed).toBe(false); + expect(result.version).toBeNull(); + expect(result.path).toBeNull(); + }); + }); + + describe('getDockerInstallInstructions', () => { + test('returns Windows instructions', () => { + const instructions = checker.getDockerInstallInstructions('windows'); + + expect(instructions.title).toContain('Windows'); + expect(instructions.steps).toBeInstanceOf(Array); + expect(instructions.steps.length).toBeGreaterThan(0); + expect(instructions.requiresWSL2).toBe(true); + expect(instructions.url).toContain('docker.com'); + }); + + test('returns macOS instructions', () => { + const instructions = checker.getDockerInstallInstructions('macos'); + + expect(instructions.title).toContain('Mac'); + expect(instructions.steps).toBeInstanceOf(Array); + expect(instructions.requiresWSL2).toBe(false); + expect(instructions.url).toContain('docker.com'); + }); + + test('returns Linux instructions', () => { + const instructions = checker.getDockerInstallInstructions('linux'); + + expect(instructions.title).toContain('Linux'); + expect(instructions.steps).toBeInstanceOf(Array); + expect(instructions.packageManagers).toBeDefined(); + expect(instructions.packageManagers.ubuntu).toBeDefined(); + }); + }); + + describe('getCaddyInstallInstructions', () => { + test('returns Windows instructions', () => { + const instructions = checker.getCaddyInstallInstructions('windows'); + + expect(instructions.title).toContain('Windows'); + expect(instructions.steps).toBeInstanceOf(Array); + expect(instructions.automated).toBe(false); + expect(instructions.url).toContain('caddyserver.com'); + }); + + test('returns macOS instructions with automation', () => { + const instructions = checker.getCaddyInstallInstructions('macos'); + + expect(instructions.title).toContain('macOS'); + expect(instructions.automated).toBe(true); + expect(instructions.command).toBe('brew install caddy'); + }); + + test('returns Linux instructions with package managers', () => { + const instructions = checker.getCaddyInstallInstructions('linux'); + + expect(instructions.title).toContain('Linux'); + expect(instructions.automated).toBe(true); + expect(instructions.packageManagers).toBeDefined(); + expect(instructions.packageManagers.ubuntu).toBeDefined(); + expect(instructions.packageManagers.fedora).toBeDefined(); + }); + }); + + describe('installDocker', () => { + test('returns manual installation instructions', async () => { + const result = await checker.installDocker('windows'); + + expect(result.success).toBe(false); + expect(result.automated).toBe(false); + expect(result.instructions).toBeDefined(); + expect(result.message).toContain('manual'); + }); + }); + + describe('installCaddy', () => { + test('returns manual instructions for Windows', async () => { + const result = await checker.installCaddy('windows'); + + expect(result.success).toBe(false); + expect(result.automated).toBe(false); + expect(result.instructions).toBeDefined(); + }); + + test('attempts automated installation on macOS', async () => { + // Mock successful brew install + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'Caddy installed', stderr: '' }); + }); + + // Mock successful caddy version check + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'v2.7.6', stderr: '' }); + }); + + // Mock successful which caddy + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: '/usr/local/bin/caddy', stderr: '' }); + }); + + const result = await checker.installCaddy('macos'); + + expect(result.automated).toBe(true); + // Note: success depends on mocked execution + }); + }); + + describe('executeCommand', () => { + test('executes command successfully', async () => { + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'success', stderr: '' }); + }); + + const result = await checker.executeCommand('test command'); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('success'); + }); + + test('handles command failure', async () => { + const error = new Error('command failed'); + error.stderr = 'error message'; + + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(error); + }); + + const result = await checker.executeCommand('test command'); + + expect(result.success).toBe(false); + expect(result.stderr).toContain('error'); + }); + }); + + describe('detectLinuxDistro', () => { + test('detects Ubuntu', async () => { + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'ID=ubuntu\nNAME="Ubuntu"', stderr: '' }); + }); + + const distro = await checker.detectLinuxDistro(); + + expect(distro).toBe('ubuntu'); + }); + + test('detects Fedora', async () => { + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'ID=fedora\nNAME="Fedora"', stderr: '' }); + }); + + const distro = await checker.detectLinuxDistro(); + + expect(distro).toBe('fedora'); + }); + + test('returns unknown for unrecognized distro', async () => { + exec.mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: 'ID=unknown', stderr: '' }); + }); + + const distro = await checker.detectLinuxDistro(); + + expect(distro).toBe('unknown'); + }); + }); +}); diff --git a/dashcaddy-installer/src/main/download-manager.js b/dashcaddy-installer/src/main/download-manager.js new file mode 100644 index 0000000..fda23c9 --- /dev/null +++ b/dashcaddy-installer/src/main/download-manager.js @@ -0,0 +1,357 @@ +/** + * Download Manager + * Handles downloading Docker Desktop and Caddy binaries + */ + +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { URL } = require('url'); + +class DownloadManager { + constructor() { + // Docker Desktop download URLs + this.dockerUrls = { + windows: 'https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe', + macos_amd64: 'https://desktop.docker.com/mac/main/amd64/Docker.dmg', + macos_arm64: 'https://desktop.docker.com/mac/main/arm64/Docker.dmg' + }; + + // Caddy releases API + this.caddyReleasesUrl = 'https://api.github.com/repos/caddyserver/caddy/releases/latest'; + + // Temporary download directory + this.tempDir = path.join(require('os').tmpdir(), 'dashcaddy-installer'); + } + + /** + * Ensure temp directory exists + */ + async ensureTempDir() { + try { + await fs.promises.mkdir(this.tempDir, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + } + + /** + * Download a file with progress tracking + */ + downloadFile(url, destPath, progressCallback) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const protocol = urlObj.protocol === 'https:' ? https : http; + + const request = protocol.get(url, { + headers: { + 'User-Agent': 'DashCaddy-Installer/1.0' + } + }, (response) => { + // Handle redirects + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + return this.downloadFile(response.headers.location, destPath, progressCallback) + .then(resolve) + .catch(reject); + } + + if (response.statusCode !== 200) { + reject(new Error(`Download failed: HTTP ${response.statusCode}`)); + return; + } + + const totalSize = parseInt(response.headers['content-length'], 10) || 0; + let downloadedSize = 0; + + const fileStream = fs.createWriteStream(destPath); + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + if (progressCallback && totalSize > 0) { + progressCallback({ + downloaded: downloadedSize, + total: totalSize, + percent: Math.round((downloadedSize / totalSize) * 100) + }); + } + }); + + response.pipe(fileStream); + + fileStream.on('finish', () => { + fileStream.close(); + resolve({ + success: true, + path: destPath, + size: downloadedSize + }); + }); + + fileStream.on('error', (err) => { + fs.unlink(destPath, () => {}); // Clean up partial file + reject(err); + }); + }); + + request.on('error', reject); + request.setTimeout(60000, () => { + request.destroy(); + reject(new Error('Download timeout')); + }); + }); + } + + /** + * Get Docker Desktop download URL for platform + */ + getDockerDownloadUrl(platform, arch) { + if (platform === 'windows') { + return this.dockerUrls.windows; + } else if (platform === 'macos') { + return arch === 'arm64' ? this.dockerUrls.macos_arm64 : this.dockerUrls.macos_amd64; + } else { + // Linux uses package managers, return null + return null; + } + } + + /** + * Download Docker Desktop + */ + async downloadDocker(platform, arch, progressCallback) { + await this.ensureTempDir(); + + const url = this.getDockerDownloadUrl(platform, arch); + + if (!url) { + return { + success: false, + automated: false, + message: 'Docker installation on Linux uses package managers', + instructions: this.getLinuxDockerInstructions() + }; + } + + const filename = platform === 'windows' ? 'DockerDesktopInstaller.exe' : 'Docker.dmg'; + const destPath = path.join(this.tempDir, filename); + + try { + progressCallback?.({ status: 'downloading', message: 'Downloading Docker Desktop...' }); + + const result = await this.downloadFile(url, destPath, (progress) => { + progressCallback?.({ + status: 'downloading', + message: `Downloading Docker Desktop... ${progress.percent}%`, + ...progress + }); + }); + + return { + success: true, + path: result.path, + size: result.size + }; + } catch (err) { + return { + success: false, + error: err.message + }; + } + } + + /** + * Get latest Caddy release info from GitHub + */ + async getCaddyLatestRelease() { + return new Promise((resolve, reject) => { + const urlObj = new URL(this.caddyReleasesUrl); + + const request = https.get({ + hostname: urlObj.hostname, + path: urlObj.pathname, + headers: { + 'User-Agent': 'DashCaddy-Installer/1.0', + 'Accept': 'application/vnd.github.v3+json' + } + }, (response) => { + let data = ''; + + response.on('data', chunk => { + data += chunk; + }); + + response.on('end', () => { + try { + const release = JSON.parse(data); + resolve(release); + } catch (err) { + reject(new Error('Failed to parse GitHub API response')); + } + }); + }); + + request.on('error', reject); + request.setTimeout(30000, () => { + request.destroy(); + reject(new Error('GitHub API timeout')); + }); + }); + } + + /** + * Get Caddy download URL for platform/arch + */ + getCaddyAssetName(platform, arch) { + const archMap = { + 'x64': 'amd64', + 'arm64': 'arm64', + 'ia32': '386' + }; + + const mappedArch = archMap[arch] || 'amd64'; + + if (platform === 'windows') { + return `caddy_*_windows_${mappedArch}.zip`; + } else if (platform === 'macos') { + return `caddy_*_darwin_${mappedArch}.tar.gz`; + } else { + return `caddy_*_linux_${mappedArch}.tar.gz`; + } + } + + /** + * Find matching asset in release + */ + findCaddyAsset(release, platform, arch) { + const pattern = this.getCaddyAssetName(platform, arch); + const regex = new RegExp(pattern.replace('*', '.*')); + + return release.assets?.find(asset => regex.test(asset.name)); + } + + /** + * Download Caddy binary + */ + async downloadCaddy(platform, arch, progressCallback) { + await this.ensureTempDir(); + + try { + // Get latest release info + progressCallback?.({ status: 'fetching', message: 'Fetching latest Caddy version...' }); + const release = await this.getCaddyLatestRelease(); + + // Find the right asset + const asset = this.findCaddyAsset(release, platform, arch); + + if (!asset) { + return { + success: false, + error: `No Caddy binary found for ${platform} ${arch}` + }; + } + + const destPath = path.join(this.tempDir, asset.name); + + // Download the asset + progressCallback?.({ status: 'downloading', message: `Downloading Caddy ${release.tag_name}...` }); + + const result = await this.downloadFile(asset.browser_download_url, destPath, (progress) => { + progressCallback?.({ + status: 'downloading', + message: `Downloading Caddy ${release.tag_name}... ${progress.percent}%`, + ...progress + }); + }); + + return { + success: true, + path: result.path, + version: release.tag_name, + size: result.size + }; + } catch (err) { + return { + success: false, + error: err.message + }; + } + } + + /** + * Extract Caddy from archive + */ + async extractCaddy(archivePath, targetDir, platform) { + const { exec } = require('child_process'); + const util = require('util'); + const execAsync = util.promisify(exec); + + await fs.promises.mkdir(targetDir, { recursive: true }); + + try { + if (platform === 'windows') { + // Use PowerShell to extract zip + await execAsync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${targetDir}' -Force"`); + return { + success: true, + binaryPath: path.join(targetDir, 'caddy.exe') + }; + } else { + // Use tar on Unix + await execAsync(`tar -xzf "${archivePath}" -C "${targetDir}"`); + const binaryPath = path.join(targetDir, 'caddy'); + + // Make executable + await execAsync(`chmod +x "${binaryPath}"`); + + return { + success: true, + binaryPath + }; + } + } catch (err) { + return { + success: false, + error: err.message + }; + } + } + + /** + * Clean up temp files + */ + async cleanup() { + try { + const files = await fs.promises.readdir(this.tempDir); + for (const file of files) { + await fs.promises.unlink(path.join(this.tempDir, file)); + } + } catch (err) { + // Ignore cleanup errors + } + } + + /** + * Linux Docker installation instructions + */ + getLinuxDockerInstructions() { + return ` +Linux Docker Installation: + +For Ubuntu/Debian: + curl -fsSL https://get.docker.com -o get-docker.sh + sudo sh get-docker.sh + sudo usermod -aG docker $USER + +For Fedora: + sudo dnf install docker-ce docker-ce-cli containerd.io + sudo systemctl enable --now docker + +For Arch Linux: + sudo pacman -S docker + sudo systemctl enable --now docker +`; + } +} + +module.exports = DownloadManager; diff --git a/dashcaddy-installer/src/main/file-deployer.js b/dashcaddy-installer/src/main/file-deployer.js new file mode 100644 index 0000000..89c3ace --- /dev/null +++ b/dashcaddy-installer/src/main/file-deployer.js @@ -0,0 +1,230 @@ +const fs = require('fs'); +const path = require('path'); +const { promisify } = require('util'); + +const copyFile = promisify(fs.copyFile); +const mkdir = promisify(fs.mkdir); +const readdir = promisify(fs.readdir); + +/** + * FileDeployer - Handles copying dashboard and API files to installation directory + * + * In dev mode: reads from sibling directories (../../../status, ../../../dashcaddy-api) + * In packaged exe: reads from extraResources (process.resourcesPath/status, .../dashcaddy-api) + */ +class FileDeployer { + constructor() { + this._resolveSourcePaths(); + } + + _resolveSourcePaths() { + let app; + try { + app = require('electron').app; + } catch { + // Not in Electron context + } + + const isPackaged = app?.isPackaged || false; + + if (isPackaged) { + // Packaged exe: files are in extraResources + this.dashboardSource = path.join(process.resourcesPath, 'status'); + this.apiSource = path.join(process.resourcesPath, 'dashcaddy-api'); + } else { + // Dev mode: sibling directories in the project tree + this.dashboardSource = path.join(__dirname, '../../../status'); + this.apiSource = path.join(__dirname, '../../../dashcaddy-api'); + } + } + + /** + * Copy directory recursively, skipping node_modules, .git, and test files + */ + async copyDirectory(src, dest, progressCallback = null) { + try { + await mkdir(dest, { recursive: true }); + + const entries = await readdir(src, { withFileTypes: true }); + let copiedCount = 0; + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + // Skip unnecessary directories and files + if (['node_modules', '.git', '__tests__', 'test', '.nyc_output', 'coverage'].includes(entry.name)) { + continue; + } + // Skip test files + if (entry.name.endsWith('.test.js') || entry.name.endsWith('.spec.js')) { + continue; + } + + if (entry.isDirectory()) { + const subResult = await this.copyDirectory(srcPath, destPath, progressCallback); + if (subResult.success) copiedCount += subResult.filesCopied; + } else { + await copyFile(srcPath, destPath); + copiedCount++; + + if (progressCallback) { + progressCallback({ + type: 'file', + source: srcPath, + destination: destPath, + count: copiedCount + }); + } + } + } + + return { success: true, filesCopied: copiedCount }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Deploy dashboard files to installation directory + */ + async deployDashboard(installPath, progressCallback = null) { + try { + const dashboardDest = path.join(installPath, 'sites', 'status'); + + if (progressCallback) { + progressCallback({ + type: 'start', + message: 'Deploying dashboard files...', + source: this.dashboardSource, + destination: dashboardDest + }); + } + + // Verify source exists + if (!fs.existsSync(this.dashboardSource)) { + throw new Error(`Dashboard source not found at: ${this.dashboardSource}`); + } + + const result = await this.copyDirectory( + this.dashboardSource, + dashboardDest, + progressCallback + ); + + if (!result.success) { + throw new Error(result.error); + } + + if (progressCallback) { + progressCallback({ + type: 'complete', + message: `Dashboard deployed (${result.filesCopied} files)`, + filesCopied: result.filesCopied + }); + } + + return { + success: true, + path: dashboardDest, + filesCopied: result.filesCopied + }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Deploy API server files to installation directory + */ + async deployAPI(installPath, progressCallback = null) { + try { + const apiDest = path.join(installPath, 'sites', 'dashcaddy-api'); + + if (progressCallback) { + progressCallback({ + type: 'start', + message: 'Deploying API server files...', + source: this.apiSource, + destination: apiDest + }); + } + + if (!fs.existsSync(this.apiSource)) { + throw new Error(`API source not found at: ${this.apiSource}`); + } + + const result = await this.copyDirectory( + this.apiSource, + apiDest, + progressCallback + ); + + if (!result.success) { + throw new Error(result.error); + } + + if (progressCallback) { + progressCallback({ + type: 'complete', + message: `API server deployed (${result.filesCopied} files)`, + filesCopied: result.filesCopied + }); + } + + return { + success: true, + path: apiDest, + filesCopied: result.filesCopied + }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Deploy both dashboard and API files + */ + async deployComplete(installPath, progressCallback = null) { + try { + const dashboardResult = await this.deployDashboard(installPath, progressCallback); + if (!dashboardResult.success) { + throw new Error(`Dashboard deployment failed: ${dashboardResult.error}`); + } + + const apiResult = await this.deployAPI(installPath, progressCallback); + if (!apiResult.success) { + throw new Error(`API deployment failed: ${apiResult.error}`); + } + + return { + success: true, + dashboard: dashboardResult, + api: apiResult + }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Check if source directories exist + */ + async validateSources() { + const checks = { + dashboard: fs.existsSync(this.dashboardSource), + api: fs.existsSync(this.apiSource) + }; + + return { + valid: checks.dashboard && checks.api, + checks, + paths: { + dashboard: this.dashboardSource, + api: this.apiSource + } + }; + } +} + +module.exports = FileDeployer; diff --git a/dashcaddy-installer/src/main/index.js b/dashcaddy-installer/src/main/index.js new file mode 100644 index 0000000..bc9d664 --- /dev/null +++ b/dashcaddy-installer/src/main/index.js @@ -0,0 +1,1047 @@ +const { app, BrowserWindow, ipcMain, dialog } = require('electron'); +const path = require('path'); + +// Disable GPU acceleration to prevent crashes +app.disableHardwareAcceleration(); + +// Add error handling +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); +}); + +let mainWindow; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 900, + height: 700, + minWidth: 800, + minHeight: 600, + icon: path.join(__dirname, '../../assets/favicon.ico'), + webPreferences: { + preload: path.join(__dirname, '../preload/index.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: false + }, + autoHideMenuBar: true, + resizable: true, + show: false // Don't show until ready + }); + + mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); + + // Show window when ready + mainWindow.once('ready-to-show', () => { + mainWindow.show(); + }); + + // Open DevTools in development mode + if (process.argv.includes('--dev')) { + mainWindow.webContents.openDevTools(); + } + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +// App lifecycle handlers +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +// Import utilities +const { DEFAULT_PORTS } = require('../shared/constants'); +const { getPlatformInfo } = require('../shared/platform-utils'); +const DependencyChecker = require('./dependency-checker'); +const ConfigManager = require('./config-manager'); +const FileDeployer = require('./file-deployer'); +const CaddyfileGenerator = require('./caddyfile-generator'); +const BrowserLauncher = require('./browser-launcher'); +const ServiceManager = require('./service-manager'); + +// Create instances +const dependencyChecker = new DependencyChecker(); +const configManager = new ConfigManager(); +const fileDeployer = new FileDeployer(); +const caddyfileGenerator = new CaddyfileGenerator(); +const browserLauncher = new BrowserLauncher(); +const serviceManager = new ServiceManager(); + +// IPC handlers +ipcMain.handle('check-platform', async () => { + try { + return { + success: true, + data: getPlatformInfo() + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('check-dependencies', async (event, options) => { + try { + // Accept optional search paths for Caddy (e.g. user's configured caddy folder) + const additionalPaths = options?.caddySearchPaths || []; + const [docker, caddy, platform] = await Promise.all([ + dependencyChecker.checkDocker(), + dependencyChecker.checkCaddy(additionalPaths), + dependencyChecker.checkPlatform() + ]); + + return { + success: true, + data: { + docker, + caddy, + platform + } + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('check-docker', async () => { + try { + const result = await dependencyChecker.checkDocker(); + return { + success: true, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('check-caddy', async (event, options) => { + try { + const additionalPaths = options?.caddySearchPaths || []; + const result = await dependencyChecker.checkCaddy(additionalPaths); + return { + success: true, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('install-docker', async () => { + try { + const platform = getPlatformInfo().os; + const result = await dependencyChecker.installDocker(platform); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('install-caddy', async (event, options) => { + try { + const platform = getPlatformInfo().os; + const targetPath = options?.targetPath || null; + const result = await dependencyChecker.installCaddy(platform, null, targetPath); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Configuration management handlers +ipcMain.handle('validate-path', async (event, testPath) => { + try { + const result = await configManager.validatePath(testPath); + return { + success: result.valid, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('save-config', async (event, config) => { + try { + const result = await configManager.saveConfig(config, config.installPath); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('load-config', async (event, installPath) => { + try { + const result = await configManager.loadConfig(installPath); + return { + success: result.exists, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// File deployment handlers +ipcMain.handle('validate-sources', async () => { + try { + const result = await fileDeployer.validateSources(); + return { + success: result.valid, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('deploy-dashboard', async (event, installPath) => { + try { + const result = await fileDeployer.deployDashboard(installPath, (progress) => { + event.sender.send('deployment-progress', progress); + }); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('deploy-api', async (event, installPath) => { + try { + const result = await fileDeployer.deployAPI(installPath, (progress) => { + event.sender.send('deployment-progress', progress); + }); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('deploy-complete', async (event, installPath) => { + try { + const result = await fileDeployer.deployComplete(installPath, (progress) => { + event.sender.send('deployment-progress', progress); + }); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Caddyfile generation handlers +ipcMain.handle('create-caddyfile', async (event, installPath, options) => { + try { + const result = await caddyfileGenerator.createCaddyfileSetup(installPath, options); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +ipcMain.handle('create-docker-compose', async (event, installPath) => { + try { + const result = await caddyfileGenerator.createDockerCompose(installPath); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Browser launcher handlers +ipcMain.handle('open-dashboard', async (event, port, hostname) => { + try { + const result = await browserLauncher.openDashboardWhenReady(port, hostname); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Folder selection handler +ipcMain.handle('select-folder', async () => { + try { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory', 'createDirectory'], + title: 'Select Installation Folder' + }); + + if (result.canceled) { + return { success: false, canceled: true }; + } + + return { + success: true, + path: result.filePaths[0] + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// File selection handler (for logo, etc.) +ipcMain.handle('select-file', async (event, options) => { + try { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: options?.filters || [ + { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico'] } + ], + title: options?.title || 'Select File' + }); + + if (result.canceled) { + return { success: false, canceled: true }; + } + + return { + success: true, + path: result.filePaths[0] + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Installation orchestration handler (tier-aware) +ipcMain.handle('run-installation', async (event, config) => { + try { + const tier = config.tier || 'basic'; + const isDocker = tier !== 'basic'; // intermediate or advanced needs Docker + + // Build steps dynamically based on tier + const steps = [ + { name: 'Creating directories', weight: 5 }, + { name: 'Creating seed files', weight: 3 } + ]; + + steps.push({ name: 'Deploying dashboard files', weight: 20 }); + + if (isDocker) { + steps.push({ name: 'Deploying API server', weight: 15 }); + } + + steps.push({ name: 'Generating Caddyfile', weight: 10 }); + + if (isDocker) { + steps.push({ name: 'Creating docker-compose.yml', weight: 8 }); + } + + steps.push({ name: 'Setting up branding', weight: 5 }); + + if (config.dns) { + steps.push({ name: 'Saving DNS credentials', weight: 5 }); + } + + steps.push({ name: 'Saving configuration', weight: 5 }); + steps.push({ name: 'Starting services', weight: 10 }); + + let completedWeight = 0; + const totalWeight = steps.reduce((sum, s) => sum + s.weight, 0); + + const sendProgress = (stepName, stepProgress = 100) => { + const overallProgress = Math.round((completedWeight / totalWeight) * 100); + event.sender.send('step-progress', { + task: stepName, + progress: overallProgress, + stepProgress + }); + }; + + const completeStep = (stepName) => { + const step = steps.find(s => s.name === stepName); + if (step) completedWeight += step.weight; + event.sender.send('step-complete', { step: stepName }); + }; + + // Step: Create directories + sendProgress('Creating directories', 0); + await configManager.createDirectories(config.installPath); + completeStep('Creating directories'); + + // Step: Restore preserved settings from a previous uninstall (if any) + try { + const restoreResult = await configManager.restoreUserSettings(config.installPath); + if (restoreResult.success && restoreResult.restored.length > 0) { + console.log('[Installer] Restored preserved settings:', restoreResult.restored); + event.sender.send('step-progress', { + task: `Restored ${restoreResult.restored.length} preserved file(s) from previous install`, + progress: 0, + stepProgress: 100 + }); + } + } catch (err) { + console.warn('Settings restore check:', err.message); + } + + // Step: Create seed files (services.json, credentials.json, etc.) + sendProgress('Creating seed files', 0); + const seedResult = await configManager.seedFiles(config.installPath); + if (!seedResult.success) { + console.warn('Seed file creation warning:', seedResult.error); + } + completeStep('Creating seed files'); + + // Step: Deploy dashboard + sendProgress('Deploying dashboard files', 0); + await fileDeployer.deployDashboard(config.installPath, (progress) => { + sendProgress('Deploying dashboard files', progress.percent || 0); + }); + completeStep('Deploying dashboard files'); + + // Step: Deploy API (intermediate/advanced only) + if (isDocker) { + sendProgress('Deploying API server', 0); + await fileDeployer.deployAPI(config.installPath, (progress) => { + sendProgress('Deploying API server', progress.percent || 0); + }); + completeStep('Deploying API server'); + } + + // Step: Generate Caddyfile + sendProgress('Generating Caddyfile', 0); + const caddyfileOptions = { + port: config.dashboardPort || 8080, + apiPort: config.apiPort || 3001, + tier: tier, + domainMode: config.domainMode || 'local' + }; + if (config.domainMode === 'public' && config.domain) { + caddyfileOptions.publicDomain = config.domain.publicDomain; + caddyfileOptions.email = config.domain.email; + } else if (config.domainMode === 'custom-tld' && config.domain) { + caddyfileOptions.tld = config.domain.tld; + caddyfileOptions.caName = config.domain.caName || 'DashCaddy Local CA'; + } + await caddyfileGenerator.createCaddyfileSetup(config.installPath, caddyfileOptions); + completeStep('Generating Caddyfile'); + + // Step: Create docker-compose (intermediate/advanced only) + if (isDocker) { + sendProgress('Creating docker-compose.yml', 0); + await caddyfileGenerator.createDockerCompose(config.installPath, { + apiPort: config.apiPort || 3001, + domainMode: config.domainMode || 'local', + lanIP: config.lanIP || '', + tailscaleIP: config.tailscaleIP || '' + }); + completeStep('Creating docker-compose.yml'); + } + + // Step: Setup branding + sendProgress('Setting up branding', 0); + if (config.branding) { + await configManager.saveBranding(config.branding, config.installPath); + } + completeStep('Setting up branding'); + + // Step: Save DNS credentials (if provided) + if (config.dns) { + sendProgress('Saving DNS credentials', 0); + await configManager.saveDNSCredentials(config.dns, config.installPath); + completeStep('Saving DNS credentials'); + } + + // Step: Save configuration + sendProgress('Saving configuration', 0); + + // Derive dashboardHost and tld from domain mode + let dashboardHost, tld = null; + if (config.domainMode === 'public' && config.domain) { + dashboardHost = config.domain.publicDomain; + } else if (config.domainMode === 'custom-tld' && config.domain) { + tld = config.domain.tld; + dashboardHost = `dashcaddy${tld}`; + } else { + dashboardHost = `localhost:${config.dashboardPort || 8080}`; + } + + await configManager.saveConfig({ + setupComplete: true, + configurationType: config.domainMode === 'custom-tld' ? 'homelab' : (config.domainMode === 'public' ? 'public' : 'local'), + tier: tier, + domainMode: config.domainMode || 'local', + tld: tld, + dashboardHost: dashboardHost, + dns: config.dns ? { + provider: 'technitium', + ip: config.dns.server ? config.dns.server.replace(/^https?:\/\//, '').replace(/:\d+$/, '') : '', + port: '5380' + } : undefined, + installedAt: new Date().toISOString(), + version: '1.0.0' + }, config.installPath); + completeStep('Saving configuration'); + + // Step: Start services + sendProgress('Starting services', 0); + if (config.autoStart) { + const caddyfilePath = path.join(config.installPath, 'Caddyfile'); + const caddyBinaryPath = config.caddyBinaryPath || null; + + // Start Caddy + sendProgress('Starting Caddy...', 30); + const caddyResult = await serviceManager.startCaddy(caddyfilePath, caddyBinaryPath); + if (!caddyResult.success) { + console.warn('Caddy start warning:', caddyResult.error); + } + + // Start Docker Compose only for intermediate/advanced tiers + if (isDocker) { + sendProgress('Starting Docker containers...', 60); + const dockerResult = await serviceManager.startDockerCompose(config.installPath); + if (!dockerResult.success) { + console.warn('Docker Compose start warning:', dockerResult.error); + } + } + } + completeStep('Starting services'); + + // Installation complete - determine dashboard URL + let dashboardUrl; + if (config.domainMode === 'public' && config.domain) { + dashboardUrl = `https://${config.domain.publicDomain}`; + } else if (config.domainMode === 'custom-tld' && config.domain) { + dashboardUrl = `https://dashcaddy${config.domain.tld}`; + } else { + dashboardUrl = `http://localhost:${config.dashboardPort || 8080}`; + } + + // Quick health check (non-blocking, just informational) + let healthResults = null; + if (config.autoStart) { + sendProgress('Verifying services...', 95); + // Wait a moment for services to start up + await new Promise(r => setTimeout(r, 3000)); + try { + const http = require('http'); + const caddyOk = await new Promise((resolve) => { + const req = http.get(`http://localhost:${DEFAULT_PORTS.CADDY_ADMIN}/config/`, { timeout: 5000 }, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); + healthResults = { caddy: caddyOk }; + } catch { + healthResults = { caddy: false }; + } + } + + sendProgress('Installation complete', 100); + event.sender.send('installation-complete', { + success: true, + installPath: config.installPath, + dashboardUrl, + health: healthResults + }); + + return { + success: true, + data: { + installPath: config.installPath, + dashboardUrl + } + }; + } catch (error) { + event.sender.send('step-error', { error: error.message }); + return { + success: false, + error: error.message + }; + } +}); + +// DNS connection test handler +ipcMain.handle('test-dns-connection', async (event, credentials) => { + try { + // Test connectivity to the DNS server + if (!credentials?.server) { + return { + success: false, + error: 'No server URL provided' + }; + } + + const http = require('http'); + const https = require('https'); + const { URL } = require('url'); + + const url = new URL(credentials.server); + const protocol = url.protocol === 'https:' ? https : http; + + const connected = await new Promise((resolve) => { + const req = protocol.get(url.href, { timeout: 10000 }, (res) => { + resolve(true); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); + + return { + success: true, + data: { + connected, + message: connected ? 'Successfully connected to DNS server' : 'Could not reach DNS server' + } + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Network detection handler - detects LAN and Tailscale IPs +ipcMain.handle('detect-network', async () => { + try { + const os = require('os'); + const interfaces = os.networkInterfaces(); + let lanIP = ''; + let tailscaleIP = ''; + + for (const [name, addrs] of Object.entries(interfaces)) { + for (const addr of addrs) { + if (addr.family !== 'IPv4' || addr.internal) continue; + + // Tailscale interfaces: typically 100.x.x.x range + if (addr.address.startsWith('100.') && (name.toLowerCase().includes('tailscale') || name.toLowerCase().includes('utun'))) { + tailscaleIP = addr.address; + } + // LAN: common private ranges (not 100.x Tailscale, not 172.17+ Docker) + else if ( + addr.address.startsWith('192.168.') || + addr.address.startsWith('10.') || + (addr.address.startsWith('172.') && !addr.address.startsWith('172.17.')) + ) { + if (!lanIP) lanIP = addr.address; + } + } + } + + // Fallback: try to find Tailscale IP from 100.x range even without name match + if (!tailscaleIP) { + for (const addrs of Object.values(interfaces)) { + for (const addr of addrs) { + if (addr.family === 'IPv4' && addr.address.startsWith('100.') && !addr.internal) { + tailscaleIP = addr.address; + break; + } + } + if (tailscaleIP) break; + } + } + + return { + success: true, + data: { lanIP, tailscaleIP } + }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// Health check handler - verify services are running after install +ipcMain.handle('health-check', async (event, config) => { + try { + const http = require('http'); + const results = {}; + + // Check Caddy admin API + results.caddy = await new Promise((resolve) => { + const req = http.get(`http://localhost:${DEFAULT_PORTS.CADDY_ADMIN}/config/`, { timeout: 5000 }, (res) => { + resolve({ running: res.statusCode === 200, statusCode: res.statusCode }); + }); + req.on('error', () => resolve({ running: false })); + req.on('timeout', () => { req.destroy(); resolve({ running: false }); }); + }); + + // Check API server (only for non-basic tiers) + const apiPort = config?.apiPort || DEFAULT_PORTS.API; + results.api = await new Promise((resolve) => { + const req = http.get(`http://localhost:${apiPort}/api/health`, { timeout: 5000 }, (res) => { + resolve({ running: res.statusCode === 200, statusCode: res.statusCode }); + }); + req.on('error', () => resolve({ running: false })); + req.on('timeout', () => { req.destroy(); resolve({ running: false }); }); + }); + + // Check dashboard is reachable + let dashboardPort = config?.dashboardPort || 8080; + let dashboardProto = 'http'; + if (config?.domainMode === 'custom-tld' || config?.domainMode === 'public') { + dashboardPort = 443; + dashboardProto = 'https'; + } + + results.dashboard = await new Promise((resolve) => { + const mod = dashboardProto === 'https' ? require('https') : http; + const opts = { timeout: 5000 }; + if (dashboardProto === 'https') opts.rejectUnauthorized = false; + const req = mod.get(`${dashboardProto}://localhost:${dashboardPort}/`, opts, (res) => { + resolve({ running: res.statusCode >= 200 && res.statusCode < 400, statusCode: res.statusCode }); + }); + req.on('error', () => resolve({ running: false })); + req.on('timeout', () => { req.destroy(); resolve({ running: false }); }); + }); + + return { success: true, data: results }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// DNS credential handlers +ipcMain.handle('save-dns-credentials', async (event, credentials, installPath) => { + try { + const result = await configManager.saveDNSCredentials(credentials, installPath); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Branding handler +ipcMain.handle('save-branding', async (event, branding, installPath) => { + try { + const result = await configManager.saveBranding(branding, installPath); + return { + success: result.success, + data: result + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +}); + +// Service management handlers +ipcMain.handle('start-services', async (event, config) => { + try { + const result = await serviceManager.startAll(config.installPath, { + caddyfilePath: config.caddyfilePath, + caddyBinaryPath: config.caddyBinaryPath + }); + return { success: result.success, data: result }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('stop-services', async (event, config) => { + try { + const result = await serviceManager.stopAll(config.installPath); + return { success: result.success, data: result }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('check-service-status', async (event, config) => { + try { + const [caddyStatus, dockerStatus] = await Promise.all([ + serviceManager.checkCaddyStatus(), + serviceManager.checkDockerComposeStatus(config.installPath) + ]); + return { + success: true, + data: { caddy: caddyStatus, docker: dockerStatus } + }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// Detect existing installation +ipcMain.handle('detect-installation', async () => { + try { + const { DEFAULT_PATHS } = require('../shared/constants'); + const platformInfo = getPlatformInfo(); + const defaultPath = DEFAULT_PATHS[platformInfo.platform] || DEFAULT_PATHS.win32; + + // Check default path first + const exists = await configManager.installationExists(defaultPath); + if (exists) { + const configResult = await configManager.loadConfig(defaultPath); + return { + success: true, + data: { found: true, installPath: defaultPath, config: configResult.config } + }; + } + + // Check common alternative paths + const alternatives = ['C:\\DashCaddy', 'C:\\caddy', '/opt/dashcaddy', '/Applications/DashCaddy']; + for (const altPath of alternatives) { + if (altPath === defaultPath) continue; + const altExists = await configManager.installationExists(altPath); + if (altExists) { + const configResult = await configManager.loadConfig(altPath); + return { + success: true, + data: { found: true, installPath: altPath, config: configResult.config } + }; + } + } + + return { success: true, data: { found: false } }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// Check for preserved settings from previous uninstall +ipcMain.handle('check-preserved-settings', async (event, installPath) => { + try { + const settingsDir = path.join(installPath, '.dashcaddy-settings'); + try { + await require('fs').promises.access(settingsDir); + const files = await require('fs').promises.readdir(settingsDir); + return { success: true, data: { found: true, files, settingsPath: settingsDir } }; + } catch { + return { success: true, data: { found: false } }; + } + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// Restore preserved settings during reinstall +ipcMain.handle('restore-preserved-settings', async (event, installPath) => { + try { + const result = await configManager.restoreUserSettings(installPath); + return { success: result.success, data: result }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// Uninstall orchestration handler with progress tracking +ipcMain.handle('run-uninstallation', async (event, options) => { + try { + const { installPath, preserveSettings, preserveCA } = options; + + // Verify installation exists + const exists = await configManager.installationExists(installPath); + if (!exists) { + return { success: false, error: 'No DashCaddy installation found at this path' }; + } + + // Load config to understand what was installed + const configResult = await configManager.loadConfig(installPath); + const config = configResult.config || {}; + const isDocker = config.tier && config.tier !== 'basic'; + + // Build uninstall steps + const uninstallSteps = []; + uninstallSteps.push({ name: 'Stopping Caddy', weight: 10 }); + if (isDocker) { + uninstallSteps.push({ name: 'Stopping Docker containers', weight: 15 }); + uninstallSteps.push({ name: 'Removing Docker containers', weight: 10 }); + } + if (preserveSettings || preserveCA) { + uninstallSteps.push({ name: 'Backing up preserved data', weight: 10 }); + } + uninstallSteps.push({ name: 'Removing installation files', weight: 40 }); + + let completedWeight = 0; + const totalWeight = uninstallSteps.reduce((sum, s) => sum + s.weight, 0); + + const sendProgress = (stepName, stepProgress = 0) => { + const overallProgress = Math.round((completedWeight / totalWeight) * 100); + event.sender.send('uninstall-progress', { + task: stepName, + progress: overallProgress, + stepProgress + }); + }; + + const completeStep = (stepName) => { + const step = uninstallSteps.find(s => s.name === stepName); + if (step) completedWeight += step.weight; + event.sender.send('uninstall-step-complete', { step: stepName }); + }; + + // Step: Stop Caddy + sendProgress('Stopping Caddy'); + try { + await serviceManager.stopCaddy(); + } catch (err) { + console.warn('Caddy stop warning:', err.message); + } + completeStep('Stopping Caddy'); + + // Step: Stop & remove Docker containers (if applicable) + if (isDocker) { + sendProgress('Stopping Docker containers'); + try { + await serviceManager.stopDockerCompose(installPath); + } catch (err) { + console.warn('Docker stop warning:', err.message); + } + completeStep('Stopping Docker containers'); + + sendProgress('Removing Docker containers'); + try { + const execPromise = require('util').promisify(require('child_process').exec); + const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml'); + await execPromise(`docker compose -f "${composePath}" down --rmi local --volumes`, { + cwd: path.dirname(composePath), + timeout: 120000 + }); + } catch (err) { + console.warn('Docker cleanup warning:', err.message); + } + completeStep('Removing Docker containers'); + } + + // Step: Back up preserved data + if (preserveSettings || preserveCA) { + sendProgress('Backing up preserved data'); + } + + // Step: Remove files (config-manager handles backup internally) + sendProgress('Removing installation files'); + const removeResult = await configManager.removeInstallation(installPath, { + preserveSettings, + preserveCA + }); + + if (!removeResult.success) { + event.sender.send('uninstall-error', { error: removeResult.error || removeResult.message }); + return { success: false, error: removeResult.message }; + } + + if (preserveSettings || preserveCA) { + completeStep('Backing up preserved data'); + } + completeStep('Removing installation files'); + + sendProgress('Uninstall complete', 100); + event.sender.send('uninstall-complete', { + success: true, + preservedSettings: preserveSettings, + preservedCA: preserveCA, + settingsPath: removeResult.settingsPath || null + }); + + return { + success: true, + data: { + preservedSettings: preserveSettings, + preservedCA: preserveCA, + settingsPath: removeResult.settingsPath || null + } + }; + } catch (error) { + event.sender.send('uninstall-error', { error: error.message }); + return { success: false, error: error.message }; + } +}); + +module.exports = { mainWindow }; diff --git a/dashcaddy-installer/src/main/service-manager.js b/dashcaddy-installer/src/main/service-manager.js new file mode 100644 index 0000000..119f27d --- /dev/null +++ b/dashcaddy-installer/src/main/service-manager.js @@ -0,0 +1,392 @@ +/** + * Service Manager + * Handles starting, stopping, and managing Caddy and Docker services + */ + +const { exec, spawn } = require('child_process'); +const { promisify } = require('util'); +const path = require('path'); +const fs = require('fs').promises; +const platformUtils = require('../shared/platform-utils'); + +const execAsync = promisify(exec); + +class ServiceManager { + constructor() { + this.platform = platformUtils.getPlatformInfo().os; + } + + /** + * Start Caddy with the specified Caddyfile + * @param {string} caddyfilePath - Path to Caddyfile + * @param {string} caddyBinaryPath - Optional path to Caddy binary + * @returns {Promise} { success: boolean, message: string } + */ + async startCaddy(caddyfilePath, caddyBinaryPath) { + try { + const caddyCmd = caddyBinaryPath || 'caddy'; + + // Check if Caddyfile exists + try { + await fs.access(caddyfilePath); + } catch { + return { + success: false, + error: 'Caddyfile not found' + }; + } + + if (this.platform === 'windows') { + // On Windows, start Caddy in background + const caddy = spawn(caddyCmd, ['run', '--config', caddyfilePath], { + detached: true, + stdio: 'ignore', + windowsHide: true + }); + + caddy.unref(); + + // Wait a moment for Caddy to start + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check if Caddy is running + const status = await this.checkCaddyStatus(); + + return { + success: status.running, + message: status.running ? 'Caddy started successfully' : 'Caddy may have failed to start', + pid: caddy.pid + }; + } else { + // On Unix, use systemd if available, otherwise start directly + const systemdAvailable = await this.isSystemdAvailable(); + + if (systemdAvailable) { + // Create systemd service file + await this.createCaddySystemdService(caddyfilePath, caddyBinaryPath); + + // Start the service + await execAsync('sudo systemctl daemon-reload'); + await execAsync('sudo systemctl enable caddy'); + await execAsync('sudo systemctl start caddy'); + + return { + success: true, + message: 'Caddy started via systemd' + }; + } else { + // Start directly + const caddy = spawn(caddyCmd, ['run', '--config', caddyfilePath], { + detached: true, + stdio: 'ignore' + }); + + caddy.unref(); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + return { + success: true, + message: 'Caddy started in background', + pid: caddy.pid + }; + } + } + } catch (error) { + return { + success: false, + error: error.message + }; + } + } + + /** + * Stop Caddy + * @returns {Promise} { success: boolean, message: string } + */ + async stopCaddy() { + try { + if (this.platform === 'windows') { + await execAsync('taskkill /IM caddy.exe /F'); + } else { + const systemdAvailable = await this.isSystemdAvailable(); + + if (systemdAvailable) { + await execAsync('sudo systemctl stop caddy'); + } else { + await execAsync('pkill caddy'); + } + } + + return { + success: true, + message: 'Caddy stopped' + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } + + /** + * Check if Caddy is running + * @returns {Promise} { running: boolean } + */ + async checkCaddyStatus() { + try { + if (this.platform === 'windows') { + const result = await execAsync('tasklist /FI "IMAGENAME eq caddy.exe"'); + const running = result.stdout.includes('caddy.exe'); + + return { running }; + } else { + const result = await execAsync('pgrep caddy'); + return { running: result.stdout.trim().length > 0 }; + } + } catch { + return { running: false }; + } + } + + /** + * Start Docker Compose services + * @param {string} installPath - Installation directory containing docker-compose.yml + * @returns {Promise} { success: boolean, message: string } + */ + async startDockerCompose(installPath) { + try { + const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml'); + + // Check if docker-compose.yml exists + try { + await fs.access(composePath); + } catch { + return { + success: false, + error: 'docker-compose.yml not found' + }; + } + + // Start containers (cwd must be the compose directory for build context) + const composeDir = path.dirname(composePath); + await execAsync(`docker compose -f "${composePath}" up -d --build`, { + cwd: composeDir, + timeout: 300000 // 5 minute timeout for initial build + }); + + return { + success: true, + message: 'Docker containers started' + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } + + /** + * Stop Docker Compose services + * @param {string} installPath - Installation directory containing docker-compose.yml + * @returns {Promise} { success: boolean, message: string } + */ + async stopDockerCompose(installPath) { + try { + const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml'); + + await execAsync(`docker compose -f "${composePath}" down`, { + cwd: path.dirname(composePath), + timeout: 60000 + }); + + return { + success: true, + message: 'Docker containers stopped' + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } + + /** + * Check Docker Compose status + * @param {string} installPath - Installation directory + * @returns {Promise} { running: boolean, containers: array } + */ + async checkDockerComposeStatus(installPath) { + try { + const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml'); + + const result = await execAsync(`docker compose -f "${composePath}" ps --format json`, { + cwd: path.dirname(composePath) + }); + + const containers = result.stdout.trim() + .split('\n') + .filter(line => line.trim()) + .map(line => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); + + return { + running: containers.some(c => c.State === 'running'), + containers + }; + } catch { + return { + running: false, + containers: [] + }; + } + } + + /** + * Start all services + * @param {string} installPath - Installation directory + * @param {Object} options - Options including caddyfilePath + * @returns {Promise} { success: boolean, services: object } + */ + async startAll(installPath, options = {}) { + const results = { + caddy: null, + docker: null + }; + + // Start Caddy + const caddyfilePath = options.caddyfilePath || path.join(installPath, 'Caddyfile'); + results.caddy = await this.startCaddy(caddyfilePath, options.caddyBinaryPath); + + // Start Docker Compose + results.docker = await this.startDockerCompose(installPath); + + return { + success: results.caddy.success && results.docker.success, + services: results + }; + } + + /** + * Stop all services + * @param {string} installPath - Installation directory + * @returns {Promise} { success: boolean, services: object } + */ + async stopAll(installPath) { + const results = { + caddy: null, + docker: null + }; + + results.caddy = await this.stopCaddy(); + results.docker = await this.stopDockerCompose(installPath); + + return { + success: results.caddy.success && results.docker.success, + services: results + }; + } + + /** + * Check if systemd is available + * @returns {Promise} + */ + async isSystemdAvailable() { + try { + await execAsync('systemctl --version'); + return true; + } catch { + return false; + } + } + + /** + * Create systemd service file for Caddy + * @param {string} caddyfilePath - Path to Caddyfile + * @param {string} caddyBinaryPath - Path to Caddy binary + */ + async createCaddySystemdService(caddyfilePath, caddyBinaryPath) { + const caddyCmd = caddyBinaryPath || '/usr/bin/caddy'; + + const serviceContent = `[Unit] +Description=Caddy Web Server (DashCaddy) +After=network.target network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +ExecStart=${caddyCmd} run --config ${caddyfilePath} +ExecReload=${caddyCmd} reload --config ${caddyfilePath} +Restart=on-failure +RestartSec=5 +LimitNOFILE=1048576 +LimitNPROC=512 + +[Install] +WantedBy=multi-user.target +`; + + const servicePath = '/etc/systemd/system/caddy.service'; + + // Write service file (requires sudo) + await execAsync(`echo '${serviceContent}' | sudo tee ${servicePath}`); + } + + /** + * Check if Docker daemon is running + * @returns {Promise} + */ + async isDockerRunning() { + try { + await execAsync('docker info'); + return true; + } catch { + return false; + } + } + + /** + * Wait for a service to become available + * @param {string} url - URL to check + * @param {number} timeout - Timeout in milliseconds + * @returns {Promise} + */ + async waitForService(url, timeout = 30000) { + const startTime = Date.now(); + const http = require('http'); + const https = require('https'); + + while (Date.now() - startTime < timeout) { + try { + await new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http; + const req = protocol.get(url, (res) => { + resolve(res.statusCode); + }); + req.on('error', reject); + req.setTimeout(5000, () => { + req.destroy(); + reject(new Error('Timeout')); + }); + }); + return true; + } catch { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + return false; + } +} + +module.exports = ServiceManager; diff --git a/dashcaddy-installer/src/preload/index.js b/dashcaddy-installer/src/preload/index.js new file mode 100644 index 0000000..9fbfc48 --- /dev/null +++ b/dashcaddy-installer/src/preload/index.js @@ -0,0 +1,102 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld('electronAPI', { + // Platform detection + checkPlatform: () => ipcRenderer.invoke('check-platform'), + + // Dependency checking + checkDependencies: (options) => ipcRenderer.invoke('check-dependencies', options), + checkDocker: () => ipcRenderer.invoke('check-docker'), + checkCaddy: (options) => ipcRenderer.invoke('check-caddy', options), + installDocker: () => ipcRenderer.invoke('install-docker'), + installCaddy: (options) => ipcRenderer.invoke('install-caddy', options), + + // Configuration + validatePath: (path) => ipcRenderer.invoke('validate-path', path), + selectFolder: () => ipcRenderer.invoke('select-folder'), + saveConfig: (config) => ipcRenderer.invoke('save-config', config), + loadConfig: (path) => ipcRenderer.invoke('load-config', path), + + // File deployment + validateSources: () => ipcRenderer.invoke('validate-sources'), + deployDashboard: (installPath) => ipcRenderer.invoke('deploy-dashboard', installPath), + deployAPI: (installPath) => ipcRenderer.invoke('deploy-api', installPath), + deployComplete: (installPath) => ipcRenderer.invoke('deploy-complete', installPath), + + // Caddyfile generation + createCaddyfile: (installPath, options) => ipcRenderer.invoke('create-caddyfile', installPath, options), + createDockerCompose: (installPath) => ipcRenderer.invoke('create-docker-compose', installPath), + + // Browser launcher + openDashboard: (port, hostname) => ipcRenderer.invoke('open-dashboard', port, hostname), + + // Installation + runInstallation: (config) => ipcRenderer.invoke('run-installation', config), + + // DNS + testDNSConnection: (credentials) => ipcRenderer.invoke('test-dns-connection', credentials), + + // Network detection + detectNetwork: () => ipcRenderer.invoke('detect-network'), + + // Health check + healthCheck: (config) => ipcRenderer.invoke('health-check', config), + + // Uninstallation + detectInstallation: () => ipcRenderer.invoke('detect-installation'), + checkPreservedSettings: (installPath) => ipcRenderer.invoke('check-preserved-settings', installPath), + restorePreservedSettings: (installPath) => ipcRenderer.invoke('restore-preserved-settings', installPath), + runUninstallation: (options) => ipcRenderer.invoke('run-uninstallation', options), + + // Service management + startServices: (config) => ipcRenderer.invoke('start-services', config), + stopServices: (config) => ipcRenderer.invoke('stop-services', config), + checkServiceStatus: (config) => ipcRenderer.invoke('check-service-status', config), + + // DNS credentials + saveDNSCredentials: (credentials, installPath) => + ipcRenderer.invoke('save-dns-credentials', credentials, installPath), + + // Branding + saveBranding: (branding, installPath) => + ipcRenderer.invoke('save-branding', branding, installPath), + + // File selection (for logo, etc.) + selectFile: (options) => ipcRenderer.invoke('select-file', options), + + // Event listeners + onStepProgress: (callback) => { + ipcRenderer.on('step-progress', (event, data) => callback(data)); + }, + onStepComplete: (callback) => { + ipcRenderer.on('step-complete', (event, data) => callback(data)); + }, + onStepError: (callback) => { + ipcRenderer.on('step-error', (event, data) => callback(data)); + }, + onInstallationComplete: (callback) => { + ipcRenderer.on('installation-complete', (event, data) => callback(data)); + }, + onDeploymentProgress: (callback) => { + ipcRenderer.on('deployment-progress', (event, data) => callback(data)); + }, + onUninstallProgress: (callback) => { + ipcRenderer.on('uninstall-progress', (event, data) => callback(data)); + }, + onUninstallStepComplete: (callback) => { + ipcRenderer.on('uninstall-step-complete', (event, data) => callback(data)); + }, + onUninstallComplete: (callback) => { + ipcRenderer.on('uninstall-complete', (event, data) => callback(data)); + }, + onUninstallError: (callback) => { + ipcRenderer.on('uninstall-error', (event, data) => callback(data)); + }, + + // Remove listeners + removeListener: (channel) => { + ipcRenderer.removeAllListeners(channel); + } +}); diff --git a/dashcaddy-installer/src/renderer/index.html b/dashcaddy-installer/src/renderer/index.html new file mode 100644 index 0000000..52d61b8 --- /dev/null +++ b/dashcaddy-installer/src/renderer/index.html @@ -0,0 +1,23 @@ + + + + + + + DashCaddy Installer + + + +
+ +
+
+
+

Initializing DashCaddy Installer...

+
+
+
+ + + + diff --git a/dashcaddy-installer/src/renderer/styles.css b/dashcaddy-installer/src/renderer/styles.css new file mode 100644 index 0000000..4c64b35 --- /dev/null +++ b/dashcaddy-installer/src/renderer/styles.css @@ -0,0 +1,1036 @@ +/* DashCaddy Installer Styles */ + +:root { + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-light: #818cf8; + --success: #22c55e; + --success-bg: #dcfce7; + --error: #ef4444; + --error-bg: #fee2e2; + --warning: #f59e0b; + --warning-bg: #fef3c7; + --text: #1f2937; + --text-muted: #6b7280; + --bg: #f9fafb; + --card-bg: #ffffff; + --border: #e5e7eb; + --shadow: 0 20px 60px rgba(0, 0, 0, 0.15); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + color: var(--text); + overflow: hidden; +} + +#root { + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; +} + +/* Main Container */ +.installer-container { + display: flex; + flex-direction: column; + height: 100%; + max-width: 900px; + width: 95%; + margin: 20px auto; + background: var(--card-bg); + border-radius: 16px; + box-shadow: var(--shadow); + overflow: hidden; +} + +/* Step Indicator */ +.step-indicator { + display: flex; + justify-content: center; + padding: 24px 40px; + background: var(--bg); + border-bottom: 1px solid var(--border); + gap: 8px; +} + +.step-item { + display: flex; + align-items: center; + gap: 8px; +} + +.step-circle { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + background: var(--border); + color: var(--text-muted); + transition: all 0.3s ease; +} + +.step-item.active .step-circle { + background: var(--primary); + color: white; +} + +.step-item.completed .step-circle { + background: var(--success); + color: white; +} + +/* Checkmark rendered in HTML, no ::after needed */ + +.step-label { + font-size: 13px; + color: var(--text-muted); + display: none; +} + +.step-item.active .step-label { + display: block; + color: var(--text); + font-weight: 500; +} + +.step-connector { + width: 40px; + height: 2px; + background: var(--border); +} + +.step-item.completed + .step-connector { + background: var(--success); +} + +/* Content Area */ +.step-content { + flex: 1; + padding: 40px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Welcome Step */ +.welcome-content { + text-align: center; + max-width: 500px; + margin: 0 auto; +} + +.logo-container { + margin-bottom: 24px; + display: flex; + justify-content: center; + align-items: center; +} + +.logo-container img { + max-width: 200px; + max-height: 120px; + height: auto; + object-fit: contain; +} + +.logo-placeholder { + width: 120px; + height: 120px; + background: linear-gradient(135deg, var(--primary) 0%, #764ba2 100%); + border-radius: 24px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + color: white; + font-size: 48px; + font-weight: bold; + /* Fallback if logo image fails to load */ +} + +.step-content h1 { + font-size: 32px; + color: var(--text); + margin-bottom: 12px; +} + +.step-content h2 { + font-size: 24px; + color: var(--text); + margin-bottom: 16px; +} + +.step-content p { + color: var(--text-muted); + line-height: 1.6; + margin-bottom: 16px; +} + +.feature-list { + list-style: none; + text-align: left; + margin: 24px 0; + padding: 0; +} + +.feature-list li { + padding: 12px 0; + padding-left: 32px; + position: relative; + color: var(--text); + border-bottom: 1px solid var(--border); +} + +.feature-list li:last-child { + border-bottom: none; +} + +.feature-list li::before { + content: '✓'; + position: absolute; + left: 0; + color: var(--success); + font-weight: bold; +} + +.platform-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg); + border-radius: 20px; + font-size: 14px; + color: var(--text-muted); + margin-top: 16px; +} + +/* Folder Selection */ +.folder-inputs { + display: flex; + flex-direction: column; + gap: 24px; + max-width: 600px; + margin: 0 auto; + width: 100%; +} + +.folder-input { + display: flex; + flex-direction: column; + gap: 8px; +} + +.folder-input label { + font-weight: 600; + color: var(--text); + font-size: 14px; +} + +.folder-input .hint { + font-size: 13px; + color: var(--text-muted); + margin: 0; +} + +.input-row { + display: flex; + gap: 8px; +} + +.input-row input { + flex: 1; + padding: 12px 16px; + border: 2px solid var(--border); + border-radius: 8px; + font-size: 14px; + background: var(--bg); + color: var(--text); + outline: none; + transition: border-color 0.2s; +} + +.input-row input:focus { + border-color: var(--primary); +} + +.input-row input::placeholder { + color: var(--text-muted); +} + +.btn-browse { + padding: 12px 20px; + background: var(--bg); + border: 2px solid var(--border); + border-radius: 8px; + cursor: pointer; + font-size: 14px; + color: var(--text); + transition: all 0.2s; + white-space: nowrap; +} + +.btn-browse:hover { + border-color: var(--primary); + background: white; +} + +/* Dependency Cards */ +.dependency-cards { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 600px; + margin: 0 auto; + width: 100%; +} + +.dependency-card { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: var(--bg); + border-radius: 12px; + border: 2px solid var(--border); +} + +.dependency-card.checking { + border-color: var(--warning); + background: var(--warning-bg); +} + +.dependency-card.installed { + border-color: var(--success); + background: var(--success-bg); +} + +.dependency-card.missing { + border-color: var(--error); + background: var(--error-bg); +} + +.dependency-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: white; + font-size: 24px; +} + +.dependency-info { + flex: 1; +} + +.dependency-info h3 { + font-size: 16px; + margin-bottom: 4px; +} + +.dependency-info p { + font-size: 13px; + margin: 0; +} + +.dependency-status { + display: flex; + align-items: center; + gap: 8px; +} + +.status-icon { + font-size: 20px; +} + +.status-icon.checking::after { + content: '⏳'; +} + +.status-icon.installed::after { + content: '✓'; + color: var(--success); +} + +.status-icon.missing::after { + content: '✗'; + color: var(--error); +} + +.btn-install { + padding: 8px 16px; + background: var(--primary); + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.btn-install:hover { + background: var(--primary-hover); +} + +.btn-install:disabled { + background: var(--border); + cursor: not-allowed; +} + +/* Installation Progress */ +.installation-progress { + max-width: 600px; + margin: 0 auto; + width: 100%; + text-align: center; +} + +.progress-container { + margin: 32px 0; +} + +.progress-bar-bg { + width: 100%; + height: 12px; + background: var(--border); + border-radius: 6px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--primary) 0%, var(--primary-light) 100%); + border-radius: 6px; + transition: width 0.3s ease; +} + +.progress-text { + margin-top: 12px; + font-size: 14px; + color: var(--text-muted); +} + +.progress-percentage { + font-size: 48px; + font-weight: bold; + color: var(--primary); + margin-bottom: 16px; +} + +.task-log { + margin-top: 24px; + text-align: left; + max-height: 200px; + overflow-y: auto; + background: var(--bg); + border-radius: 8px; + padding: 16px; +} + +.task-log-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + font-size: 13px; + border-bottom: 1px solid var(--border); +} + +.task-log-item:last-child { + border-bottom: none; +} + +.task-log-item.completed { + color: var(--success); +} + +.task-log-item.current { + color: var(--primary); + font-weight: 500; +} + +.task-log-item.pending { + color: var(--text-muted); +} + +/* Complete Step */ +.complete-content { + text-align: center; + max-width: 500px; + margin: 0 auto; +} + +.success-icon { + width: 80px; + height: 80px; + background: var(--success-bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 24px; + font-size: 40px; + color: var(--success); +} + +.install-summary { + background: var(--bg); + border-radius: 12px; + padding: 20px; + margin: 24px 0; + text-align: left; +} + +.install-summary h3 { + font-size: 14px; + color: var(--text-muted); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.install-summary .summary-item { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} + +.install-summary .summary-item:last-child { + border-bottom: none; +} + +.install-summary .summary-label { + color: var(--text-muted); +} + +.install-summary .summary-value { + color: var(--text); + font-weight: 500; + font-family: monospace; +} + +/* Footer / Navigation */ +.step-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 40px; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.btn { + padding: 12px 32px; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-hover); +} + +.btn-primary:disabled { + background: var(--border); + color: var(--text-muted); + cursor: not-allowed; +} + +.btn-secondary { + background: transparent; + color: var(--text-muted); + border: 2px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--primary); + color: var(--primary); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-success:hover { + background: #16a34a; +} + +/* Loading States */ +.loading { + text-align: center; + padding: 40px; +} + +.spinner { + border: 4px solid var(--border); + border-top: 4px solid var(--primary); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.spinner-small { + width: 20px; + height: 20px; + border-width: 2px; + display: inline-block; + vertical-align: middle; + margin-right: 8px; +} + +/* Error States */ +.error-message { + background: var(--error-bg); + color: var(--error); + padding: 16px; + border-radius: 8px; + margin: 16px 0; + display: flex; + align-items: center; + gap: 12px; +} + +.error-message::before { + content: '⚠'; + font-size: 20px; +} + +/* Tier Selection Cards */ +.tier-cards { + display: flex; + gap: 16px; + max-width: 700px; + margin: 0 auto; + width: 100%; +} + +.tier-card { + flex: 1; + padding: 20px; + background: var(--bg); + border-radius: 12px; + border: 2px solid var(--border); + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.tier-card:hover { + border-color: var(--primary-light); +} + +.tier-card.selected { + border-color: var(--primary); + background: white; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15); +} + +.tier-badge { + position: absolute; + top: -10px; + right: 12px; + background: var(--primary); + color: white; + font-size: 11px; + padding: 2px 10px; + border-radius: 10px; + font-weight: 600; +} + +.tier-header { + margin-bottom: 12px; +} + +.tier-card h3 { + font-size: 18px; + margin: 0; +} + +.tier-description { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.tier-features { + list-style: none; + padding: 0; + margin: 0; +} + +.tier-features li { + font-size: 12px; + padding: 4px 0 4px 20px; + position: relative; + color: var(--text); +} + +.tier-features li::before { + content: '+'; + position: absolute; + left: 0; + color: var(--success); + font-weight: bold; +} + +/* Access Mode Cards */ +.access-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--primary) 0%, #764ba2 100%); + color: white; + font-weight: 700; + font-size: 14px; + margin-bottom: 12px; +} + +.access-note { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + margin: 0; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +/* Toggle */ +.toggle-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-weight: 500; + margin-bottom: 16px; +} + +.toggle-label input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary); +} + +/* Form group */ +.form-group { + margin-bottom: 16px; +} + +/* Info message */ +.info-message { + background: #e0e7ff; + color: #3730a3; + padding: 16px; + border-radius: 8px; + margin: 16px 0; +} + +/* Success message */ +.success-message { + background: var(--success-bg); + color: #166534; + padding: 16px; + border-radius: 8px; + margin: 16px 0; + display: flex; + align-items: center; + gap: 12px; +} + +/* Color input styling */ +input[type="color"] { + border: 2px solid var(--border); + border-radius: 8px; + background: var(--bg); + cursor: pointer; +} + +/* Uninstall Link on Welcome */ +.uninstall-link { + margin-top: 24px; +} + +.uninstall-link a { + color: var(--text-muted); + font-size: 13px; + text-decoration: none; + transition: color 0.2s; +} + +.uninstall-link a:hover { + color: var(--error); + text-decoration: underline; +} + +/* Uninstall Content */ +.uninstall-content { + max-width: 560px; + margin: 0 auto; + width: 100%; +} + +.uninstall-content h1 { + text-align: center; +} + +.uninstall-content > p { + text-align: center; +} + +.uninstall-content .step-footer { + margin-top: 32px; + padding: 0; + background: none; + border-top: none; +} + +/* Danger button */ +.btn-danger { + background: var(--error); + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-danger:disabled { + background: var(--border); + color: var(--text-muted); + cursor: not-allowed; +} + +/* Detecting spinner */ +.detecting-box { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + background: var(--bg); + border-radius: 8px; + margin-bottom: 16px; + color: var(--text-muted); + font-size: 14px; +} + +/* Path input row */ +.path-input-row { + display: flex; + gap: 8px; +} + +.path-input-row .input { + flex: 1; + padding: 12px 16px; + border: 2px solid var(--border); + border-radius: 8px; + font-size: 14px; + background: var(--bg); + color: var(--text); + outline: none; + transition: border-color 0.2s; +} + +.path-input-row .input:focus { + border-color: var(--primary); +} + +/* Detection badges */ +.detected-badge { + display: inline-block; + margin-top: 6px; + padding: 4px 12px; + background: var(--success-bg); + color: #166534; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.not-detected-badge { + display: inline-block; + margin-top: 6px; + padding: 4px 12px; + background: var(--error-bg); + color: var(--error); + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +/* Preserve options */ +.preserve-options { + margin-top: 24px; + background: var(--bg); + border-radius: 12px; + padding: 20px; +} + +.preserve-options h3 { + font-size: 14px; + color: var(--text); + margin-bottom: 4px; +} + +.preserve-description { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 16px; +} + +.checkbox-row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + cursor: pointer; + border-bottom: 1px solid var(--border); +} + +.checkbox-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.checkbox-row input[type="checkbox"] { + width: 18px; + height: 18px; + margin-top: 2px; + accent-color: var(--primary); + flex-shrink: 0; +} + +.checkbox-row strong { + display: block; + font-size: 14px; + color: var(--text); +} + +.checkbox-hint { + display: block; + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; + line-height: 1.4; +} + +/* Uninstall progress */ +.uninstall-content .progress-container { + margin: 24px 0; +} + +.uninstall-content .progress-bar { + width: 100%; + height: 12px; + background: var(--border); + border-radius: 6px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary) 0%, var(--primary-light) 100%); + border-radius: 6px; + transition: width 0.3s ease; +} + +.current-task { + text-align: center; + color: var(--text-muted); + font-size: 14px; + margin-top: 8px; +} + +.completed-tasks { + margin-top: 20px; + background: var(--bg); + border-radius: 8px; + padding: 16px; +} + +.task-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + font-size: 13px; + color: var(--text); +} + +.task-item.completed .task-check { + color: var(--success); + font-weight: bold; +} + +/* Preserve note */ +.preserve-note { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + margin-top: 12px; + margin-bottom: 0; +} + +/* Responsive */ +@media (max-width: 768px) { + .tier-cards { + flex-direction: column; + } + + .installer-container { + margin: 10px; + width: calc(100% - 20px); + } + + .step-content { + padding: 24px; + } + + .step-footer { + padding: 16px 24px; + } + + .step-label { + display: none !important; + } + + .step-connector { + width: 24px; + } +} diff --git a/dashcaddy-installer/src/renderer/wizard.js b/dashcaddy-installer/src/renderer/wizard.js new file mode 100644 index 0000000..9af7a08 --- /dev/null +++ b/dashcaddy-installer/src/renderer/wizard.js @@ -0,0 +1,1414 @@ +/** + * DashCaddy Installer Wizard + * A step-by-step installation wizard for DashCaddy + */ + +// Wizard State +const state = { + currentStep: 0, + platform: null, + paths: { + install: '', + dockerData: '', + caddyConfig: '' + }, + dependencies: { + docker: { installed: false, version: null, checking: true }, + caddy: { installed: false, version: null, checking: true } + }, + // Tier selection + tier: 'intermediate', + // DNS configuration + dns: { + enabled: false, + serverUrl: '', + username: '', + password: '', + token: '', + testResult: null + }, + // Dashboard branding + branding: { + name: 'DashCaddy', + title: 'DashCaddy Dashboard', + primaryColor: '#6366f1', + logoSourcePath: '', + dashboardPort: 8080, + apiPort: 3001 + }, + // Access mode + domainMode: 'local', // 'local' | 'public' | 'custom-tld' + domain: { + publicDomain: '', + tld: '.home', + email: '', + caName: 'DashCaddy Local CA' + }, + // Detected network IPs + network: { + lanIP: '', + tailscaleIP: '', + detected: false + }, + installation: { + status: 'pending', // pending, running, complete, error + progress: 0, + currentTask: '', + completedTasks: [], + error: null + }, + result: { + dashboardUrl: '', + installPath: '', + health: null + }, + // Uninstall mode + uninstallMode: false, + uninstall: { + step: 'confirm', // 'confirm' | 'progress' | 'complete' + installPath: '', + config: null, + detected: false, + detecting: false, + preserveSettings: true, + preserveCA: true, + status: 'pending', // pending, running, complete, error + progress: 0, + currentTask: '', + completedTasks: [], + error: null, + settingsPath: null + } +}; + +// Step definitions (8 steps) +const steps = [ + { id: 'welcome', title: 'Welcome' }, + { id: 'dependencies', title: 'Dependencies' }, + { id: 'folder', title: 'Install Path' }, + { id: 'tier', title: 'Tier' }, + { id: 'access', title: 'Access' }, + { id: 'dns', title: 'DNS' }, + { id: 'dashboard', title: 'Dashboard' }, + { id: 'install', title: 'Install' }, + { id: 'complete', title: 'Complete' } +]; + +// DOM Elements +let root; + +// Initialize wizard +async function initWizard() { + root = document.getElementById('root'); + + // Check platform + try { + const result = await window.electronAPI.checkPlatform(); + if (result.success) { + state.platform = result.data; + setDefaultPaths(); + } + } catch (err) { + console.error('Platform detection failed:', err); + } + + // Detect network IPs + try { + const netResult = await window.electronAPI.detectNetwork(); + if (netResult.success) { + state.network.lanIP = netResult.data.lanIP || ''; + state.network.tailscaleIP = netResult.data.tailscaleIP || ''; + state.network.detected = true; + } + } catch (err) { + console.warn('Network detection failed:', err); + } + + // Setup event listeners for installation progress + setupEventListeners(); + + // Render initial state + render(); +} + +// Set default paths based on platform +function setDefaultPaths() { + const os = state.platform?.os || 'windows'; + + if (os === 'windows') { + state.paths.install = 'C:\\DashCaddy'; + state.paths.dockerData = 'C:\\DashCaddy\\docker-data'; + state.paths.caddyConfig = 'C:\\DashCaddy\\caddy'; + } else if (os === 'macos') { + state.paths.install = '/Applications/DashCaddy'; + state.paths.dockerData = '/Applications/DashCaddy/docker-data'; + state.paths.caddyConfig = '/Applications/DashCaddy/caddy'; + } else { + state.paths.install = '/opt/dashcaddy'; + state.paths.dockerData = '/opt/dashcaddy/docker-data'; + state.paths.caddyConfig = '/opt/dashcaddy/caddy'; + } +} + +// Setup IPC event listeners +function setupEventListeners() { + window.electronAPI.onStepProgress((data) => { + state.installation.progress = data.progress; + state.installation.currentTask = data.task; + render(); + }); + + window.electronAPI.onStepComplete((data) => { + state.installation.completedTasks.push(data.step); + render(); + }); + + window.electronAPI.onStepError((data) => { + state.installation.status = 'error'; + state.installation.error = data.error; + render(); + }); + + window.electronAPI.onInstallationComplete((data) => { + state.installation.status = 'complete'; + state.result.dashboardUrl = data.dashboardUrl; + state.result.installPath = data.installPath; + state.result.health = data.health || null; + state.currentStep = 8; // Move to complete step + render(); + }); + + // Uninstall progress listeners + window.electronAPI.onUninstallProgress((data) => { + state.uninstall.progress = data.progress; + state.uninstall.currentTask = data.task; + render(); + }); + + window.electronAPI.onUninstallStepComplete((data) => { + state.uninstall.completedTasks.push(data.step); + render(); + }); + + window.electronAPI.onUninstallComplete((data) => { + state.uninstall.status = 'complete'; + state.uninstall.step = 'complete'; + state.uninstall.settingsPath = data.settingsPath || null; + render(); + }); + + window.electronAPI.onUninstallError((data) => { + state.uninstall.status = 'error'; + state.uninstall.error = data.error; + render(); + }); +} + +// Navigation +function nextStep() { + if (state.currentStep < steps.length - 1) { + state.currentStep++; + + // Trigger actions on step enter + switch (state.currentStep) { + case 1: // Dependencies + checkDependencies(); + break; + case 7: // Installation + startInstallation(); + break; + } + + render(); + } +} + +function prevStep() { + if (state.currentStep > 0) { + state.currentStep--; + render(); + } +} + +// Check dependencies +async function checkDependencies() { + state.dependencies.docker.checking = true; + state.dependencies.caddy.checking = true; + render(); + + try { + // Pass user-configured caddy path so the checker can find caddy.exe there + const caddySearchPaths = []; + if (state.paths.caddyConfig) { + caddySearchPaths.push(state.paths.caddyConfig); + } + if (state.paths.install) { + const sep = state.platform?.os === 'windows' ? '\\' : '/'; + caddySearchPaths.push(state.paths.install + sep + 'caddy'); + caddySearchPaths.push(state.paths.install); + } + + const result = await window.electronAPI.checkDependencies({ caddySearchPaths }); + if (result.success) { + state.dependencies.docker = { + installed: result.data.docker.installed, + version: result.data.docker.version, + checking: false + }; + state.dependencies.caddy = { + installed: result.data.caddy.installed, + version: result.data.caddy.version, + checking: false + }; + } + } catch (err) { + console.error('Dependency check failed:', err); + state.dependencies.docker.checking = false; + state.dependencies.caddy.checking = false; + } + + render(); +} + +// Install dependency +async function installDependency(type) { + state.dependencies[type].checking = true; + render(); + + try { + if (type === 'docker') { + await window.electronAPI.installDocker(); + } else { + await window.electronAPI.installCaddy({ + targetPath: state.paths.caddyConfig || null + }); + } + await checkDependencies(); + } catch (err) { + console.error(`${type} installation failed:`, err); + state.dependencies[type].checking = false; + render(); + } +} + +// Select folder +async function selectFolder(pathKey) { + try { + const result = await window.electronAPI.selectFolder(); + if (result.success && result.path) { + state.paths[pathKey] = result.path; + + // Auto-set child paths if main install path changes + if (pathKey === 'install') { + const sep = state.platform?.os === 'windows' ? '\\' : '/'; + state.paths.dockerData = result.path + sep + 'docker-data'; + state.paths.caddyConfig = result.path + sep + 'caddy'; + } + + render(); + } + } catch (err) { + console.error('Folder selection failed:', err); + } +} + +// Tier selection +function selectTier(tierId) { + state.tier = tierId; + // Reset DNS if downgrading from advanced + if (tierId !== 'advanced') { + state.dns.enabled = false; + } + render(); +} + +// Access mode functions +function selectDomainMode(mode) { + state.domainMode = mode; + render(); +} + +function updateDomain(field, value) { + state.domain[field] = value; + if (field === 'tld' && value && !value.startsWith('.')) { + state.domain.tld = '.' + value; + } +} + +// DNS functions +function toggleDNS(enabled) { + state.dns.enabled = enabled; + render(); +} + +function updateDNS(field, value) { + state.dns[field] = value; +} + +async function testDNSConnection() { + state.dns.testResult = null; + render(); + + try { + const result = await window.electronAPI.testDNSConnection({ + server: state.dns.serverUrl, + username: state.dns.username, + password: state.dns.password, + token: state.dns.token + }); + state.dns.testResult = { + success: result.success && result.data?.connected, + message: result.data?.message || (result.success ? 'Connected' : result.error) + }; + } catch (err) { + state.dns.testResult = { success: false, message: err.message }; + } + render(); +} + +// Branding functions +function updateBranding(field, value) { + state.branding[field] = value; + // Re-render for color to sync both inputs + if (field === 'primaryColor') render(); +} + +async function selectLogo() { + try { + const result = await window.electronAPI.selectFile({ + title: 'Select Dashboard Logo', + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'svg'] }] + }); + if (result.success && result.path) { + state.branding.logoSourcePath = result.path; + render(); + } + } catch (err) { + console.error('Logo selection failed:', err); + } +} + +// Start installation +async function startInstallation() { + state.installation.status = 'running'; + state.installation.progress = 0; + state.installation.completedTasks = []; + state.installation.error = null; + render(); + + try { + await window.electronAPI.runInstallation({ + installPath: state.paths.install, + dockerDataPath: state.paths.dockerData, + caddyConfigPath: state.paths.caddyConfig, + tier: state.tier, + dashboardPort: state.branding.dashboardPort, + apiPort: state.branding.apiPort, + domainMode: state.domainMode, + domain: state.domain, + lanIP: state.network.lanIP, + tailscaleIP: state.network.tailscaleIP, + branding: { + name: state.branding.name, + title: state.branding.title, + primaryColor: state.branding.primaryColor, + logoSourcePath: state.branding.logoSourcePath || null + }, + dns: state.dns.enabled ? { + server: state.dns.serverUrl, + username: state.dns.username, + password: state.dns.password, + token: state.dns.token + } : null, + autoStart: true + }); + } catch (err) { + state.installation.status = 'error'; + state.installation.error = err.message; + render(); + } +} + +// Open dashboard +async function openDashboard() { + try { + await window.electronAPI.openDashboard(state.branding.dashboardPort || 8080); + } catch (err) { + console.error('Failed to open dashboard:', err); + } +} + +// Render functions +function render() { + root.innerHTML = renderContainer(); +} + +function renderContainer() { + if (state.uninstallMode) { + return ` +
+
+ ${renderUninstallView()} +
+
+ `; + } + + return ` +
+ ${renderStepIndicator()} +
+ ${renderCurrentStep()} +
+ ${renderFooter()} +
+ `; +} + +function renderStepIndicator() { + return ` +
+ ${steps.map((step, index) => ` +
+
${index < state.currentStep ? '\u2713' : index + 1}
+ ${step.title} +
+ ${index < steps.length - 1 ? '
' : ''} + `).join('')} +
+ `; +} + +function renderCurrentStep() { + switch (state.currentStep) { + case 0: return renderWelcome(); + case 1: return renderDependencies(); + case 2: return renderFolderSelection(); + case 3: return renderTierSelection(); + case 4: return renderAccessMode(); + case 5: return renderDNSConfiguration(); + case 6: return renderDashboardSetup(); + case 7: return renderInstallation(); + case 8: return renderComplete(); + default: return ''; + } +} + +function renderWelcome() { + const platformName = { + 'windows': 'Windows', + 'macos': 'macOS', + 'linux': 'Linux' + }[state.platform?.os] || 'Unknown'; + + return ` +
+
+ DashCaddy Logo +
+

Welcome to DashCaddy

+

The unified management platform for your home lab. This wizard will guide you through the installation process.

+ +
    +
  • Manage all your Docker containers from one dashboard
  • +
  • Automatic HTTPS with Caddy reverse proxy
  • +
  • DNS integration for local domains
  • +
  • 50+ pre-configured app templates
  • +
  • Real-time service health monitoring
  • +
+ +
+ Detected Platform: + ${platformName} ${state.platform?.arch || ''} +
+ + +
+ `; +} + +function renderDependencies() { + const docker = state.dependencies.docker; + const caddy = state.dependencies.caddy; + + return ` +
+

System Requirements

+

DashCaddy requires Docker and Caddy to be installed. We'll check and install them for you.

+ +
+
+
D
+
+

Docker

+

${docker.checking ? 'Checking...' : docker.installed ? `Version ${docker.version}` : 'Not installed'}

+
+
+ ${docker.checking ? + '
' : + docker.installed ? + '' : + `` + } +
+
+ +
+
C
+
+

Caddy

+

${caddy.checking ? 'Checking...' : caddy.installed ? `Version ${caddy.version}` : 'Not installed'}

+
+
+ ${caddy.checking ? + '
' : + caddy.installed ? + '' : + `` + } +
+
+
+ + ${!docker.checking && !caddy.checking && (!docker.installed || !caddy.installed) ? ` +
+ Please install the missing dependencies before continuing. +
+ ` : ''} +
+ `; +} + +function renderFolderSelection() { + return ` +
+

Choose Installation Folders

+

Select where DashCaddy and its components should be installed.

+ +
+
+ +
+ + +
+

Main folder where DashCaddy will be installed

+
+ +
+ +
+ + +
+

Where Docker containers will store their data (volumes)

+
+ +
+ +
+ + +
+

Where Caddyfile and SSL certificates will be stored

+
+
+
+ `; +} + +function renderTierSelection() { + const tiers = [ + { + id: 'basic', + name: 'Basic', + description: 'Caddy serves the DashCaddy dashboard as a static site.', + includes: ['Caddy reverse proxy', 'Dashboard UI', 'Static file serving'] + }, + { + id: 'intermediate', + name: 'Standard', + description: 'Adds the DashCaddy API server running in Docker for container management.', + includes: ['Everything in Basic', 'Docker API server', 'Container management', '50+ app templates'], + recommended: true + }, + { + id: 'advanced', + name: 'Full Stack', + description: 'Complete setup with DNS management via Technitium DNS integration.', + includes: ['Everything in Standard', 'DNS management', 'Automatic DNS records', 'Local domain support'] + } + ]; + + return ` +
+

Choose Deployment Tier

+

Select the level of functionality you need. You can upgrade later.

+ +
+ ${tiers.map(tier => ` +
+ ${tier.recommended ? '
Recommended
' : ''} +
+

${tier.name}

+
+

${tier.description}

+
    + ${tier.includes.map(f => `
  • ${f}
  • `).join('')} +
+
+ `).join('')} +
+
+ `; +} + +function renderAccessMode() { + const modes = [ + { + id: 'local', + name: 'Local Only', + icon: '127', + description: 'Access your dashboard via IP address or localhost. No domain names or HTTPS required.', + note: 'Best for: Testing, single-machine use, simple setups' + }, + { + id: 'public', + name: 'Public Domain', + icon: 'WWW', + description: 'Use a real domain name with automatic Let\'s Encrypt HTTPS certificates.', + note: 'Requires: Ports 80 + 443 open, DNS pointing to this server' + }, + { + id: 'custom-tld', + name: 'Custom TLD', + icon: 'CA', + description: 'Use a custom top-level domain like .home or .lab with an internal Certificate Authority.', + note: 'Requires: A local DNS server (configured in the next step)', + badge: 'Expert' + } + ]; + + return ` +
+

How will you access DashCaddy?

+

Choose how you want to reach your dashboard and services on the network.

+ +
+ ${modes.map(mode => ` +
+ ${mode.badge ? `
${mode.badge}
` : ''} +
${mode.icon}
+
+

${mode.name}

+
+

${mode.description}

+

${mode.note}

+
+ `).join('')} +
+ + ${state.domainMode === 'public' ? ` +
+
+ +
+ +
+

The domain where your dashboard will be accessible

+
+
+ +
+ +
+

Used for certificate expiry notifications (required by Let's Encrypt)

+
+
+ ` : ''} + + ${state.domainMode === 'custom-tld' ? ` +
+
+ +
+ +
+

Your dashboard will be at dashcaddy${escapeHtml(state.domain.tld)}, services at <name>${escapeHtml(state.domain.tld)}

+
+
+ +
+ +
+

Name for your internal Certificate Authority

+
+
+ Caddy will run an internal Certificate Authority to issue HTTPS certificates for your ${escapeHtml(state.domain.tld)} domains. You'll need a local DNS server to resolve these names — configure it in the next step. +
+
+ ` : ''} + + ${state.network.detected && (state.network.lanIP || state.network.tailscaleIP) ? ` +
+ Detected Network: + ${state.network.lanIP ? `LAN: ${escapeHtml(state.network.lanIP)}` : ''} + ${state.network.lanIP && state.network.tailscaleIP ? ' | ' : ''} + ${state.network.tailscaleIP ? `Tailscale: ${escapeHtml(state.network.tailscaleIP)}` : ''} +
+ ` : ''} +
+ `; +} + +function renderDNSConfiguration() { + // Local mode: DNS not needed + if (state.domainMode === 'local') { + return ` +
+

DNS Configuration

+
+ DNS is not needed for local/IP-based access. You can skip this step. +
+

If you add a DNS server later, you can configure it from the DashCaddy dashboard.

+
+ `; + } + + // Public domain: DNS managed externally + if (state.domainMode === 'public') { + return ` +
+

DNS Configuration

+
+ Your domain's DNS is managed by your registrar or DNS provider. + Make sure ${escapeHtml(state.domain.publicDomain)} points to this server's IP address. +
+

DashCaddy can optionally manage a local DNS server for additional services. You can configure this later.

+
+ `; + } + + // Custom TLD but not Full Stack tier + if (state.domainMode === 'custom-tld' && state.tier !== 'advanced') { + return ` +
+

DNS Configuration

+
+ Your custom TLD ${escapeHtml(state.domain.tld)} requires a DNS server to resolve domain names. + DNS management is available with the Full Stack tier — you selected ${state.tier === 'basic' ? 'Basic' : 'Standard'}. +
+

You'll need to manually configure your DNS server to resolve *${escapeHtml(state.domain.tld)} domains to this machine's IP address.

+
+ `; + } + + // Custom TLD + Full Stack tier: full DNS form + return ` +
+

DNS Configuration

+

Configure your Technitium DNS server connection for automatic DNS record management.

+ +
+ +
+ + ${state.dns.enabled ? ` +
+
+ +
+ +
+

Technitium DNS server address including port

+
+ +
+ +
+ +
+
+ +
+ +
+ +
+

Stored encrypted on disk

+
+ +
+ +
+ +
+

Alternative to username/password authentication

+
+ + + + ${state.dns.testResult !== null ? ` +
+ ${escapeHtml(state.dns.testResult.message)} +
+ ` : ''} +
+ ` : ''} +
+ `; +} + +function renderDashboardSetup() { + return ` +
+

Dashboard Setup

+

Customize your DashCaddy dashboard appearance and ports.

+ +
+
+ +
+ +
+

Displayed in the dashboard header

+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+

PNG or SVG, recommended 200x120px. Leave empty for default logo.

+
+ +
+ +
+ +
+

Port where the dashboard will be accessible (default: 8080)

+
+ + ${state.tier !== 'basic' ? ` +
+ +
+ +
+

Port for the DashCaddy API server (default: 3001)

+
+ ` : ''} +
+
+ `; +} + +function renderInstallation() { + const { status, progress, currentTask, completedTasks, error } = state.installation; + + if (error) { + return ` +
+

Installation Failed

+
${escapeHtml(error)}
+ +
+ `; + } + + return ` +
+

Installing DashCaddy

+

Please wait while we set up DashCaddy on your system.

+ +
${progress}%
+ +
+
+
+
+

${currentTask || 'Starting installation...'}

+
+ +
+ ${completedTasks.map(task => ` +
✓ ${escapeHtml(task)}
+ `).join('')} + ${currentTask && !completedTasks.includes(currentTask) ? ` +
+
+ ${escapeHtml(currentTask)} +
+ ` : ''} +
+
+ `; +} + +function renderComplete() { + const port = state.branding.dashboardPort || 8080; + const tierLabels = { basic: 'Basic', intermediate: 'Standard', advanced: 'Full Stack' }; + const modeLabels = { 'local': 'Local/IP', 'public': 'Public Domain', 'custom-tld': 'Custom TLD' }; + + return ` +
+
+

Installation Complete!

+

${escapeHtml(state.branding.name)} has been successfully installed on your system.

+ +
+

Installation Details

+
+ Tier + ${tierLabels[state.tier] || state.tier} +
+
+ Access Mode + ${modeLabels[state.domainMode] || state.domainMode}${state.domainMode === 'custom-tld' ? ' (' + escapeHtml(state.domain.tld) + ')' : ''} +
+
+ Dashboard URL + ${escapeHtml(state.result.dashboardUrl || 'http://localhost:' + port)} +
+
+ Install Path + ${escapeHtml(state.result.installPath || state.paths.install)} +
+ ${state.tier !== 'basic' ? ` +
+ API Server + http://localhost:${state.branding.apiPort || 3001} +
+ ` : ''} + ${state.dns.enabled ? ` +
+ DNS Server + ${escapeHtml(state.dns.serverUrl)} +
+ ` : ''} +
+ + ${state.result.health ? ` +
+

Service Status

+
+ Caddy + ${state.result.health.caddy ? 'Running' : 'Not responding'} +
+
+ ` : ''} + + +
+ `; +} + +function renderFooter() { + const isFirst = state.currentStep === 0; + const isLast = state.currentStep === steps.length - 1; + const isInstalling = state.currentStep === 7 && state.installation.status === 'running'; + + // Determine if user can proceed + let canProceed = true; + switch (state.currentStep) { + case 1: // Dependencies + canProceed = state.dependencies.docker.installed && state.dependencies.caddy.installed; + break; + case 2: // Folder + canProceed = !!state.paths.install; + break; + case 4: // Access mode + if (state.domainMode === 'public') { + canProceed = !!state.domain.publicDomain && !!state.domain.email; + } else if (state.domainMode === 'custom-tld') { + canProceed = !!state.domain.tld; + } + break; + } + + if (isLast) { + return ` + + `; + } + + if (isInstalling) { + return ` + + `; + } + + // Button text + let nextLabel = 'Next'; + if (state.currentStep === 6) nextLabel = 'Install'; + + return ` + + `; +} + +// ── Uninstall Mode ──────────────────────────────────────────────────────── + +async function enterUninstallMode() { + state.uninstallMode = true; + state.uninstall.detecting = true; + state.uninstall.step = 'confirm'; + render(); + + // Try to auto-detect existing installation + try { + const result = await window.electronAPI.detectInstallation(); + if (result.success && result.data.found) { + state.uninstall.installPath = result.data.installPath; + state.uninstall.config = result.data.config; + state.uninstall.detected = true; + } + } catch (err) { + console.warn('Installation detection failed:', err); + } + + state.uninstall.detecting = false; + render(); +} + +function exitUninstallMode() { + state.uninstallMode = false; + // Reset uninstall state + state.uninstall = { + step: 'confirm', + installPath: '', + config: null, + detected: false, + detecting: false, + preserveSettings: true, + preserveCA: true, + status: 'pending', + progress: 0, + currentTask: '', + completedTasks: [], + error: null, + settingsPath: null + }; + render(); +} + +async function selectUninstallPath() { + try { + const result = await window.electronAPI.selectFolder(); + if (result.success && result.path) { + state.uninstall.installPath = result.path; + state.uninstall.detected = false; + + // Try to load config from selected path + const configResult = await window.electronAPI.loadConfig(result.path); + if (configResult.success && configResult.data?.config) { + state.uninstall.config = configResult.data.config; + state.uninstall.detected = true; + } else { + state.uninstall.config = null; + } + render(); + } + } catch (err) { + console.error('Folder selection failed:', err); + } +} + +function togglePreserveSettings(checked) { + state.uninstall.preserveSettings = checked; + render(); +} + +function togglePreserveCA(checked) { + state.uninstall.preserveCA = checked; + render(); +} + +async function startUninstallation() { + state.uninstall.status = 'running'; + state.uninstall.step = 'progress'; + state.uninstall.progress = 0; + state.uninstall.completedTasks = []; + state.uninstall.error = null; + render(); + + try { + await window.electronAPI.runUninstallation({ + installPath: state.uninstall.installPath, + preserveSettings: state.uninstall.preserveSettings, + preserveCA: state.uninstall.preserveCA + }); + } catch (err) { + state.uninstall.status = 'error'; + state.uninstall.error = err.message; + render(); + } +} + +function renderUninstallView() { + switch (state.uninstall.step) { + case 'confirm': return renderUninstallConfirm(); + case 'progress': return renderUninstallProgress(); + case 'complete': return renderUninstallComplete(); + default: return ''; + } +} + +function renderUninstallConfirm() { + const u = state.uninstall; + const tierLabels = { basic: 'Basic', intermediate: 'Standard', advanced: 'Full Stack' }; + + return ` +
+

Uninstall DashCaddy

+

This will remove DashCaddy from your system. Services will be stopped and files removed.

+ + ${u.detecting ? ` +
+
+ Searching for existing installation... +
+ ` : ''} + +
+ +
+ + +
+ ${u.detected ? ` +
Installation detected
+ ` : u.installPath && !u.detecting ? ` +
No installation found at this path
+ ` : ''} +
+ + ${u.config ? ` +
+

Detected Installation

+
+ Tier + ${tierLabels[u.config.tier] || u.config.tier || 'Unknown'} +
+ ${u.config.domainMode ? ` +
+ Domain Mode + ${u.config.domainMode}${u.config.tld ? ' (' + escapeHtml(u.config.tld) + ')' : ''} +
+ ` : ''} + ${u.config.installedAt ? ` +
+ Installed + ${new Date(u.config.installedAt).toLocaleDateString()} +
+ ` : ''} +
+ ` : ''} + +
+

Preserve for Reinstall

+

Keep these files so a fresh install can pick up where you left off.

+ + + + +
+ + +
+ `; +} + +function renderUninstallProgress() { + const u = state.uninstall; + + return ` +
+

Uninstalling DashCaddy

+

Please wait while DashCaddy is being removed...

+ +
+
+
+
+
${u.progress}%
+
+ +
${escapeHtml(u.currentTask)}
+ + ${u.completedTasks.length > 0 ? ` +
+ ${u.completedTasks.map(task => ` +
+ + ${escapeHtml(task)} +
+ `).join('')} +
+ ` : ''} + + ${u.status === 'error' ? ` +
+ Error: ${escapeHtml(u.error)} +
+ + ` : ''} +
+ `; +} + +function renderUninstallComplete() { + const u = state.uninstall; + const preserved = []; + if (u.preserveSettings) preserved.push('user settings'); + if (u.preserveCA) preserved.push('CA certificates'); + + return ` +
+
+

Uninstall Complete

+

DashCaddy has been removed from your system.

+ + ${preserved.length > 0 ? ` +
+

Preserved Data

+
+ Saved + ${preserved.join(', ')} +
+ ${u.settingsPath ? ` +
+ Location + ${escapeHtml(u.settingsPath)} +
+ ` : ''} +

These will be automatically restored when you reinstall to the same path.

+
+ ` : ''} + + +
+ `; +} + +// Utility functions +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Initialize on DOM ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initWizard); +} else { + initWizard(); +} diff --git a/dashcaddy-installer/src/shared/constants.js b/dashcaddy-installer/src/shared/constants.js new file mode 100644 index 0000000..a53bb26 --- /dev/null +++ b/dashcaddy-installer/src/shared/constants.js @@ -0,0 +1,51 @@ +// Installation tiers +const TIERS = { + BASIC: 'basic', + INTERMEDIATE: 'intermediate', + ADVANCED: 'advanced' +}; + +// Default installation paths by platform +const DEFAULT_PATHS = { + win32: 'C:\\Program Files\\DashCaddy', + darwin: '/Applications/DashCaddy', + linux: '/opt/dashcaddy' +}; + +// Required subdirectories (matches production layout) +const REQUIRED_DIRS = ['sites', 'sites/status', 'sites/status/assets', 'sites/dashcaddy-api']; + +// Default ports +const DEFAULT_PORTS = { + API: 3001, + CADDY_ADMIN: 2019 +}; + +// Error categories +const ERROR_CATEGORIES = { + CRITICAL: 'critical', + RECOVERABLE: 'recoverable', + NON_CRITICAL: 'non-critical', + USER_ERROR: 'user-error' +}; + +// Installation steps +const INSTALLATION_STEPS = [ + { id: 'welcome', label: 'Welcome' }, + { id: 'dependencies', label: 'Dependencies' }, + { id: 'folder', label: 'Installation Path' }, + { id: 'tier', label: 'Deployment Tier' }, + { id: 'dns', label: 'DNS Configuration' }, + { id: 'dashboard', label: 'Dashboard Setup' }, + { id: 'install', label: 'Installation' }, + { id: 'complete', label: 'Complete' } +]; + +module.exports = { + TIERS, + DEFAULT_PATHS, + REQUIRED_DIRS, + DEFAULT_PORTS, + ERROR_CATEGORIES, + INSTALLATION_STEPS +}; diff --git a/dashcaddy-installer/src/shared/platform-utils.js b/dashcaddy-installer/src/shared/platform-utils.js new file mode 100644 index 0000000..726f87a --- /dev/null +++ b/dashcaddy-installer/src/shared/platform-utils.js @@ -0,0 +1,174 @@ +const os = require('os'); +const path = require('path'); + +/** + * Detects the current operating system + * @returns {string} 'windows', 'macos', or 'linux' + */ +function detectOS() { + const platform = process.platform; + + switch (platform) { + case 'win32': + return 'windows'; + case 'darwin': + return 'macos'; + case 'linux': + return 'linux'; + default: + return 'unknown'; + } +} + +/** + * Detects the system architecture + * @returns {string} 'x64', 'arm64', etc. + */ +function detectArchitecture() { + return process.arch; +} + +/** + * Detects the appropriate shell for the platform + * @returns {string} 'powershell', 'bash', or 'zsh' + */ +function detectShell() { + const platform = process.platform; + + if (platform === 'win32') { + return 'powershell'; + } + + // Check for zsh on macOS (default since Catalina) + if (platform === 'darwin') { + const shell = process.env.SHELL || ''; + if (shell.includes('zsh')) { + return 'zsh'; + } + return 'bash'; + } + + // Linux typically uses bash + return 'bash'; +} + +/** + * Gets the default installation path for the current platform + * @returns {string} Default installation path + */ +function getDefaultInstallPath() { + const platform = process.platform; + + switch (platform) { + case 'win32': + return 'C:\\Program Files\\DashCaddy'; + case 'darwin': + return '/Applications/DashCaddy'; + case 'linux': + // Use /opt for system-wide or home directory for user install + return '/opt/dashcaddy'; + default: + return path.join(os.homedir(), 'DashCaddy'); + } +} + +/** + * Gets the user's home directory installation path + * @returns {string} User home installation path + */ +function getUserInstallPath() { + return path.join(os.homedir(), 'DashCaddy'); +} + +/** + * Normalizes a file path for the current platform + * @param {string} filePath - Path to normalize + * @returns {string} Normalized path + */ +function normalizePath(filePath) { + return path.normalize(filePath); +} + +/** + * Joins path segments using the correct separator for the platform + * @param {...string} segments - Path segments to join + * @returns {string} Joined path + */ +function joinPath(...segments) { + return path.join(...segments); +} + +/** + * Gets platform-specific information + * @returns {Object} Platform information + */ +function getPlatformInfo() { + return { + os: detectOS(), + arch: detectArchitecture(), + shell: detectShell(), + platform: process.platform, + hostname: os.hostname(), + homedir: os.homedir(), + defaultInstallPath: getDefaultInstallPath(), + userInstallPath: getUserInstallPath() + }; +} + +/** + * Checks if running on Windows + * @returns {boolean} + */ +function isWindows() { + return process.platform === 'win32'; +} + +/** + * Checks if running on macOS + * @returns {boolean} + */ +function isMacOS() { + return process.platform === 'darwin'; +} + +/** + * Checks if running on Linux + * @returns {boolean} + */ +function isLinux() { + return process.platform === 'linux'; +} + +/** + * Gets the command to execute based on platform + * @param {string} command - Base command + * @returns {Object} Command execution details + */ +function getCommandExecution(command) { + if (isWindows()) { + return { + shell: 'powershell.exe', + args: ['-Command', command] + }; + } + + return { + shell: detectShell(), + args: ['-c', command] + }; +} + +module.exports = { + detectOS, + detectArchitecture, + detectShell, + getDefaultInstallPath, + getUserInstallPath, + normalizePath, + joinPath, + getPlatformInfo, + isWindows, + isMacOS, + isLinux, + getCommandExecution +}; diff --git a/dashcaddy-installer/src/shared/platform-utils.property.test.js b/dashcaddy-installer/src/shared/platform-utils.property.test.js new file mode 100644 index 0000000..26d16e6 --- /dev/null +++ b/dashcaddy-installer/src/shared/platform-utils.property.test.js @@ -0,0 +1,289 @@ +const fc = require('fast-check'); +const platformUtils = require('./platform-utils'); + +/** + * Feature: dashcaddy-installer, Property 1: OS Detection Accuracy + * For any supported platform (Windows, macOS, Linux), the installer should + * correctly detect the operating system and architecture. + * Validates: Requirements 1.1, 14.1, 14.2, 14.3 + */ +describe('Property 1: OS Detection Accuracy', () => { + test('detectOS returns valid OS for any platform value', () => { + fc.assert( + fc.property( + fc.constantFrom('win32', 'darwin', 'linux', 'freebsd', 'openbsd', 'sunos', 'aix'), + (platform) => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + + const detectedOS = platformUtils.detectOS(); + const validOSes = ['windows', 'macos', 'linux', 'unknown']; + const isValid = validOSes.includes(detectedOS); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + + return isValid; + } + ), + { numRuns: 100 } + ); + }); + + test('detectOS maps supported platforms correctly', () => { + const platformMappings = [ + { platform: 'win32', expected: 'windows' }, + { platform: 'darwin', expected: 'macos' }, + { platform: 'linux', expected: 'linux' } + ]; + + platformMappings.forEach(({ platform, expected }) => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + + expect(platformUtils.detectOS()).toBe(expected); + + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + }); + + test('detectArchitecture always returns a non-empty string', () => { + fc.assert( + fc.property( + fc.constant(null), // We don't need to generate anything, just run the test + () => { + const arch = platformUtils.detectArchitecture(); + return typeof arch === 'string' && arch.length > 0; + } + ), + { numRuns: 100 } + ); + }); + + test('getPlatformInfo returns consistent data structure', () => { + fc.assert( + fc.property( + fc.constant(null), + () => { + const info = platformUtils.getPlatformInfo(); + + // Check all required fields exist + const hasAllFields = + typeof info.os === 'string' && + typeof info.arch === 'string' && + typeof info.shell === 'string' && + typeof info.platform === 'string' && + typeof info.hostname === 'string' && + typeof info.homedir === 'string' && + typeof info.defaultInstallPath === 'string' && + typeof info.userInstallPath === 'string'; + + // Check that OS is valid + const validOSes = ['windows', 'macos', 'linux', 'unknown']; + const hasValidOS = validOSes.includes(info.os); + + // Check that shell is valid + const validShells = ['powershell', 'bash', 'zsh']; + const hasValidShell = validShells.includes(info.shell); + + return hasAllFields && hasValidOS && hasValidShell; + } + ), + { numRuns: 100 } + ); + }); + + test('platform detection is deterministic', () => { + fc.assert( + fc.property( + fc.constant(null), + () => { + const info1 = platformUtils.getPlatformInfo(); + const info2 = platformUtils.getPlatformInfo(); + + // Same platform should return same results + return ( + info1.os === info2.os && + info1.arch === info2.arch && + info1.shell === info2.shell && + info1.platform === info2.platform + ); + } + ), + { numRuns: 100 } + ); + }); +}); + +/** + * Feature: dashcaddy-installer, Property 18: Platform-Specific Path Handling + * For any detected operating system, the installer should use the correct + * default installation path and path separator for that platform. + * Validates: Requirements 2.1, 14.1, 14.2, 14.3, 14.6 + */ +describe('Property 18: Platform-Specific Path Handling', () => { + test('getDefaultInstallPath returns platform-appropriate paths', () => { + const testCases = [ + { platform: 'win32', shouldContain: 'C:\\', shouldNotContain: '/' }, + { platform: 'darwin', shouldContain: '/Applications', shouldNotContain: '\\' }, + { platform: 'linux', shouldContain: '/opt', shouldNotContain: '\\' } + ]; + + testCases.forEach(({ platform, shouldContain, shouldNotContain }) => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + + const path = platformUtils.getDefaultInstallPath(); + expect(path).toContain(shouldContain); + if (shouldNotContain) { + expect(path).not.toContain(shouldNotContain); + } + + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + }); + + test('normalizePath handles any path string', () => { + fc.assert( + fc.property( + fc.string(), + (pathStr) => { + try { + const normalized = platformUtils.normalizePath(pathStr); + return typeof normalized === 'string'; + } catch (error) { + // Some strings might be invalid paths, that's okay + return true; + } + } + ), + { numRuns: 100 } + ); + }); + + test('joinPath produces valid paths for any segments', () => { + fc.assert( + fc.property( + fc.array(fc.string({ minLength: 1, maxLength: 20 }), { minLength: 1, maxLength: 5 }), + (segments) => { + try { + const joined = platformUtils.joinPath(...segments); + return typeof joined === 'string' && joined.length > 0; + } catch (error) { + // Some combinations might be invalid, that's okay + return true; + } + } + ), + { numRuns: 100 } + ); + }); + + test('getUserInstallPath always includes DashCaddy', () => { + fc.assert( + fc.property( + fc.constant(null), + () => { + const userPath = platformUtils.getUserInstallPath(); + return userPath.includes('DashCaddy'); + } + ), + { numRuns: 100 } + ); + }); +}); + +/** + * Feature: dashcaddy-installer, Property 19: Platform-Specific Shell Selection + * For any detected operating system, the installer should use the appropriate + * shell (PowerShell on Windows, bash/zsh on Unix-like systems) for executing commands. + * Validates: Requirements 14.7 + */ +describe('Property 19: Platform-Specific Shell Selection', () => { + test('detectShell returns valid shell for any platform', () => { + fc.assert( + fc.property( + fc.constantFrom('win32', 'darwin', 'linux'), + (platform) => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + + const shell = platformUtils.detectShell(); + const validShells = ['powershell', 'bash', 'zsh']; + const isValid = validShells.includes(shell); + + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + + return isValid; + } + ), + { numRuns: 100 } + ); + }); + + test('Windows always uses PowerShell', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + + expect(platformUtils.detectShell()).toBe('powershell'); + + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + test('Unix-like systems use bash or zsh', () => { + const unixPlatforms = ['darwin', 'linux']; + + unixPlatforms.forEach(platform => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + + const shell = platformUtils.detectShell(); + expect(['bash', 'zsh']).toContain(shell); + + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + }); + + test('getCommandExecution returns valid structure for any command', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 100 }), + (command) => { + const exec = platformUtils.getCommandExecution(command); + + return ( + typeof exec === 'object' && + typeof exec.shell === 'string' && + Array.isArray(exec.args) && + exec.args.length > 0 && + exec.args.includes(command) + ); + } + ), + { numRuns: 100 } + ); + }); + + test('getCommandExecution uses correct shell for platform', () => { + const testCases = [ + { platform: 'win32', expectedShell: 'powershell.exe' }, + { platform: 'darwin', expectedShells: ['bash', 'zsh'] }, + { platform: 'linux', expectedShells: ['bash', 'zsh'] } + ]; + + testCases.forEach(({ platform, expectedShell, expectedShells }) => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + + const exec = platformUtils.getCommandExecution('test'); + + if (expectedShell) { + expect(exec.shell).toBe(expectedShell); + } else if (expectedShells) { + expect(expectedShells).toContain(exec.shell); + } + + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + }); +}); diff --git a/dashcaddy-installer/src/shared/platform-utils.test.js b/dashcaddy-installer/src/shared/platform-utils.test.js new file mode 100644 index 0000000..120c3c1 --- /dev/null +++ b/dashcaddy-installer/src/shared/platform-utils.test.js @@ -0,0 +1,195 @@ +const platformUtils = require('./platform-utils'); + +describe('Platform Detection', () => { + describe('detectOS', () => { + test('detects Windows correctly', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(platformUtils.detectOS()).toBe('windows'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('detects macOS correctly', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + expect(platformUtils.detectOS()).toBe('macos'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('detects Linux correctly', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + + expect(platformUtils.detectOS()).toBe('linux'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('returns unknown for unsupported platforms', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'freebsd' }); + + expect(platformUtils.detectOS()).toBe('unknown'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); + + describe('detectArchitecture', () => { + test('returns current architecture', () => { + const arch = platformUtils.detectArchitecture(); + expect(typeof arch).toBe('string'); + expect(arch.length).toBeGreaterThan(0); + }); + }); + + describe('detectShell', () => { + test('returns powershell for Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(platformUtils.detectShell()).toBe('powershell'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('returns bash or zsh for macOS', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + const shell = platformUtils.detectShell(); + expect(['bash', 'zsh']).toContain(shell); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('returns bash for Linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + + expect(platformUtils.detectShell()).toBe('bash'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); + + describe('getDefaultInstallPath', () => { + test('returns Windows path for Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(platformUtils.getDefaultInstallPath()).toBe('C:\\Program Files\\DashCaddy'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('returns macOS path for macOS', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + expect(platformUtils.getDefaultInstallPath()).toBe('/Applications/DashCaddy'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('returns Linux path for Linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + + expect(platformUtils.getDefaultInstallPath()).toBe('/opt/dashcaddy'); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); + + describe('Platform checks', () => { + test('isWindows returns boolean', () => { + expect(typeof platformUtils.isWindows()).toBe('boolean'); + }); + + test('isMacOS returns boolean', () => { + expect(typeof platformUtils.isMacOS()).toBe('boolean'); + }); + + test('isLinux returns boolean', () => { + expect(typeof platformUtils.isLinux()).toBe('boolean'); + }); + + test('exactly one platform check returns true', () => { + const checks = [ + platformUtils.isWindows(), + platformUtils.isMacOS(), + platformUtils.isLinux() + ]; + const trueCount = checks.filter(Boolean).length; + expect(trueCount).toBe(1); + }); + }); + + describe('getPlatformInfo', () => { + test('returns complete platform information', () => { + const info = platformUtils.getPlatformInfo(); + + expect(info).toHaveProperty('os'); + expect(info).toHaveProperty('arch'); + expect(info).toHaveProperty('shell'); + expect(info).toHaveProperty('platform'); + expect(info).toHaveProperty('hostname'); + expect(info).toHaveProperty('homedir'); + expect(info).toHaveProperty('defaultInstallPath'); + expect(info).toHaveProperty('userInstallPath'); + + expect(typeof info.os).toBe('string'); + expect(typeof info.arch).toBe('string'); + expect(typeof info.shell).toBe('string'); + expect(typeof info.hostname).toBe('string'); + expect(typeof info.homedir).toBe('string'); + }); + }); + + describe('Path utilities', () => { + test('normalizePath normalizes paths', () => { + const normalized = platformUtils.normalizePath('/path//to///file'); + expect(normalized).not.toContain('//'); + }); + + test('joinPath joins path segments', () => { + const joined = platformUtils.joinPath('path', 'to', 'file'); + expect(joined).toContain('path'); + expect(joined).toContain('file'); + }); + + test('getUserInstallPath returns path in home directory', () => { + const userPath = platformUtils.getUserInstallPath(); + expect(userPath).toContain('DashCaddy'); + }); + }); + + describe('getCommandExecution', () => { + test('returns PowerShell execution for Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const exec = platformUtils.getCommandExecution('test command'); + expect(exec.shell).toBe('powershell.exe'); + expect(exec.args).toEqual(['-Command', 'test command']); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test('returns shell execution for Unix-like systems', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + + const exec = platformUtils.getCommandExecution('test command'); + expect(['bash', 'zsh']).toContain(exec.shell); + expect(exec.args).toEqual(['-c', 'test command']); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); +}); diff --git a/dashcaddy-installer/templates/Caddyfile.template b/dashcaddy-installer/templates/Caddyfile.template new file mode 100644 index 0000000..7c4e62a --- /dev/null +++ b/dashcaddy-installer/templates/Caddyfile.template @@ -0,0 +1,23 @@ +# DashCaddy Caddyfile +# Generated by DashCaddy Installer + +# Global options +{ + admin localhost:2019 + auto_https off +} + +# Dashboard +:{{PORT}} { + root * {{DASHBOARD_PATH}} + file_server + encode gzip + + # API proxy + handle /api/* { + reverse_proxy localhost:{{API_PORT}} + } + + # SPA fallback + try_files {path} /index.html +} diff --git a/dashcaddy-installer/templates/README.md b/dashcaddy-installer/templates/README.md new file mode 100644 index 0000000..ec72309 --- /dev/null +++ b/dashcaddy-installer/templates/README.md @@ -0,0 +1,21 @@ +# DashCaddy Installer Templates + +This directory contains template files used by the installer to generate configuration files. + +## Templates + +- **Caddyfile.template** - Template for generating Caddyfile configuration +- **docker-compose.template.yml** - Template for generating docker-compose.yml + +## Template Variables + +Templates use `{{VARIABLE}}` syntax for variable substitution: + +### Caddyfile.template +- `{{PORT}}` - Dashboard port (default: 8080) +- `{{DASHBOARD_PATH}}` - Path to dashboard files +- `{{API_PORT}}` - API server port (default: 3001) + +### docker-compose.template.yml +- `{{API_PATH}}` - Path to API server directory +- `{{API_PORT}}` - API server port (default: 3001) diff --git a/dashcaddy-installer/templates/docker-compose.template.yml b/dashcaddy-installer/templates/docker-compose.template.yml new file mode 100644 index 0000000..6d80f07 --- /dev/null +++ b/dashcaddy-installer/templates/docker-compose.template.yml @@ -0,0 +1,21 @@ +services: + dashcaddy-api: + build: {{API_PATH}} + container_name: dashcaddy-api + ports: + - "{{API_PORT}}:{{API_PORT}}" + volumes: + - {{API_PATH}}:/app + - /var/run/docker.sock:/var/run/docker.sock + environment: + - NODE_ENV=production + - PORT={{API_PORT}} + - SERVICES_FILE=/app/services.json + - CADDY_ADMIN_URL=http://host.docker.internal:2019 + restart: unless-stopped + networks: + - dashcaddy + +networks: + dashcaddy: + driver: bridge diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..554a5e5 --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,79 @@ +# DashCaddy Deployment Script +# Deploys changes from Dev (E:) to Prod (C:) + +$DevRoot = "E:\CaddyCerts\sites" +$ProdRoot = "C:\Caddy" +$ErrorActionPreference = "Stop" + +Write-Host "Deploying DashCaddy Changes..." -ForegroundColor Cyan + +# 1. Pre-deploy validation - syntax check all JS files +Write-Host "Validating JavaScript syntax..." -ForegroundColor Yellow +$syntaxErrors = 0 +Get-ChildItem "$DevRoot\dashcaddy-api" -Filter "*.js" | ForEach-Object { + $result = & node -c $_.FullName 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Syntax error in $($_.Name): $result" + $syntaxErrors++ + } +} +Get-ChildItem "$DevRoot\dashcaddy-api\routes" -Filter "*.js" | ForEach-Object { + $result = & node -c $_.FullName 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Syntax error in routes/$($_.Name): $result" + $syntaxErrors++ + } +} +if ($syntaxErrors -gt 0) { + Write-Error "Aborting deploy: $syntaxErrors syntax error(s) found." + exit 1 +} +Write-Host " All files pass syntax check." -ForegroundColor Green + +# 2. Update Frontend +Write-Host "Updating Dashboard UI..." -ForegroundColor Yellow +if (Test-Path "$ProdRoot\sites\status") { + Copy-Item "$DevRoot\status\index.html" "$ProdRoot\sites\status\index.html" -Force +} else { + Write-Warning "Target status folder not found. Skipping UI update." +} + +# 3. Update Backend API +Write-Host "Updating API Server..." -ForegroundColor Yellow +if (Test-Path "$ProdRoot\sites\dashcaddy-api") { + # Copy all JS files, package files, API spec + Get-ChildItem "$DevRoot\dashcaddy-api" -Filter "*.js" | Copy-Item -Destination "$ProdRoot\sites\dashcaddy-api\" -Force + Copy-Item "$DevRoot\dashcaddy-api\package.json" "$ProdRoot\sites\dashcaddy-api\" -Force + Copy-Item "$DevRoot\dashcaddy-api\package-lock.json" "$ProdRoot\sites\dashcaddy-api\" -Force -ErrorAction SilentlyContinue + Copy-Item "$DevRoot\dashcaddy-api\openapi.yaml" "$ProdRoot\sites\dashcaddy-api\" -Force -ErrorAction SilentlyContinue + + # Copy route modules + if (!(Test-Path "$ProdRoot\sites\dashcaddy-api\routes")) { + New-Item -ItemType Directory -Path "$ProdRoot\sites\dashcaddy-api\routes" | Out-Null + } + Copy-Item "$DevRoot\dashcaddy-api\routes\*" "$ProdRoot\sites\dashcaddy-api\routes\" -Force + + # 4. Rebuild and Restart + Write-Host "Rebuilding API Container..." -ForegroundColor Yellow + Set-Location $ProdRoot + docker-compose up -d --build dashcaddy-api + + # 5. Post-deploy health check + Write-Host "Waiting for container startup..." -ForegroundColor Yellow + Start-Sleep -Seconds 5 + try { + $health = Invoke-RestMethod -Uri "http://localhost:3001/health" -TimeoutSec 10 -ErrorAction Stop + if ($health.status -eq 'ok') { + Write-Host " Health check passed." -ForegroundColor Green + } else { + Write-Warning "Health check returned unexpected status: $($health.status)" + } + } catch { + Write-Warning "Health check failed: $_" + Write-Warning "Check logs with: docker logs --tail 30 dashcaddy-api" + } +} else { + Write-Warning "Target API folder not found. Skipping API update." +} + +Write-Host "Deployment Complete! Refresh your dashboard." -ForegroundColor Green diff --git a/skills/deploy-service.json b/skills/deploy-service.json new file mode 100644 index 0000000..c5c315a --- /dev/null +++ b/skills/deploy-service.json @@ -0,0 +1,25 @@ +{ + "name": "deploy-service", + "version": "1.0.0", + "description": "Deploy a Docker container as a service with DNS, Caddy reverse proxy, and dashboard integration", + "instructions": "You are a service deployment specialist. Your job is to deploy Docker containers and integrate them into the user's infrastructure.\n\n## Your Capabilities\n\n1. **Docker Deployment**: Deploy any Docker container with proper configuration\n2. **DNS Management**: Create DNS records via Technitium DNS\n3. **Reverse Proxy**: Configure Caddy reverse proxy with SSL\n4. **Dashboard Integration**: Add services to the status dashboard\n\n## Available APIs\n\n### Automation API\n- **Create Service**: `POST http://localhost:3001/api/automation/service/create`\n- **Delete Service**: `DELETE http://localhost:3001/api/automation/service/delete`\n\n### Docker API\n- **Deploy App**: `POST http://localhost:3001/api/apps/deploy`\n- **List Containers**: Use Docker commands via Bash tool\n\n## Deployment Workflow\n\nWhen the user requests a service deployment (e.g., \"Deploy Emby\" or \"Set up Jellyfin\"):\n\n### Step 1: Gather Information\nAsk the user (or infer from context):\n- Service name (e.g., \"Emby\", \"Jellyfin\", \"Nginx\")\n- Subdomain preference (suggest based on service name)\n- Port mapping (suggest common ports: Emby=8096, Plex=32400, etc.)\n- Docker image to use\n- Any environment variables needed\n- Volume mounts required\n\n### Step 2: Deploy Docker Container\n\nUse the Docker API or direct docker commands:\n\n```bash\ndocker run -d \\\n --name service-name \\\n -p HOST_PORT:CONTAINER_PORT \\\n -v /path/to/config:/config \\\n -v /path/to/media:/media \\\n -e ENV_VAR=value \\\n --restart unless-stopped \\\n IMAGE_NAME:TAG\n```\n\nGet the container IP or use the host IP if using port mapping.\n\n### Step 3: Integrate with Infrastructure\n\nCall the automation API to create DNS, Caddy config, and dashboard entry:\n\n```bash\ncurl -X POST http://localhost:3001/api/automation/service/create \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Service Display Name\",\n \"subdomain\": \"service-subdomain\",\n \"ip\": \"container.ip.or.host.ip\",\n \"port\": \"exposed_port\",\n \"ttl\": 300,\n \"token\": \"ca5f88874d8d8545f387787a7c82a66e39fc61294e38762acd76fc52c2c40d2a\",\n \"zone\": \"sami\",\n \"createDns\": true,\n \"logo\": \"/assets/service.png\"\n }'\n```\n\n### Step 4: Verify and Report\n\n1. Check container is running: `docker ps | grep service-name`\n2. Verify DNS record exists\n3. Test Caddy reverse proxy\n4. Confirm dashboard entry\n5. Provide user with access URL: `https://service.sami`\n\n## Common Services Configuration\n\n### Emby Media Server\n- **Image**: `emby/embyserver:latest`\n- **Port**: 8096\n- **Volumes**: `/config`, `/media`\n- **Subdomain suggestion**: `emby.sami`\n\n### Jellyfin Media Server\n- **Image**: `jellyfin/jellyfin:latest`\n- **Port**: 8096\n- **Volumes**: `/config`, `/media`, `/cache`\n- **Subdomain suggestion**: `jellyfin.sami`\n\n### Nginx\n- **Image**: `nginx:alpine`\n- **Port**: 80\n- **Volumes**: `/usr/share/nginx/html`\n- **Subdomain suggestion**: `web.sami`\n\n### Portainer\n- **Image**: `portainer/portainer-ce:latest`\n- **Port**: 9000\n- **Volumes**: `/var/run/docker.sock`, `/data`\n- **Subdomain suggestion**: `portainer.sami`\n\n### Home Assistant\n- **Image**: `homeassistant/home-assistant:stable`\n- **Port**: 8123\n- **Volumes**: `/config`\n- **Network**: `--network host` (recommended)\n- **Subdomain suggestion**: `home.sami`\n\n### Uptime Kuma\n- **Image**: `louislam/uptime-kuma:1`\n- **Port**: 3001\n- **Volumes**: `/app/data`\n- **Subdomain suggestion**: `uptime.sami`\n\n### Grafana\n- **Image**: `grafana/grafana:latest`\n- **Port**: 3000\n- **Volumes**: `/var/lib/grafana`\n- **Subdomain suggestion**: `grafana.sami`\n\n### Nextcloud\n- **Image**: `nextcloud:latest`\n- **Port**: 80\n- **Volumes**: `/var/www/html`\n- **Subdomain suggestion**: `cloud.sami`\n- **Note**: May require database container\n\n## DNS Token\n\nThe DNS2 admin token is: `ca5f88874d8d8545f387787a7c82a66e39fc61294e38762acd76fc52c2c40d2a`\n\nUse this token for all DNS operations via the automation API.\n\n## Important Notes\n\n1. **Always use the automation API** for service integration - it handles DNS, Caddy, and dashboard in one call\n2. **Check for port conflicts** before deploying Docker containers\n3. **Use meaningful subdomains** that match the service name\n4. **Verify each step** and report status to the user\n5. **Handle errors gracefully** - if one step fails, explain what succeeded and what needs manual intervention\n6. **Use localhost or 127.0.0.1** for services running directly on the host\n7. **Get container IPs** from Docker when using bridge networking\n\n## Error Handling\n\nIf deployment fails:\n1. Check Docker container logs: `docker logs service-name`\n2. Verify port availability: `netstat -an | grep PORT`\n3. Check if subdomain already exists\n4. Validate DNS token is correct\n5. Ensure Caddy can reach the service\n\n## Example Interaction\n\n**User**: \"Deploy Emby and put it on my dashboard\"\n\n**You**:\n1. Acknowledge request: \"I'll deploy Emby Media Server with full integration\"\n2. Deploy Docker container with appropriate config\n3. Call automation API to create DNS, Caddy, and dashboard entry\n4. Verify all components\n5. Report success: \"Emby is now available at https://emby.sami\"\n\n**User**: \"Set up Jellyfin on port 8097\"\n\n**You**:\n1. Deploy Jellyfin container on port 8097\n2. Integrate with automation API\n3. Report: \"Jellyfin deployed at https://jellyfin.sami\"\n\nAlways be proactive, efficient, and provide clear status updates throughout the deployment process.", + "triggers": [ + "deploy", + "setup", + "install", + "create service", + "add service", + "spin up", + "run", + "start" + ], + "examples": [ + "Deploy Emby and put it on my dashboard", + "Set up Jellyfin server", + "Install Portainer", + "Create an Nginx web server", + "Add Home Assistant to my dashboard", + "Spin up Grafana", + "Deploy Nextcloud" + ] +} diff --git a/skills/deploy.skill.md b/skills/deploy.skill.md new file mode 100644 index 0000000..776025c --- /dev/null +++ b/skills/deploy.skill.md @@ -0,0 +1,154 @@ +# Deploy Service Skill + +Deploy Docker containers with complete infrastructure integration (DNS, reverse proxy, dashboard). + +## When to Use This Skill + +Activate this skill when the user wants to: +- Deploy a Docker container +- Set up a new service +- Install an application +- Add a service to the dashboard +- Run/start/spin up a container + +## Workflow + +### 1. Understand Requirements + +Parse the user's request to identify: +- Service name (Emby, Jellyfin, Nginx, etc.) +- Port preferences (if specified) +- Subdomain preferences (if specified) +- Volume/storage requirements + +### 2. Deploy Docker Container + +Use appropriate Docker image and configuration: + +```bash +docker run -d \ + --name \ + -p : \ + -v : \ + -e = \ + --restart unless-stopped \ + +``` + +**Get the service IP/host:** +- If using port mapping → use `localhost` or `127.0.0.1` + host port +- If using bridge network → get container IP with `docker inspect` + +### 3. Integrate with Infrastructure + +Call the automation API (single call does everything): + +```bash +curl -X POST http://localhost:3001/api/automation/service/create \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Display Name", + "subdomain": "subdomain", + "ip": "service.ip.address", + "port": "exposed_port", + "ttl": 300, + "token": "ca5f88874d8d8545f387787a7c82a66e39fc61294e38762acd76fc52c2c40d2a", + "zone": "sami", + "createDns": true, + "logo": "/assets/service.png" + }' +``` + +This creates: +- ✓ DNS record (subdomain.sami → IP) +- ✓ Caddy reverse proxy config +- ✓ Dashboard entry + +### 4. Verify & Report + +- Check container: `docker ps | grep ` +- Test URL: `curl -k https://.sami` +- Report to user: "Service deployed at https://subdomain.sami" + +## Common Services + +### Emby +```bash +docker run -d --name emby \ + -p 8096:8096 \ + -v /path/config:/config \ + -v /path/media:/media \ + --restart unless-stopped \ + emby/embyserver:latest +``` +Then: subdomain=emby, ip=localhost, port=8096 + +### Jellyfin +```bash +docker run -d --name jellyfin \ + -p 8096:8096 \ + -v /path/config:/config \ + -v /path/media:/media \ + --restart unless-stopped \ + jellyfin/jellyfin:latest +``` +Then: subdomain=jellyfin, ip=localhost, port=8096 + +### Portainer +```bash +docker run -d --name portainer \ + -p 9000:9000 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v portainer_data:/data \ + --restart unless-stopped \ + portainer/portainer-ce:latest +``` +Then: subdomain=portainer, ip=localhost, port=9000 + +### Nginx +```bash +docker run -d --name nginx \ + -p 8080:80 \ + -v /path/html:/usr/share/nginx/html \ + --restart unless-stopped \ + nginx:alpine +``` +Then: subdomain=nginx or web, ip=localhost, port=8080 + +### Uptime Kuma +```bash +docker run -d --name uptime-kuma \ + -p 3001:3001 \ + -v uptime-kuma:/app/data \ + --restart unless-stopped \ + louislam/uptime-kuma:1 +``` +Then: subdomain=uptime, ip=localhost, port=3001 + +## DNS Token + +**DNS2 Admin Token:** `ca5f88874d8d8545f387787a7c82a66e39fc61294e38762acd76fc52c2c40d2a` + +Always use this token in the automation API calls. + +## Key Points + +1. **Always use automation API** - it handles DNS + Caddy + Dashboard in one call +2. **Port mapping** - Host port can differ from container port +3. **Use localhost** - When using `-p` port mapping, target is `localhost:` +4. **Check conflicts** - Verify port is available before deploying +5. **Meaningful names** - Subdomain should match service name +6. **Verify success** - Check container logs if issues arise + +## Example + +**User:** "Deploy Emby and put it on my dashboard" + +**Actions:** +1. Deploy Emby container on port 8096 +2. Call automation API with: + - name: "Emby" + - subdomain: "emby" + - ip: "localhost" + - port: "8096" +3. Verify and report: "Emby deployed at https://emby.sami" diff --git a/status/api/README.md b/status/api/README.md new file mode 100644 index 0000000..4047120 --- /dev/null +++ b/status/api/README.md @@ -0,0 +1,242 @@ +# SAMI-CLOUD Status Dashboard API + +Cross-platform Node.js API server for managing Caddy reverse proxy and DNS records via REST APIs. + +## Features + +- **Cross-Platform**: Works on Windows, Linux, and macOS +- **API-Based**: Uses Caddy Admin API and Technitium DNS API (no PowerShell required) +- **App Deployment**: Deploy apps by creating DNS records and Caddy reverse proxy routes +- **App Deletion**: Clean removal of DNS records and Caddy routes +- **Automatic Rollback**: If deployment fails, automatically rolls back changes + +## Prerequisites + +1. **Node.js** (v14 or higher) +2. **Caddy** with Admin API enabled +3. **Technitium DNS Server** (optional, for DNS management) + +## Installation + +```bash +cd api +npm install +``` + +## Configuration + +Set the following environment variables (or use defaults): + +```bash +# Caddy Admin API endpoint (default: http://localhost:2019) +export CADDY_ADMIN_API=http://localhost:2019 + +# Technitium DNS Server API endpoint (default: http://192.168.254.204:5380) +export DNS_SERVER_API=http://192.168.254.204:5380 + +# Technitium DNS API token (required for DNS operations) +export TECHNITIUM_API_TOKEN=your_api_token_here +``` + +### Windows (PowerShell) +```powershell +$env:CADDY_ADMIN_API="http://localhost:2019" +$env:DNS_SERVER_API="http://192.168.254.204:5380" +$env:TECHNITIUM_API_TOKEN="your_api_token_here" +``` + +### Windows (Command Prompt) +```cmd +set CADDY_ADMIN_API=http://localhost:2019 +set DNS_SERVER_API=http://192.168.254.204:5380 +set TECHNITIUM_API_TOKEN=your_api_token_here +``` + +## Running the Server + +```bash +npm start +``` + +Or directly: +```bash +node caddy-api.js +``` + +The server will start on port 3001. + +## API Endpoints + +### Deploy an App +```http +POST /api/apps/deploy +Content-Type: application/json + +{ + "appId": "myapp", + "config": { + "subdomain": "myapp", + "ip": "192.168.1.100", + "port": "8080", + "createDns": true, + "dnsType": "private", + "sslType": "internal" + } +} +``` + +**Response:** +```json +{ + "success": true, + "message": "App myapp deployed successfully", + "url": "https://myapp.sami", + "domain": "myapp.sami", + "ip": "192.168.1.100", + "port": "8080", + "dnsCreated": true, + "caddyConfigured": true +} +``` + +### Delete an App +```http +POST /api/apps/delete +Content-Type: application/json + +{ + "domain": "myapp.sami", + "ip": "192.168.1.100" +} +``` + +### Get Services List +```http +GET /api/services +``` + +### Get Caddy Configuration +```http +GET /api/caddy/config +``` + +### Test API +```http +GET /api/caddy/test +``` + +### Health Check +```http +GET /health +``` + +## Caddy Configuration Requirements + +Your Caddyfile should have the Admin API enabled: + +```caddyfile +{ + admin localhost:2019 { + origins localhost localhost:2019 + } +} +``` + +For the status dashboard to proxy API requests, add this to your Caddyfile: + +```caddyfile +status.sami { + tls internal + + # API proxy to Node.js server + handle /api/* { + reverse_proxy localhost:3001 + } + + # Static site + root * /path/to/sites/status + file_server +} +``` + +## Getting Technitium DNS API Token + +1. Open Technitium DNS web interface +2. Go to Settings → API +3. Create a new API token or copy existing one +4. Set it as the `TECHNITIUM_API_TOKEN` environment variable + +## Deployment Flow + +When deploying an app: + +1. **Validate** - Checks required fields (appId, subdomain, ip) +2. **DNS Record** - Creates A record in DNS (if `createDns: true` and `dnsType: "private"`) +3. **Caddy Route** - Adds reverse proxy route via Caddy Admin API +4. **Rollback** - If Caddy configuration fails, removes DNS record + +## Troubleshooting + +### Caddy Admin API not accessible +- Verify Caddy is running +- Check that admin API is enabled in your Caddyfile +- Confirm the CADDY_ADMIN_API URL is correct + +### DNS operations failing +- Verify TECHNITIUM_API_TOKEN is set correctly +- Check DNS_SERVER_API URL is accessible +- Ensure the API token has permissions to manage zones + +### Routes not appearing in Caddy +- Check Caddy logs: `caddy logs` +- Verify the route was added: `curl http://localhost:2019/config/` +- Ensure the domain resolves correctly in DNS + +## Production Deployment + +For production use: + +1. Set up environment variables persistently +2. Use a process manager (PM2, systemd, etc.) +3. Configure proper logging +4. Set up SSL/TLS for the API if exposed externally + +### Using PM2 +```bash +npm install -g pm2 +pm2 start caddy-api.js --name sami-api +pm2 save +pm2 startup +``` + +### Using systemd (Linux) +Create `/etc/systemd/system/sami-api.service`: + +```ini +[Unit] +Description=SAMI-CLOUD API Server +After=network.target + +[Service] +Type=simple +User=caddy +WorkingDirectory=/path/to/sites/status/api +Environment="CADDY_ADMIN_API=http://localhost:2019" +Environment="DNS_SERVER_API=http://192.168.254.204:5380" +Environment="TECHNITIUM_API_TOKEN=your_token" +ExecStart=/usr/bin/node caddy-api.js +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Then: +```bash +sudo systemctl enable sami-api +sudo systemctl start sami-api +``` + +## License + +MIT diff --git a/status/api/caddy-api.js b/status/api/caddy-api.js new file mode 100644 index 0000000..c09b6e4 --- /dev/null +++ b/status/api/caddy-api.js @@ -0,0 +1,362 @@ +// Cross-platform Node.js API server for Caddy management +// Uses Caddy Admin API and Technitium DNS API directly +// Run with: node caddy-api.js + +const express = require('express'); +const path = require('path'); +const cors = require('cors'); +const fs = require('fs'); + +const app = express(); +const PORT = 3001; + +// Middleware +app.use(cors()); +app.use(express.json()); +app.use(express.static('public')); + +// Configuration +const CADDY_ADMIN_API = process.env.CADDY_ADMIN_API || 'http://localhost:2019'; +const DNS_SERVER_API = process.env.DNS_SERVER_API || 'http://192.168.254.204:5380'; +const DNS_API_TOKEN = process.env.TECHNITIUM_API_TOKEN || ''; + +// Helper function to make HTTP requests +async function makeRequest(url, options = {}) { + const https = url.startsWith('https:') ? require('https') : require('http'); + const urlObj = new URL(url); + + return new Promise((resolve, reject) => { + const reqOptions = { + hostname: urlObj.hostname, + port: urlObj.port, + path: urlObj.pathname + urlObj.search, + method: options.method || 'GET', + headers: options.headers || {}, + ...options + }; + + const req = https.request(reqOptions, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + resolve({ status: res.statusCode, data: parsed }); + } catch (e) { + resolve({ status: res.statusCode, data: data }); + } + }); + }); + + req.on('error', reject); + + if (options.body) { + req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body)); + } + + req.end(); + }); +} + +// Get current Caddy configuration +app.get('/api/caddy/config', async (req, res) => { + try { + const response = await makeRequest(`${CADDY_ADMIN_API}/config/`); + + if (response.status === 200) { + res.json({ + status: 'success', + config: response.data + }); + } else { + res.status(response.status).json({ + status: 'error', + message: 'Failed to get Caddy configuration', + details: response.data + }); + } + } catch (error) { + console.error('Error getting Caddy config:', error); + res.status(500).json({ + status: 'error', + message: error.message + }); + } +}); + +// Get list of services (from apps.json + custom apps) +app.get('/api/services', async (req, res) => { + try { + const servicesPath = path.join(__dirname, '../apps.json'); + + if (fs.existsSync(servicesPath)) { + const servicesData = fs.readFileSync(servicesPath, 'utf8'); + const services = JSON.parse(servicesData); + res.json({ status: 'success', services }); + } else { + res.json({ status: 'success', services: [] }); + } + } catch (error) { + console.error('Error reading services:', error); + res.status(500).json({ + status: 'error', + message: error.message + }); + } +}); + +// Add DNS record via Technitium API +async function addDnsRecord(domain, ipAddress, ttl = 3600) { + if (!DNS_API_TOKEN) { + throw new Error('DNS API token not configured. Set TECHNITIUM_API_TOKEN environment variable.'); + } + + const url = `${DNS_SERVER_API}/api/zones/records/add?token=${DNS_API_TOKEN}&domain=${domain}&type=A&ipAddress=${ipAddress}&ttl=${ttl}`; + + console.log('Adding DNS record:', { domain, ipAddress, ttl }); + const response = await makeRequest(url); + + if (response.data.status === 'ok') { + return { success: true, message: 'DNS record added successfully' }; + } else { + throw new Error(`DNS API error: ${response.data.message || 'Unknown error'}`); + } +} + +// Delete DNS record via Technitium API +async function deleteDnsRecord(domain, ipAddress) { + if (!DNS_API_TOKEN) { + console.warn('DNS API token not configured. Skipping DNS deletion.'); + return { success: true, message: 'DNS deletion skipped (no token)' }; + } + + const url = `${DNS_SERVER_API}/api/zones/records/delete?token=${DNS_API_TOKEN}&domain=${domain}&type=A&ipAddress=${ipAddress}`; + + console.log('Deleting DNS record:', { domain, ipAddress }); + const response = await makeRequest(url); + + if (response.data.status === 'ok') { + return { success: true, message: 'DNS record deleted successfully' }; + } else { + throw new Error(`DNS API error: ${response.data.message || 'Unknown error'}`); + } +} + +// Add route to Caddy via Admin API +async function addCaddyRoute(domain, upstreamUrl, useTls = true) { + // Build Caddy route configuration + const routeConfig = { + "@id": domain, + "match": [{ + "host": [domain] + }], + "handle": [{ + "handler": "reverse_proxy", + "upstreams": [{ + "dial": upstreamUrl.replace(/^https?:\/\//, '') + }] + }], + "terminal": true + }; + + // If using internal TLS, we need to add TLS configuration + if (useTls) { + // Caddy handles TLS automatically for matched domains + // Internal CA is configured in the global Caddyfile + } + + console.log('Adding Caddy route:', JSON.stringify(routeConfig, null, 2)); + + // Add the route to the HTTP server + const response = await makeRequest(`${CADDY_ADMIN_API}/config/apps/http/servers/srv0/routes/0`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(routeConfig) + }); + + if (response.status === 200 || response.status === 201) { + return { success: true, message: 'Caddy route added successfully' }; + } else { + throw new Error(`Caddy API error: ${JSON.stringify(response.data)}`); + } +} + +// Deploy app endpoint - handles DNS and Caddy configuration via APIs +app.post('/api/apps/deploy', async (req, res) => { + try { + const { appId, config } = req.body; + const { subdomain, ip, createDns, port, sslType, dnsType } = config; + + console.log('Deploying app:', { appId, config }); + + // Validate required fields + if (!appId || !subdomain || !ip) { + return res.status(400).json({ + success: false, + message: 'Missing required fields: appId, subdomain, ip' + }); + } + + // Build the full domain + const domain = subdomain.includes('.') ? subdomain : `${subdomain}.sami`; + const finalPort = port || '80'; + const upstreamUrl = `${ip}:${finalPort}`; + + // Step 1: Add DNS record if requested (private DNS) + if (createDns && dnsType === 'private') { + try { + await addDnsRecord(domain, ip); + } catch (dnsError) { + console.error('DNS creation failed:', dnsError); + return res.status(500).json({ + success: false, + message: `DNS creation failed: ${dnsError.message}`, + step: 'dns' + }); + } + } + + // Step 2: Add route to Caddy via Admin API + try { + const useTls = sslType === 'internal'; + await addCaddyRoute(domain, upstreamUrl, useTls); + } catch (caddyError) { + console.error('Caddy route addition failed:', caddyError); + + // Rollback DNS if it was created + if (createDns && dnsType === 'private') { + try { + await deleteDnsRecord(domain, ip); + } catch (rollbackError) { + console.error('DNS rollback failed:', rollbackError); + } + } + + return res.status(500).json({ + success: false, + message: `Caddy configuration failed: ${caddyError.message}`, + step: 'caddy' + }); + } + + // Step 3: Return success response + res.json({ + success: true, + message: `App ${appId} deployed successfully`, + url: `https://${domain}`, + domain: domain, + ip: ip, + port: finalPort, + containerId: null, + dnsCreated: createDns && dnsType === 'private', + caddyConfigured: true + }); + + } catch (error) { + console.error('Deployment error:', error); + res.status(500).json({ + success: false, + message: error.message + }); + } +}); + +// Delete app endpoint - removes DNS and Caddy configuration +app.post('/api/apps/delete', async (req, res) => { + try { + const { domain, ip } = req.body; + + if (!domain) { + return res.status(400).json({ + success: false, + message: 'Domain is required' + }); + } + + console.log('Deleting app:', { domain, ip }); + + // Step 1: Remove from Caddy + try { + const response = await makeRequest(`${CADDY_ADMIN_API}/id/${domain}`, { + method: 'DELETE' + }); + + if (response.status !== 200) { + console.warn('Caddy route deletion warning:', response.data); + } + } catch (caddyError) { + console.error('Caddy route deletion failed:', caddyError); + // Continue anyway to try DNS deletion + } + + // Step 2: Remove DNS record if IP provided + if (ip) { + try { + await deleteDnsRecord(domain, ip); + } catch (dnsError) { + console.error('DNS deletion failed:', dnsError); + return res.status(500).json({ + success: false, + message: `DNS deletion failed: ${dnsError.message}`, + caddyDeleted: true, + dnsDeleted: false + }); + } + } + + res.json({ + success: true, + message: 'App deleted successfully', + domain: domain, + caddyDeleted: true, + dnsDeleted: !!ip + }); + + } catch (error) { + console.error('Deletion error:', error); + res.status(500).json({ + success: false, + message: error.message + }); + } +}); + +// Test endpoint +app.get('/api/caddy/test', (req, res) => { + res.json({ + status: 'success', + message: 'Caddy API is running', + timestamp: new Date().toISOString(), + platform: process.platform, + caddyAdminApi: CADDY_ADMIN_API, + dnsServerApi: DNS_SERVER_API, + dnsTokenConfigured: !!DNS_API_TOKEN + }); +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'healthy', timestamp: new Date().toISOString() }); +}); + +// Start server +app.listen(PORT, () => { + console.log(`\n====================================`); + console.log(`Caddy API server running on http://localhost:${PORT}`); + console.log(`====================================`); + console.log(`Caddy Admin API: ${CADDY_ADMIN_API}`); + console.log(`DNS Server API: ${DNS_SERVER_API}`); + console.log(`DNS Token: ${DNS_API_TOKEN ? '✓ Configured' : '✗ Not configured'}`); + console.log(`\nEndpoints:`); + console.log(` POST /api/apps/deploy - Deploy an app (DNS + Caddy)`); + console.log(` POST /api/apps/delete - Delete an app (DNS + Caddy)`); + console.log(` GET /api/services - Get list of services`); + console.log(` GET /api/caddy/config - Get current Caddy configuration`); + console.log(` GET /api/caddy/test - Test API connectivity`); + console.log(` GET /health - Health check`); + console.log(`====================================\n`); +}); + +module.exports = app; diff --git a/status/api/caddy-manager.ps1 b/status/api/caddy-manager.ps1 new file mode 100644 index 0000000..d5bd6fe --- /dev/null +++ b/status/api/caddy-manager.ps1 @@ -0,0 +1,206 @@ +# Caddy Configuration Manager for Windows +# This script adds new service configurations to your Caddyfile + +param( + [string]$Config, + [string]$Subdomain, + [string]$CaddyfilePath = "C:\caddy\Caddyfile", + [bool]$ReloadCaddy = $true +) + +# Function to write JSON response +function Write-JsonResponse { + param($Status, $Message, $Data = $null) + + $response = @{ + status = $Status + message = $Message + timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + } + + if ($Data) { + $response.data = $Data + } + + return $response | ConvertTo-Json +} + +# Function to extract CA names from Caddyfile +function Get-CaddyfileCAs { + param([string]$CaddyfilePath) + + try { + Write-Host "DEBUG: Checking file path: $CaddyfilePath" + + if (-not (Test-Path $CaddyfilePath)) { + Write-Host "DEBUG: File not found" + return @() + } + + $content = Get-Content $CaddyfilePath -Raw + Write-Host "DEBUG: File content length: $($content.Length)" + Write-Host "DEBUG: First 200 chars: $($content.Substring(0, [Math]::Min(200, $content.Length)))" + + $caNames = @() + + # Pattern 1: PKI block with CA definitions - pki { ca ca_name { name "Friendly Name" } } + $pkiPattern = 'pki\s*\{[^}]*?ca\s+([^\s\{]+)\s*\{([^}]*?)\}' + Write-Host "DEBUG: Searching for PKI pattern: $pkiPattern" + + $pkiMatches = [regex]::Matches($content, $pkiPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline) + Write-Host "DEBUG: PKI matches found: $($pkiMatches.Count)" + + foreach ($match in $pkiMatches) { + $caId = $match.Groups[1].Value + $caBlock = $match.Groups[2].Value + Write-Host "DEBUG: Found CA ID: $caId" + Write-Host "DEBUG: CA Block: $caBlock" + + # Try to extract the friendly name from within the CA block + $nameMatch = [regex]::Match($caBlock, 'name\s+"([^"]+)"', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($nameMatch.Success) { + $friendlyName = $nameMatch.Groups[1].Value + Write-Host "DEBUG: Found friendly name: $friendlyName" + $caNames += "$caId ($friendlyName)" + } else { + Write-Host "DEBUG: No friendly name found, using ID only" + $caNames += $caId + } + } + + # Pattern 2: tls { ca ca_name } + $matches1 = [regex]::Matches($content, 'tls\s*\{\s*ca\s+([^\s\}]+)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + Write-Host "DEBUG: TLS block matches: $($matches1.Count)" + foreach ($match in $matches1) { + $caNames += $match.Groups[1].Value + } + + # Pattern 3: tls ca_name (direct) + $matches2 = [regex]::Matches($content, 'tls\s+([^\s\{]+)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + Write-Host "DEBUG: Direct TLS matches: $($matches2.Count)" + foreach ($match in $matches2) { + $caName = $match.Groups[1].Value + if ($caName -ne "internal" -and $caName -ne "off") { + $caNames += $caName + } + } + + Write-Host "DEBUG: Total CAs found: $($caNames.Count)" + Write-Host "DEBUG: CA list: $($caNames -join ', ')" + + # Remove duplicates and return + return $caNames | Sort-Object | Get-Unique + } + catch { + Write-Host "DEBUG: Exception in Get-CaddyfileCAs: $($_.Exception.Message)" + Write-Host "Error reading CAs from Caddyfile: $($_.Exception.Message)" + return @() + } +} + +# Handle get-cas command +if ($args[0] -eq "get-cas") { + $CaddyfilePath = if ($args[1]) { $args[1] } else { "C:\caddy\Caddyfile" } + + Write-Host "DEBUG: get-cas command received" + Write-Host "DEBUG: Caddyfile path: $CaddyfilePath" + Write-Host "DEBUG: File exists: $(Test-Path $CaddyfilePath)" + + try { + $cas = Get-CaddyfileCAs -CaddyfilePath $CaddyfilePath + + Write-Host "DEBUG: CAs found: $($cas -join ', ')" + + $response = @{ + status = "success" + message = "CAs retrieved successfully" + data = @{ + cas = $cas + count = $cas.Count + caddyfilePath = $CaddyfilePath + } + timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + } + + Write-Output ($response | ConvertTo-Json) + exit 0 + } + catch { + Write-Host "DEBUG: Error occurred: $($_.Exception.Message)" + + $response = @{ + status = "error" + message = "Failed to retrieve CAs: $($_.Exception.Message)" + timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + } + + Write-Output ($response | ConvertTo-Json) + exit 1 + } +} + +# Main configuration addition logic +try { + # Validate required parameters for config addition + if (-not $Config -or -not $Subdomain) { + Write-Output (Write-JsonResponse "error" "Config and Subdomain parameters are required") + exit 1 + } + + # Check if Caddyfile exists + if (-not (Test-Path $CaddyfilePath)) { + Write-Output (Write-JsonResponse "error" "Caddyfile not found at: $CaddyfilePath") + exit 1 + } + + # Read existing Caddyfile + $existingConfig = Get-Content $CaddyfilePath -Raw -ErrorAction Stop + + # Check if subdomain already exists + if ($existingConfig -match "$Subdomain\.sami\s*\{") { + Write-Output (Write-JsonResponse "error" "Subdomain '$Subdomain.sami' already exists in Caddyfile") + exit 1 + } + + # Create backup + $backupPath = "$CaddyfilePath.backup.$(Get-Date -Format 'yyyyMMdd-HHmmss')" + Copy-Item $CaddyfilePath $backupPath -ErrorAction Stop + Write-Host "Backup created: $backupPath" + + # Append new configuration + $newContent = $existingConfig.TrimEnd() + "`n`n" + $Config.TrimEnd() + "`n" + Set-Content -Path $CaddyfilePath -Value $newContent -NoNewline -ErrorAction Stop + + Write-Host "Configuration added successfully" + + # Reload Caddy if requested + if ($ReloadCaddy) { + Write-Host "Reloading Caddy..." + + # Try to reload Caddy + $reloadResult = & caddy reload --config $CaddyfilePath 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host "Caddy reloaded successfully" + Write-Output (Write-JsonResponse "success" "Configuration added and Caddy reloaded successfully" @{ + backup = $backupPath + subdomain = "$Subdomain.sami" + }) + } else { + Write-Host "Caddy reload failed: $reloadResult" + # Restore backup if reload failed + Copy-Item $backupPath $CaddyfilePath -Force + Write-Output (Write-JsonResponse "error" "Caddy reload failed. Configuration rolled back. Error: $reloadResult") + exit 1 + } + } else { + Write-Output (Write-JsonResponse "success" "Configuration added successfully (Caddy not reloaded)" @{ + backup = $backupPath + subdomain = "$Subdomain.sami" + }) + } + +} catch { + Write-Output (Write-JsonResponse "error" "Error: $($_.Exception.Message)") + exit 1 +} \ No newline at end of file diff --git a/status/api/install-and-run.bat b/status/api/install-and-run.bat new file mode 100644 index 0000000..a54c518 --- /dev/null +++ b/status/api/install-and-run.bat @@ -0,0 +1,63 @@ +@echo off +title SAMI Caddy API Server +echo ======================================== +echo Installing SAMI Caddy API Server... +echo ======================================== + +REM Check if Node.js is installed +echo Checking for Node.js... +node --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo. + echo ERROR: Node.js is not installed or not in PATH + echo Please install Node.js from https://nodejs.org/ + echo. + pause + exit /b 1 +) + +echo Node.js found: +node --version + +echo. +echo Installing dependencies... +echo. + +REM Install npm dependencies +npm install + +if %errorlevel% neq 0 ( + echo. + echo ERROR: Failed to install dependencies + echo Check the error messages above + echo. + pause + exit /b 1 +) + +echo. +echo ======================================== +echo Dependencies installed successfully! +echo ======================================== +echo. +echo Starting Caddy API server... +echo. +echo Server URL: http://localhost:3001 +echo Test URL: http://localhost:3001/api/caddy/test +echo. +echo Press Ctrl+C to stop the server +echo ======================================== +echo. + +REM Start the server and keep window open on error +npm start +if %errorlevel% neq 0 ( + echo. + echo ERROR: Server failed to start + echo Check the error messages above + echo. +) + +echo. +echo Server stopped. +pause \ No newline at end of file diff --git a/status/api/package-lock.json b/status/api/package-lock.json new file mode 100644 index 0000000..35eeddf --- /dev/null +++ b/status/api/package-lock.json @@ -0,0 +1,1227 @@ +{ + "name": "sami-caddy-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sami-caddy-api", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/status/api/package.json b/status/api/package.json new file mode 100644 index 0000000..df922c6 --- /dev/null +++ b/status/api/package.json @@ -0,0 +1,21 @@ +{ + "name": "sami-caddy-api", + "version": "2.0.0", + "description": "Cross-platform API server for managing Caddy and DNS via REST APIs", + "main": "caddy-api.js", + "scripts": { + "start": "node caddy-api.js", + "dev": "nodemon caddy-api.js", + "test": "node test-api.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "keywords": ["caddy", "api", "dns", "technitium", "reverse-proxy", "cross-platform", "sami-cloud"], + "author": "SAMI-CLOUD", + "license": "MIT" +} \ No newline at end of file diff --git a/status/api/start.bat b/status/api/start.bat new file mode 100644 index 0000000..a3aa5b2 --- /dev/null +++ b/status/api/start.bat @@ -0,0 +1,44 @@ +@echo off +REM Quick start script for SAMI-CLOUD API Server (Windows) + +echo ==================================== +echo SAMI-CLOUD API Server +echo ==================================== +echo. + +REM Check if Node.js is installed +where node >nul 2>nul +if %errorlevel% neq 0 ( + echo Error: Node.js is not installed or not in PATH + echo Please install Node.js from https://nodejs.org/ + pause + exit /b 1 +) + +REM Check if node_modules exists +if not exist "node_modules" ( + echo Installing dependencies... + call npm install + echo. +) + +REM Check environment variables +if "%CADDY_ADMIN_API%"=="" ( + echo Warning: CADDY_ADMIN_API not set, using default: http://localhost:2019 + set CADDY_ADMIN_API=http://localhost:2019 +) + +if "%DNS_SERVER_API%"=="" ( + echo Warning: DNS_SERVER_API not set, using default: http://192.168.254.204:5380 + set DNS_SERVER_API=http://192.168.254.204:5380 +) + +if "%TECHNITIUM_API_TOKEN%"=="" ( + echo Warning: TECHNITIUM_API_TOKEN not set - DNS operations will fail + echo Set it with: set TECHNITIUM_API_TOKEN=your_token + echo. +) + +echo Starting API server... +echo. +node caddy-api.js diff --git a/status/api/start.sh b/status/api/start.sh new file mode 100644 index 0000000..f858480 --- /dev/null +++ b/status/api/start.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Quick start script for SAMI-CLOUD API Server (Linux/macOS) + +echo "====================================" +echo "SAMI-CLOUD API Server" +echo "====================================" +echo "" + +# Check if Node.js is installed +if ! command -v node &> /dev/null; then + echo "Error: Node.js is not installed or not in PATH" + echo "Please install Node.js from https://nodejs.org/" + exit 1 +fi + +# Check if node_modules exists +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + npm install + echo "" +fi + +# Check environment variables +if [ -z "$CADDY_ADMIN_API" ]; then + echo "Warning: CADDY_ADMIN_API not set, using default: http://localhost:2019" + export CADDY_ADMIN_API="http://localhost:2019" +fi + +if [ -z "$DNS_SERVER_API" ]; then + echo "Warning: DNS_SERVER_API not set, using default: http://192.168.254.204:5380" + export DNS_SERVER_API="http://192.168.254.204:5380" +fi + +if [ -z "$TECHNITIUM_API_TOKEN" ]; then + echo "Warning: TECHNITIUM_API_TOKEN not set - DNS operations will fail" + echo "Set it with: export TECHNITIUM_API_TOKEN=your_token" + echo "" +fi + +echo "Starting API server..." +echo "" +node caddy-api.js diff --git a/status/api/test-api.js b/status/api/test-api.js new file mode 100644 index 0000000..ad5affb --- /dev/null +++ b/status/api/test-api.js @@ -0,0 +1,72 @@ +// Simple test script to verify API connectivity +const http = require('http'); + +const API_URL = 'http://localhost:3001'; + +function makeRequest(path) { + return new Promise((resolve, reject) => { + http.get(`${API_URL}${path}`, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(data) }); + } catch (e) { + resolve({ status: res.statusCode, data: data }); + } + }); + }).on('error', reject); + }); +} + +async function runTests() { + console.log('Testing SAMI-CLOUD API...\n'); + + // Test 1: Health Check + console.log('1. Testing health endpoint...'); + try { + const health = await makeRequest('/health'); + if (health.status === 200) { + console.log(' ✓ Health check passed'); + } else { + console.log(' ✗ Health check failed:', health.status); + } + } catch (error) { + console.log(' ✗ Health check error:', error.message); + } + + // Test 2: API Test Endpoint + console.log('\n2. Testing API test endpoint...'); + try { + const test = await makeRequest('/api/caddy/test'); + if (test.status === 200) { + console.log(' ✓ API test passed'); + console.log(' Platform:', test.data.platform); + console.log(' Caddy Admin API:', test.data.caddyAdminApi); + console.log(' DNS Server API:', test.data.dnsServerApi); + console.log(' DNS Token:', test.data.dnsTokenConfigured ? 'Configured' : 'Not configured'); + } else { + console.log(' ✗ API test failed:', test.status); + } + } catch (error) { + console.log(' ✗ API test error:', error.message); + } + + // Test 3: Services Endpoint + console.log('\n3. Testing services endpoint...'); + try { + const services = await makeRequest('/api/services'); + if (services.status === 200) { + console.log(' ✓ Services endpoint passed'); + console.log(' Found', services.data.services.length, 'services'); + } else { + console.log(' ✗ Services endpoint failed:', services.status); + } + } catch (error) { + console.log(' ✗ Services endpoint error:', error.message); + } + + console.log('\nTests complete!'); +} + +runTests().catch(console.error); diff --git a/status/api/test-server.bat b/status/api/test-server.bat new file mode 100644 index 0000000..a410eea --- /dev/null +++ b/status/api/test-server.bat @@ -0,0 +1,84 @@ +@echo off +title SAMI Caddy API Server - Debug Mode +echo ======================================== +echo SAMI Caddy API Server - Debug Mode +echo ======================================== + +cd /d "%~dp0" +echo Current directory: %CD% + +echo. +echo Checking Node.js... +node --version +if %errorlevel% neq 0 ( + echo ERROR: Node.js not found + pause + exit /b 1 +) + +echo. +echo Checking files... +if exist "caddy-api.js" ( + echo ✓ caddy-api.js found +) else ( + echo ✗ caddy-api.js NOT found + pause + exit /b 1 +) + +if exist "package.json" ( + echo ✓ package.json found +) else ( + echo ✗ package.json NOT found + pause + exit /b 1 +) + +if exist "caddy-manager.ps1" ( + echo ✓ caddy-manager.ps1 found +) else ( + echo ✗ caddy-manager.ps1 NOT found + pause + exit /b 1 +) + +echo. +echo Installing dependencies... +npm install +if %errorlevel% neq 0 ( + echo ERROR: npm install failed + pause + exit /b 1 +) + +echo. +echo ======================================== +echo Starting server with error capture... +echo ======================================== +echo Server will run on: http://localhost:3001 +echo Test endpoint: http://localhost:3001/api/caddy/test +echo. +echo If server starts successfully, you'll see "Caddy API server running..." +echo Press Ctrl+C to stop the server +echo ======================================== +echo. + +REM Capture both stdout and stderr +node caddy-api.js 2>&1 +set SERVER_EXIT_CODE=%errorlevel% + +echo. +echo ======================================== +echo Server exited with code: %SERVER_EXIT_CODE% +echo ======================================== + +if %SERVER_EXIT_CODE% neq 0 ( + echo ERROR: Server failed to start or crashed + echo Check the error messages above +) else ( + echo Server stopped normally +) + +echo. +echo Press any key to close this window... +pause >nul \ No newline at end of file diff --git a/status/apps.json b/status/apps.json new file mode 100644 index 0000000..d22292a --- /dev/null +++ b/status/apps.json @@ -0,0 +1,62 @@ +[ + { + "id": "plex", + "name": "Plex", + "logo": "assets/plex.png", + "url": "https://plex.sami" + }, + { + "id": "jellyfin", + "name": "Jellyfin", + "logo": "📺", + "url": "https://jellyfin.sami" + }, + { + "id": "emby", + "name": "Emby", + "logo": "🎬", + "url": "https://emby.sami" + }, + { + "id": "router", + "name": "Router UI", + "logo": "assets/router.png", + "url": "https://router.sami" + }, + { + "id": "chat", + "name": "Chat", + "logo": "assets/chat.png", + "url": "https://chat.sami" + }, + { + "id": "torrent", + "name": "qBittorrent", + "logo": "assets/qBittorrent.png", + "url": "https://torrent.sami" + }, + { + "id": "sync", + "name": "Syncthing", + "logo": "assets/syncthing.png", + "url": "https://sync.sami" + }, + { + "id": "radarr", + "name": "Radarr", + "logo": "assets/radarr.png", + "url": "https://radarr.sami" + }, + { + "id": "sonarr", + "name": "Sonarr", + "logo": "assets/sonarr.png", + "url": "https://sonarr.sami" + }, + { + "id": "prowlarr", + "name": "Prowlarr", + "logo": "assets/prowlarr.png", + "url": "https://prowlarr.sami" + } +] \ No newline at end of file diff --git a/status/assets/.htaccess b/status/assets/.htaccess new file mode 100644 index 0000000..b44f090 --- /dev/null +++ b/status/assets/.htaccess @@ -0,0 +1,21 @@ +# Font file headers to prevent sanitizer issues + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, POST, OPTIONS" + Header set Access-Control-Allow-Headers "Content-Type" + Header set Cache-Control "public, max-age=31536000" + + # Proper MIME types + + AddType font/woff2 .woff2 + AddType font/woff .woff + AddType font/ttf .ttf + AddType application/vnd.ms-fontobject .eot + + + +# Prevent direct access to font conversion scripts + + Order allow,deny + Deny from all + \ No newline at end of file diff --git a/status/assets/SAMI-CLOUD.png b/status/assets/SAMI-CLOUD.png new file mode 100644 index 0000000..d4d00f5 Binary files /dev/null and b/status/assets/SAMI-CLOUD.png differ diff --git a/status/assets/SAMI-CLOUD.svg b/status/assets/SAMI-CLOUD.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/status/assets/SAMI-CLOUD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/apple-touch-icon.png b/status/assets/apple-touch-icon.png new file mode 100644 index 0000000..c6fa919 Binary files /dev/null and b/status/assets/apple-touch-icon.png differ diff --git a/status/assets/chat.png b/status/assets/chat.png new file mode 100644 index 0000000..63735ad Binary files /dev/null and b/status/assets/chat.png differ diff --git a/status/assets/cloud-favicon-512.png b/status/assets/cloud-favicon-512.png new file mode 100644 index 0000000..af4acae Binary files /dev/null and b/status/assets/cloud-favicon-512.png differ diff --git a/status/assets/custom-logo.png b/status/assets/custom-logo.png new file mode 100644 index 0000000..d4d00f5 Binary files /dev/null and b/status/assets/custom-logo.png differ diff --git a/status/assets/dashcaddy-favicon.ico b/status/assets/dashcaddy-favicon.ico new file mode 100644 index 0000000..a796077 Binary files /dev/null and b/status/assets/dashcaddy-favicon.ico differ diff --git a/status/assets/dashcaddy-favicon.png b/status/assets/dashcaddy-favicon.png new file mode 100644 index 0000000..4fb8ce9 Binary files /dev/null and b/status/assets/dashcaddy-favicon.png differ diff --git a/status/assets/dashcaddy-logo-dark.png b/status/assets/dashcaddy-logo-dark.png new file mode 100644 index 0000000..3fd4d58 Binary files /dev/null and b/status/assets/dashcaddy-logo-dark.png differ diff --git a/status/assets/dashcaddy-logo-light.png b/status/assets/dashcaddy-logo-light.png new file mode 100644 index 0000000..58b4646 Binary files /dev/null and b/status/assets/dashcaddy-logo-light.png differ diff --git a/status/assets/dashcaddy-logo.svg b/status/assets/dashcaddy-logo.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/status/assets/dashcaddy-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/emby.png b/status/assets/emby.png new file mode 100644 index 0000000..ad7a8cd Binary files /dev/null and b/status/assets/emby.png differ diff --git a/status/assets/favicon.ico b/status/assets/favicon.ico new file mode 100644 index 0000000..cf04d23 Binary files /dev/null and b/status/assets/favicon.ico differ diff --git a/status/assets/favicon.svg b/status/assets/favicon.svg new file mode 100644 index 0000000..adcf3b4 --- /dev/null +++ b/status/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/fonts.css b/status/assets/fonts.css new file mode 100644 index 0000000..729c28c --- /dev/null +++ b/status/assets/fonts.css @@ -0,0 +1,91 @@ +/* Sami Sans Font Family - External CSS */ + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Italic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Medium.woff2') format('woff2'), + url('fonts/SamiSans-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'), + url('fonts/SamiSans-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Bold.woff2') format('woff2'), + url('fonts/SamiSans-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'), + url('fonts/SamiSans-ExtraBold.ttf') format('truetype'); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Black.woff2') format('woff2'), + url('fonts/SamiSans-Black.ttf') format('truetype'); + font-weight: 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Light.woff2') format('woff2'), + url('fonts/SamiSans-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'), + url('fonts/SamiSans-ExtraLight.ttf') format('truetype'); + font-weight: 200; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Thin.woff2') format('woff2'), + url('fonts/SamiSans-Thin.ttf') format('truetype'); + font-weight: 100; + font-style: normal; + font-display: swap; +} diff --git a/status/assets/fonts/DSEG7Classic-Bold.ttf b/status/assets/fonts/DSEG7Classic-Bold.ttf new file mode 100644 index 0000000..5f71db4 Binary files /dev/null and b/status/assets/fonts/DSEG7Classic-Bold.ttf differ diff --git a/status/assets/fonts/DSEG7Classic-Bold.woff2 b/status/assets/fonts/DSEG7Classic-Bold.woff2 new file mode 100644 index 0000000..558eec4 Binary files /dev/null and b/status/assets/fonts/DSEG7Classic-Bold.woff2 differ diff --git a/status/assets/fonts/SamiSans-Black.ttf b/status/assets/fonts/SamiSans-Black.ttf new file mode 100644 index 0000000..c44894b Binary files /dev/null and b/status/assets/fonts/SamiSans-Black.ttf differ diff --git a/status/assets/fonts/SamiSans-Black.woff2 b/status/assets/fonts/SamiSans-Black.woff2 new file mode 100644 index 0000000..6929b36 Binary files /dev/null and b/status/assets/fonts/SamiSans-Black.woff2 differ diff --git a/status/assets/fonts/SamiSans-BlackItalic.ttf b/status/assets/fonts/SamiSans-BlackItalic.ttf new file mode 100644 index 0000000..eccde7d Binary files /dev/null and b/status/assets/fonts/SamiSans-BlackItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-BlackItalic.woff2 b/status/assets/fonts/SamiSans-BlackItalic.woff2 new file mode 100644 index 0000000..3048ee4 Binary files /dev/null and b/status/assets/fonts/SamiSans-BlackItalic.woff2 differ diff --git a/status/assets/fonts/SamiSans-Bold.ttf b/status/assets/fonts/SamiSans-Bold.ttf new file mode 100644 index 0000000..6bc519f Binary files /dev/null and b/status/assets/fonts/SamiSans-Bold.ttf differ diff --git a/status/assets/fonts/SamiSans-Bold.woff2 b/status/assets/fonts/SamiSans-Bold.woff2 new file mode 100644 index 0000000..185b3a3 Binary files /dev/null and b/status/assets/fonts/SamiSans-Bold.woff2 differ diff --git a/status/assets/fonts/SamiSans-BoldItalic.ttf b/status/assets/fonts/SamiSans-BoldItalic.ttf new file mode 100644 index 0000000..a47ea80 Binary files /dev/null and b/status/assets/fonts/SamiSans-BoldItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-BoldItalic.woff2 b/status/assets/fonts/SamiSans-BoldItalic.woff2 new file mode 100644 index 0000000..62f36be Binary files /dev/null and b/status/assets/fonts/SamiSans-BoldItalic.woff2 differ diff --git a/status/assets/fonts/SamiSans-ExtraBold.ttf b/status/assets/fonts/SamiSans-ExtraBold.ttf new file mode 100644 index 0000000..8cdb5a9 Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraBold.ttf differ diff --git a/status/assets/fonts/SamiSans-ExtraBold.woff2 b/status/assets/fonts/SamiSans-ExtraBold.woff2 new file mode 100644 index 0000000..7ce126b Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraBold.woff2 differ diff --git a/status/assets/fonts/SamiSans-ExtraBoldItalic.ttf b/status/assets/fonts/SamiSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000..9e0cc55 Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraBoldItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-ExtraBoldItalic.woff2 b/status/assets/fonts/SamiSans-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..521db52 Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraBoldItalic.woff2 differ diff --git a/status/assets/fonts/SamiSans-ExtraLight.ttf b/status/assets/fonts/SamiSans-ExtraLight.ttf new file mode 100644 index 0000000..b39c51e Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraLight.ttf differ diff --git a/status/assets/fonts/SamiSans-ExtraLight.woff2 b/status/assets/fonts/SamiSans-ExtraLight.woff2 new file mode 100644 index 0000000..a0890de Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraLight.woff2 differ diff --git a/status/assets/fonts/SamiSans-ExtraLightItalic.ttf b/status/assets/fonts/SamiSans-ExtraLightItalic.ttf new file mode 100644 index 0000000..5222446 Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraLightItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-ExtraLightItalic.woff2 b/status/assets/fonts/SamiSans-ExtraLightItalic.woff2 new file mode 100644 index 0000000..f23459a Binary files /dev/null and b/status/assets/fonts/SamiSans-ExtraLightItalic.woff2 differ diff --git a/status/assets/fonts/SamiSans-Italic.ttf b/status/assets/fonts/SamiSans-Italic.ttf new file mode 100644 index 0000000..97fcbe3 Binary files /dev/null and b/status/assets/fonts/SamiSans-Italic.ttf differ diff --git a/status/assets/fonts/SamiSans-Italic.woff2 b/status/assets/fonts/SamiSans-Italic.woff2 new file mode 100644 index 0000000..d7e72d1 Binary files /dev/null and b/status/assets/fonts/SamiSans-Italic.woff2 differ diff --git a/status/assets/fonts/SamiSans-Light.ttf b/status/assets/fonts/SamiSans-Light.ttf new file mode 100644 index 0000000..446e73c Binary files /dev/null and b/status/assets/fonts/SamiSans-Light.ttf differ diff --git a/status/assets/fonts/SamiSans-Light.woff2 b/status/assets/fonts/SamiSans-Light.woff2 new file mode 100644 index 0000000..9c4227f Binary files /dev/null and b/status/assets/fonts/SamiSans-Light.woff2 differ diff --git a/status/assets/fonts/SamiSans-LightItalic.ttf b/status/assets/fonts/SamiSans-LightItalic.ttf new file mode 100644 index 0000000..ab054fc Binary files /dev/null and b/status/assets/fonts/SamiSans-LightItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-LightItalic.woff2 b/status/assets/fonts/SamiSans-LightItalic.woff2 new file mode 100644 index 0000000..6ea5bc7 Binary files /dev/null and b/status/assets/fonts/SamiSans-LightItalic.woff2 differ diff --git a/status/assets/fonts/SamiSans-Medium.ttf b/status/assets/fonts/SamiSans-Medium.ttf new file mode 100644 index 0000000..d440388 Binary files /dev/null and b/status/assets/fonts/SamiSans-Medium.ttf differ diff --git a/status/assets/fonts/SamiSans-Medium.woff2 b/status/assets/fonts/SamiSans-Medium.woff2 new file mode 100644 index 0000000..bc2048c Binary files /dev/null and b/status/assets/fonts/SamiSans-Medium.woff2 differ diff --git a/status/assets/fonts/SamiSans-MediumItalic.ttf b/status/assets/fonts/SamiSans-MediumItalic.ttf new file mode 100644 index 0000000..d61074b Binary files /dev/null and b/status/assets/fonts/SamiSans-MediumItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-MediumItalic.woff2 b/status/assets/fonts/SamiSans-MediumItalic.woff2 new file mode 100644 index 0000000..05f6488 Binary files /dev/null and b/status/assets/fonts/SamiSans-MediumItalic.woff2 differ diff --git a/status/assets/fonts/SamiSans-Regular.ttf b/status/assets/fonts/SamiSans-Regular.ttf new file mode 100644 index 0000000..09cdb41 Binary files /dev/null and b/status/assets/fonts/SamiSans-Regular.ttf differ diff --git a/status/assets/fonts/SamiSans-Regular.woff2 b/status/assets/fonts/SamiSans-Regular.woff2 new file mode 100644 index 0000000..55b58bd Binary files /dev/null and b/status/assets/fonts/SamiSans-Regular.woff2 differ diff --git a/status/assets/fonts/SamiSans-SemiBold.ttf b/status/assets/fonts/SamiSans-SemiBold.ttf new file mode 100644 index 0000000..956a313 Binary files /dev/null and b/status/assets/fonts/SamiSans-SemiBold.ttf differ diff --git a/status/assets/fonts/SamiSans-SemiBold.woff2 b/status/assets/fonts/SamiSans-SemiBold.woff2 new file mode 100644 index 0000000..e14f439 Binary files /dev/null and b/status/assets/fonts/SamiSans-SemiBold.woff2 differ diff --git a/status/assets/fonts/SamiSans-SemiBoldItalic.ttf b/status/assets/fonts/SamiSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..494082a Binary files /dev/null and b/status/assets/fonts/SamiSans-SemiBoldItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-SemiBoldItalic.woff2 b/status/assets/fonts/SamiSans-SemiBoldItalic.woff2 new file mode 100644 index 0000000..406aa7d Binary files /dev/null and b/status/assets/fonts/SamiSans-SemiBoldItalic.woff2 differ diff --git a/status/assets/fonts/SamiSans-Thin.ttf b/status/assets/fonts/SamiSans-Thin.ttf new file mode 100644 index 0000000..1e50ae1 Binary files /dev/null and b/status/assets/fonts/SamiSans-Thin.ttf differ diff --git a/status/assets/fonts/SamiSans-Thin.woff2 b/status/assets/fonts/SamiSans-Thin.woff2 new file mode 100644 index 0000000..281a96a Binary files /dev/null and b/status/assets/fonts/SamiSans-Thin.woff2 differ diff --git a/status/assets/fonts/SamiSans-ThinItalic.ttf b/status/assets/fonts/SamiSans-ThinItalic.ttf new file mode 100644 index 0000000..8eb93ba Binary files /dev/null and b/status/assets/fonts/SamiSans-ThinItalic.ttf differ diff --git a/status/assets/fonts/SamiSans-ThinItalic.woff2 b/status/assets/fonts/SamiSans-ThinItalic.woff2 new file mode 100644 index 0000000..03a9b3f Binary files /dev/null and b/status/assets/fonts/SamiSans-ThinItalic.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf new file mode 100644 index 0000000..ea4b04f Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Black.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-Black.woff2 new file mode 100644 index 0000000..655d944 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Black.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf new file mode 100644 index 0000000..005a92a Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.woff2 new file mode 100644 index 0000000..8b478a8 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf new file mode 100644 index 0000000..7ee1c38 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Bold.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-Bold.woff2 new file mode 100644 index 0000000..7df48e5 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Bold.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf new file mode 100644 index 0000000..6f2f6ba Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.woff2 new file mode 100644 index 0000000..84755c3 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf new file mode 100644 index 0000000..269b97d Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Light.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-Light.woff2 new file mode 100644 index 0000000..0137d31 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Light.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf new file mode 100644 index 0000000..d10a4c6 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.woff2 new file mode 100644 index 0000000..d127d87 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf new file mode 100644 index 0000000..0dca4b4 Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Medium.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-Medium.woff2 new file mode 100644 index 0000000..51fbd9a Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Medium.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf new file mode 100644 index 0000000..4ab921f Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.woff2 new file mode 100644 index 0000000..a6e849a Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf new file mode 100644 index 0000000..2d3b18d Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-Regular.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-Regular.woff2 new file mode 100644 index 0000000..51506fd Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-Regular.woff2 differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf b/status/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf new file mode 100644 index 0000000..6c15c7e Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf differ diff --git a/status/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.woff2 b/status/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.woff2 new file mode 100644 index 0000000..bf440dc Binary files /dev/null and b/status/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.woff2 differ diff --git a/status/assets/icon-192.png b/status/assets/icon-192.png new file mode 100644 index 0000000..07a6369 Binary files /dev/null and b/status/assets/icon-192.png differ diff --git a/status/assets/icon-512.png b/status/assets/icon-512.png new file mode 100644 index 0000000..af4acae Binary files /dev/null and b/status/assets/icon-512.png differ diff --git a/status/assets/jellyfin.png b/status/assets/jellyfin.png new file mode 100644 index 0000000..c8c07ef Binary files /dev/null and b/status/assets/jellyfin.png differ diff --git a/status/assets/nginx.png b/status/assets/nginx.png new file mode 100644 index 0000000..07a6369 Binary files /dev/null and b/status/assets/nginx.png differ diff --git a/status/assets/pics.png b/status/assets/pics.png new file mode 100644 index 0000000..951ecfe Binary files /dev/null and b/status/assets/pics.png differ diff --git a/status/assets/plex.png b/status/assets/plex.png new file mode 100644 index 0000000..f30efe3 Binary files /dev/null and b/status/assets/plex.png differ diff --git a/status/assets/prowlarr.png b/status/assets/prowlarr.png new file mode 100644 index 0000000..964f18b Binary files /dev/null and b/status/assets/prowlarr.png differ diff --git a/status/assets/qBittorrent.png b/status/assets/qBittorrent.png new file mode 100644 index 0000000..97bdb6c Binary files /dev/null and b/status/assets/qBittorrent.png differ diff --git a/status/assets/radarr.png b/status/assets/radarr.png new file mode 100644 index 0000000..49943a5 Binary files /dev/null and b/status/assets/radarr.png differ diff --git a/status/assets/router.png b/status/assets/router.png new file mode 100644 index 0000000..241b9aa Binary files /dev/null and b/status/assets/router.png differ diff --git a/status/assets/sami7777-logo.png b/status/assets/sami7777-logo.png new file mode 100644 index 0000000..ed98888 Binary files /dev/null and b/status/assets/sami7777-logo.png differ diff --git a/status/assets/site.webmanifest b/status/assets/site.webmanifest new file mode 100644 index 0000000..e83acb0 --- /dev/null +++ b/status/assets/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "SAMI-CLOUD Status", + "short_name": "SAMI-CLOUD", + "start_url": "index.html", + "display": "standalone", + "background_color": "#0b0f1a", + "theme_color": "#0e1116", + "icons": [ + { + "src": "assets/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "assets/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/status/assets/sonarr.png b/status/assets/sonarr.png new file mode 100644 index 0000000..c904551 Binary files /dev/null and b/status/assets/sonarr.png differ diff --git a/status/assets/sounds/church-bell.mp3 b/status/assets/sounds/church-bell.mp3 new file mode 100644 index 0000000..a6cd645 Binary files /dev/null and b/status/assets/sounds/church-bell.mp3 differ diff --git a/status/assets/syncthing.png b/status/assets/syncthing.png new file mode 100644 index 0000000..ee5e1ec Binary files /dev/null and b/status/assets/syncthing.png differ diff --git a/status/assets/weather/clear-day.svg b/status/assets/weather/clear-day.svg new file mode 100644 index 0000000..d0d36ca --- /dev/null +++ b/status/assets/weather/clear-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/clear-night.svg b/status/assets/weather/clear-night.svg new file mode 100644 index 0000000..bd3f1cb --- /dev/null +++ b/status/assets/weather/clear-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/cloudy.svg b/status/assets/weather/cloudy.svg new file mode 100644 index 0000000..b868d87 --- /dev/null +++ b/status/assets/weather/cloudy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/drizzle.svg b/status/assets/weather/drizzle.svg new file mode 100644 index 0000000..27513c8 --- /dev/null +++ b/status/assets/weather/drizzle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/fog.svg b/status/assets/weather/fog.svg new file mode 100644 index 0000000..12208db --- /dev/null +++ b/status/assets/weather/fog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/partly-cloudy-day.svg b/status/assets/weather/partly-cloudy-day.svg new file mode 100644 index 0000000..6fcec43 --- /dev/null +++ b/status/assets/weather/partly-cloudy-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/partly-cloudy-night.svg b/status/assets/weather/partly-cloudy-night.svg new file mode 100644 index 0000000..2c49905 --- /dev/null +++ b/status/assets/weather/partly-cloudy-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/rain.svg b/status/assets/weather/rain.svg new file mode 100644 index 0000000..74b33d3 --- /dev/null +++ b/status/assets/weather/rain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/sleet.svg b/status/assets/weather/sleet.svg new file mode 100644 index 0000000..03d6a3a --- /dev/null +++ b/status/assets/weather/sleet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/snow.svg b/status/assets/weather/snow.svg new file mode 100644 index 0000000..e444068 --- /dev/null +++ b/status/assets/weather/snow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/thunderstorm.svg b/status/assets/weather/thunderstorm.svg new file mode 100644 index 0000000..390f11b --- /dev/null +++ b/status/assets/weather/thunderstorm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/assets/weather/wind.svg b/status/assets/weather/wind.svg new file mode 100644 index 0000000..55d168c --- /dev/null +++ b/status/assets/weather/wind.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/status/build.js b/status/build.js new file mode 100644 index 0000000..966a260 --- /dev/null +++ b/status/build.js @@ -0,0 +1,127 @@ +const fs = require('fs'); +const path = require('path'); +const esbuild = require('esbuild'); + +const JS = (...parts) => path.join(__dirname, 'js', ...parts); +const DIST = path.join(__dirname, 'dist'); + +// Bundle definitions — files are concatenated in order, then minified +const bundles = { + 'core.js': [ + JS('globals.js'), + JS('skeleton-loader.js'), + JS('theme.js'), + JS('totp-auth.js'), + JS('service-credentials.js'), + JS('totp-settings.js'), + JS('core', 'credentials.js'), + JS('core', 'grid.js'), + JS('core', 'dns.js'), + JS('core', 'logs.js'), + JS('core', 'service-modals.js'), + JS('core', 'service-infrastructure.js'), + JS('core', 'service-crud.js'), + JS('core', 'service-create.js'), + ], + 'features.js': [ + JS('logo-customization.js'), + JS('setup-wizard.js'), + JS('app-selector.js'), + JS('recipes.js'), + JS('import-export.js'), + JS('error-logs.js'), + JS('smart-arr-connect.js'), + JS('notification-settings.js'), + JS('panel-tabs.js'), + JS('backup-restore.js'), + JS('resource-monitor.js'), + JS('health-check.js'), + JS('update-management.js'), + JS('audit-log.js'), + JS('weather.js'), + JS('clock.js'), + JS('card-badges.js'), + JS('theme-builder.js'), + JS('license.js'), + ], + 'onboarding.js': [ + JS('driver.min.js'), + JS('error-handler.js'), + JS('progress-tracker.js'), + JS('theme-adapter.js'), + JS('tooltip-definitions.js'), + JS('dns-template-selector.js'), + JS('tour-manager.js'), + JS('onboarding.js'), + ], + 'init.js': [ + JS('core', 'init.js'), + JS('keyboard-shortcuts.js'), + ], +}; + +async function build() { + // Ensure dist/ exists + if (!fs.existsSync(DIST)) fs.mkdirSync(DIST); + + const results = {}; + + for (const [outName, files] of Object.entries(bundles)) { + // Read and concatenate + const parts = []; + for (const file of files) { + if (!fs.existsSync(file)) { + console.warn(` WARN: ${path.relative(__dirname, file)} not found, skipping`); + continue; + } + parts.push(fs.readFileSync(file, 'utf8')); + } + const concatenated = parts.join(';\n'); + + // Minify with esbuild (safe to re-minify already-minified code like driver.min.js) + const { code } = await esbuild.transform(concatenated, { + minify: true, + target: 'es2020', + }); + + const outPath = path.join(DIST, outName); + fs.writeFileSync(outPath, code); + + const rawSize = (Buffer.byteLength(concatenated) / 1024).toFixed(1); + const minSize = (Buffer.byteLength(code) / 1024).toFixed(1); + results[outName] = { rawSize, minSize, fileCount: files.length }; + } + + // Summary + console.log('\n DashCaddy Frontend Build\n'); + console.log(' Bundle Files Raw Min'); + console.log(' ─────────────────────────────────────────'); + let totalRaw = 0, totalMin = 0; + for (const [name, r] of Object.entries(results)) { + console.log(` ${name.padEnd(18)} ${String(r.fileCount).padStart(3)} ${r.rawSize.padStart(6)} KB ${r.minSize.padStart(6)} KB`); + totalRaw += parseFloat(r.rawSize); + totalMin += parseFloat(r.minSize); + } + console.log(' ─────────────────────────────────────────'); + console.log(` ${'Total'.padEnd(18)} ${totalRaw.toFixed(1).padStart(6)} KB ${totalMin.toFixed(1).padStart(6)} KB`); + console.log(`\n Output: ${DIST}\n`); +} + +// Watch mode +if (process.argv.includes('--watch')) { + console.log(' Watching for changes...\n'); + build(); + + const jsDir = path.join(__dirname, 'js'); + let debounce = null; + fs.watch(jsDir, { recursive: true }, (event, filename) => { + if (!filename || !filename.endsWith('.js')) return; + clearTimeout(debounce); + debounce = setTimeout(() => { + console.log(` Changed: ${filename}`); + build(); + }, 200); + }); +} else { + build(); +} diff --git a/status/css/dashboard.css b/status/css/dashboard.css new file mode 100644 index 0000000..0c559d8 --- /dev/null +++ b/status/css/dashboard.css @@ -0,0 +1,3694 @@ +/* Sami Grotesk Custom Font Family */ +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Light.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; + font-display: block; +} + +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Regular.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: block; +} + +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Medium.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: block; +} + +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Bold.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: block; +} + +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Black.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf') format('truetype'); + font-weight: 900; + font-style: normal; + font-display: block; +} + +/* Italic variants */ +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; + font-display: block; +} + +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf') format('truetype'); + font-weight: 500; + font-style: italic; + font-display: block; +} + +@font-face { + font-family: 'Sami Grotesk'; + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.woff2') format('woff2'), + url('/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf') format('truetype'); + font-weight: 700; + font-style: italic; + font-display: block; +} + +/* Theme variables and transitions are in themes.css */ + +* { + box-sizing: border-box +} + +html, +body { + height: 100% +} + +body { + margin: 0; + padding: 24px; + background: + radial-gradient(1200px 900px at 8% -12%, rgba(96, 128, 255, .18), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(150, 60, 255, .12), transparent 55%), + radial-gradient(900px 650px at 50% 120%, rgba(30, 60, 160, .16), transparent 60%), + var(--bg); + color: var(--fg); + font: 16px/1.5 'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* Per-theme body backgrounds are in themes.css */ + +/* Optional: faint watermark */ +body::before { + content: ""; + position: fixed; + inset: auto auto 6% 50%; + translate: -50% 0; + width: min(60vw, 900px); + aspect-ratio: 3 / 1; + background: url(/assets/dashcaddy-logo-dark.png) center/contain no-repeat; + opacity: .06; + filter: blur(.4px); + pointer-events: none; + z-index: 0; +} + +.muted { + color: var(--muted) +} + +button { + padding: .38rem .75rem; + border-radius: 10px; + border: 1px solid var(--border); + background: transparent; + color: var(--fg); + cursor: pointer; + font-size: .88rem; + font-family: 'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, "Helvetica Neue", Arial, sans-serif; + transition: background-color .15s ease, border-color .15s ease, transform .12s ease, box-shadow .15s ease, backdrop-filter .15s ease; +} + +/* (1) Glassy button hover effects */ +button:hover { + background: color-mix(in srgb, var(--accent) 20%, transparent); + backdrop-filter: blur(4px) saturate(160%); + box-shadow: 0 0 4px rgba(255, 255, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border) 60%); +} + +/* Per-theme button hover overrides are in themes.css */ + +button:active { + transform: translateY(1px) +} + +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* ===== Top bar: logo and weather on top row, tools below ===== */ +.bar { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 0 8px; + padding-right: 8px; +} + +.top-row { + display: flex; + align-items: flex-start; + width: 100%; + position: relative; +} + +.brand-weather-group { + display: flex; + align-items: center; + gap: 20px; +} + +.reload-caddy-container { + position: absolute; + right: 0; + top: 0; + padding-top: 10px; + display: flex; + align-items: center; + gap: 18px; +} + +.license-status-topbar { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + border: 1px solid transparent; +} +.license-status-topbar:hover { + opacity: 0.85; +} +.license-status-topbar.premium { + background: rgba(46, 204, 113, 0.12); + color: var(--ok-fg); + border-color: rgba(46, 204, 113, 0.25); +} +.license-status-topbar.free { + background: rgba(149, 165, 166, 0.12); + color: var(--muted); + border-color: rgba(149, 165, 166, 0.25); +} +.license-status-topbar .license-topbar-time { + font-weight: 400; + font-size: 0.78rem; + opacity: 0.85; + margin-left: 2px; +} + +.tools-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-left: 0; +} + +/* ===== Collapsible Toolbar Sections ===== */ +.tools-primary { + flex-wrap: wrap; +} + +.tools-sections { + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: flex-start; +} + +.tools-section { + display: flex; + align-items: center; + gap: 0; + flex-wrap: wrap; +} + +.tools-section-header { + display: inline-flex; + align-items: center; + gap: 4px; + padding: .28rem .6rem !important; + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent) !important; + background: color-mix(in srgb, var(--card-base) 40%, transparent) !important; + color: var(--muted) !important; + font-size: .74rem !important; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .6px; + border-radius: 8px !important; + cursor: pointer; + transition: all .15s ease; + line-height: 1; + white-space: nowrap; + min-width: auto; +} + +.tools-section-header:hover { + color: var(--fg) !important; + border-color: var(--border) !important; + background: color-mix(in srgb, var(--accent) 8%, transparent) !important; + backdrop-filter: none !important; + box-shadow: none !important; +} + +.tools-section-arrow { + font-size: .65rem; + transition: transform .2s ease; + display: inline-block; +} + +.tools-section.open .tools-section-arrow { + transform: rotate(90deg); +} + +.tools-section-label { + pointer-events: none; +} + +.tools-section-items { + display: none; + gap: 6px; + align-items: center; + margin-left: 4px; + animation: sectionFadeIn .2s ease; +} + +.tools-section.open .tools-section-items { + display: flex; +} + +@keyframes sectionFadeIn { + from { opacity: 0; transform: translateX(-6px); } + to { opacity: 1; transform: translateX(0); } +} + +#brand { + display: flex; + align-items: flex-end; + gap: 12px; + min-height: calc(var(--brand-h) + 4px); + cursor: pointer; + transition: opacity 0.2s; +} +#brand:hover { + opacity: 0.85; +} + +/* Logo position variants - logo and weather grouped together */ +/* Left: Logo then Weather then Clock, group on left */ +.top-row[data-logo-pos="left"] .brand-weather-group { margin-right: auto; } +.top-row[data-logo-pos="left"] #brand { order: 0; } +.top-row[data-logo-pos="left"] .weather-widget-container { order: 1; } +.top-row[data-logo-pos="left"] .clock-widget-container { order: 2; } + +/* Center: Logo then Weather then Clock, group centered */ +.top-row[data-logo-pos="center"] .brand-weather-group { margin: 0 auto; } +.top-row[data-logo-pos="center"] #brand { order: 0; } +.top-row[data-logo-pos="center"] .weather-widget-container { order: 1; } +.top-row[data-logo-pos="center"] .clock-widget-container { order: 2; } + +/* Right: Clock then Weather then Logo, group on right - weather aligned to logo center */ +.top-row[data-logo-pos="right"] .brand-weather-group { margin-left: auto; gap: 150px; align-items: center; } +.top-row[data-logo-pos="right"] #brand { order: 2; } +.top-row[data-logo-pos="right"] .weather-widget-container { order: 1; margin-top: -80px; } +.top-row[data-logo-pos="right"] .clock-widget-container { order: 0; margin-top: -80px; } + +#brand img { + height: var(--brand-h); + width: auto; + max-width: 70vw; + display: block; + filter: drop-shadow(0 3px 10px rgba(0, 0, 0, .38)); +} +#brand .brand-logo-light { display: none; } +:root.light-bg #brand .brand-logo-dark { display: none; } +:root.light-bg #brand .brand-logo-light { display: block; } + +/* Weather widget positioned directly right of logo */ +.weather-widget-container { + display: flex; + align-items: center; + margin-bottom: 0; + flex-shrink: 1; +} + +.tools { + display: flex; + gap: 10px; + align-items: center +} + +.tools .chip { + font-size: .8rem; + padding: .3rem .55rem; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent +} + +/* Weather Widget - Large, no background, same line as logo */ +.weather-widget { + display: flex; + align-items: flex-end; + gap: 16px; + padding: 0; + border: none; + background: transparent; + backdrop-filter: none; + position: relative; + min-width: 0; + box-shadow: none; + margin-bottom: 0; + max-width: 100%; +} + +.weather-content { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.weather-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.weather-icon-img { + width: clamp(80px, 9vw, 140px); + height: auto; + filter: drop-shadow(0 3px 12px rgba(0, 0, 0, .4)); + object-fit: contain; +} + +.weather-emoji { + font-size: clamp(3rem, 6vw, 6rem); + line-height: 1; + font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", emoji; + filter: drop-shadow(0 3px 12px rgba(0, 0, 0, .4)); +} + +.weather-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + align-items: flex-start; + flex: 1; +} + +.weather-location { + font-size: clamp(0.8rem, 1vw, 1rem); + color: var(--muted); + line-height: 1.2; + font-weight: 700; + white-space: nowrap; + overflow: visible; + text-overflow: clip; + max-width: 100%; + margin-bottom: 4px; +} + +.weather-temp { + font-weight: 800; + font-size: clamp(1.5rem, 2.5vw, 2.5rem); + line-height: 1; + text-shadow: 0 2px 4px rgba(0, 0, 0, .4); + color: var(--fg); + white-space: nowrap; +} + +:root.light .weather-temp { + text-shadow: 0 1px 2px rgba(0, 0, 0, .2); +} + +.weather-condition { + font-size: clamp(0.75rem, 0.9vw, 0.9rem); + color: var(--fg); + line-height: 1.2; + font-weight: 600; + white-space: nowrap; + text-transform: capitalize; +} + +.weather-wind { + font-size: clamp(0.7rem, 0.85vw, 0.85rem); + color: var(--muted); + line-height: 1.2; + font-weight: 500; + white-space: nowrap; +} + +.weather-settings-btn { + padding: 6px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: 6px; + font-size: 1.2rem; + transition: all .15s ease; + opacity: .6; + margin-left: 8px; +} + +.weather-settings-btn:hover { + background: rgba(255, 255, 255, .1); + color: var(--fg); + opacity: 1; + transform: scale(1.15); +} + +/* Weather settings button at bottom */ +.weather-settings-btn-bottom { + padding: 4px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: 4px; + font-size: 0.9rem; + transition: all .15s ease; + opacity: .7; + margin-top: 4px; + align-self: flex-start; +} + +.weather-settings-btn-bottom:hover { + background: rgba(255, 255, 255, .1); + color: var(--fg); + opacity: 1; + transform: scale(1.1); +} + +/* LCD Clock Font */ +@font-face { + font-family: 'DSEG7'; + src: url('/assets/fonts/DSEG7Classic-Bold.woff2') format('woff2'), + url('/assets/fonts/DSEG7Classic-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +/* Digital Clock Widget */ +.clock-widget-container { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.clock-widget { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 0; + border: none; + background: transparent; +} + +.clock-time { + font-size: clamp(1.8rem, 3vw, 3.2rem); + font-weight: 700; + color: var(--fg); + line-height: 1.1; + letter-spacing: 2px; + font-variant-numeric: tabular-nums; + font-family: inherit; + text-shadow: 0 2px 8px rgba(0, 0, 0, .25); +} + +.clock-time .clock-ampm { + font-size: 0.45em; + font-weight: 600; + color: var(--muted); + letter-spacing: 1px; + margin-left: 4px; + vertical-align: baseline; +} + +.clock-time .clock-seconds { + font-size: 0.55em; + color: var(--muted); + font-weight: 500; +} + +.clock-date { + font-size: clamp(0.75rem, 1vw, 0.95rem); + color: var(--muted); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +/* Clock settings gear button */ +.clock-settings-btn { + padding: 4px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: 4px; + font-size: 0.9rem; + transition: all .15s ease; + opacity: .7; + margin-top: 4px; + align-self: flex-start; +} + +.clock-settings-btn:hover { + background: rgba(255, 255, 255, .1); + color: var(--fg); + opacity: 1; + transform: scale(1.1); +} + +/* LCD Green Clock Style */ +.clock-widget.lcd { + background: rgba(0, 0, 0, 0.6); + border-radius: 8px; + padding: 8px 14px; + border: 1px solid rgba(0, 255, 65, 0.15); +} +.clock-widget.lcd .clock-time { + font-family: 'DSEG7', monospace; + color: #00ff41; + text-shadow: 0 0 8px rgba(0, 255, 65, 0.6), 0 0 20px rgba(0, 255, 65, 0.3); + letter-spacing: 3px; +} +.clock-widget.lcd .clock-time .clock-ampm, +.clock-widget.lcd .clock-time .clock-seconds { + font-family: 'Sami Grotesk', sans-serif; + color: #00ff41; + opacity: 0.7; + text-shadow: 0 0 6px rgba(0, 255, 65, 0.4); +} +.clock-widget.lcd .clock-date { + font-family: 'Sami Grotesk', sans-serif; + color: #00ff41; + opacity: 0.7; + text-shadow: 0 0 6px rgba(0, 255, 65, 0.3); +} + +/* LCD Blue Clock Style */ +.clock-widget.lcd-blue { + background: rgba(0, 0, 0, 0.6); + border-radius: 8px; + padding: 8px 14px; + border: 1px solid rgba(0, 136, 255, 0.15); +} +.clock-widget.lcd-blue .clock-time { + font-family: 'DSEG7', monospace; + color: #00aaff; + text-shadow: 0 0 8px rgba(0, 136, 255, 0.6), 0 0 20px rgba(0, 136, 255, 0.3); + letter-spacing: 3px; +} +.clock-widget.lcd-blue .clock-time .clock-ampm, +.clock-widget.lcd-blue .clock-time .clock-seconds { + font-family: 'Sami Grotesk', sans-serif; + color: #00aaff; + opacity: 0.7; + text-shadow: 0 0 6px rgba(0, 136, 255, 0.4); +} +.clock-widget.lcd-blue .clock-date { + font-family: 'Sami Grotesk', sans-serif; + color: #00aaff; + opacity: 0.7; + text-shadow: 0 0 6px rgba(0, 136, 255, 0.3); +} + +/* LCD Amber Clock Style */ +.clock-widget.lcd-amber { + background: rgba(0, 0, 0, 0.6); + border-radius: 8px; + padding: 8px 14px; + border: 1px solid rgba(255, 170, 0, 0.15); +} +.clock-widget.lcd-amber .clock-time { + font-family: 'DSEG7', monospace; + color: #ffaa00; + text-shadow: 0 0 8px rgba(255, 170, 0, 0.6), 0 0 20px rgba(255, 170, 0, 0.3); + letter-spacing: 3px; +} +.clock-widget.lcd-amber .clock-time .clock-ampm, +.clock-widget.lcd-amber .clock-time .clock-seconds { + font-family: 'Sami Grotesk', sans-serif; + color: #ffaa00; + opacity: 0.7; + text-shadow: 0 0 6px rgba(255, 170, 0, 0.4); +} +.clock-widget.lcd-amber .clock-date { + font-family: 'Sami Grotesk', sans-serif; + color: #ffaa00; + opacity: 0.7; + text-shadow: 0 0 6px rgba(255, 170, 0, 0.3); +} + +/* LCD Retro Green Clock Style */ +.clock-widget.lcd-retro { + background: #43895a; + border-radius: 8px; + padding: 8px 14px; + border: 2px solid #2d5e3e; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); +} +.clock-widget.lcd-retro .clock-time { + font-family: 'DSEG7', monospace; + color: #0a1f10; + text-shadow: 0 0 2px rgba(10, 31, 16, 0.3); + letter-spacing: 3px; +} +.clock-widget.lcd-retro .clock-time .clock-ampm, +.clock-widget.lcd-retro .clock-time .clock-seconds { + font-family: 'Sami Grotesk', sans-serif; + color: #0a1f10; + opacity: 0.8; +} +.clock-widget.lcd-retro .clock-date { + font-family: 'Sami Grotesk', sans-serif; + color: #0a1f10; + opacity: 0.7; +} + +/* LCD Taxi (Black & Yellow) Clock Style */ +.clock-widget.lcd-taxi { + background: #1a1a1a; + border-radius: 8px; + padding: 8px 14px; + border: 2px solid #ccaa00; + box-shadow: inset 0 0 0 1px rgba(255, 221, 0, 0.08); +} +.clock-widget.lcd-taxi .clock-time { + font-family: 'DSEG7', monospace; + color: #ffdd00; + text-shadow: 0 0 8px rgba(255, 221, 0, 0.6), 0 0 20px rgba(255, 221, 0, 0.25); + letter-spacing: 3px; +} +.clock-widget.lcd-taxi .clock-time .clock-ampm, +.clock-widget.lcd-taxi .clock-time .clock-seconds { + font-family: 'Sami Grotesk', sans-serif; + color: #ffdd00; + opacity: 0.7; + text-shadow: 0 0 6px rgba(255, 221, 0, 0.4); +} +.clock-widget.lcd-taxi .clock-date { + font-family: 'Sami Grotesk', sans-serif; + color: #ffdd00; + opacity: 0.7; + text-shadow: 0 0 6px rgba(255, 221, 0, 0.3); +} + +/* Flip Clock Style */ +.flip-clock-row { + display: flex; + align-items: center; + gap: 3px; +} +.flip-card { + display: flex; + flex-direction: column; + width: clamp(24px, 3vw, 36px); + height: clamp(36px, 4.5vw, 52px); + border-radius: 4px; + overflow: hidden; + position: relative; + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--border); +} +.flip-top, .flip-bottom { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + font-size: clamp(1.2rem, 2vw, 1.8rem); + font-weight: 700; + color: var(--fg); + font-variant-numeric: tabular-nums; +} +.flip-top { + background: rgba(40, 40, 40, 0.8); + border-bottom: 1px solid rgba(0, 0, 0, 0.3); +} +.flip-bottom { + background: rgba(30, 30, 30, 0.8); +} +.flip-card.flipping { + animation: flipBounce 0.3s ease-out; +} +@keyframes flipBounce { + 0% { transform: scaleY(0.85); } + 50% { transform: scaleY(1.05); } + 100% { transform: scaleY(1); } +} +.flip-colon { + font-size: clamp(1.2rem, 2vw, 1.8rem); + font-weight: 700; + color: var(--fg); + padding: 0 2px; + line-height: 1; +} +.flip-ampm { + font-size: clamp(0.6rem, 0.9vw, 0.85rem); + font-weight: 600; + color: var(--muted); + margin-left: 6px; + align-self: flex-end; + margin-bottom: 4px; +} +.clock-widget.flip { + padding: 0; + background: transparent; +} +.clock-widget.flip .clock-date { + margin-top: 4px; +} + +/* Binary Clock Style */ +.binary-clock { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + background: rgba(0, 0, 0, 0.5); + border-radius: 8px; + padding: 10px 14px; + border: 1px solid rgba(0, 200, 255, 0.15); +} +.binary-labels { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 6px; + width: 100%; + text-align: center; + font-size: 0.6rem; + color: rgba(0, 200, 255, 0.5); + font-weight: 600; +} +.binary-row { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 6px; +} +.binary-dot { + width: clamp(10px, 1.5vw, 16px); + height: clamp(10px, 1.5vw, 16px); + border-radius: 50%; + background: rgba(0, 200, 255, 0.1); + border: 1px solid rgba(0, 200, 255, 0.2); + transition: all 0.2s ease; +} +.binary-dot.on { + background: #00c8ff; + box-shadow: 0 0 6px rgba(0, 200, 255, 0.6), 0 0 12px rgba(0, 200, 255, 0.3); + border-color: #00c8ff; +} +.binary-values { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 6px; + width: 100%; + text-align: center; + font-size: 0.7rem; + color: #00c8ff; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.binary-ampm { + font-size: 0.7rem; + color: #00c8ff; + opacity: 0.7; + margin-top: 2px; +} +.clock-widget.binary { + padding: 0; + background: transparent; +} +.clock-widget.binary .clock-date { + color: #00c8ff; + opacity: 0.6; + margin-top: 4px; + text-align: center; +} + +/* Analog Clock Style */ +.analog-clock-wrap { + display: flex; + align-items: center; + gap: 12px; +} +.analog-clock-svg { + flex-shrink: 0; +} +.analog-info { + display: flex; + flex-direction: column; + gap: 2px; +} +.analog-digital { + font-size: clamp(1rem, 1.5vw, 1.4rem); + font-weight: 700; + color: var(--fg); + font-variant-numeric: tabular-nums; +} +.analog-date-sm { + font-size: clamp(0.7rem, 0.9vw, 0.85rem); + color: var(--muted); + font-weight: 600; + white-space: nowrap; +} +.clock-widget.analog, .clock-widget.roman { + padding: 0; + background: transparent; +} +.clock-widget.analog .clock-date, +.clock-widget.roman .clock-date { + display: none; +} + +/* Clock Settings Style Grid */ +.clock-style-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; +} +.clock-style-option { + cursor: pointer; +} +.clock-style-option input[type="radio"] { + display: none; +} +.clock-style-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 6px; + border-radius: 8px; + border: 2px solid var(--border); + transition: all 0.15s ease; + text-align: center; +} +.clock-style-option input:checked + .clock-style-card { + border-color: var(--ok-fg); + background: rgba(46, 204, 113, 0.1); +} +.clock-style-card:hover { + border-color: var(--muted); + background: rgba(255, 255, 255, 0.03); +} +.clock-style-icon { + font-size: 1.5rem; +} +.clock-style-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--fg); +} + +/* Weather Settings Modal */ +.weather-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .6); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.weather-modal.show { + display: flex +} + +.weather-modal-content { + background: var(--card-base); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + min-width: 300px; + box-shadow: 0 20px 60px rgba(0, 0, 0, .4); + position: relative; + resize: both; + overflow: auto; + min-height: 200px; + max-width: 90vw; + max-height: 90vh; +} + +/* Draggable Dialog Enhancements */ +.draggable-dialog { + position: fixed !important; + z-index: 1000; + background: var(--card-base); + border: 2px solid var(--accent); + border-radius: var(--radius); + box-shadow: 0 20px 60px rgba(0, 0, 0, .8); + resize: both; + overflow: auto; + min-width: 300px; + min-height: 200px; + max-width: 95vw; + max-height: 95vh; +} + +.dialog-header { + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + color: var(--bg); + padding: 12px 16px; + margin: -24px -24px 16px -24px; + cursor: move; + user-select: none; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + border-radius: var(--radius) var(--radius) 0 0; +} + +.dialog-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.dialog-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + color: var(--bg); + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: background 0.2s ease; +} + +.dialog-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.dialog-resize-handle { + position: absolute; + bottom: 0; + right: 0; + width: 20px; + height: 20px; + cursor: se-resize; + background: linear-gradient(-45deg, transparent 0%, transparent 40%, var(--accent) 50%, transparent 60%, transparent 100%); +} + +/* Make all modals draggable */ +.logs-modal .logs-modal-content, +.weather-modal .weather-modal-content, +#add-service-modal .weather-modal-content, +#app-selector-modal .app-selector-content { + position: fixed; + resize: both; + overflow: auto; + min-width: 300px; + min-height: 200px; + max-width: 95vw; + max-height: 95vh; +} + +/* Add Service Modal - Ultra Compact */ +#add-service-modal { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + width: 750px !important; + max-width: 95vw !important; + max-height: 92vh !important; + background: var(--card-base) !important; + z-index: 1000 !important; + display: none !important; + overflow-y: auto !important; + border: 2px solid var(--accent) !important; + border-radius: 8px !important; + box-shadow: 0 20px 60px rgba(0, 0, 0, .8) !important; +} + +#add-service-modal.show { + display: block !important; +} + +#add-service-modal .weather-modal-content { + width: 100% !important; + margin: 0 !important; + padding: 14px !important; + background: transparent !important; + border: none !important; +} + +#add-service-modal::-webkit-scrollbar { + width: 8px !important; +} + +#add-service-modal::-webkit-scrollbar-track { + background: var(--bg) !important; +} + +#add-service-modal::-webkit-scrollbar-thumb { + background: var(--accent) !important; + border-radius: 4px !important; +} + +#add-service-modal::-webkit-scrollbar-thumb:hover { + background: var(--accent-strong) !important; +} + +/* Make form elements ultra compact */ +#add-service-modal input, +#add-service-modal select, +#add-service-modal textarea { + font-size: 0.75rem !important; + padding: 4px 6px !important; + margin-bottom: 3px !important; + line-height: 1.2 !important; +} + +#add-service-modal label { + font-size: 0.7rem !important; + margin-bottom: 2px !important; + display: block !important; + line-height: 1.2 !important; +} + +#add-service-modal h3 { + font-size: 0.95rem !important; + margin: 0 0 8px !important; + font-weight: 600 !important; +} + +#add-service-modal h4 { + font-size: 0.8rem !important; + margin: 6px 0 4px !important; + padding-top: 4px !important; + font-weight: 600 !important; +} + +#add-service-modal h4:first-of-type { + padding-top: 0 !important; + margin-top: 0 !important; +} + +/* Compact sections */ +#add-service-modal .weather-modal-content > div { + gap: 6px !important; +} + +#add-service-modal p { + font-size: 0.7rem !important; + margin: 2px 0 4px !important; + line-height: 1.3 !important; +} + +#add-service-modal details { + margin-top: 6px !important; +} + +#add-service-modal details summary { + font-size: 0.75rem !important; + padding: 3px 0 !important; +} + +#add-service-modal details > div { + margin-top: 4px !important; + gap: 6px !important; +} + +/* Quick IP selection buttons - compact */ +.quick-ip-btn { + padding: 3px 8px !important; + font-size: 0.7rem !important; + background: color-mix(in srgb, var(--accent) 15%, transparent) !important; + border: 1px solid var(--accent) !important; + border-radius: 3px !important; + color: var(--accent) !important; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + line-height: 1.2 !important; +} + +.quick-ip-btn:hover { + background: color-mix(in srgb, var(--accent) 30%, transparent) !important; +} + +.quick-ip-btn.active { + background: var(--accent) !important; + color: var(--bg) !important; +} + +.quick-ip-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Compact modal buttons */ +#add-service-modal button { + padding: 5px 10px !important; + font-size: 0.75rem !important; + line-height: 1.2 !important; +} + +#add-service-modal .weather-modal-buttons { + margin-top: 10px !important; + gap: 6px !important; +} + +/* Compact grid spacing */ +#add-service-modal div[style*="grid"] { + gap: 5px !important; +} + +#add-service-modal .quick-ip-buttons { + gap: 4px !important; + margin-top: 3px !important; +} + +.weather-modal h3 { + margin: 0 0 16px; + color: var(--fg) +} + +.weather-modal label { + display: block; + margin-bottom: 8px; + color: var(--fg); + font-size: .9rem +} + +.weather-modal input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg); + font-size: .9rem; + margin-bottom: 16px; +} + +.weather-modal-buttons { + display: flex; + gap: 8px; + justify-content: flex-end +} + +.weather-modal button { + margin: 0 +} + +/* Weather Unit Toggle */ +.weather-unit-option { + cursor: pointer; + flex: 1; +} +.weather-unit-option input[type="radio"] { + display: none; +} +.weather-unit-card { + display: block; + text-align: center; + padding: 8px 12px; + border-radius: 6px; + border: 2px solid var(--border); + font-size: 0.85rem; + font-weight: 600; + color: var(--fg); + transition: all 0.15s ease; +} +.weather-unit-option input:checked + .weather-unit-card { + border-color: var(--ok-fg); + background: rgba(46, 204, 113, 0.1); +} +.weather-unit-card:hover { + border-color: var(--muted); + background: rgba(255, 255, 255, 0.03); +} + +/* Setup Wizard Styles */ +.setup-wizard { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .85); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + backdrop-filter: blur(8px); +} + +.setup-wizard-content { + background: var(--card-base); + border: 2px solid var(--accent); + border-radius: 16px; + padding: 40px; + min-width: 600px; + max-width: 800px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 80px rgba(0, 0, 0, .6); +} + +.setup-step { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.setup-options { + display: grid; + gap: 16px; + margin-bottom: 32px; +} + +.setup-option { + display: flex; + align-items: flex-start; + padding: 20px; + background: var(--card-bg); + border: 2px solid var(--border); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.setup-option:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, .2); +} + +.setup-option input[type="radio"] { + margin-right: 16px; + margin-top: 4px; + width: 20px; + height: 20px; + cursor: pointer; +} + +.setup-option input[type="radio"]:checked ~ .setup-option-content { + color: var(--accent); +} + +.setup-option:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 5%, transparent); +} + +.setup-option-content { + flex: 1; +} + +.setup-option-icon { + font-size: 2rem; + margin-bottom: 8px; +} + +.setup-option-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 6px; +} + +.setup-option-desc { + font-size: 0.9rem; + color: var(--muted); + line-height: 1.5; + margin-bottom: 8px; +} + +.setup-option-example { + font-size: 0.85rem; + color: var(--accent); + font-family: monospace; + padding: 6px 10px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-radius: 4px; + display: inline-block; +} + +.setup-wizard-buttons { + display: flex; + gap: 12px; + margin-top: 32px; + align-items: center; + position: relative; + z-index: 10; +} + +.setup-wizard-buttons button { + padding: 12px 24px; + border-radius: 8px; + border: 2px solid var(--border); + background: var(--card-bg); + color: var(--fg); + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + z-index: 11; +} + +.setup-wizard-buttons button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, .2); +} + +.setup-btn-primary { + background: color-mix(in srgb, var(--accent) 20%, transparent) !important; + border-color: var(--accent) !important; + color: var(--accent) !important; + font-weight: 600 !important; +} + +.setup-btn-primary:hover { + background: color-mix(in srgb, var(--accent) 30%, transparent) !important; +} + +/* App Selector Modal - No backdrop, positioned in corner */ +#app-selector-modal { + background: transparent; + backdrop-filter: none; + align-items: flex-start; + justify-content: flex-end; + padding: 20px; +} + +#app-selector-modal.show { + display: flex; +} + +.app-selector-content { + background: var(--card-base); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + width: 600px; + max-width: calc(100vw - 40px); + max-height: calc(100vh - 40px); + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, .6), 0 0 0 1px rgba(255, 255, 255, .08); + backdrop-filter: blur(20px) saturate(150%); + -webkit-backdrop-filter: blur(20px) saturate(150%); +} + +.app-selector-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.app-option { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .01)); + cursor: pointer; + transition: all .2s ease; + text-align: center; +} + +.app-option:hover { + transform: translateY(-4px); + border-color: var(--accent); + background: linear-gradient(180deg, rgba(255, 255, 255, .10), rgba(255, 255, 255, .04)); + box-shadow: 0 8px 24px rgba(0, 0, 0, .3), 0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent); +} + +.app-option-icon { + font-size: 48px; + margin-bottom: 8px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .3)); +} + +.app-option-name { + font-size: .9rem; + font-weight: 600; + color: var(--fg); + margin-bottom: 4px; +} + +.app-option-desc { + font-size: .75rem; + color: var(--muted); + line-height: 1.3; +} + +.app-category-header { + grid-column: 1 / -1; + font-size: 1.1rem; + font-weight: 700; + color: var(--accent-strong); + margin-top: 16px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 2px solid var(--border); +} + +@media (max-width: 800px) { + #app-selector-modal { + justify-content: center; + align-items: center; + } + + .app-selector-content { + width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + padding: 20px 16px; + } + + .app-selector-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; + } + + .app-option { + padding: 12px 6px; + } + + .app-option-icon { + font-size: 32px; + } + + .app-option-name { + font-size: .85rem; + } + + .app-option-desc { + font-size: .7rem; + } +} + +@keyframes spin { + to { + transform: rotate(360deg) + } +} + +.brand-spinner { + width: 18px; + height: 18px; + display: inline-block; + vertical-align: -3px; + margin-right: 8px; + mask: url(/assets/dashcaddy-logo-dark.png) center/contain no-repeat; + -webkit-mask: url(/assets/dashcaddy-logo-dark.png) center/contain no-repeat; + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + animation: spin 1.2s linear infinite; +} + +/* ---------- TOP ANCHOR ROW (DNS/Internet) ---------- */ +.top { + display: grid; + gap: 16px; + margin: 16px 0 24px; + grid-template-columns: repeat(3, minmax(280px, 1fr)); + align-items: stretch; + position: relative; +} + +.top::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -10px; + height: 2px; + background: linear-gradient(90deg, transparent 0%, var(--accent) 25%, var(--accent-strong) 50%, var(--accent) 75%, transparent 100%); + opacity: .35; + filter: blur(.2px); +} + +@media (max-width: 1100px) { + .top { + grid-template-columns: repeat(2, minmax(260px, 1fr)) + } +} + +@media (max-width: 760px) { + .top { + grid-template-columns: 1fr + } +} + +.top .card { + width: auto; + min-width: 0; + min-height: 200px; + padding-bottom: 48px; +} + +.top .row .name { + font-size: clamp(20px, 1.4vw + 18px, 26px) +} + +/* ---------- APP GRID ---------- */ +.grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + align-items: stretch; + position: relative; + z-index: 1; +} + +/* === Glass cards === */ +.card { + position: relative; + background: + linear-gradient(180deg, rgba(180, 200, 255, .10), rgba(180, 200, 255, .03)), + rgba(18, 24, 38, .42); + border: 1px solid rgba(120, 170, 255, .22); + border-radius: var(--radius); + padding: 14px 16px 60px; + min-height: 180px; + box-shadow: + 0 10px 34px rgba(0, 10, 40, .55), + inset 0 1px 0 rgba(200, 220, 255, .14); + backdrop-filter: blur(12px) saturate(135%); + -webkit-backdrop-filter: blur(12px) saturate(135%); + display: flex; + flex-direction: column; + gap: .6rem; + transition: + box-shadow .18s ease, + border-color .18s ease, + background-color .18s ease, + backdrop-filter .18s ease, + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1); + padding-bottom: 44px; + opacity: 0; + transform: translateY(20px); +} + +/* Staggered loading animation */ +.card.loaded { + opacity: 1; + transform: translateY(0); +} + +.grid .card { + min-width: 300px; + width: clamp(300px, 30vw, 380px); + max-width: 100%; +} + +/* Sheen animation (all themes) */ +@keyframes sheen { + from { + background-position: -200% 0 + } + + to { + background-position: 200% 0 + } +} + +.card::before, +.card::after { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--radius); + pointer-events: none +} + +.card::before { + background: linear-gradient(180deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 28%); + mix-blend-mode: screen; + opacity: .55; +} + +.card:hover::before { + background: + linear-gradient(180deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 28%), + linear-gradient(120deg, rgba(255, 255, 255, .25) 8%, rgba(255, 255, 255, 0) 22%, rgba(255, 255, 255, .18) 36%, rgba(255, 255, 255, 0) 52%); + background-size: 100% 100%, 220% 220%; + animation: sheen 2s ease-in-out infinite; + opacity: .5; +} + +.card::after { + background: + radial-gradient(1px 1px at 20% 30%, rgba(255, 255, 255, .06), transparent 60%), + radial-gradient(1px 1px at 70% 60%, rgba(255, 255, 255, .05), transparent 60%), + radial-gradient(1px 1px at 40% 80%, rgba(255, 255, 255, .04), transparent 60%); + opacity: .35; + filter: saturate(130%); +} + +/* (2) Floating motion on hover */ +@keyframes float { + + 0%, + 100% { + transform: translateY(-2px); + } + + 50% { + transform: translateY(-6px); + } +} + +.card:hover { + animation: float 3.5s ease-in-out infinite; +} + +/* (3) Animated border pulse (subtle) */ +@keyframes borderPulse { + 0% { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, rgba(192, 192, 192, 0.25)), 0 6px 24px color-mix(in srgb, var(--accent-strong) 12%, rgba(255, 255, 255, 0.12)), 0 0 18px 4px color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.10)), inset 0 1px 0 rgba(255, 255, 255, 0.08); + } + + 50% { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, rgba(192, 192, 192, 0.35)), 0 10px 28px color-mix(in srgb, var(--accent-strong) 18%, rgba(255, 255, 255, 0.16)), 0 0 24px 6px color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.14)), inset 0 1px 0 rgba(255, 255, 255, 0.12); + } + + 100% { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, rgba(192, 192, 192, 0.25)), 0 6px 24px color-mix(in srgb, var(--accent-strong) 12%, rgba(255, 255, 255, 0.12)), 0 0 18px 4px color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.10)), inset 0 1px 0 rgba(255, 255, 255, 0.08); + } +} + +.card:hover { + animation-name: float, borderPulse; + animation-duration: 3.5s, 2.2s; + animation-timing-function: ease-in-out, ease-in-out; + animation-iteration-count: infinite, infinite; +} + +/* Content rows */ +.row { + display: flex; + align-items: center; + gap: .6rem; + flex-wrap: wrap; +} + +.row .name { + font-weight: 700; + font-size: clamp(18px, 1.1vw + 16px, 24px); + line-height: 1.25; + letter-spacing: .1px; + text-shadow: 0 1px 1px rgba(0, 0, 0, .35); + flex: 1 1 140px; + min-width: 0; + white-space: normal; + word-break: normal; + overflow-wrap: anywhere; + hyphens: auto; +} + +:root.light .row .name { + text-shadow: none +} + +.row .spacer { + margin-left: auto +} + +.dot { + width: .75rem; + height: .75rem; + border-radius: 50%; + position: relative; + box-shadow: 0 0 0 2px rgba(0, 0, 0, .25), 0 0 10px rgba(0, 0, 0, .35) inset +} + +.dot.ok { + background: var(--dot-ok) +} + +.dot.bad { + background: var(--dot-bad); + animation: pulse-bad 2.4s ease-out infinite +} + +@keyframes pulse-bad { + 0% { + box-shadow: 0 0 0 0 rgba(255, 103, 103, .35) + } + + 70% { + box-shadow: 0 0 0 8px rgba(255, 103, 103, 0) + } + + 100% { + box-shadow: 0 0 0 0 rgba(255, 103, 103, 0) + } +} + +.at-bl { + position: absolute; + bottom: 12px; + left: 16px +} + +/* Glow dot when hovering Open button */ +.card:has(.btn-row:hover) .dot.at-bl { + box-shadow: + 0 0 0 2px rgba(0, 0, 0, 0.25), + 0 0 10px rgba(0, 0, 0, 0.35) inset, + 0 0 12px 4px currentColor; + transition: box-shadow .2s ease; +} + +.card:has(.btn-row:hover) .dot.ok { + background-color: color-mix(in srgb, var(--dot-ok) 90%, white 10%) +} + +.card:has(.btn-row:hover) .dot.bad { + background-color: color-mix(in srgb, var(--dot-bad) 90%, white 10%) +} + +.badge { + display: inline-block; + font-weight: 700; + padding: 6px 12px 6px 28px; + border-radius: 999px; + position: relative; + letter-spacing: .25px; + box-shadow: 0 1px 0 rgba(255, 255, 255, .06) inset; + white-space: nowrap; +} + +.badge::before { + content: ""; + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + filter: drop-shadow(0 0 6px currentColor); + opacity: .9 +} + +.badge.on { + background: color-mix(in srgb, var(--ok-bg) 80%, var(--accent) 20%); + color: var(--ok-fg) +} + +.badge.off { + background: color-mix(in srgb, var(--bad-bg) 85%, var(--accent-strong) 15%); + color: var(--bad-fg) +} + +.logo-wrap { + width: 64px; + height: 64px; + border-radius: 14px; + background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .01)); + border: 1px solid color-mix(in oklab, var(--border) 60%, #fff 3%); + display: grid; + place-items: center; + flex: 0 0 64px; +} + +:root.light .logo-wrap { + background: linear-gradient(180deg, rgba(0, 0, 0, .03), rgba(0, 0, 0, .01)); + border: 1px solid rgba(0, 0, 0, .06) +} + +:root.blue .logo-wrap { + background: linear-gradient(180deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, .04)); + border: 1px solid rgba(255, 255, 255, .35) +} + +/* PNG Logo Images - Reliable and Fast */ +.logo-img { + width: 54px; + height: 54px; + object-fit: contain; + display: block; + transition: all .2s ease; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .3)); +} + +.card:hover .logo-img { + transform: scale(1.1); + filter: drop-shadow(0 3px 8px rgba(0, 0, 0, .4)); +} + +.card.loaded .logo-img { + animation: iconFadeIn 0.4s ease-out 0.2s both; +} + +@keyframes iconFadeIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* DNS and Internet icons in top row */ +.top .logo-img { + width: 48px; + height: 48px; +} + + + +.btn-row { + position: absolute; + right: 12px; + bottom: 12px +} + +.btn-row button { + opacity: .95; + transition: opacity .15s ease, transform .15s +} + +.card:hover .btn-row button { + opacity: 1; + transform: translateY(-1px) +} + +/* Logs button styling */ +.logs-btn { + margin-left: 8px !important; + font-size: .8rem !important; + padding: .3rem .6rem !important; +} + +/* Delete button styling */ +.delete-btn { + margin-right: 8px !important; + font-size: .8rem !important; + padding: .3rem .5rem !important; + background: color-mix(in srgb, var(--bad-fg) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--bad-fg) 40%, var(--border)) !important; + color: var(--bad-fg) !important; + opacity: 0.7; + transition: all 0.2s ease; +} + +.delete-btn:hover { + background: color-mix(in srgb, var(--bad-fg) 25%, transparent) !important; + border-color: var(--bad-fg) !important; + opacity: 1; + transform: scale(1.1); +} + +/* Settings button styling */ +.settings-btn { + margin-left: 4px !important; + font-size: .8rem !important; + padding: .3rem .5rem !important; + min-width: auto !important; +} + +/* Options button styling */ +.options-btn { + margin-right: 8px !important; + font-size: .8rem !important; + padding: .3rem .5rem !important; + background: color-mix(in srgb, var(--accent) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)) !important; + color: var(--accent) !important; + opacity: 0.7; + transition: all 0.2s ease; +} + +.options-btn:hover { + background: color-mix(in srgb, var(--accent) 25%, transparent) !important; + border-color: var(--accent) !important; + opacity: 1; + transform: scale(1.1); +} + +/* Update button styling */ +.update-btn { + margin-left: 4px !important; + font-size: .8rem !important; + padding: .3rem .6rem !important; +} + +.update-btn:disabled { + opacity: 0.5; + cursor: wait; +} + +/* Credentials (key) button styling */ +.creds-btn { + margin-right: 8px !important; + font-size: .8rem !important; + padding: .3rem .5rem !important; + background: color-mix(in srgb, var(--ok-fg, #74dfc4) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--ok-fg, #74dfc4) 40%, var(--border)) !important; + color: var(--ok-fg, #74dfc4) !important; + opacity: 0.7; + transition: all 0.2s ease; +} +.creds-btn:hover { + background: color-mix(in srgb, var(--ok-fg, #74dfc4) 25%, transparent) !important; + border-color: var(--ok-fg, #74dfc4) !important; + opacity: 1; + transform: scale(1.1); +} +.creds-btn.has-creds { + opacity: 1; + background: color-mix(in srgb, var(--ok-fg, #74dfc4) 20%, transparent) !important; +} + +/* Service credentials modal */ +#service-creds-modal { + position: fixed; + inset: 0; + z-index: 1500; + display: none; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.5); + backdrop-filter: blur(4px); +} +#service-creds-modal.show { display: flex; } +.service-creds-content { + background: var(--card-base, #1a1f2e); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + min-width: 340px; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} + +/* Internet card packet blink */ +.card[data-app="internet"] .dot { + transition: all 0.1s ease; +} +.card[data-app="internet"] .dot.packet-rx { + box-shadow: 0 0 8px 2px #4caf50; + background: #4caf50; +} +.card[data-app="internet"] .dot.packet-tx { + box-shadow: 0 0 8px 2px #2196f3; + background: #2196f3; +} + +/* Restart button styling */ +.restart-btn { + margin-right: 8px !important; + font-size: .8rem !important; + padding: .3rem .6rem !important; +} + +/* Token Management Modal Styles */ +.token-section { + background: color-mix(in srgb, var(--accent) 5%, var(--card-base) 95%); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; +} + +.token-section-title { + margin: 0 0 10px; + color: var(--accent); + font-size: 0.95rem; + font-weight: 600; +} + +.token-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.token-field label { + display: block; + font-size: 0.8rem; + color: var(--muted); + margin-bottom: 4px; +} + +.token-input-row { + display: flex; + gap: 4px; +} + +.token-input-row input { + flex: 1; + font-size: 0.85rem; + padding: 6px 10px; +} + +.token-toggle { + padding: 6px 10px !important; + min-width: auto !important; + font-size: 0.8rem !important; + background: color-mix(in srgb, var(--accent) 10%, transparent) !important; + border-color: var(--border) !important; +} + +.token-toggle:hover { + background: color-mix(in srgb, var(--accent) 20%, transparent) !important; +} + +.token-status { + margin-top: 8px; + font-size: 0.75rem; + min-height: 18px; +} + +.token-status.success { + color: var(--ok-fg); +} + +.token-status.error { + color: var(--bad-fg); +} + +@media (max-width: 600px) { + .token-grid { + grid-template-columns: 1fr; + } +} + +/* DNS Logs Modal */ +.logs-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .7); + display: none; + align-items: center; + justify-content: center; + z-index: 1001; + backdrop-filter: blur(6px); +} + +.logs-modal.show { + display: flex; +} + +.logs-modal-content { + background: var(--card-base); + border: 1px solid var(--border); + border-radius: var(--radius); + width: min(90vw, 800px); + height: min(80vh, 600px); + box-shadow: 0 25px 80px rgba(0, 0, 0, .5); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.logs-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--card-base) 95%, var(--accent) 5%); +} + +.logs-header h3 { + margin: 0; + color: var(--fg); + font-size: 1.1rem; +} + +.logs-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.logs-controls label { + color: var(--muted); + font-size: .9rem; + font-weight: 500; +} + +.logs-controls select { + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--fg); + font-size: .85rem; +} + +.pause-btn, .close-btn { + padding: 4px 8px !important; + font-size: .85rem !important; + min-width: auto !important; +} + +.pause-btn.paused { + background: color-mix(in srgb, #ff6600 15%, transparent) !important; + border-color: #ff6600 !important; + color: #ff6600 !important; +} + +.close-btn { + background: color-mix(in srgb, var(--bad-fg) 15%, transparent) !important; + border-color: var(--bad-fg) !important; + color: var(--bad-fg) !important; +} + +.stream-btn { + padding: 4px 10px !important; + font-size: .85rem !important; + min-width: auto !important; + transition: all 0.3s ease; +} + +.stream-btn.active { + background: color-mix(in srgb, #00cc66 20%, transparent) !important; + border-color: #00cc66 !important; + color: #00cc66 !important; + animation: pulse-glow 2s infinite; +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 5px rgba(0, 204, 102, 0.3); } + 50% { box-shadow: 0 0 15px rgba(0, 204, 102, 0.6); } +} + +.logs-container { + flex: 1; + overflow: hidden; + position: relative; +} + +.logs-content { + height: 100%; + overflow-y: auto; + padding: 16px; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: .8rem; + line-height: 1.4; + background: color-mix(in srgb, var(--bg) 60%, var(--card-base) 40%); +} + +.logs-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--muted); + font-style: italic; +} + +.log-entry { + margin-bottom: 4px; + padding: 4px 8px; + border-radius: 4px; + border-left: 3px solid transparent; + transition: all .2s ease; + word-wrap: break-word; + white-space: pre-wrap; +} + +.log-entry:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.log-entry.error { + border-left-color: var(--bad-fg); + background: color-mix(in srgb, var(--bad-fg) 5%, transparent); + color: color-mix(in srgb, var(--bad-fg) 90%, var(--fg) 10%); +} + +.log-entry.warning { + border-left-color: #ffaa00; + background: color-mix(in srgb, #ffaa00 5%, transparent); + color: color-mix(in srgb, #ffaa00 90%, var(--fg) 10%); +} + +.log-entry.info { + border-left-color: var(--accent); + background: color-mix(in srgb, var(--accent) 5%, transparent); + color: var(--fg); +} + +.log-timestamp { + color: var(--muted); + font-weight: 600; + margin-right: 8px; +} + +.log-level { + font-weight: 700; + margin-right: 8px; + text-transform: uppercase; + font-size: .75rem; +} + +.log-message { + color: var(--fg); +} + +/* Response time display */ +.response-row { + display: flex; + justify-content: flex-start; + margin-top: 4px; + margin-bottom: 8px; +} + +.response-time { + font-size: .75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 6px; + letter-spacing: .3px; + text-transform: uppercase; + transition: all .2s ease; + min-width: 60px; + text-align: center; + border: 1px solid transparent; +} + +/* Response time color coding */ +.response-time.excellent { + background: color-mix(in srgb, #00ff88 15%, var(--card-base) 85%); + color: #00ff88; + border-color: color-mix(in srgb, #00ff88 30%, transparent 70%); + box-shadow: 0 0 8px color-mix(in srgb, #00ff88 20%, transparent 80%); +} + +.response-time.good { + background: color-mix(in srgb, var(--ok-fg) 15%, var(--card-base) 85%); + color: var(--ok-fg); + border-color: color-mix(in srgb, var(--ok-fg) 30%, transparent 70%); + box-shadow: 0 0 6px color-mix(in srgb, var(--ok-fg) 15%, transparent 85%); +} + +.response-time.fair { + background: color-mix(in srgb, #ffaa00 15%, var(--card-base) 85%); + color: #ffaa00; + border-color: color-mix(in srgb, #ffaa00 30%, transparent 70%); + box-shadow: 0 0 6px color-mix(in srgb, #ffaa00 15%, transparent 85%); +} + +.response-time.slow { + background: color-mix(in srgb, #ff6600 15%, var(--card-base) 85%); + color: #ff6600; + border-color: color-mix(in srgb, #ff6600 30%, transparent 70%); + box-shadow: 0 0 6px color-mix(in srgb, #ff6600 15%, transparent 85%); +} + +.response-time.timeout { + background: color-mix(in srgb, var(--bad-fg) 15%, var(--card-base) 85%); + color: var(--bad-fg); + border-color: color-mix(in srgb, var(--bad-fg) 30%, transparent 70%); + box-shadow: 0 0 6px color-mix(in srgb, var(--bad-fg) 15%, transparent 85%); +} + +/* Light theme adjustments */ +:root.light .response-time.excellent { + background: color-mix(in srgb, #00cc66 12%, white 88%); + color: #00aa55; + border-color: color-mix(in srgb, #00cc66 25%, transparent 75%); + box-shadow: 0 1px 3px color-mix(in srgb, #00cc66 20%, transparent 80%); +} + +:root.light .response-time.fair { + background: color-mix(in srgb, #ff8800 12%, white 88%); + color: #cc6600; + border-color: color-mix(in srgb, #ff8800 25%, transparent 75%); + box-shadow: 0 1px 3px color-mix(in srgb, #ff8800 20%, transparent 80%); +} + +:root.light .response-time.slow { + background: color-mix(in srgb, #ff4400 12%, white 88%); + color: #cc3300; + border-color: color-mix(in srgb, #ff4400 25%, transparent 75%); + box-shadow: 0 1px 3px color-mix(in srgb, #ff4400 20%, transparent 80%); +} + +/* Blue theme adjustments */ +:root.blue .response-time.excellent { + background: color-mix(in srgb, #88ffcc 18%, var(--card-base) 82%); + color: #88ffcc; + border-color: color-mix(in srgb, #88ffcc 35%, transparent 65%); +} + +:root.blue .response-time.fair { + background: color-mix(in srgb, #ffcc88 18%, var(--card-base) 82%); + color: #ffcc88; + border-color: color-mix(in srgb, #ffcc88 35%, transparent 65%); +} + +:root.blue .response-time.slow { + background: color-mix(in srgb, #ff8888 18%, var(--card-base) 82%); + color: #ff8888; + border-color: color-mix(in srgb, #ff8888 35%, transparent 65%); +} + +@media (max-width: 1200px) { + .top-row { + flex-direction: column; + align-items: center; + gap: 16px; + } + + .tools-row { + justify-content: center; + } + + .weather-widget-container { + max-width: 90vw; + } + + .weather-icon { + font-size: clamp(48px, 6vw, 64px); + } + + .weather-temp { + font-size: clamp(1.5rem, 2.5vw, 2.5rem); + } + + .clock-time { + font-size: clamp(1.5rem, 2.5vw, 2.5rem); + } +} + +@media (max-width: 900px) { + body { + padding: 20px + } + + #brand img { + max-width: 76vw + } + + .card { + min-height: 170px; + padding: 12px 14px 56px + } + + .weather-widget { + min-width: 180px; + padding: 12px 16px; + } + + .weather-icon { + font-size: 36px; + } + + .weather-temp { + font-size: 1.2rem; + } + + .clock-time { + font-size: 1.2rem; + } + + .clock-date { + font-size: 0.75rem; + } +} + +@media (max-width: 640px) { + body { + padding: 16px + } + + .grid .card { + min-width: 260px; + width: clamp(260px, 80vw, 340px) + } + + .logo-wrap { + width: 56px; + height: 56px + } + + .logo-img { + width: 48px; + height: 48px + } + + :root { + --brand-min: 110px; + --brand-max: 240px; + --brand-h: clamp(var(--brand-min), 28vw, var(--brand-max)); + } + + .weather-widget { + min-width: 160px; + padding: 10px 14px; + gap: 10px; + } + + .weather-icon { + font-size: 32px; + } + + .weather-temp { + font-size: 1.1rem; + } + + .weather-location { + font-size: .8rem; + } + + .clock-time { + font-size: 1.1rem; + } + + .clock-date { + font-size: 0.7rem; + } +} + +@media (max-width: 420px) { + .grid .card { + min-width: 240px; + width: 100% + } + + :root { + --brand-min: 100px; + --brand-max: 220px; + --brand-h: clamp(var(--brand-min), 32vw, var(--brand-max)); + } + + .weather-widget { + min-width: 140px; + padding: 8px 12px; + } + + .weather-icon { + font-size: 28px; + } + + .weather-temp { + font-size: 1rem; + } + + .weather-location { + font-size: .75rem; + } + + .clock-time { + font-size: 1rem; + } + + .clock-date { + font-size: 0.65rem; + } +} + +/* qBittorrent title slight tweak for awkward wraps at some widths */ +.card[data-app="torrent"] .row .name { + font-size: clamp(17px, 1vw + 16px, 22px); +} + +/* Responsive behavior for weather widget */ +@media (max-width: 1200px) { + #brand { + flex-direction: column; + align-items: center; + gap: 16px; + } + + .weather-widget-container { + margin-bottom: 0; + } + + .weather-icon { + font-size: clamp(48px, 6vw, 64px); + } + + .weather-temp { + font-size: clamp(1.5rem, 2.5vw, 2.5rem); + } +} + +@media (max-width: 900px) { + body { + padding: 20px; + } + + #brand img { + max-width: 76vw; + } + + .card { + min-height: 170px; + padding: 12px 14px 56px; + } + + .weather-icon { + font-size: clamp(40px, 5vw, 56px); + } + + .weather-temp { + font-size: clamp(1.3rem, 2.2vw, 2rem); + } +} + +@media (max-width: 640px) { + body { + padding: 16px; + } + + .grid .card { + min-width: 260px; + width: clamp(260px, 80vw, 340px); + } + + .logo-wrap { + width: 56px; + height: 56px; + } + + .logo-img { + width: 48px; + height: 48px; + } + + :root { + --brand-min: 110px; + --brand-max: 240px; + --brand-h: clamp(var(--brand-min), 28vw, var(--brand-max)); + } + + .weather-icon { + font-size: clamp(36px, 4.5vw, 48px); + } + + .weather-temp { + font-size: clamp(1.1rem, 2vw, 1.8rem); + } + + .weather-location { + font-size: clamp(0.8rem, 1.1vw, 1rem); + } +} + +@media (max-width: 420px) { + .grid .card { + min-width: 240px; + width: 100%; + } + + :root { + --brand-min: 100px; + --brand-max: 220px; + --brand-h: clamp(var(--brand-min), 32vw, var(--brand-max)); + } + + .weather-icon { + font-size: clamp(32px, 4vw, 40px); + } + + .weather-temp { + font-size: clamp(1rem, 1.8vw, 1.5rem); + } + + .weather-location { + font-size: clamp(0.75rem, 1vw, 0.9rem); + } +} + +/* ===== MONITORING & HEALTH PANEL STYLES ===== */ + +/* Tab bar used in multi-section modals */ +.panel-tabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--border); + margin-bottom: 16px; +} + +.panel-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: var(--muted); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.2s, border-color 0.2s; +} + +.panel-tab:hover { + color: var(--fg); + background: transparent; + box-shadow: none; +} + +.panel-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: transparent; + box-shadow: none; +} + +.panel-section { + display: none; +} + +.panel-section.active { + display: block; +} + +/* Status badges (Health and Updates) */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-badge.up { + background: color-mix(in srgb, var(--dot-ok) 15%, transparent); + color: var(--dot-ok); + border: 1px solid color-mix(in srgb, var(--dot-ok) 30%, transparent); +} + +.status-badge.down { + background: color-mix(in srgb, var(--dot-bad) 15%, transparent); + color: var(--dot-bad); + border: 1px solid color-mix(in srgb, var(--dot-bad) 30%, transparent); +} + +.status-badge.warning { + background: color-mix(in srgb, #f39c12 15%, transparent); + color: #f39c12; + border: 1px solid color-mix(in srgb, #f39c12 30%, transparent); +} + +.status-badge.info { + background: color-mix(in srgb, #3498db 15%, transparent); + color: #3498db; + border: 1px solid color-mix(in srgb, #3498db 30%, transparent); +} + +.status-badge.success { + background: color-mix(in srgb, #2ecc71 15%, transparent); + color: #2ecc71; + border: 1px solid color-mix(in srgb, #2ecc71 30%, transparent); +} + +/* CSS-only mini bar chart */ +.mini-bar-chart { + display: flex; + align-items: flex-end; + gap: 1px; + height: 32px; + padding: 0; +} + +.mini-bar-chart .bar-segment { + flex: 1; + min-width: 2px; + max-width: 6px; + background: var(--accent); + border-radius: 1px 1px 0 0; + transition: height 0.3s, background 0.3s; +} + +/* Uptime bar (horizontal) */ +.uptime-bar { + display: flex; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: color-mix(in srgb, var(--dot-bad) 20%, transparent); +} + +.uptime-bar .up-segment { + background: var(--dot-ok); + height: 100%; + transition: width 0.3s; +} + +/* Stat mini card for aggregated metrics */ +.stat-mini-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px; + background: color-mix(in srgb, var(--accent) 6%, transparent); + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent); +} + +.stat-mini-card .stat-val { + font-size: 1.1rem; + font-weight: 700; + color: var(--accent); +} + +.stat-mini-card .stat-lbl { + font-size: 0.65rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Service row card (Health panel) */ +.service-health-row { + padding: 12px; + background: var(--card-base); + border-radius: 8px; + border: 1px solid var(--border); + transition: border-color 0.2s; +} + +.service-health-row:hover { + border-color: var(--accent); +} + +/* Update row card */ +.update-row { + padding: 12px; + background: var(--card-base); + border-radius: 8px; + border: 1px solid var(--border); +} + +/* Consistent empty state */ +.panel-empty { + text-align: center; + padding: 40px 20px; + color: var(--muted); + font-size: 0.9rem; +} + +.panel-empty .empty-icon { + font-size: 2rem; + margin-bottom: 8px; + display: block; +} + +/* Alert config form rows */ +.alert-config-row { + display: grid; + grid-template-columns: 120px 1fr 60px; + gap: 8px; + align-items: center; + font-size: 0.85rem; +} + +.alert-config-row label { + margin: 0; + font-size: 0.8rem; +} + +.alert-config-row input[type="number"] { + width: 100%; + padding: 4px 8px; + margin: 0; + font-size: 0.85rem; +} + +/* Card health/uptime row */ +.health-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + padding: 0 2px; + min-height: 18px; +} + +.health-row .uptime-chip { + font-size: 0.65rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + letter-spacing: 0.3px; +} + +.health-row .uptime-chip.excellent { + background: color-mix(in srgb, var(--uptime) 12%, var(--card-base) 88%); + color: var(--uptime); +} + +.health-row .uptime-chip.good { + background: color-mix(in srgb, var(--uptime) 12%, var(--card-base) 88%); + color: var(--uptime); +} + +.health-row .uptime-chip.degraded { + background: color-mix(in srgb, #ffaa00 12%, var(--card-base) 88%); + color: #ffaa00; +} + +.health-row .uptime-chip.poor { + background: color-mix(in srgb, #ff6600 12%, var(--card-base) 88%); + color: #ff6600; +} + +.health-row .uptime-mini-bar { + flex: 1; + height: 4px; + border-radius: 2px; + background: color-mix(in srgb, var(--dot-bad) 15%, transparent); + overflow: hidden; +} + +.health-row .uptime-mini-bar .fill { + height: 100%; + border-radius: 2px; + background: var(--uptime); + transition: width 0.5s ease; +} + +/* Update available badge on card */ +.card .update-available-badge { + display: none; + font-size: 0.6rem; + font-weight: 700; + padding: 1px 5px; + border-radius: 4px; + background: color-mix(in srgb, #f39c12 18%, transparent); + color: #f39c12; + border: 1px solid color-mix(in srgb, #f39c12 30%, transparent); + text-transform: uppercase; + letter-spacing: 0.3px; + margin-left: 6px; + animation: pulse-update 2s infinite; +} + +.card .update-available-badge.visible { + display: inline-flex; +} + +@keyframes pulse-update { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* Incident row */ +.incident-row { + padding: 10px 12px; + background: var(--card-base); + border-radius: 6px; + border: 1px solid var(--border); + font-size: 0.85rem; +} + +.incident-row .incident-severity { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.incident-row .incident-severity.critical { color: var(--dot-bad); } +.incident-row .incident-severity.high { color: #e74c3c; } +.incident-row .incident-severity.medium { color: #f39c12; } +.incident-row .incident-severity.low { color: #3498db; } + +/* Driver.js tour - completely hide overlay */ +.driver-overlay { + display: none !important; + opacity: 0 !important; + pointer-events: none !important; +} +.driver-popover { + z-index: 999999 !important; +} +/* ===== TOTP Authentication Overlay ===== */ +#totp-overlay { + position: fixed; + inset: 0; + background: var(--bg, #0b0f1a); + z-index: 2000; + display: none; + align-items: center; + justify-content: center; + flex-direction: column; +} +#totp-overlay.show { display: flex; } + +.totp-card { + background: var(--card-base, #121826); + border: 1px solid var(--border, #263552); + border-radius: 16px; + padding: 24px 36px 36px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + max-width: 400px; + width: 90%; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} +.totp-logo { + width: 300px; + margin-bottom: 6px; + opacity: 0.85; +} +.totp-logo-light { display: none; } +:root.light-bg .totp-logo-dark { display: none; } +:root.light-bg .totp-logo-light { display: inline; } +:root.light-bg .totp-card .subtitle { color: var(--fg, #111); } +.totp-card h2 { + margin: 0 0 4px; + color: var(--fg, #e8ecf5); + font-size: 1.5rem; + font-weight: 600; +} +.totp-card .subtitle { + color: var(--muted, #9aa6bf); + font-size: 1.1rem; + margin: 0 0 20px; +} +.totp-digits { + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 16px; +} +.totp-digits input { + width: 46px; + height: 58px; + text-align: center; + font-size: 1.6rem; + font-weight: 600; + font-family: 'Sami Grotesk', monospace; + border: 2px solid var(--border, #263552); + border-radius: 10px; + background: var(--bg, #0b0f1a); + color: var(--fg, #e8ecf5); + outline: none; + transition: border-color 0.2s; + caret-color: transparent; +} +.totp-digits input:focus { + border-color: var(--accent, #8FD6FF); + box-shadow: 0 0 0 3px rgba(143, 214, 255, 0.15); +} +.totp-error { + color: var(--bad-fg, #ff9aa3); + font-size: 0.85rem; + min-height: 1.2em; + margin-top: 4px; +} +.totp-error.verifying { color: var(--accent, #8FD6FF); } + +/* ============================================= + Extracted Utility Classes (inline style cleanup) + ============================================= */ + +/* --- Form Labels --- */ +.form-label-accent { + display: block; + margin-bottom: 8px; + color: var(--accent); + font-weight: 500; +} +.form-label-accent-sm { + display: block; + margin-bottom: 6px; + color: var(--accent); + font-weight: 500; +} +.form-label-bold { + display: block; + margin-bottom: 8px; + font-weight: 600; +} +.label-bold { + font-size: 0.8rem; + font-weight: 600; + color: var(--fg); +} +.field-label { + font-size: 0.85rem; + color: var(--muted); +} +.field-label-sm { + font-size: 0.8rem; +} + +/* --- Form Inputs --- */ +.form-input { + width: 100%; + padding: 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg); +} +.form-input-lg { + width: 100%; + padding: 12px; + background: var(--card-bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; +} +.form-input-md { + width: 100%; + padding: 10px; + background: var(--card-bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; +} +.form-input-card { + width: 100%; + padding: 8px; + background: var(--card-bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; +} + +/* --- Radio/Checkbox Option Labels --- */ +.radio-option { + display: flex; + align-items: center; + padding: 10px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; +} +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} +.checkbox-label-sm { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + cursor: pointer; +} +.option-label { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +/* --- Buttons --- */ +.btn-accent { + background: color-mix(in srgb, var(--accent) 20%, transparent); + border-color: var(--accent); + color: var(--accent); +} +.btn-accent-solid { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); +} +.btn-sm { + padding: 6px 12px; + font-size: 0.8rem; +} +.btn-xs { + padding: 4px 10px; + font-size: 0.75rem; +} +.btn-option { + flex: 1; + padding: 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--card-bg); + color: var(--fg); + cursor: pointer; +} + +/* --- Text Utilities --- */ +.fw-500 { font-weight: 500; } +.text-hint { + font-size: 0.8rem; + color: var(--muted); +} +.text-muted-sm { + font-size: 0.85rem; + color: var(--muted); +} +.text-tiny-muted { + font-size: 0.75rem; + color: var(--muted); +} +.form-hint { + font-size: 0.85rem; + color: var(--muted); + margin-top: 6px; +} +.form-hint-sm { + font-size: 0.8rem; + color: var(--muted); + margin-top: 4px; +} +.tiny-hint { + font-size: 0.75rem; + color: var(--muted); + margin-top: 4px; +} +.micro-hint { + font-size: 0.7rem; + color: var(--muted); + margin: 4px 0 0; +} +.micro-hint-indented { + font-size: 0.7rem; + color: var(--muted); + margin: 4px 0 0 24px; +} +.modal-subtitle { + color: var(--muted); + margin: 0 0 12px; + font-size: 0.9rem; +} +.text-auto-right { + margin-left: auto; + font-size: 0.75rem; + color: var(--muted); +} + +/* --- Layout Utilities --- */ +.flex-row-gap { + display: flex; + gap: 8px; +} +.flex-row-gap-center { + display: flex; + gap: 8px; + align-items: center; +} +.flex-col-gap { + display: flex; + flex-direction: column; + gap: 8px; +} +.grid-2col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +/* --- Scrollable Containers --- */ +.scroll-container { + max-height: 500px; + overflow-y: auto; +} + +/* --- Card / Section Patterns --- */ +.provider-card { + padding: 12px; + background: var(--card-base); + border-radius: 8px; + margin-bottom: 10px; + border: 1px solid var(--border); +} +.provider-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.section-heading { + margin: 16px 0 8px; + color: var(--accent); + font-size: 0.9rem; +} +.heading-accent { + margin: 0 0 8px; + color: var(--accent); +} +.modal-footer-bar { + margin-top: 16px; +} +.panel-bottom-bar { + margin-top: 16px; + display: flex; + align-items: center; + gap: 12px; +} +.hr-divider { + border: none; + border-top: 1px solid var(--border); + margin: 16px 0; +} +.mb-16 { margin-bottom: 16px; } + +.input-flex { + flex: 1; + padding: 10px; + background: var(--card-bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; +} +.input-creds { + width: 100%; + padding: 8px 10px; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.85rem; + box-sizing: border-box; +} +.input-card-alt { + width: 100%; + padding: 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--card-bg); + color: var(--fg); +} +.checkbox-sm { + width: 16px; + height: 16px; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} +.font-bold-sm { + font-size: 0.85rem; + font-weight: 600; + color: var(--fg); +} +.hint-micro { + font-size: 0.7rem; + color: var(--muted); + margin: 2px 0 8px; +} +.accent-info-box { + margin-bottom: 16px; + padding: 12px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-radius: 8px; + border: 1px solid var(--accent); +} +.divider-line { + flex: 1; + height: 1px; + background: var(--border); +} +.setup-desc { + color: var(--muted); + margin-bottom: 24px; +} +.heading-accent-lg { + margin: 24px 0 16px; + color: var(--accent); +} +.heading-accent-md { + margin: 0 0 12px; + color: var(--accent); +} +.heading-accent-section { + margin: 0 0 12px; + color: var(--accent); + font-size: 0.9rem; +} + +/* ===== Skeleton loading placeholders ===== */ +.skeleton-card { + background: var(--card-base); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px 44px; + min-height: 180px; + min-width: 300px; + width: clamp(300px, 30vw, 380px); + max-width: 100%; + display: flex; + flex-direction: column; + gap: .6rem; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s ease, transform 0.3s ease; +} +.skeleton-card.loaded { + opacity: 1; + transform: translateY(0); +} +.skeleton-bar { + border-radius: 4px; + background: linear-gradient(90deg, + var(--card-base) 0%, + color-mix(in srgb, var(--fg) 8%, var(--card-base)) 50%, + var(--card-base) 100%); + background-size: 200% 100%; + animation: sheen 1.5s ease-in-out infinite; +} + +/* ===== Theme Toggle (top-right) ===== */ +.theme-toggle-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.theme-toggle-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + background: var(--card-base); + color: var(--accent); + border: 1px solid var(--border); +} + +.theme-toggle-btn:hover { + background: color-mix(in srgb, var(--accent) 22%, transparent); +} + +.theme-customize-link { + background: none !important; + border: none !important; + color: var(--muted); + font-size: 0.7rem; + cursor: pointer; + padding: 2px 6px; + font-family: inherit; + opacity: 0.8; + transition: opacity 0.2s, color 0.2s; +} + +.theme-customize-link:hover { + opacity: 1; + color: var(--accent); +} + +/* ===== Theme Picker Popover ===== */ +.theme-picker-wrap { + position: relative; + display: inline-block; +} + +.theme-picker { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 999; + min-width: 240px; + background: var(--card-base); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 12px 40px rgba(0, 0, 0, .35); + backdrop-filter: blur(12px); + padding: 6px; + display: none; +} + +.theme-picker.show { + display: block; + animation: fadeIn .15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.theme-picker-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + border: none; + background: transparent; + width: 100%; + font-size: .85rem; + color: var(--fg); + font-family: inherit; +} + +.theme-picker-item:hover { + background: var(--hover); +} + +.theme-picker-item.active { + background: color-mix(in srgb, var(--accent) 15%, transparent); +} + +.theme-picker-name { + flex: 1; + text-align: left; +} + +.theme-picker-dots { + display: flex; + gap: 3px; +} + +.theme-picker-dot { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid rgba(128, 128, 128, .3); +} + +.theme-picker-check { + width: 16px; + text-align: center; + font-size: .75rem; + color: var(--accent); +} + +.theme-picker-divider { + height: 1px; + background: var(--border); + margin: 4px 6px; +} + +.theme-picker-customize { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + border: none; + background: transparent; + width: 100%; + font-size: .85rem; + color: var(--accent); + font-family: inherit; + font-weight: 500; +} + +.theme-picker-customize:hover { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +/* ===== Theme Builder Modal ===== */ +.theme-builder-preview { + padding: 20px; + border-radius: var(--radius); + border: 1px solid var(--border); + margin-bottom: 20px; +} + +.theme-builder-card { + padding: 16px; + border-radius: 8px; + border: 1px solid; + max-width: 320px; + margin: 0 auto; +} + +.theme-builder-card .preview-title { + font-weight: 600; + font-size: 1rem; + margin-bottom: 4px; +} + +.theme-builder-card .preview-muted { + font-size: .85rem; + margin-bottom: 12px; +} + +.theme-builder-card .preview-badges { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.theme-builder-card .preview-badge { + padding: 3px 10px; + border-radius: 6px; + font-size: .75rem; + font-weight: 600; +} + +.theme-builder-card .preview-dots { + display: flex; + gap: 12px; + margin-bottom: 12px; + font-size: .8rem; +} + +.theme-builder-card .preview-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 4px; + vertical-align: middle; +} + +.theme-builder-card .preview-btn { + padding: 6px 16px; + border-radius: 8px; + border: none; + font-size: .8rem; + font-weight: 500; + cursor: default; +} + +.theme-builder-section { + margin-bottom: 16px; +} + +.theme-builder-section-title { + font-size: .8rem; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: .05em; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} + +.theme-builder-row { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 0; +} + +.theme-builder-label { + flex: 1; + font-size: .85rem; + color: var(--fg); +} + +.theme-builder-color { + width: 32px; + height: 32px; + border: 2px solid var(--border); + border-radius: 6px; + cursor: pointer; + padding: 0; + background: none; + -webkit-appearance: none; + appearance: none; +} + +.theme-builder-color::-webkit-color-swatch-wrapper { + padding: 0; +} + +.theme-builder-color::-webkit-color-swatch { + border: none; + border-radius: 4px; +} + +.theme-builder-color::-moz-color-swatch { + border: none; + border-radius: 4px; +} + +.theme-builder-hex { + font-family: monospace; + font-size: .8rem; + color: var(--muted); + width: 70px; + text-align: right; +} + +/* ===== Footer ===== */ +.dashcaddy-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + padding: 40px 0 20px; + margin-top: 48px; + opacity: 0.45; + transition: opacity 0.3s ease; +} + +.dashcaddy-footer:hover { + opacity: 0.8; +} + +.footer-copy { + font-size: 1.1rem; + color: var(--muted); + letter-spacing: 0.5px; +} + +.footer-logo { + height: 140px; + width: auto; +} diff --git a/status/css/driver.min.css b/status/css/driver.min.css new file mode 100644 index 0000000..7ce0bd6 --- /dev/null +++ b/status/css/driver.min.css @@ -0,0 +1 @@ +.driver-active .driver-overlay,.driver-active *{pointer-events:none}.driver-active .driver-active-element,.driver-active .driver-active-element *,.driver-popover,.driver-popover *{pointer-events:auto}@keyframes animate-fade-in{0%{opacity:0}to{opacity:1}}.driver-fade .driver-overlay{animation:animate-fade-in .2s ease-in-out}.driver-fade .driver-popover{animation:animate-fade-in .2s}.driver-popover{all:unset;box-sizing:border-box;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px #0006;z-index:1000000000;position:fixed;top:0;right:0;background-color:#fff}.driver-popover *{font-family:Helvetica Neue,Inter,ui-sans-serif,"Apple Color Emoji",Helvetica,Arial,sans-serif}.driver-popover-title{font:19px/normal sans-serif;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1;margin:0}.driver-popover-close-btn{all:unset;position:absolute;top:0;right:0;width:32px;height:28px;cursor:pointer;font-size:18px;font-weight:500;color:#d2d2d2;z-index:1;text-align:center;transition:color;transition-duration:.2s}.driver-popover-close-btn:hover,.driver-popover-close-btn:focus{color:#2d2d2d}.driver-popover-title[style*=block]+.driver-popover-description{margin-top:5px}.driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;font-weight:400;zoom:1}.driver-popover-footer{margin-top:15px;text-align:right;zoom:1;display:flex;align-items:center;justify-content:space-between}.driver-popover-progress-text{font-size:13px;font-weight:400;color:#727272;zoom:1}.driver-popover-footer button{all:unset;display:inline-block;box-sizing:border-box;padding:3px 7px;text-decoration:none;text-shadow:1px 1px 0 #fff;background-color:#fff;color:#2d2d2d;font:12px/normal sans-serif;cursor:pointer;outline:0;zoom:1;line-height:1.3;border:1px solid #ccc;border-radius:3px}.driver-popover-footer .driver-popover-btn-disabled{opacity:.5;pointer-events:none}:not(body):has(>.driver-active-element){overflow:hidden!important}.driver-no-interaction,.driver-no-interaction *{pointer-events:none!important}.driver-popover-footer button:hover,.driver-popover-footer button:focus{background-color:#f7f7f7}.driver-popover-navigation-btns{display:flex;flex-grow:1;justify-content:flex-end}.driver-popover-navigation-btns button+button{margin-left:4px}.driver-popover-arrow{content:"";position:absolute;border:5px solid #fff}.driver-popover-arrow-side-over{display:none}.driver-popover-arrow-side-left{left:100%;border-right-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-right{right:100%;border-left-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-top{top:100%;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent}.driver-popover-arrow-side-bottom{bottom:100%;border-left-color:transparent;border-top-color:transparent;border-right-color:transparent}.driver-popover-arrow-side-center{display:none}.driver-popover-arrow-side-left.driver-popover-arrow-align-start,.driver-popover-arrow-side-right.driver-popover-arrow-align-start{top:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-start,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start{left:15px}.driver-popover-arrow-align-end.driver-popover-arrow-side-left,.driver-popover-arrow-align-end.driver-popover-arrow-side-right{bottom:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-end,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end{right:15px}.driver-popover-arrow-side-left.driver-popover-arrow-align-center,.driver-popover-arrow-side-right.driver-popover-arrow-align-center{top:50%;margin-top:-5px}.driver-popover-arrow-side-top.driver-popover-arrow-align-center,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center{left:50%;margin-left:-5px}.driver-popover-arrow-none{display:none} diff --git a/status/css/onboarding.css b/status/css/onboarding.css new file mode 100644 index 0000000..6c7b7c9 --- /dev/null +++ b/status/css/onboarding.css @@ -0,0 +1,354 @@ +/** + * Onboarding Tooltip Styles + * Custom styling for Driver.js tooltips to match DashCaddy theme + */ + +/* Driver.js overrides are injected dynamically by ThemeAdapter */ +/* This file contains additional custom styles */ + +.driver-popover { + max-width: 500px !important; + z-index: 10000 !important; +} + +.driver-popover-title { + font-size: 1.2rem !important; + margin-bottom: 12px !important; +} + +.driver-popover-description { + font-size: 0.95rem !important; + line-height: 1.6 !important; +} + +.driver-popover-description p { + margin: 8px 0 !important; +} + +.driver-popover-description ul { + margin: 8px 0 !important; + padding-left: 20px !important; +} + +.driver-popover-description li { + margin: 4px 0 !important; +} + +.driver-popover-description code { + background: rgba(0, 0, 0, 0.1) !important; + padding: 2px 6px !important; + border-radius: 3px !important; + font-family: 'Courier New', monospace !important; + font-size: 0.9em !important; +} + +.driver-popover-footer { + margin-top: 16px !important; + display: flex !important; + gap: 8px !important; + justify-content: flex-end !important; +} + +.driver-popover-footer button { + padding: 8px 16px !important; + border-radius: 8px !important; + font-size: 0.9rem !important; + cursor: pointer !important; + transition: all 0.2s ease !important; +} + +.driver-popover-footer button:hover { + transform: translateY(-1px) !important; +} + +.driver-popover-close-btn { + position: absolute !important; + top: 12px !important; + right: 12px !important; + width: 24px !important; + height: 24px !important; + border-radius: 50% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + opacity: 0.6 !important; + transition: opacity 0.2s ease !important; +} + +.driver-popover-close-btn:hover { + opacity: 1 !important; +} + +.driver-popover-arrow { + border-width: 8px !important; +} + +/* Progress indicator */ +.driver-popover-progress-text { + font-size: 0.85rem !important; + margin-bottom: 8px !important; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .driver-popover { + max-width: calc(100vw - 32px) !important; + } + + .driver-popover-title { + font-size: 1.1rem !important; + } + + .driver-popover-description { + font-size: 0.9rem !important; + } + + .driver-popover-footer button { + padding: 6px 12px !important; + font-size: 0.85rem !important; + } +} + +/* Restart tour button in dashboard */ +#restart-tour-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +#restart-tour-btn::before { + content: "🎓"; + font-size: 1.1em; +} + + +/* DNS Template Selector Modal */ +.dns-template-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; +} + +.dns-template-modal-content { + background: var(--card-base); + border-radius: 12px; + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.dns-template-header { + padding: 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.dns-template-header h2 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 28px; +} + +.dns-template-header p { + margin: 0; + color: var(--fg-muted); + font-size: 14px; +} + +.dns-template-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + font-size: 32px; + color: var(--fg-muted); + cursor: pointer; + padding: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; +} + +.dns-template-close:hover { + background: var(--hover); + color: var(--fg); +} + +.dns-template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + padding: 30px; +} + +.dns-template-card { + background: var(--card-hover); + border: 2px solid var(--border); + border-radius: 12px; + padding: 20px; + transition: all 0.3s; + position: relative; + display: flex; + flex-direction: column; +} + +.dns-template-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + border-color: var(--accent); +} + +.dns-template-card.recommended { + border-color: var(--accent); + background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%); +} + +.recommended-badge { + position: absolute; + top: -10px; + right: 20px; + background: var(--accent); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dns-template-icon { + font-size: 48px; + margin-bottom: 15px; + text-align: center; +} + +.dns-template-card h3 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 18px; + text-align: center; +} + +.dns-template-description { + color: var(--fg-muted); + font-size: 13px; + margin: 0 0 15px 0; + text-align: center; + flex-grow: 1; +} + +.dns-template-difficulty { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-align: center; + margin: 0 auto 15px auto; +} + +.difficulty-easy { + background: #2ecc71; + color: white; +} + +.difficulty-intermediate { + background: #f39c12; + color: white; +} + +.difficulty-advanced { + background: #e74c3c; + color: white; +} + +.dns-template-features { + list-style: none; + padding: 0; + margin: 0 0 20px 0; + font-size: 12px; + color: var(--fg-muted); +} + +.dns-template-features li { + padding: 6px 0; + padding-left: 20px; + position: relative; +} + +.dns-template-features li:before { + content: "✓"; + position: absolute; + left: 0; + color: var(--accent); + font-weight: bold; +} + +.dns-template-select-btn { + background: var(--accent); + color: white; + border: none; + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + width: 100%; +} + +.dns-template-select-btn:hover { + background: var(--accent-strong); + transform: scale(1.02); +} + +.dns-template-footer { + padding: 20px 30px; + border-top: 1px solid var(--border); + text-align: center; +} + +.dns-template-later-btn { + background: transparent; + color: var(--fg-muted); + border: 1px solid var(--border); + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.dns-template-later-btn:hover { + background: var(--hover); + color: var(--fg); + border-color: var(--fg-muted); +} + +/* Responsive design */ +@media (max-width: 768px) { + .dns-template-grid { + grid-template-columns: 1fr; + } + + .dns-template-modal-content { + max-height: 95vh; + } +} diff --git a/status/css/themes.css b/status/css/themes.css new file mode 100644 index 0000000..f65d4ac --- /dev/null +++ b/status/css/themes.css @@ -0,0 +1,478 @@ +/* + * DashCaddy Theme Definitions + * ============================ + * + * To add a new theme: + * 1. Add a :root.mytheme { ... } block below with all the color variables + * 2. Add 'mytheme' to the THEMES array in js/theme.js + * 3. (Optional) Add per-theme body background and button hover overrides + * + * Variables reference: + * --bg Page background color + * --fg Primary text color + * --muted Secondary/dimmed text + * --fg-muted Very secondary text (lighter than --muted) + * --card-base Card background color + * --card-bg Alias for card-base (used in inline styles) + * --border Border color for cards, buttons, inputs + * --hover Hover background for interactive elements + * --card-hover Card hover background + * --base Subdued background for tags/badges + * --ok-bg/fg Success state background/text (e.g. "ON" badges) + * --bad-bg/fg Error state background/text (e.g. "OFF" badges) + * --dot-ok/bad Status dot colors (green/red indicators) + * --success Green for success states (buttons, labels) + * --error Red for error states + * --warning Orange for warning states + * --accent Brand accent (links, highlights, hover states) + * --accent-strong Stronger accent variant (active states) + */ + +/* ===== Dark (default) ===== */ +:root { + --accent: #8FD6FF; + --accent-strong: #1F7BFF; + + --bg: #0b0f1a; + --fg: #e8ecf5; + --muted: #9aa6bf; + --fg-muted: #6b7a94; + --card-base: #121826; + --card-bg: #121826; + --border: #263552; + --hover: #1a2235; + --card-hover: #161e2e; + --base: #151c2b; + + --ok-bg: #0c2430; + --ok-fg: #7ef2ff; + --bad-bg: #2a121a; + --bad-fg: #ff9aa3; + --dot-ok: #35d1ff; + --dot-bad: #ff5f7a; + --uptime: #35d1ff; + + --success: #4caf50; + --error: #e74c3c; + --warning: #f39c12; + + --brand-min: 100px; + --brand-max: 320px; + --brand-h: clamp(var(--brand-min), 22vw, var(--brand-max)); + --radius: 12px; +} + +/* ===== Light ===== */ +:root.light { + --bg: #f6f7fb; + --fg: #0f1115; + --muted: #5f6b7a; + --fg-muted: #8993a4; + --card-base: #ffffff; + --card-bg: #ffffff; + --border: #e2e7ef; + --hover: #eef1f6; + --card-hover: #f5f6fa; + --base: #ebeef3; + + --ok-bg: #eafff1; + --ok-fg: #0a7c3a; + --bad-bg: #ffefef; + --bad-fg: #b00020; + --dot-ok: #0fb15a; + --dot-bad: #d93b3b; + --uptime: #0fb15a; + + --success: #0a7c3a; + --error: #b00020; + --warning: #d68a00; + + --accent: #4a90d9; + --accent-strong: #2563eb; +} + +/* ===== Blue (deep royal) ===== */ +:root.blue { + --bg: #1908AC; + --fg: #e8f1ff; + --muted: #d6e2ff; + --fg-muted: #9eafdb; + --card-base: #0d1533; + --card-bg: #0d1533; + --border: #1c2d6a; + --hover: #141f4a; + --card-hover: #111a3e; + --base: #0f1840; + + --ok-bg: rgba(255, 255, 255, .14); + --ok-fg: #edffff; + --bad-bg: rgba(0, 0, 0, .18); + --bad-fg: #ffb3c0; + --dot-ok: #c7e5ff; + --dot-bad: #ffd6dc; + --uptime: #7ec8ff; + + --success: #7ec8ff; + --error: #ffb3c0; + --warning: #ffd080; + + --accent: #9cd4ff; + --accent-strong: #6fb2ff; +} + +/* ===== Black (black / white / red accent) ===== */ +:root.black { + --bg: #0e0e0e; + --fg: #f5f5f5; + --muted: #999999; + --fg-muted: #666666; + --card-base: #1a1a1a; + --card-bg: #1a1a1a; + --border: #2e2e2e; + --hover: #242424; + --card-hover: #202020; + --base: #161616; + + --ok-bg: #0f2a12; + --ok-fg: #66ff7a; + --bad-bg: #2a0f0f; + --bad-fg: #ff6b6b; + --dot-ok: #4caf50; + --dot-bad: #ff4444; + --uptime: #e0e0e0; + + --success: #4caf50; + --error: #ff4444; + --warning: #ff9800; + + --accent: #E63946; + --accent-strong: #C62828; +} + +/* ===== Nord ===== */ +:root.nord { + --bg: #2e3440; + --fg: #eceff4; + --muted: #81a1c1; + --fg-muted: #6882a0; + --card-base: #3b4252; + --card-bg: #3b4252; + --border: #4c566a; + --hover: #434c5e; + --card-hover: #3f4858; + --base: #353c4a; + + --ok-bg: #2d4f3e; + --ok-fg: #a3be8c; + --bad-bg: #4a2c2a; + --bad-fg: #bf616a; + --dot-ok: #a3be8c; + --dot-bad: #bf616a; + --uptime: #a3be8c; + + --success: #a3be8c; + --error: #bf616a; + --warning: #ebcb8b; + + --accent: #88c0d0; + --accent-strong: #5e81ac; +} + +/* ===== Dracula ===== */ +:root.dracula { + --bg: #282a36; + --fg: #f8f8f2; + --muted: #6272a4; + --fg-muted: #515d85; + --card-base: #44475a; + --card-bg: #44475a; + --border: #6272a4; + --hover: #4e5170; + --card-hover: #494c63; + --base: #363848; + + --ok-bg: #1e3a2e; + --ok-fg: #50fa7b; + --bad-bg: #3d1a1a; + --bad-fg: #ff5555; + --dot-ok: #50fa7b; + --dot-bad: #ff5555; + --uptime: #50fa7b; + + --success: #50fa7b; + --error: #ff5555; + --warning: #f1fa8c; + + --accent: #bd93f9; + --accent-strong: #8be9fd; +} + +/* ===== Solarized Dark ===== */ +:root.solarized-dark { + --bg: #002b36; + --fg: #839496; + --muted: #586e75; + --fg-muted: #4a5f65; + --card-base: #073642; + --card-bg: #073642; + --border: #586e75; + --hover: #0d4050; + --card-hover: #0a3a48; + --base: #053340; + + --ok-bg: #0d3d2c; + --ok-fg: #859900; + --bad-bg: #3d1a1a; + --bad-fg: #dc322f; + --dot-ok: #859900; + --dot-bad: #dc322f; + --uptime: #b5bd68; + + --success: #859900; + --error: #dc322f; + --warning: #b58900; + + --accent: #268bd2; + --accent-strong: #2aa198; +} + +/* ===== Solarized Light ===== */ +:root.solarized-light { + --bg: #fdf6e3; + --fg: #657b83; + --muted: #93a1a1; + --fg-muted: #adb8b8; + --card-base: #eee8d5; + --card-bg: #eee8d5; + --border: #93a1a1; + --hover: #e6dfcb; + --card-hover: #eae3cf; + --base: #e8e1cd; + + --ok-bg: #e8f5e8; + --ok-fg: #859900; + --bad-bg: #fdf2f2; + --bad-fg: #dc322f; + --dot-ok: #859900; + --dot-bad: #dc322f; + --uptime: #859900; + + --success: #859900; + --error: #dc322f; + --warning: #b58900; + + --accent: #268bd2; + --accent-strong: #2aa198; +} + +/* ===== Taxi (black & yellow) ===== */ +:root.taxi { + --bg: #f3d321; + --fg: #0e0e00; + --muted: #4a4a10; + --fg-muted: #6b6b30; + --card-base: #ffd700; + --card-bg: #ffd700; + --border: #b8a840; + --hover: #ffe84d; + --card-hover: #ffe033; + --base: #f0d000; + + --ok-bg: #d4ffd9; + --ok-fg: #0f2a0f; + --bad-bg: #ffd4d4; + --bad-fg: #2a0f0f; + --dot-ok: #4caf50; + --dot-bad: #ff4444; + --uptime: #0e0e00; + + --success: #2e7d32; + --error: #c62828; + --warning: #e65100; + + --accent: #0e0e00; + --accent-strong: #1a1a05; +} + +/* ===== Ocean ===== */ +:root.ocean { + --bg: #2060b0; + --fg: #faf5eb; + --muted: #dcd2c0; + --fg-muted: #b0a890; + --card-base: #7a94ed; + --card-bg: #7a94ed; + --border: #deb67a; + --hover: #8aa0f0; + --card-hover: #8298e8; + --base: #6888e0; + + --ok-bg: #4f5bb0; + --ok-fg: #c7d7eb; + --bad-bg: #f41a3a; + --bad-fg: #6a1818; + --dot-ok: #30a050; + --dot-bad: #d04040; + --uptime: #2d32f2; + + --success: #30a050; + --error: #d04040; + --warning: #e6a030; + + --accent: #1860a0; + --accent-strong: #104080; +} + +/* ===== Theme transitions ===== */ +:root { + transition: + --bg 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --fg 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --muted 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --fg-muted 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --card-base 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --border 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --hover 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --card-hover 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --base 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --ok-bg 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --ok-fg 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --bad-bg 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --bad-fg 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --dot-ok 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --dot-bad 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --uptime 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --success 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --error 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --warning 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --accent 0.3s cubic-bezier(0.4, 0, 0.2, 1), + --accent-strong 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body, .card, button, .badge, .logo-wrap, .weather-modal-content { + transition: + background 0.3s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ===== Per-theme body backgrounds ===== */ +:root.light body { + background: + radial-gradient(1200px 800px at 10% -10%, rgba(90, 120, 255, .1), transparent 60%), + radial-gradient(1000px 700px at 110% 10%, rgba(255, 120, 200, .08), transparent 55%), + var(--bg); +} + +:root.blue body { + background: + radial-gradient(1200px 820px at 8% -10%, rgba(255, 255, 255, 0.06), transparent 60%), + radial-gradient(1000px 700px at 110% 0%, rgba(0, 0, 0, 0.12), transparent 55%), + radial-gradient(900px 650px at 50% 120%, rgba(15, 0, 60, 0.12), transparent 60%), + #1908AC; +} + +:root.black body { + background: + radial-gradient(1200px 820px at 8% -10%, rgba(230, 57, 70, .06), transparent 60%), + radial-gradient(1000px 700px at 110% 0%, rgba(198, 40, 40, .04), transparent 55%), + radial-gradient(900px 650px at 50% 120%, rgba(230, 57, 70, .03), transparent 60%), + #0e0e0e; +} + +:root.nord body { + background: + radial-gradient(1200px 900px at 8% -12%, rgba(136, 192, 208, .12), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(94, 129, 172, .10), transparent 55%), + var(--bg); +} + +:root.dracula body { + background: + radial-gradient(1200px 900px at 8% -12%, rgba(189, 147, 249, .10), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(139, 233, 253, .08), transparent 55%), + var(--bg); +} + +:root.solarized-dark body { + background: + radial-gradient(1200px 900px at 8% -12%, rgba(38, 139, 210, .10), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(42, 161, 152, .08), transparent 55%), + var(--bg); +} + +:root.solarized-light body { + background: + radial-gradient(1200px 800px at 10% -10%, rgba(38, 139, 210, .08), transparent 60%), + radial-gradient(1000px 700px at 110% 10%, rgba(42, 161, 152, .06), transparent 55%), + var(--bg); +} + +:root.taxi body { + background: + radial-gradient(1200px 800px at 10% -10%, rgba(14, 14, 0, .08), transparent 60%), + radial-gradient(1000px 700px at 110% 10%, rgba(184, 168, 64, .12), transparent 55%), + var(--bg); +} + +:root.ocean body { + background: + radial-gradient(1200px 900px at 8% -12%, rgba(122, 148, 237, .18), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(222, 182, 122, .10), transparent 55%), + radial-gradient(900px 650px at 50% 120%, rgba(16, 64, 128, .15), transparent 60%), + var(--bg); +} + +/* ===== Per-theme button hover overrides ===== */ +:root.light button:hover { + background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); + border-color: rgba(0, 0, 0, .15); + box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8); +} + +:root.blue button:hover { + background: color-mix(in srgb, var(--accent) 24%, transparent); + border-color: rgba(255, 255, 255, .35); + box-shadow: 0 0 6px rgba(255, 255, 255, .22), inset 0 1px 0 rgba(255, 255, 255, .18); +} + +:root.black button:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +:root.nord button:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +:root.dracula button:hover { + background: color-mix(in srgb, var(--accent) 20%, transparent); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); +} + +:root.solarized-dark button:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); +} + +:root.solarized-light button:hover { + background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); + border-color: rgba(0, 0, 0, .12); + box-shadow: 0 1px 6px rgba(0, 0, 0, .06); +} + +:root.taxi button:hover { + background: color-mix(in srgb, var(--accent-strong) 15%, var(--card-base) 85%); + border-color: rgba(0, 0, 0, .2); + box-shadow: 0 1px 6px rgba(0, 0, 0, .1); +} + +:root.ocean button:hover { + background: color-mix(in srgb, var(--accent) 22%, transparent); + border-color: color-mix(in srgb, var(--border) 60%, white 40%); + box-shadow: 0 0 6px rgba(255, 255, 255, .12); +} + diff --git a/status/help-errors.html b/status/help-errors.html new file mode 100644 index 0000000..2fd58c7 --- /dev/null +++ b/status/help-errors.html @@ -0,0 +1,487 @@ + + + + + DashCaddy - Troubleshooting + + + + + + +
+

Troubleshooting

+ ← Back to Dashboard +
+ + + +
No matching errors found.
+ +
+ + +
+

Authentication & CSRF (DC-1xx)

+ +
+
+ DC-100 + CSRF token missing +
+
+
Cause
+
Your browser cookie expired or was blocked. This often happens after the browser has been idle for a long time.
+
Fix
+
Hard refresh the page with Ctrl+Shift+R (or Cmd+Shift+R on Mac), then try again.
+
+
+ +
+
+ DC-101 + CSRF token invalid +
+
+
Cause
+
The security token in your browser doesn't match the one the server expects. This can happen after long idle periods or if you have multiple tabs open.
+
Fix
+
Refresh the page and try the action again.
+
+
+ +
+
+ DC-110 + Authentication required +
+
+
Cause
+
Your TOTP session has expired. Sessions last for the duration configured in your TOTP settings (default: 24 hours).
+
Fix
+
Enter your TOTP code from your authenticator app to re-authenticate.
+
+
+ +
+
+ DC-111 + Invalid code +
+
+
Cause
+
The TOTP code you entered is incorrect or has already expired. Codes refresh every 30 seconds.
+
Fix
+
Check your authenticator app for the current code. Wait for a fresh code if the current one is about to expire.
+
+
+ +
+
+ DC-120 + Access denied - Tailscale required +
+
+
Cause
+
The dashboard is configured to require a Tailscale VPN connection, and you're not currently connected.
+
Fix
+
Connect to Tailscale VPN first, then try accessing the dashboard again.
+
+
+
+ + +
+

Deployment & Containers (DC-2xx)

+ +
+
+ DC-200 + Port is already in use +
+
+
Cause
+
Another container or process on the host is already using the port you selected. This sometimes happens after a failed deploy leaves a stale container behind.
+
Fix
+
Use a different port in Advanced Options, or stop the conflicting service first. Check the Monitor panel to see which container is using the port.
+
+
+ +
+
+ DC-201 + Failed to pull image +
+
+
Cause
+
Docker couldn't download the container image. This is usually a network issue, or the image name/tag doesn't exist on Docker Hub.
+
Fix
+
Check your internet connection. If using a custom image, verify the image name and tag are correct.
+
+
+ +
+
+ DC-202 + Container failed to start +
+
+
Cause
+
The container was created but couldn't start or become healthy. Common reasons include misconfigured environment variables, missing volume mounts, or insufficient resources.
+
Fix
+
Check the Logs panel for container-specific error messages. Try redeploying with default settings.
+
+
+ +
+
+ DC-203 + Port conflict with another service +
+
+
Cause
+
Another DashCaddy-managed service is already using the port you specified. DashCaddy checks for this before deploying to prevent conflicts.
+
Fix
+
Choose a different port in Advanced Options, or remove the conflicting service first.
+
+
+ +
+
+ DC-210 + Invalid IP address +
+
+
Cause
+
The IP address field contains an invalid value. It must be a valid IPv4 or IPv6 address, or one of the allowed hostnames.
+
Fix
+
Use a valid IP address (e.g., 192.168.1.100) or "localhost". Leave blank to use the default.
+
+
+
+ + +
+

DNS & Caddy Configuration (DC-3xx)

+ +
+
+ DC-300 + DNS token unavailable +
+
+
Cause
+
The DNS server credentials have expired or are missing. DashCaddy needs valid DNS credentials to create and manage DNS records.
+
Fix
+
Go to Tokens in the toolbar, select DNS, and re-enter your DNS server credentials.
+
+
+ +
+
+ DC-301 + Invalid domain/subdomain format +
+
+
Cause
+
The domain or subdomain name contains invalid characters. Domain names can only contain lowercase letters, numbers, hyphens, and dots.
+
Fix
+
Use only letters (a-z), numbers (0-9), hyphens (-), and dots (.) in your domain name.
+
+
+ +
+
+ DC-302 + Site already exists in Caddyfile +
+
+
Cause
+
A site block for this domain is already present in the Caddy configuration file. You can't create a duplicate entry.
+
Fix
+
Edit the existing site configuration, or remove it first before creating a new one.
+
+
+ +
+
+ DC-303 + Caddy reload failed +
+
+
Cause
+
Caddy couldn't reload its configuration. This usually means there's a syntax error in the Caddyfile, or Caddy's admin API is unreachable.
+
Fix
+
Check the Logs panel for the specific Caddy error. The Caddyfile change was automatically rolled back. If the problem persists, verify Caddy is running.
+
+
+
+ + +
+

Service Management (DC-4xx)

+ +
+
+ DC-400 + Container not found +
+
+
Cause
+
The Docker container was removed outside of DashCaddy (e.g., via Docker Desktop or the CLI). DashCaddy still has it registered but can't find it.
+
Fix
+
Delete the service card from DashCaddy, then redeploy the app from the App Selector.
+
+
+ +
+
+ DC-401 + Update failed +
+
+
Cause
+
The container update process failed during image pull, container recreation, or startup. The old container may have been removed but the new one couldn't start.
+
Fix
+
Check the Logs panel for details. If the container is gone, redeploy the app from the App Selector.
+
+
+
+ + +
+

Credentials & Auto-Login (DC-5xx)

+ +
+
+ DC-500 + No credentials stored +
+
+
Cause
+
Auto-login was attempted but no username/password is saved for this service. DashCaddy needs stored credentials to log you in automatically.
+
Fix
+
Click the key icon (🔑) on the service card to add your credentials for auto-login.
+
+
+ +
+
+ DC-501 + Login failed +
+
+
Cause
+
The stored credentials were rejected by the service. The username or password may be incorrect, or the service's auth endpoint may have changed.
+
Fix
+
Click the key icon (🔑) on the service card to update your credentials. Verify the username and password are correct by logging into the service directly first.
+
+
+
+ +
+ +
+ DashCaddy v0.95 · Error codes help you quickly find solutions. Search by code or keyword above. +
+ + + + + diff --git a/status/index.html b/status/index.html new file mode 100644 index 0000000..9a7ab72 --- /dev/null +++ b/status/index.html @@ -0,0 +1,528 @@ + + + + + + DashCaddy + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +

Enter your 6-digit authentication code

+
+ + + + + + +
+
+
+
+ + +
+
+ +
+
+ DashCaddy + DashCaddy +
+ + +
+
+
+
🌤️
+
+
--
+
--°
+
--
+
--
+ +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + FREE TIER + +
+ +
+
+ + +
+ +
+
last check: —
+ +
+ + +
+
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + + + + + +
+
+
+
+
+ + +
+ + +
+ +
+
+ + + + + + +
+ Internet + + OFF +
+
+ -- +
+
+
+ +
+ +
+
+ + + + + + +
+ Auth + + NO +
+
+ Not configured +
+
+ +
+
+ +
+ +
+
+ 🔐 +
+ DashCA + + OFF +
+
+ -- +
+
+ -- +
+
+
+ + + + +
+
+
+ + +
+ + +
+ + + +
+ + + + + + + + + + +
+ © + +
+ + + + + + + + + diff --git a/status/js/README.md b/status/js/README.md new file mode 100644 index 0000000..c5b83dd --- /dev/null +++ b/status/js/README.md @@ -0,0 +1,40 @@ +# DashCaddy Onboarding System + +This directory contains the JavaScript modules for the user onboarding tooltip system. + +## Files + +- **onboarding.js** - Main entry point, initializes the onboarding system +- **tour-manager.js** - Orchestrates the onboarding flow and manages tour state +- **progress-tracker.js** - Manages persistent storage of user progress +- **tooltip-definitions.js** - Defines all tooltip content and positioning +- **dns-template-selector.js** - Presents DNS server template options +- **theme-adapter.js** - Ensures tooltips match the current dashboard theme + +## Load Order + +The scripts are loaded in the following order (as defined in status/index.html): + +1. progress-tracker.js +2. theme-adapter.js +3. tooltip-definitions.js +4. dns-template-selector.js +5. tour-manager.js +6. onboarding.js (main initialization) + +## Dependencies + +- **Driver.js** - Loaded from CDN (https://cdn.jsdelivr.net/npm/driver.js@1.3.1/) +- Dashboard CSS variables (for theming) +- Browser localStorage API (for progress tracking) + +## Integration + +The onboarding system integrates with: +- Dashboard theme system (via CSS variables) +- App template selector (for DNS server deployment) +- Local storage (for progress persistence) + +## Development + +Each module is wrapped in an IIFE (Immediately Invoked Function Expression) to avoid global namespace pollution. Modules communicate through well-defined interfaces and the window object where necessary. diff --git a/status/js/app-selector.js b/status/js/app-selector.js new file mode 100644 index 0000000..b76a132 --- /dev/null +++ b/status/js/app-selector.js @@ -0,0 +1,1047 @@ +// App Selector System +(function () { + injectModal('app-selector-modal', `
+
+

Choose an App

+
+
+ +
+
+
`); + + injectModal('app-deploy-modal', `
+
+

Deploy Application

+ +
+ +
+ + +
+ Your app will be available at: uptime.home +
+
+ + +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+ + + + + + + + +
+ +
+ +
+ Checking Tailscale status... +
+
+
+ + +
+ ⚙️ Advanced Options +
+
+ + +
+
+ + +
+ Use 'localhost' for same-host containers, or specific IP for remote services +
+
+ +
+
+
+ +
+ + +
+
+
`); + + const APPS_KEY = 'custom-apps'; + + // Cache for API templates + let apiTemplates = null; + let apiCategories = null; + + const modal = document.getElementById('app-selector-modal'); + const grid = document.getElementById('app-selector-grid'); + + // Fetch app templates from API + async function fetchAppTemplates() { + try { + const response = await fetch('/api/v1/apps/templates'); + const data = await response.json(); + if (data.success) { + apiTemplates = data.templates; + apiCategories = data.categories; + return true; + } + } catch (e) { + console.error('Failed to fetch app templates:', e); + } + return false; + } + + // Check port availability + async function checkPortAvailability(port) { + try { + const response = await fetch(`/api/v1/apps/ports/${port}/check`); + const data = await response.json(); + return data; + } catch (e) { + console.error('Failed to check port:', e); + return { available: true }; // Assume available on error + } + } + + // Get suggested available port + async function getSuggestedPort(basePort) { + try { + const response = await fetch(`/api/v1/apps/ports/${basePort}/suggest`); + const data = await response.json(); + if (data.success) { + return data.suggestedPort; + } + } catch (e) { + console.error('Failed to get suggested port:', e); + } + return basePort; + } + + // Build app selector grid from API templates + async function buildAppSelector() { + grid.innerHTML = '
Loading app templates...
'; + + // Fetch templates if not cached + if (!apiTemplates) { + const success = await fetchAppTemplates(); + if (!success) { + grid.innerHTML = '
Failed to load app templates. Please try again.
'; + return; + } + } + + grid.innerHTML = ''; + + // Group templates by category + const byCategory = {}; + for (const [appId, template] of Object.entries(apiTemplates)) { + const category = template.category || 'Other'; + if (!byCategory[category]) { + byCategory[category] = []; + } + byCategory[category].push({ id: appId, ...template }); + } + + // Sort categories by the order in apiCategories if available + const categoryOrder = apiCategories ? Object.keys(apiCategories) : Object.keys(byCategory).sort(); + + for (const category of categoryOrder) { + const apps = byCategory[category]; + if (!apps || apps.length === 0) continue; + + // Sort apps by popularity (descending) + apps.sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); + + // Category header with icon and color from API + const header = document.createElement('div'); + header.className = 'app-category-header'; + const categoryInfo = apiCategories?.[category] || {}; + header.innerHTML = `${categoryInfo.icon || ''} ${category}`; + if (categoryInfo.color) { + header.style.borderBottomColor = categoryInfo.color; + } + grid.appendChild(header); + + // App options + apps.forEach(app => { + const option = document.createElement('div'); + option.className = 'app-option'; + + // Widget enabled badge + const isWidget = app.isDashboardWidget; + const widgetEnabled = isWidget && safeGet('widget-' + app.id + '-enabled') !== 'false'; + const widgetBadge = isWidget + ? `
${widgetEnabled ? 'ON' : 'OFF'}
` + : ''; + + // Show difficulty badge (non-widgets only) + const difficultyBadge = !isWidget && app.difficulty ? + `
${app.difficulty}
` : ''; + + option.innerHTML = ` +
${escapeHtml(app.icon || '📦')}
+
${escapeHtml(app.name)}
+
${escapeHtml(app.description || '')}
+ ${widgetBadge}${difficultyBadge} + `; + + if (isWidget) { + option.onclick = () => toggleDashboardWidget(app, option); + } else { + option.onclick = () => showDeployConfig(app); + } + grid.appendChild(option); + }); + } + + // Render recipe cards at the end of the grid + if (window.renderRecipeCards) { + await window.renderRecipeCards(grid); + } + } + + // Toggle a dashboard widget on/off + function toggleDashboardWidget(app, optionEl) { + const key = 'widget-' + app.id + '-enabled'; + const currentlyEnabled = safeGet(key) !== 'false'; + const newState = !currentlyEnabled; + safeSet(key, String(newState)); + + // Update visibility immediately + const selector = app.widgetSelector; + if (selector) { + const el = document.querySelector(selector); + if (el) el.style.display = newState ? '' : 'none'; + } + + // Update the badge in the app selector card + const badge = optionEl.querySelector('div[style*="border-radius: 4px"]'); + if (badge) { + badge.textContent = newState ? 'ON' : 'OFF'; + badge.style.background = newState ? '#2ecc7130' : '#e74c3c30'; + badge.style.color = newState ? '#2ecc71' : '#e74c3c'; + } + + showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000); + } + + // Show deployment configuration modal + async function showDeployConfig(appTemplate) { + const deployModal = document.getElementById('app-deploy-modal'); + const title = document.getElementById('app-deploy-title'); + const subdomainInput = document.getElementById('deploy-subdomain'); + const urlPreview = document.getElementById('deploy-url-preview'); + const ipInput = document.getElementById('deploy-ip'); + const portInput = document.getElementById('deploy-port'); + const tailscaleCheckbox = document.getElementById('deploy-tailscale-only'); + const tailscaleStatus = document.getElementById('tailscale-status'); + + // Check for existing container with same image + try { + const checkResponse = await secureFetch('/api/v1/apps/check-existing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId: appTemplate.id }) + }); + const checkResult = await checkResponse.json(); + + if (checkResult.success && checkResult.exists) { + const container = checkResult.container; + const useExisting = confirm( + `Found existing ${appTemplate.name} container:\n\n` + + `Container: ${container.name}\n` + + `Status: ${container.status}\n` + + `Port: ${container.primaryPort || 'N/A'}\n\n` + + `Would you like to use this existing container?\n\n` + + `Click OK to configure DNS/Caddy for the existing container.\n` + + `Click Cancel to deploy a new container.` + ); + + if (useExisting) { + // Store existing container info for deployment + appTemplate._useExisting = true; + appTemplate._existingContainer = container; + } + } + } catch (e) { + // Ignore container check errors + } + + // Set title with app info + title.textContent = `Deploy ${appTemplate.name}`; + + // Pre-fill subdomain from template or app ID + const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, ''); + subdomainInput.value = defaultSubdomain; + + // Pre-select DNS/SSL from site config (set during setup wizard) + const cfgDnsType = SITE.defaults.dnsType || (SITE.configurationType === 'public' ? 'public' : 'private'); + const cfgSslType = SITE.defaults.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'internal'); + const dnsRadio = document.querySelector(`input[name="dns-type"][value="${cfgDnsType}"]`); + const sslRadio = document.querySelector(`input[name="ssl-type"][value="${cfgSslType}"]`); + if (dnsRadio) dnsRadio.checked = true; + else document.querySelector('input[name="dns-type"][value="private"]').checked = true; + if (sslRadio) sslRadio.checked = true; + else document.querySelector('input[name="ssl-type"][value="internal"]').checked = true; + ipInput.value = SITE.defaults.targetIP || 'localhost'; + tailscaleCheckbox.checked = false; + + // Move DNS/SSL into Advanced section when already configured + const dnsSection = document.querySelector('#app-deploy-modal .flex-col-gap')?.closest('div'); + const advancedDetails = document.querySelector('#app-deploy-modal details'); + const advancedContent = advancedDetails?.querySelector('div'); + if (advancedDetails && advancedContent && (SITE.configurationType === 'public' || SITE.configurationType === 'homelab')) { + // Move DNS and SSL sections inside Advanced + const dnsSectionEl = document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest('div.flex-col-gap')?.parentElement; + const sslSectionEl = document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest('div.flex-col-gap')?.parentElement; + if (dnsSectionEl && !dnsSectionEl.dataset.moved) { + advancedContent.appendChild(dnsSectionEl); + dnsSectionEl.dataset.moved = '1'; + } + if (sslSectionEl && !sslSectionEl.dataset.moved) { + advancedContent.appendChild(sslSectionEl); + sslSectionEl.dataset.moved = '1'; + } + } + + // Handle media path section for media apps + const mediaPathSection = document.getElementById('media-path-section'); + const mediaPathInput = document.getElementById('deploy-media-path'); + const mediaPathDescription = document.getElementById('media-path-description'); + + if (appTemplate.mediaMount) { + mediaPathSection.style.display = 'block'; + mediaPathInput.value = ''; + mediaPathInput.placeholder = 'E:/Movies, E:/TVShows or click Browse'; + + // Fetch detected mounts from existing media servers + const detectedMountsContainer = document.getElementById('detected-mounts-container'); + const detectedMountsList = document.getElementById('detected-mounts-list'); + + try { + const mountsResponse = await fetch('/api/v1/media/detected-mounts'); + const mountsResult = await mountsResponse.json(); + + if (mountsResult.success && mountsResult.mounts.length > 0) { + detectedMountsContainer.style.display = 'block'; + detectedMountsList.innerHTML = ''; + + // Auto-fill media path with all detected mounts + const autoPaths = [...new Set(mountsResult.mounts.map(m => m.hostPath))]; + mediaPathInput.value = autoPaths.join(', '); + + mountsResult.mounts.forEach(mount => { + const btn = document.createElement('button'); + btn.type = 'button'; + const isSelected = autoPaths.includes(mount.hostPath); + btn.style.cssText = `padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${isSelected ? '40%' : '15%'}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`; + btn.innerHTML = `${mount.folderName}
from ${mount.sourceImage}`; + btn.title = `${mount.hostPath} (from ${mount.sourceContainer})`; + btn.onclick = () => { + const currentPaths = mediaPathInput.value.split(',').map(p => p.trim()).filter(p => p); + const idx = currentPaths.indexOf(mount.hostPath); + if (idx >= 0) { + currentPaths.splice(idx, 1); + btn.style.background = 'color-mix(in srgb, var(--success) 15%, var(--card-bg))'; + } else { + currentPaths.push(mount.hostPath); + btn.style.background = 'color-mix(in srgb, var(--success) 40%, var(--card-bg))'; + } + mediaPathInput.value = currentPaths.join(', '); + }; + detectedMountsList.appendChild(btn); + }); + } else { + detectedMountsContainer.style.display = 'none'; + } + } catch (e) { + detectedMountsContainer.style.display = 'none'; + } + + // Set up browse button + document.getElementById('browse-media-btn').onclick = () => { + openFolderBrowser(mediaPathInput); + }; + } else { + mediaPathSection.style.display = 'none'; + mediaPathInput.value = ''; + document.getElementById('detected-mounts-container').style.display = 'none'; + } + + // Show Plex claim token section for Plex deployments + const plexClaimSection = document.getElementById('plex-claim-section'); + if (plexClaimSection) { + if (appTemplate.id === 'plex' || appTemplate.claimToken) { + plexClaimSection.style.display = 'block'; + document.getElementById('deploy-plex-claim').value = ''; + } else { + plexClaimSection.style.display = 'none'; + } + } + + // Populate volume mounts in Advanced Options + const volumeSection = document.getElementById('volume-mounts-section'); + const volumeList = document.getElementById('volume-mounts-list'); + volumeList.innerHTML = ''; + + if (appTemplate.docker?.volumes?.length) { + const mediaContainerPath = appTemplate.mediaMount?.containerPath; + const nonMediaVolumes = appTemplate.docker.volumes.filter(v => !v.includes('{{MEDIA_PATH}}') && !(mediaContainerPath && v.endsWith(':' + mediaContainerPath))); + + if (nonMediaVolumes.length > 0) { + volumeSection.style.display = 'block'; + nonMediaVolumes.forEach((vol, i) => { + const [hostDefault, containerPath] = vol.split(':'); + const row = document.createElement('div'); + row.style.cssText = 'display: flex; gap: 6px; align-items: center;'; + row.innerHTML = ` + + → ${containerPath} + + `; + volumeList.appendChild(row); + row.querySelector('.vol-browse-btn').onclick = () => { + const input = row.querySelector('.vol-host-path'); + openFolderBrowser(input); + }; + }); + } else { + volumeSection.style.display = 'none'; + } + } else { + volumeSection.style.display = 'none'; + } + + // Set default port from template and check availability + const defaultPort = appTemplate.defaultPort || 8080; + portInput.value = ''; + portInput.placeholder = `Default: ${defaultPort}`; + + // Add port status element if not exists + let portStatus = document.getElementById('deploy-port-status'); + if (!portStatus) { + portStatus = document.createElement('div'); + portStatus.id = 'deploy-port-status'; + portStatus.style.cssText = 'font-size: 0.8rem; margin-top: 4px;'; + portInput.parentNode.appendChild(portStatus); + } + + // Check default port availability + async function checkAndUpdatePortStatus() { + const portToCheck = portInput.value || defaultPort; + portStatus.innerHTML = 'Checking port...'; + + const result = await checkPortAvailability(portToCheck); + if (result.available) { + portStatus.innerHTML = `Port ${portToCheck} is available`; + } else { + const suggestedPort = await getSuggestedPort(defaultPort); + portStatus.innerHTML = ` + Port ${escapeHtml(portToCheck)} in use by ${escapeHtml(result.conflict?.usedBy || 'unknown')} + `; + const useBtn = document.createElement('button'); + useBtn.type = 'button'; + useBtn.textContent = `Use ${suggestedPort}`; + useBtn.style.cssText = 'margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;'; + useBtn.onclick = () => { + document.getElementById('deploy-port').value = suggestedPort; + portStatus.innerHTML = `Using suggested port ${escapeHtml(String(suggestedPort))}`; + }; + portStatus.appendChild(useBtn); + } + } + + // Check port on input change (debounced) + let portCheckTimeout; + portInput.oninput = function() { + clearTimeout(portCheckTimeout); + portCheckTimeout = setTimeout(checkAndUpdatePortStatus, 500); + }; + + // Initial port check + checkAndUpdatePortStatus(); + + // Fetch Tailscale status + try { + const response = await fetch('/api/v1/tailscale/status'); + const data = await response.json(); + if (data.success && data.installed && data.connected) { + tailscaleStatus.innerHTML = ` + Connected + ${data.self?.hostname} (${data.self?.ip}) + | ${data.deviceCount} devices + `; + } else if (data.installed) { + tailscaleStatus.innerHTML = `Not connected`; + } else { + tailscaleStatus.innerHTML = `Not available`; + tailscaleCheckbox.disabled = true; + } + } catch (e) { + tailscaleStatus.innerHTML = `Could not check status`; + } + + // Update URL preview in real-time + function updateUrlPreview() { + const subdomain = subdomainInput.value || 'subdomain'; + const dnsType = document.querySelector('input[name="dns-type"]:checked').value; + const sslType = document.querySelector('input[name="ssl-type"]:checked').value; + + let url = ''; + if (dnsType === 'private') { + const protocol = sslType === 'none' ? 'http' : 'https'; + url = `${protocol}://${buildDomain(subdomain)}`; + } else if (dnsType === 'public') { + const protocol = sslType === 'none' ? 'http' : 'https'; + const domain = SITE.domain || subdomain; + url = SITE.domain ? `${protocol}://${subdomain}.${SITE.domain}` : `${protocol}://${subdomain}`; + } else { + const port = portInput.value || appTemplate.defaultPort || DC.DEFAULTS.SERVICE_PORT; + url = `http://${ipInput.value}:${port}`; + } + urlPreview.textContent = url; + } + + // Attach listeners + subdomainInput.oninput = updateUrlPreview; + ipInput.oninput = updateUrlPreview; + portInput.oninput = updateUrlPreview; + document.querySelectorAll('input[name="dns-type"]').forEach(radio => { + radio.onchange = updateUrlPreview; + }); + document.querySelectorAll('input[name="ssl-type"]').forEach(radio => { + radio.onchange = updateUrlPreview; + }); + + updateUrlPreview(); + + // Close app selector, open deploy config + modal.classList.remove('show'); + deployModal.classList.add('show'); + + // Store app template for deployment + deployModal.dataset.appTemplate = JSON.stringify(appTemplate); + } + + // Add app to grid with full Docker deployment + async function addAppToGrid(deployConfig) { + const appTemplate = deployConfig.appTemplate; + const customApps = safeGetJSON(APPS_KEY, []); + + // Check if using existing container + const usingExisting = appTemplate._useExisting && appTemplate._existingContainer; + + // Check if app already exists - skip if using existing container (user already confirmed) + const existingApp = customApps.find(a => a.id === deployConfig.subdomain); + if (existingApp && !usingExisting) { + const confirmed = confirm(`An app with subdomain "${deployConfig.subdomain}" already exists. Redeploy?`); + if (!confirmed) return; + } + // Remove from localStorage to allow redeployment/update + if (existingApp) { + const index = customApps.indexOf(existingApp); + customApps.splice(index, 1); + safeSet(APPS_KEY, JSON.stringify(customApps)); + } + + // Check port availability before deployment (skip if using existing container) + if (!usingExisting) { + const portToUse = deployConfig.port || appTemplate.defaultPort || 8080; + showNotification(`Checking port ${portToUse} availability...`, 'info', 0); + + const portCheck = await checkPortAvailability(portToUse); + if (!portCheck.available) { + const suggestedPort = await getSuggestedPort(appTemplate.defaultPort || 8080); + const useAlternate = confirm( + `Port ${portToUse} is already in use by ${portCheck.conflict?.usedBy || 'another container'}.\n\n` + + `Would you like to use port ${suggestedPort} instead?` + ); + if (useAlternate) { + deployConfig.port = suggestedPort; + } else { + showNotification('Deployment cancelled - port conflict', 'error', 5000); + return; + } + } + } else { + // Use existing container's port + deployConfig.port = appTemplate._existingContainer.primaryPort; + } + showNotification( + usingExisting + ? `Configuring ${appTemplate.name} with existing container...` + : `Deploying ${appTemplate.name}...`, + 'info', 0 + ); + + try { + // Prepare deployment config from user's choices + const apiDeployConfig = { + appId: appTemplate.id, + config: { + subdomain: deployConfig.subdomain, + ip: deployConfig.ip, + createDns: deployConfig.dnsType === 'private', // Only create DNS for private + port: deployConfig.port || appTemplate.defaultPort || null, // Use custom, template default, or null + sslType: deployConfig.sslType, + dnsType: deployConfig.dnsType, + tailscaleOnly: deployConfig.tailscaleOnly || false, // Tailscale-only access restriction + mediaPath: deployConfig.mediaPath || null, // Media folder path for media apps + plexClaimToken: deployConfig.plexClaimToken || null, // Plex claim token for auto-claim + customVolumes: deployConfig.customVolumes || null // Custom volume mount overrides + } + }; + + // Add existing container info if using existing + if (usingExisting) { + apiDeployConfig.config.useExisting = true; + apiDeployConfig.config.existingContainerId = appTemplate._existingContainer.id; + apiDeployConfig.config.existingPort = appTemplate._existingContainer.primaryPort; + // Use existing container's port if no custom port specified + if (!deployConfig.port && appTemplate._existingContainer.primaryPort) { + apiDeployConfig.config.port = appTemplate._existingContainer.primaryPort; + } + } + + // Call deployment API + const response = await secureFetch('/api/v1/apps/deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(apiDeployConfig) + }); + + const result = await response.json(); + + if (result.success) { + // Add to saved apps (store IP for later deletion) + const newApp = { + id: deployConfig.subdomain, // Use subdomain as ID + name: appTemplate.name, + logo: `/assets/${appTemplate.id}.png`, + containerId: result.containerId, + url: result.url, + ip: deployConfig.ip, // Store IP for DNS record deletion + appTemplate: appTemplate.id, // Store original template ID + tailscaleOnly: deployConfig.tailscaleOnly || false // Tailscale protection status + }; + customApps.push(newApp); + safeSet(APPS_KEY, JSON.stringify(customApps)); + + // Add to APPS array and rebuild grid + // Access APPS from parent scope via window + if (window.APPS && !window.APPS.some(a => a.id === appTemplate.id)) { + window.APPS.push(newApp); + if (typeof window.buildGrid === 'function') { + window.buildGrid(); + } + if (typeof window.refreshAll === 'function') { + setTimeout(() => window.refreshAll(), 500); + } + } + + // Show success with URL (and warning if DNS failed) + let message = result.usedExisting + ? `${appTemplate.name} configured with existing container!\nURL: ${result.url}` + : `${appTemplate.name} deployed successfully!\nURL: ${result.url}`; + if (result.warning) { + message += `\n\n⚠ Warning: ${result.warning}`; + } + + showNotification(message, 'success', 8000); + + // Clean up temporary properties + delete appTemplate._useExisting; + delete appTemplate._existingContainer; + + // For HTTPS URLs, check SSL certificate status + if (result.url && result.url.startsWith('https://')) { + checkSSLCertificate(result.url, appTemplate.name); + } + + // Show setup instructions if available + if (result.setupInstructions && result.setupInstructions.length > 0) { + setTimeout(() => { + const instructions = result.setupInstructions.join('\n'); + showNotification(`Setup Instructions for ${appTemplate.name}: ${instructions}`, 'info', 10000); + }, 1000); + } + } else { + throw new Error(result.error || 'Deployment failed'); + } + } catch (error) { + console.error('Deployment error:', error); + showNotification( + `Failed to deploy ${appTemplate.name}: ${error.message}`, + 'error', + 8000 + ); + } + } + + // Check SSL certificate status and notify when ready + async function checkSSLCertificate(url, appName) { + showNotification(`⏳ Generating SSL certificate for ${appName}...`, 'warning', 60000); + + let attempts = 0; + const maxAttempts = 12; // 60 seconds total (5 second intervals) + + const checkCert = async () => { + attempts++; + + try { + // Try to fetch the URL - if SSL works, this will succeed + const response = await fetch(url, { + method: 'HEAD', + mode: 'no-cors' // Avoid CORS issues + }); + + // If we get here, SSL is working + showNotification(`✅ ${appName} is ready! SSL certificate generated.`, 'success', 5000); + return true; + } catch (error) { + // SSL not ready yet + if (attempts < maxAttempts) { + setTimeout(checkCert, 5000); // Check again in 5 seconds + } else { + showNotification( + `⚠️ ${appName} deployed but SSL certificate may still be generating.\nTry refreshing in a moment if you see a certificate error.`, + 'warning', + 10000 + ); + } + return false; + } + }; + + // Start checking after 3 seconds (give Caddy time to start) + setTimeout(checkCert, 3000); + } + + // Load custom apps on startup + function loadCustomApps() { + const customApps = safeGetJSON(APPS_KEY, []); + customApps.forEach(app => { + if (!window.APPS.some(a => a.id === app.id)) { + window.APPS.push(app); + } + }); + } + + // Event listeners + document.getElementById('add-service-btn')?.addEventListener('click', () => { + buildAppSelector(); + modal.classList.add('show'); + }); + + wireModal(modal, document.getElementById('app-selector-cancel')); + + // Deploy modal event listeners + const deployModal = document.getElementById('app-deploy-modal'); + + document.getElementById('app-deploy-cancel')?.addEventListener('click', () => { + deployModal.classList.remove('show'); + }); + + document.getElementById('app-deploy-confirm')?.addEventListener('click', () => { + // Get user configuration + const appTemplate = JSON.parse(deployModal.dataset.appTemplate); + const mediaPath = document.getElementById('deploy-media-path').value.trim(); + + // Collect custom volume overrides + const customVolumes = []; + document.querySelectorAll('#volume-mounts-list .vol-host-path').forEach(input => { + customVolumes.push({ hostPath: input.value.trim(), containerPath: input.dataset.containerPath }); + }); + + const deployConfig = { + appTemplate: appTemplate, + subdomain: document.getElementById('deploy-subdomain').value.trim(), + dnsType: document.querySelector('input[name="dns-type"]:checked').value, + sslType: document.querySelector('input[name="ssl-type"]:checked').value, + ip: document.getElementById('deploy-ip').value.trim(), + port: document.getElementById('deploy-port').value.trim(), + tailscaleOnly: document.getElementById('deploy-tailscale-only').checked, + mediaPath: mediaPath || null, + plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || null, + customVolumes: customVolumes.length > 0 ? customVolumes : null + }; + + // Validate subdomain + if (!deployConfig.subdomain) { + showNotification('Please enter a subdomain or domain name', 'warning'); + return; + } + + // Validate media path for media apps + if (appTemplate.mediaMount?.required && !mediaPath) { + showNotification('Please enter a media library path for this application', 'warning'); + return; + } + + // Close deploy modal + deployModal.classList.remove('show'); + + // Start deployment + addAppToGrid(deployConfig); + }); + + wireModal(deployModal); + + // ===== FOLDER BROWSER FUNCTIONALITY ===== + const folderBrowserModal = document.getElementById('folder-browser-modal'); + const folderBrowserPath = document.getElementById('folder-browser-path'); + const folderBrowserList = document.getElementById('folder-browser-list'); + const folderBrowserSelected = document.getElementById('folder-browser-selected'); + const folderBrowserSelectedList = document.getElementById('folder-browser-selected-list'); + let currentBrowserPath = ''; + let selectedFolders = []; + let targetMediaInput = null; + + window.openFolderBrowser = function(mediaInput) { + targetMediaInput = mediaInput; + selectedFolders = mediaInput.value.split(',').map(p => p.trim()).filter(p => p); + currentBrowserPath = ''; + updateSelectedFoldersDisplay(); + loadFolderContents(''); + folderBrowserModal.classList.add('show'); + }; + + async function loadFolderContents(path) { + folderBrowserPath.textContent = path || 'Select a drive...'; + folderBrowserList.innerHTML = '
Loading...
'; + + try { + const response = await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(path)}`); + const result = await response.json(); + + if (!result.success) { + folderBrowserList.innerHTML = `
Error: ${escapeHtml(result.error)}
`; + return; + } + + currentBrowserPath = result.path || ''; + folderBrowserPath.textContent = currentBrowserPath || 'Select a drive...'; + + let html = ''; + + // Add parent folder navigation + if (result.parent && result.parent !== result.path) { + html += `
+ ⬆️ + .. Parent Directory +
`; + } + + // Add folders + if (result.items.length === 0 && !result.parent) { + html += '
No browseable drives configured. Check your docker-compose.yml volume mounts.
'; + } else if (result.items.length === 0) { + html += '
No subfolders found
'; + } else { + result.items.forEach(item => { + const icon = item.type === 'drive' ? '💾' : '📁'; + const isSelected = selectedFolders.includes(item.path); + const selectedStyle = isSelected ? 'background: color-mix(in srgb, var(--success) 20%, transparent);' : ''; + html += `
+ ${icon} + ${escapeHtml(item.name)} + ${isSelected ? '' : ''} +
`; + }); + } + + folderBrowserList.innerHTML = html; + + // Add click handlers + folderBrowserList.querySelectorAll('.folder-item').forEach(item => { + item.addEventListener('click', () => { + loadFolderContents(item.dataset.path); + }); + item.addEventListener('mouseenter', () => { + item.style.background = 'var(--card-bg)'; + }); + item.addEventListener('mouseleave', () => { + const isSelected = selectedFolders.includes(item.dataset.path); + item.style.background = isSelected ? 'color-mix(in srgb, var(--success) 20%, transparent)' : ''; + }); + }); + + } catch (error) { + folderBrowserList.innerHTML = `
Failed to load: ${escapeHtml(error.message)}
`; + } + } + + function updateSelectedFoldersDisplay() { + if (selectedFolders.length === 0) { + folderBrowserSelected.style.display = 'none'; + return; + } + folderBrowserSelected.style.display = 'block'; + folderBrowserSelectedList.innerHTML = selectedFolders.map(path => ` + + ${escapeHtml(path)} + + + `).join(''); + } + + window.removeSelectedFolder = function(path) { + selectedFolders = selectedFolders.filter(p => p !== path); + updateSelectedFoldersDisplay(); + loadFolderContents(currentBrowserPath); // Refresh to update checkmarks + }; + + document.getElementById('folder-browser-select-current').addEventListener('click', () => { + if (currentBrowserPath && !selectedFolders.includes(currentBrowserPath)) { + selectedFolders.push(currentBrowserPath); + updateSelectedFoldersDisplay(); + loadFolderContents(currentBrowserPath); // Refresh to show checkmark + } + }); + + wireModal(folderBrowserModal, document.getElementById('folder-browser-cancel')); + + document.getElementById('folder-browser-done').addEventListener('click', () => { + if (targetMediaInput) { + targetMediaInput.value = selectedFolders.join(', '); + } + folderBrowserModal.classList.remove('show'); + }); + + // Load custom apps on page load + loadCustomApps(); +})(); diff --git a/status/js/audit-log.js b/status/js/audit-log.js new file mode 100644 index 0000000..c61bbce --- /dev/null +++ b/status/js/audit-log.js @@ -0,0 +1,140 @@ +// ========== AUDIT LOG VIEWER ========== +(function() { + // Inject modal HTML + injectModal('audit-modal', `
+
+

📜 Audit Log

+ + +
+ + + + + +
+ +
+
Loading audit log...
+
+ +
+ +
+ + +
+
`); + + const modal = document.getElementById('audit-modal'); + const openBtn = document.getElementById('audit-log-btn'); + const cancelBtn = document.getElementById('audit-cancel'); + const refreshBtn = document.getElementById('audit-refresh-btn'); + const clearBtn = document.getElementById('audit-clear-btn'); + const filterSelect = document.getElementById('audit-filter'); + const container = document.getElementById('audit-log-container'); + const loadMoreBtn = document.getElementById('audit-load-more'); + let currentOffset = 0; + const PAGE_SIZE = 50; + + async function loadAudit(append) { + try { + if (!append) { + currentOffset = 0; + container.innerHTML = '
Loading...
'; + } + const filter = filterSelect.value; + let url = `/api/v1/audit-logs?limit=${PAGE_SIZE}&offset=${currentOffset}`; + if (filter) url += `&action=${encodeURIComponent(filter)}`; + const res = await fetch(url); + const data = await res.json(); + const entries = data.success && data.entries ? data.entries : []; + + if (entries.length === 0 && !append) { + container.innerHTML = '
📜No audit log entries yet. Actions will be logged automatically.
'; + loadMoreBtn.style.display = 'none'; + return; + } + + let html = ''; + if (!append) { + html = ''; + html += ''; + } + + for (const e of entries) { + const ok = e.outcome === 'success'; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + if (e.details && Object.keys(e.details).length > 0) { + html += ``; + } + } + + if (!append) { + html += '
WhenIPActionResourceResult
${timeAgo(e.timestamp)}${e.ip || '-'}${e.action || '-'}${e.resource || '-'}${ok ? '✓' : '✗'}
'; + container.innerHTML = html; + } else { + // Append rows to existing table + const table = container.querySelector('table'); + if (table) table.insertAdjacentHTML('beforeend', html); + } + + currentOffset += entries.length; + loadMoreBtn.style.display = entries.length >= PAGE_SIZE ? '' : 'none'; + + // Toggle detail rows on click + container.querySelectorAll('.audit-row').forEach(row => { + if (row.dataset.wired) return; + row.dataset.wired = 'true'; + row.addEventListener('click', () => { + const detail = row.nextElementSibling; + if (detail && detail.classList.contains('audit-detail')) { + detail.style.display = detail.style.display === 'none' ? '' : 'none'; + } + }); + }); + } catch (e) { + container.innerHTML = `
Failed: ${e.message}
`; + } + } + + openBtn?.addEventListener('click', () => { + modal?.classList.add('show'); + loadAudit(false); + }); + wireModal(modal, cancelBtn); + refreshBtn?.addEventListener('click', () => loadAudit(false)); + filterSelect?.addEventListener('change', () => loadAudit(false)); + loadMoreBtn?.addEventListener('click', () => loadAudit(true)); + + clearBtn?.addEventListener('click', async () => { + if (!confirm('Clear the entire audit log? This cannot be undone.')) return; + try { + const res = await secureFetch('/api/v1/audit-logs', { method: 'DELETE' }); + const data = await res.json(); + if (data.success) loadAudit(false); + else showNotification('Error: ' + (data.error || 'Clear failed'), 'error'); + } catch (e) { + showNotification('Error: ' + e.message, 'error'); + } + }); +})(); diff --git a/status/js/backup-restore.js b/status/js/backup-restore.js new file mode 100644 index 0000000..d1906dd --- /dev/null +++ b/status/js/backup-restore.js @@ -0,0 +1,421 @@ +// ========== BACKUP/RESTORE (Enhanced) ========== +(function() { + // Inject modal HTML + injectModal('backup-modal', `
+
+

💾 Backup & Restore

+ + + +
+ + + +
+ + +
+ +
+

📤 Export Backup

+

+ Download all your settings: services, Caddyfile, DNS credentials, notifications. +

+ +
+ + +
+

📥 Restore Backup

+

+ Upload a backup file to restore your configuration. +

+ + + +
+ + + + + + +
+ + +
+
+
+ + Loading backup schedule... +
+
+
+ + +
+
+
+ 📋 + Loading backup history... +
+
+
+ + + +
+
`); + + const modal = document.getElementById('backup-modal'); + const openBtn = document.getElementById('backup-restore-btn'); + const cancelBtn = document.getElementById('backup-cancel'); + const exportBtn = document.getElementById('backup-export-btn'); + const selectFileBtn = document.getElementById('backup-select-file'); + const fileInput = document.getElementById('backup-file-input'); + const fileNameDiv = document.getElementById('backup-file-name'); + const previewDiv = document.getElementById('backup-preview'); + const previewContent = document.getElementById('backup-preview-content'); + const restoreBtn = document.getElementById('backup-do-restore-btn'); + const resultDiv = document.getElementById('backup-result'); + const scheduleContainer = document.getElementById('backup-schedule-container'); + const historyContainer = document.getElementById('backup-history-container'); + + let selectedBackup = null; + + // Open modal + openBtn?.addEventListener('click', () => { + modal.classList.add('show'); + if (resultDiv) resultDiv.style.display = 'none'; + if (previewDiv) previewDiv.style.display = 'none'; + if (fileNameDiv) fileNameDiv.style.display = 'none'; + selectedBackup = null; + }); + + wireModal(modal, cancelBtn); + + // Export backup + exportBtn?.addEventListener('click', async () => { + exportBtn.disabled = true; + exportBtn.innerHTML = ' Exporting...'; + try { + const response = await fetch('/api/v1/backup/export'); + const data = await response.json(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resultDiv.innerHTML = '✅ Backup downloaded successfully!'; + resultDiv.style.display = 'block'; + resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; + resultDiv.style.border = '1px solid var(--ok-fg)'; + } catch (e) { + resultDiv.innerHTML = `❌ Export failed: ${e.message}`; + resultDiv.style.display = 'block'; + resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + resultDiv.style.border = '1px solid var(--bad-fg)'; + } + exportBtn.disabled = false; + exportBtn.innerHTML = '⬇️ Download Backup'; + }); + + // Select file button + selectFileBtn?.addEventListener('click', () => fileInput.click()); + + // File selected + fileInput?.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + fileNameDiv.textContent = `📄 ${file.name}`; + fileNameDiv.style.display = 'block'; + resultDiv.style.display = 'none'; + try { + const text = await file.text(); + const backup = JSON.parse(text); + const response = await secureFetch('/api/v1/backup/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(backup) + }); + const preview = await response.json(); + if (preview.success) { + selectedBackup = backup; + let html = `
+ Exported: ${new Date(backup.exportedAt).toLocaleString()}
`; + html += '
'; + for (const [key, info] of Object.entries(preview.preview.files)) { + const icon = info.action === 'create' ? '🆕' : '📝'; + html += `${icon} ${info.description}`; + } + html += '
'; + if (preview.preview.serviceCount) { + html += `
${preview.preview.serviceCount} services in backup
`; + } + previewContent.innerHTML = html; + previewDiv.style.display = 'block'; + } else { + resultDiv.innerHTML = `⚠️ Invalid backup file: ${preview.error}`; + resultDiv.style.display = 'block'; + resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)'; + resultDiv.style.border = '1px solid #f39c12'; + previewDiv.style.display = 'none'; + } + } catch (e) { + resultDiv.innerHTML = `❌ Could not read file: ${e.message}`; + resultDiv.style.display = 'block'; + resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + resultDiv.style.border = '1px solid var(--bad-fg)'; + previewDiv.style.display = 'none'; + } + }); + + // Restore from file backup + restoreBtn?.addEventListener('click', async () => { + if (!selectedBackup) return; + if (!confirm('This will overwrite your current configuration. Continue?')) return; + restoreBtn.disabled = true; + restoreBtn.innerHTML = ' Restoring...'; + try { + const reloadCaddy = document.getElementById('backup-reload-caddy')?.checked ?? true; + const response = await secureFetch('/api/v1/backup/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backup: selectedBackup, options: { reloadCaddy } }) + }); + const data = await response.json(); + if (data.success) { + let msg = `✅ ${data.message}`; + if (data.results.caddyReloaded) msg += '
Caddy configuration reloaded'; + resultDiv.innerHTML = msg; + resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; + resultDiv.style.border = '1px solid var(--ok-fg)'; + setTimeout(() => location.reload(), 2000); + } else { + resultDiv.innerHTML = `⚠️ ${data.message}`; + if (data.results?.errors?.length > 0) { + resultDiv.innerHTML += '
' + data.results.errors.map(e => `${e.file}: ${e.error}`).join(', ') + ''; + } + resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)'; + resultDiv.style.border = '1px solid #f39c12'; + } + resultDiv.style.display = 'block'; + } catch (e) { + resultDiv.innerHTML = `❌ Restore failed: ${e.message}`; + resultDiv.style.display = 'block'; + resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + resultDiv.style.border = '1px solid var(--bad-fg)'; + } + restoreBtn.disabled = false; + restoreBtn.innerHTML = '⚡ Restore Configuration'; + }); + + // === Automated Backups Tab === + async function loadBackupSchedule() { + if (!scheduleContainer) return; + try { + const res = await fetch('/api/v1/backups/config'); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to load config'); + const cfg = data.config?.backups || {}; + const autoKey = Object.keys(cfg)[0]; + const auto = autoKey ? cfg[autoKey] : null; + + let html = `
`; + html += `

⏰ Backup Schedule

`; + html += `
`; + html += `
+
`; + html += `
+
`; + html += `
`; + html += `
+
`; + html += `
+ + +
`; + html += `
`; + html += ``; + scheduleContainer.innerHTML = html; + + document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule); + document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow); + } catch (e) { + scheduleContainer.innerHTML = `
Failed to load schedule: ${e.message}
`; + } + } + + async function saveSchedule() { + const schedule = document.getElementById('backup-schedule-select')?.value; + const retention = parseInt(document.getElementById('backup-retention-select')?.value) || 5; + const encrypt = document.getElementById('backup-encrypt-toggle')?.checked ?? true; + const resultEl = document.getElementById('backup-schedule-result'); + try { + const res = await secureFetch('/api/v1/backups/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + backups: { + auto: { + enabled: schedule !== 'disabled', + schedule: schedule === 'disabled' ? 'daily' : schedule, + include: ['all'], + encrypt, + verify: true, + retention: { keep: retention }, + destinations: [{ type: 'local' }] + } + } + }) + }); + const data = await res.json(); + if (resultEl) { + resultEl.innerHTML = data.success ? '✅ Schedule saved' : `⚠️ ${data.error}`; + resultEl.style.display = 'block'; + resultEl.style.background = data.success ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + resultEl.style.border = data.success ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)'; + setTimeout(() => { if (resultEl) resultEl.style.display = 'none'; }, 3000); + } + } catch (e) { + if (resultEl) { + resultEl.innerHTML = `❌ ${e.message}`; + resultEl.style.display = 'block'; + resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + resultEl.style.border = '1px solid var(--bad-fg)'; + } + } + } + + async function runBackupNow() { + const btn = document.getElementById('backup-run-now'); + const resultEl = document.getElementById('backup-schedule-result'); + if (btn) { btn.disabled = true; btn.innerHTML = ' Running...'; } + try { + const res = await secureFetch('/api/v1/backups/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ include: ['all'], destinations: [{ type: 'local' }] }) + }); + const data = await res.json(); + if (resultEl) { + if (data.success) { + const sizeMB = data.backup?.size ? (data.backup.size / 1024 / 1024).toFixed(2) : '?'; + resultEl.innerHTML = `✅ Backup complete (${sizeMB} MB)`; + resultEl.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; + resultEl.style.border = '1px solid var(--ok-fg)'; + } else { + resultEl.innerHTML = `⚠️ ${data.error}`; + resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + resultEl.style.border = '1px solid var(--bad-fg)'; + } + resultEl.style.display = 'block'; + } + loadBackupHistory(); + } catch (e) { + if (resultEl) { + resultEl.innerHTML = `❌ ${e.message}`; + resultEl.style.display = 'block'; + resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + resultEl.style.border = '1px solid var(--bad-fg)'; + } + } + if (btn) { btn.disabled = false; btn.innerHTML = '▶️ Run Backup Now'; } + } + + // === Backup History Tab === + async function loadBackupHistory() { + if (!historyContainer) return; + historyContainer.innerHTML = '
Loading...
'; + try { + const res = await fetch('/api/v1/backups/history?limit=50'); + const data = await res.json(); + if (!data.success || !data.history?.length) { + historyContainer.innerHTML = '
📋 No backup history yet
'; + return; + } + let html = '
'; + for (const bk of data.history) { + const statusColor = bk.status === 'success' ? '#2ecc71' : '#e74c3c'; + const sizeMB = bk.size ? (bk.size / 1024 / 1024).toFixed(2) : '?'; + html += `
+
+ ${bk.name || 'backup'} +
+ ${bk.status} + ${bk.status === 'success' ? `` : ''} +
+
+
+ ${new Date(bk.timestamp).toLocaleString()} | ${sizeMB} MB | ${bk.duration ? (bk.duration / 1000).toFixed(1) + 's' : '--'} + ${bk.encrypted ? ' | 🔒' : ''} +
+
`; + } + html += '
'; + historyContainer.innerHTML = html; + } catch (e) { + historyContainer.innerHTML = `
Failed: ${e.message}
`; + } + } + + window.__restoreServerBackup = async function(backupId) { + if (!confirm('Restore from this server backup? This will overwrite current configuration.')) return; + try { + const res = await secureFetch(`/api/v1/backups/restore/${backupId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ restoreServices: true, restoreConfig: true }) + }); + const data = await res.json(); + if (data.success) { + showNotification('Restore completed successfully!', 'success'); + location.reload(); + } else { + showNotification('Restore failed: ' + (data.error || 'Unknown error'), 'error'); + } + } catch (e) { showNotification('Restore error: ' + e.message, 'error'); } + }; + + // Lazy-load tabs + document.querySelector('[data-panel="backup-automated"]')?.addEventListener('click', loadBackupSchedule); + document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener('click', loadBackupHistory); +})(); diff --git a/status/js/card-badges.js b/status/js/card-badges.js new file mode 100644 index 0000000..a854b92 --- /dev/null +++ b/status/js/card-badges.js @@ -0,0 +1,115 @@ +// ========== CARD HEALTH & UPDATE BADGES ========== +(function() { + // Fetch health data and update card UI + async function refreshCardHealth() { + try { + const res = await fetch('/api/v1/health-checks/status'); + const data = await res.json(); + if (!data.success || !data.status) return; + + for (const [serviceId, svc] of Object.entries(data.status)) { + const uptimeEl = document.getElementById('uptime-' + serviceId); + const barEl = document.getElementById('uptime-bar-' + serviceId); + if (!uptimeEl) continue; + + const uptime24 = svc.uptime?.['24h']; + if (uptime24 !== undefined && uptime24 !== null) { + const pct = uptime24.toFixed(1); + uptimeEl.textContent = `${pct}% uptime`; + // Color class + uptimeEl.className = 'uptime-chip'; + if (uptime24 >= 99.9) uptimeEl.classList.add('excellent'); + else if (uptime24 >= 99) uptimeEl.classList.add('good'); + else if (uptime24 >= 95) uptimeEl.classList.add('degraded'); + else uptimeEl.classList.add('poor'); + + if (barEl) barEl.style.width = pct + '%'; + } + } + } catch (_) { + /* Health check API unavailable — uptime chips stay hidden */ + console.warn('[Card Badges] Health check API unavailable'); + } + } + + // Track dismissed updates (per session) + let dismissedUpdates; + try { + dismissedUpdates = new Set(JSON.parse(safeSessionGet('dismissed-updates') || '[]')); + } catch (_) { + /* Session storage unavailable */ + dismissedUpdates = new Set(); + } + + // Fetch update data and show badges + async function refreshCardUpdates() { + try { + const res = await fetch('/api/v1/updates/available'); + const data = await res.json(); + if (!data.success) return; + + // Clear all update badges first + document.querySelectorAll('.update-available-badge').forEach(el => el.classList.remove('visible')); + + if (!data.updates?.length) return; + + for (const upd of data.updates) { + // Try to match by container name to service id + const apps = window.APPS || []; + for (const app of apps) { + if (app.containerId === upd.containerId || app.id === upd.containerName || app.name === upd.containerName) { + // Skip dismissed updates + if (dismissedUpdates.has(app.id)) break; + const badge = document.getElementById('update-badge-' + app.id); + if (badge) { + badge.classList.add('visible'); + badge.title = `Image digest changed. Click to dismiss if already up to date.\n${upd.imageName || ''}`; + badge.style.cursor = 'pointer'; + badge.onclick = (e) => { + e.stopPropagation(); + badge.classList.remove('visible'); + dismissedUpdates.add(app.id); + safeSessionSet('dismissed-updates', JSON.stringify([...dismissedUpdates])); + }; + } + break; + } + } + } + } catch (_) { + /* Updates API unavailable — badges stay hidden */ + console.warn('[Card Badges] Updates API unavailable'); + } + } + + // Run health and update checks after main refresh, and periodically + function scheduleCardEnhancements() { + // Initial load (delayed to not compete with main probe checks) + setTimeout(() => { + refreshCardHealth(); + refreshCardUpdates(); + }, 5000); + + // Periodic refresh every 60 seconds + setInterval(() => { + refreshCardHealth(); + refreshCardUpdates(); + }, 60000); + } + + // Hook into main refresh cycle + const origRefreshAll = window.refreshAll; + if (origRefreshAll) { + window.refreshAll = async function() { + try { + await origRefreshAll(); + // Refresh health data after main status check + setTimeout(refreshCardHealth, 1000); + } catch (e) { + console.warn('[Card Badges] Error in refreshAll hook:', e.message); + } + }; + } + + scheduleCardEnhancements(); +})(); diff --git a/status/js/clock.js b/status/js/clock.js new file mode 100644 index 0000000..daaaa74 --- /dev/null +++ b/status/js/clock.js @@ -0,0 +1,330 @@ +// ========== DIGITAL CLOCK WIDGET ========== +(function() { + const widget = document.getElementById('clock-widget'); + const render = document.getElementById('clock-render'); + if (!widget || !render) return; + + const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; + const ROMAN = ['XII','I','II','III','IV','V','VI','VII','VIII','IX','X','XI']; + + let currentStyle = safeGet('clock-style') || 'default'; + let lastChimeHour = -1; + let chimePlaying = false; + let prevFlipDigits = ''; + + // ===== CHIMES ===== + function playChimes(count) { + if (chimePlaying) return; + if (safeGet('clock-chimes') !== 'true') return; + chimePlaying = true; + const vol = parseInt(safeGet('clock-chime-volume') || '50', 10) / 100; + let i = 0; + function strike() { + if (i >= count) { chimePlaying = false; return; } + const bell = new Audio('/assets/sounds/church-bell.mp3'); + bell.volume = vol; + bell.play().catch(() => {}); + i++; + if (i < count) setTimeout(strike, 2500); + else setTimeout(() => { chimePlaying = false; }, 2500); + } + strike(); + } + + // ===== DATE STRING ===== + function dateStr(now) { + return DAYS[now.getDay()] + ', ' + MONTHS[now.getMonth()] + ' ' + now.getDate() + ', ' + now.getFullYear(); + } + + // ===== STYLE RENDERERS ===== + + function renderDefault(now) { + const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds(); + const ampm = h24 >= 12 ? 'PM' : 'AM'; + const h = h24 % 12 || 12; + render.innerHTML = + `
${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}${ampm}
` + + `
${dateStr(now)}
`; + } + + function renderLcd(now, cls) { + const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds(); + const ampm = h24 >= 12 ? 'PM' : 'AM'; + const h = h24 % 12 || 12; + render.innerHTML = + `
${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}${ampm}
` + + `
${dateStr(now)}
`; + } + + function renderFlip(now) { + const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds(); + const ampm = h24 >= 12 ? 'PM' : 'AM'; + const h = h24 % 12 || 12; + const digits = String(h).padStart(2,' ') + String(m).padStart(2,'0') + String(s).padStart(2,'0'); + + let html = '
'; + // Hours + html += flipCard(digits[0], 0); + html += flipCard(digits[1], 1); + html += ':'; + // Minutes + html += flipCard(digits[2], 2); + html += flipCard(digits[3], 3); + html += ':'; + // Seconds + html += flipCard(digits[4], 4); + html += flipCard(digits[5], 5); + html += `${ampm}`; + html += '
'; + html += `
${dateStr(now)}
`; + render.innerHTML = html; + + // Trigger flip animation on changed digits + if (prevFlipDigits) { + for (let i = 0; i < 6; i++) { + if (digits[i] !== prevFlipDigits[i]) { + const card = render.querySelector(`.flip-card[data-idx="${i}"]`); + if (card) card.classList.add('flipping'); + } + } + } + prevFlipDigits = digits; + } + + function flipCard(digit, idx) { + const d = digit === ' ' ? '' : digit; + return `
${d}
${d}
`; + } + + function renderBinary(now) { + const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds(); + const h = h24 % 12 || 12; + const ampm = h24 >= 12 ? 'PM' : 'AM'; + // 6 columns: H tens, H ones, M tens, M ones, S tens, S ones + const cols = [ + Math.floor(h / 10), h % 10, + Math.floor(m / 10), m % 10, + Math.floor(s / 10), s % 10 + ]; + + let html = '
'; + // Labels + html += '
HHMMSS
'; + // 4 rows for bits 8,4,2,1 + for (let bit = 3; bit >= 0; bit--) { + html += '
'; + for (let col = 0; col < 6; col++) { + const on = (cols[col] >> bit) & 1; + html += `
`; + } + html += '
'; + } + // Value row + html += '
'; + for (let col = 0; col < 6; col++) { + html += `${cols[col]}`; + } + html += '
'; + html += `
${ampm}
`; + html += '
'; + html += `
${dateStr(now)}
`; + render.innerHTML = html; + } + + function renderAnalog(now, useRoman) { + const h = now.getHours(), m = now.getMinutes(), s = now.getSeconds(); + const size = 120; + const cx = size / 2, cy = size / 2; + + // Angles + const sAngle = (s / 60) * 360 - 90; + const mAngle = ((m + s / 60) / 60) * 360 - 90; + const hAngle = (((h % 12) + m / 60) / 12) * 360 - 90; + + // Number labels + let labels = ''; + for (let i = 1; i <= 12; i++) { + const angle = (i / 12) * 2 * Math.PI - Math.PI / 2; + const r = 47; + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + const label = useRoman ? ROMAN[i % 12] : i; + const fs = useRoman ? '7' : '9'; + labels += `${label}`; + } + + // Tick marks + let ticks = ''; + for (let i = 0; i < 60; i++) { + const angle = (i / 60) * 2 * Math.PI - Math.PI / 2; + const outer = 56; + const inner = i % 5 === 0 ? 52 : 54; + const x1 = cx + inner * Math.cos(angle); + const y1 = cy + inner * Math.sin(angle); + const x2 = cx + outer * Math.cos(angle); + const y2 = cy + outer * Math.sin(angle); + const w = i % 5 === 0 ? 1.5 : 0.5; + ticks += ``; + } + + const svg = ` + + ${ticks} + ${labels} + + + + + `; + + const ampm = now.getHours() >= 12 ? 'PM' : 'AM'; + render.innerHTML = `
${svg}
${(now.getHours() % 12 || 12)}:${String(m).padStart(2,'0')} ${ampm}${dateStr(now)}
`; + } + + // ===== MAIN TICK ===== + function tick() { + const now = new Date(); + const h = now.getHours() % 12 || 12; + const m = now.getMinutes(); + const s = now.getSeconds(); + + // Set style class on widget + widget.className = 'clock-widget' + (currentStyle !== 'default' ? ' ' + currentStyle : ''); + + switch (currentStyle) { + case 'lcd': renderLcd(now); break; + case 'lcd-blue': renderLcd(now); break; + case 'lcd-amber': renderLcd(now); break; + case 'lcd-retro': renderLcd(now); break; + case 'lcd-taxi': renderLcd(now); break; + case 'flip': renderFlip(now); break; + case 'binary': renderBinary(now); break; + case 'analog': renderAnalog(now, false); break; + case 'roman': renderAnalog(now, true); break; + default: renderDefault(now); + } + + // Hourly chimes + if (m === 0 && s === 0 && h !== lastChimeHour) { + lastChimeHour = h; + playChimes(h); + } + if (m !== 0) lastChimeHour = -1; + } + + tick(); + setInterval(tick, 1000); + + // ===== SETTINGS MODAL ===== + const STYLES = [ + { id: 'default', label: 'Default', icon: '🕐' }, + { id: 'lcd', label: 'LCD Green', icon: '💚' }, + { id: 'lcd-blue', label: 'LCD Blue', icon: '💙' }, + { id: 'lcd-amber', label: 'LCD Amber', icon: '🟠' }, + { id: 'lcd-retro', label: 'LCD Retro', icon: '🟩' }, + { id: 'lcd-taxi', label: 'LCD Taxi', icon: '🟡' }, + { id: 'flip', label: 'Flip Clock', icon: '📟' }, + { id: 'binary', label: 'Binary', icon: '💻' }, + { id: 'analog', label: 'Analog', icon: '⏰' }, + { id: 'roman', label: 'Roman', icon: '🏛️' }, + ]; + + let styleOptionsHtml = '
'; + STYLES.forEach(s => { + styleOptionsHtml += ``; + }); + styleOptionsHtml += '
'; + + injectModal('clock-settings-modal', `
+
+

Clock Settings

+
+ + ${styleOptionsHtml} +
+
+ +
+ Strikes the number of the hour (e.g., 3 bells at 3:00) +
+
+
+ +
+ 🔈 + + 🔊 + +
+
+
+ + +
+
+
`); + + const modal = document.getElementById('clock-settings-modal'); + const chimesToggle = document.getElementById('clock-chimes-toggle'); + const volumeSlider = document.getElementById('clock-chime-volume'); + const volumeSection = document.getElementById('clock-volume-section'); + + function loadSettings() { + const style = safeGet('clock-style') || 'default'; + const radio = modal.querySelector(`input[value="${style}"]`); + if (radio) radio.checked = true; + chimesToggle.checked = safeGet('clock-chimes') === 'true'; + volumeSlider.value = safeGet('clock-chime-volume') || '50'; + volumeSection.style.opacity = chimesToggle.checked ? '1' : '0.4'; + } + + chimesToggle?.addEventListener('change', () => { + volumeSection.style.opacity = chimesToggle.checked ? '1' : '0.4'; + }); + + document.getElementById('clock-settings')?.addEventListener('click', () => { + loadSettings(); + modal.classList.add('show'); + }); + + document.getElementById('clock-chime-test')?.addEventListener('click', () => { + const vol = parseInt(volumeSlider.value, 10) / 100; + const bell = new Audio('/assets/sounds/church-bell.mp3'); + bell.volume = vol; + bell.play().catch(() => {}); + }); + + document.getElementById('clock-settings-save')?.addEventListener('click', () => { + const radio = modal.querySelector('input[name="clock-style-radio"]:checked'); + const style = radio ? radio.value : 'default'; + safeSet('clock-style', style); + safeSet('clock-chimes', String(chimesToggle.checked)); + safeSet('clock-chime-volume', volumeSlider.value); + currentStyle = style; + prevFlipDigits = ''; + tick(); + modal.classList.remove('show'); + showNotification('Clock settings saved', 'success', 2000); + }); + + document.getElementById('clock-settings-cancel')?.addEventListener('click', () => { + modal.classList.remove('show'); + }); + + wireModal(modal); + + // Live preview when clicking style options + modal?.querySelectorAll('input[name="clock-style-radio"]').forEach(radio => { + radio.addEventListener('change', () => { + currentStyle = radio.value; + prevFlipDigits = ''; + tick(); + }); + }); +})(); diff --git a/status/js/core/credentials.js b/status/js/core/credentials.js new file mode 100644 index 0000000..5795905 --- /dev/null +++ b/status/js/core/credentials.js @@ -0,0 +1,387 @@ +// ========== CREDENTIAL MANAGEMENT ========== +(function () { + + // Inject the token-management modal HTML + injectModal('token-management-modal', ` +
+
+

🔑 DNS Credentials

+ +

+ Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates. +

+ + +
+

DNS1 (Windows)

+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ + +
+

DNS2 (Linux)

+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ + +
+

DNS3 (AlmaLinux)

+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ + +
+
+ `); + + // Simple encryption for storing credentials - key is generated per installation + function getEncryptionKey() { + let key = safeGet('dashcaddy-encryption-key'); + if (!key) { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + key = Array.from(array, b => b.toString(16).padStart(2, '0')).join(''); + safeSet('dashcaddy-encryption-key', key); + } + return key; + } + const ENCRYPTION_KEY = getEncryptionKey(); + + function simpleEncrypt(text, key) { + if (!text) return ''; + let result = ''; + for (let i = 0; i < text.length; i++) { + const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length); + result += String.fromCharCode(charCode); + } + return btoa(result); + } + + function simpleDecrypt(encryptedText, key) { + if (!encryptedText) return ''; + try { + const decoded = atob(encryptedText); + let result = ''; + for (let i = 0; i < decoded.length; i++) { + const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length); + result += String.fromCharCode(charCode); + } + return result; + } catch (e) { + return ''; + } + } + + // Credential storage functions + function getCredential(dnsId, tokenType, credType) { + const encrypted = safeGet(`${dnsId}-${tokenType}-${credType}-enc`); + return simpleDecrypt(encrypted, ENCRYPTION_KEY); + } + + function setCredential(dnsId, tokenType, credType, value) { + const key = `${dnsId}-${tokenType}-${credType}-enc`; + if (value) { + safeSet(key, simpleEncrypt(value, ENCRYPTION_KEY)); + } else { + safeRemove(key); + } + } + + function getToken(dnsId, tokenType) { + return getCredential(dnsId, tokenType, 'token'); + } + + function getUsername(dnsId, tokenType) { + return getCredential(dnsId, tokenType, 'username'); + } + + function setToken(dnsId, tokenType, token) { + setCredential(dnsId, tokenType, 'token', token); + } + + function setUsername(dnsId, tokenType, username) { + setCredential(dnsId, tokenType, 'username', username); + } + + function getAllCredentials() { + return { + dns1: { + readonly: { username: getUsername('dns1', 'readonly'), token: getToken('dns1', 'readonly') }, + admin: { username: getUsername('dns1', 'admin'), token: getToken('dns1', 'admin') } + }, + dns2: { + readonly: { username: getUsername('dns2', 'readonly'), token: getToken('dns2', 'readonly') }, + admin: { username: getUsername('dns2', 'admin'), token: getToken('dns2', 'admin') } + }, + dns3: { + readonly: { username: getUsername('dns3', 'readonly'), token: getToken('dns3', 'readonly') }, + admin: { username: getUsername('dns3', 'admin'), token: getToken('dns3', 'admin') } + } + }; + } + + function clearAllCredentials() { + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + ['readonly', 'admin'].forEach(tokenType => { + ['token', 'username'].forEach(credType => { + safeRemove(`${dnsId}-${tokenType}-${credType}-enc`); + }); + }); + safeRemove(`${dnsId}-token-enc`); + safeRemove(`${dnsId}-username-enc`); + }); + } + + function getStoredCredentials(dnsId) { + const readonlyToken = getToken(dnsId, 'readonly'); + const readonlyUsername = getUsername(dnsId, 'readonly'); + const adminToken = getToken(dnsId, 'admin'); + const adminUsername = getUsername(dnsId, 'admin'); + const oldToken = simpleDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY); + const oldUsername = simpleDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY); + + return { + username: adminUsername || readonlyUsername || oldUsername, + token: adminToken || readonlyToken || oldToken, + readonlyToken: readonlyToken || oldToken, + readonlyUsername: readonlyUsername || oldUsername, + adminToken: adminToken || oldToken, + adminUsername: adminUsername || oldUsername + }; + } + + // Token Management Modal handlers + document.getElementById('manage-tokens')?.addEventListener('click', () => { + const modal = document.getElementById('token-management-modal'); + const creds = getAllCredentials(); + + // Populate fields with existing credentials + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + document.getElementById(`${dnsId}-readonly-username`).value = creds[dnsId].readonly.username; + document.getElementById(`${dnsId}-readonly-token`).value = creds[dnsId].readonly.token; + document.getElementById(`${dnsId}-admin-username`).value = creds[dnsId].admin.username; + document.getElementById(`${dnsId}-admin-token`).value = creds[dnsId].admin.token; + document.getElementById(`${dnsId}-token-status`).textContent = ''; + }); + + modal.classList.add('show'); + }); + + // Toggle password visibility + document.querySelectorAll('.token-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const targetId = btn.dataset.target; + const input = document.getElementById(targetId); + if (input.type === 'password') { + input.type = 'text'; + btn.textContent = '\u{1F648}'; + } else { + input.type = 'password'; + btn.textContent = '\u{1F441}'; + } + }); + }); + + document.getElementById('token-save')?.addEventListener('click', async () => { + // Save all credentials to localStorage + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim()); + setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim()); + setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim()); + setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim()); + }); + + // Build per-server credentials payload for backend sync + const servers = {}; + let hasAnyCreds = false; + + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + const entry = {}; + const roUser = document.getElementById(`${dnsId}-readonly-username`).value.trim(); + const roPass = document.getElementById(`${dnsId}-readonly-token`).value.trim(); + const adminUser = document.getElementById(`${dnsId}-admin-username`).value.trim(); + const adminPass = document.getElementById(`${dnsId}-admin-token`).value.trim(); + + if (roUser && roPass) { + entry.readonly = { username: roUser, password: roPass }; + hasAnyCreds = true; + } + if (adminUser && adminPass) { + entry.admin = { username: adminUser, password: adminPass }; + hasAnyCreds = true; + } + if (Object.keys(entry).length > 0) { + servers[dnsId] = entry; + } + }); + + if (hasAnyCreds) { + // Show syncing status + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + if (servers[dnsId]) { + document.getElementById(`${dnsId}-token-status`).textContent = 'Verifying...'; + document.getElementById(`${dnsId}-token-status`).className = 'token-status'; + } + }); + + try { + const res = await secureFetch('/api/v1/dns/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ servers }) + }); + const data = await res.json(); + + if (data.results) { + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + const statusEl = document.getElementById(`${dnsId}-token-status`); + if (!servers[dnsId]) { statusEl.textContent = ''; return; } + const result = data.results[dnsId]; + if (result?.success) { + statusEl.textContent = '\u2713 Verified & saved'; + statusEl.className = 'token-status success'; + } else if (result?.partial) { + statusEl.textContent = '\u2713 ' + result.partial; + statusEl.className = 'token-status success'; + } else { + statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed'); + statusEl.className = 'token-status error'; + } + }); + } else if (data.success) { + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + if (servers[dnsId]) { + document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved'; + document.getElementById(`${dnsId}-token-status`).className = 'token-status success'; + } + }); + } else { + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + if (servers[dnsId]) { + document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed'); + document.getElementById(`${dnsId}-token-status`).className = 'token-status error'; + } + }); + } + } catch (e) { + console.error('Failed to sync DNS credentials to backend:', e); + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + if (servers[dnsId]) { + document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)'; + document.getElementById(`${dnsId}-token-status`).className = 'token-status'; + } + }); + } + } else { + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + document.getElementById(`${dnsId}-token-status`).textContent = ''; + }); + } + + // Auto-close after delay if all succeeded + setTimeout(() => { + const allGood = ['dns1', 'dns2', 'dns3'].every(dnsId => { + const status = document.getElementById(`${dnsId}-token-status`).textContent; + return !status || status.includes('\u2713'); + }); + if (allGood) closeModal('token-management-modal'); + }, 1500); + }); + + document.getElementById('token-cancel')?.addEventListener('click', () => { + closeModal('token-management-modal'); + }); + + document.getElementById('token-clear-all')?.addEventListener('click', async () => { + if (confirm('Clear all stored DNS credentials? This cannot be undone.')) { + clearAllCredentials(); + ['dns1', 'dns2', 'dns3'].forEach(dnsId => { + document.getElementById(`${dnsId}-readonly-username`).value = ''; + document.getElementById(`${dnsId}-readonly-token`).value = ''; + document.getElementById(`${dnsId}-admin-username`).value = ''; + document.getElementById(`${dnsId}-admin-token`).value = ''; + document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared'; + document.getElementById(`${dnsId}-token-status`).className = 'token-status success'; + }); + try { + await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' }); + } catch (_) {} + } + }); + + // Close modal on backdrop click + document.getElementById('token-management-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'token-management-modal') { + e.target.classList.remove('show'); + } + }); + + // Window exports + window.getToken = getToken; + window.getUsername = getUsername; + window.setToken = setToken; + window.setUsername = setUsername; + window.getAllCredentials = getAllCredentials; + window.getCredential = getCredential; + window.setCredential = setCredential; + window.getEncryptionKey = getEncryptionKey; + +})(); diff --git a/status/js/core/dns.js b/status/js/core/dns.js new file mode 100644 index 0000000..7aba3e9 --- /dev/null +++ b/status/js/core/dns.js @@ -0,0 +1,273 @@ +// ========== DNS MANAGEMENT ========== +(function () { + // Restart DNS service via backend proxy (backend handles auth automatically) + async function restartDnsService(dnsId) { + const response = await secureFetch(`/api/v1/dns/restart/${dnsId}`, { method: 'POST' }); + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Restart failed'); + } + return result; + } + + // Event delegation for DNS restart buttons (dynamic cards) + document.querySelector('.top')?.addEventListener('click', async (e) => { + const restartBtn = e.target.closest('[id$="-restart"]'); + if (!restartBtn) return; + const dnsId = restartBtn.id.replace('-restart', ''); + if (!SITE.dnsServers[dnsId]) return; + if (!confirm(`Restart ${dnsId.toUpperCase()} service?`)) return; + try { + await withButton(restartBtn, '...', () => restartDnsService(dnsId)); + setTimeout(window.refreshAll, DC.DELAYS.RELOAD); + } catch (e) { + showNotification('Restart failed: ' + e.message, 'error'); + } + }); + + // DNS Update buttons + async function updateDnsServer(dnsId, serverIp) { + const btn = document.getElementById(`${dnsId}-update`); + const originalText = btn?.textContent || '⬆️'; + + try { + // First check for updates + btn.textContent = '🔍'; + btn.disabled = true; + btn.title = 'Checking for updates...'; + + const checkResponse = await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(serverIp)}`); + const checkResult = await checkResponse.json(); + + if (!checkResult.success) { + throw new Error(checkResult.error || 'Failed to check for updates'); + } + + if (!checkResult.updateAvailable) { + btn.textContent = '✅'; + btn.title = `Already on latest version (${checkResult.currentVersion})`; + showNotification(`${dnsId.toUpperCase()} is already up to date! Current version: ${checkResult.currentVersion}`, 'info'); + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + btn.title = 'Update DNS server'; + }, 3000); + return; + } + + // Update available - confirm with user + const confirmUpdate = confirm( + `Update available for ${dnsId.toUpperCase()}!\n\n` + + `Current: ${checkResult.currentVersion}\n` + + `New: ${checkResult.updateVersion}\n\n` + + (checkResult.updateTitle ? `${checkResult.updateTitle}\n\n` : '') + + `The DNS server will restart during the update.\nProceed?` + ); + + if (!confirmUpdate) { + btn.textContent = originalText; + btn.disabled = false; + btn.title = 'Update DNS server'; + return; + } + + // Perform the update + btn.textContent = '🔄'; + btn.title = 'Updating...'; + + const updateResponse = await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(serverIp)}`, { + method: 'POST' + }); + const updateResult = await updateResponse.json(); + + if (!updateResult.success) { + throw new Error(updateResult.error || 'Update failed'); + } + + if (updateResult.manualUpdateRequired) { + // Technitium v14+ doesn't support auto-install via API + btn.textContent = '⬆️'; + btn.title = `Update available: ${updateResult.newVersion}`; + const downloadInfo = updateResult.downloadLink + ? `\nDownload: ${updateResult.downloadLink}` + : ''; + const instructionsInfo = updateResult.instructionsLink + ? `\nInstructions: ${updateResult.instructionsLink}` + : ''; + showNotification(`${dnsId.toUpperCase()} update requires manual installation. Current: ${updateResult.previousVersion} → ${updateResult.newVersion}. Please update manually on the host machine.`, 'warning', 8000); + btn.disabled = false; + return; + } + + btn.textContent = '✅'; + btn.title = 'Updated successfully!'; + showNotification(`${dnsId.toUpperCase()} updated successfully! ${updateResult.previousVersion} → ${updateResult.newVersion}. Server is restarting...`, 'success'); + + // Refresh after delay for server restart + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + btn.title = 'Update DNS server'; + window.refreshAll(); + }, 10000); + + } catch (error) { + console.error('DNS update error:', error); + btn.textContent = '❌'; + btn.title = 'Update failed'; + showNotification(`Failed to update ${dnsId.toUpperCase()}: ${error.message}`, 'error'); + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + btn.title = 'Update DNS server'; + }, 3000); + } + } + + // Event delegation for DNS update buttons (dynamic cards) + document.querySelector('.top')?.addEventListener('click', (e) => { + const updateBtn = e.target.closest('[id$="-update"]'); + if (!updateBtn) return; + const dnsId = updateBtn.id.replace('-update', ''); + if (!SITE.dnsServers[dnsId]) return; + updateDnsServer(dnsId, SITE.dnsServers[dnsId]?.ip); + }); + + // ===== DNS SETTINGS MODAL ===== + + // Inject modal HTML + injectModal('dns-settings-modal', ` +
+
+

DNS Settings

+ +
+
+ + +
+
+ + +
+
+ + +
+
Manage credentials via Tokens in the toolbar
+
+ +
+ + + +
+
+
`); + + let currentDnsId = null; + + function openDnsSettings(dnsId) { + currentDnsId = dnsId; + const server = SITE.dnsServers[dnsId] || {}; + const modal = document.getElementById('dns-settings-modal'); + + document.getElementById('dns-settings-title').textContent = `${(server.name || dnsId).toUpperCase()} Settings`; + document.getElementById('dns-edit-ip').value = server.ip || ''; + document.getElementById('dns-edit-port').value = server.port || DC.DEFAULTS.DNS_PORT; + document.getElementById('dns-edit-name').value = server.name || ''; + + modal.classList.add('show'); + } + + async function saveDnsSettings() { + if (!currentDnsId) return; + const ip = document.getElementById('dns-edit-ip').value.trim(); + const port = document.getElementById('dns-edit-port').value.trim() || DC.DEFAULTS.DNS_PORT; + const name = document.getElementById('dns-edit-name').value.trim(); + + if (!ip) { + showNotification('Server IP is required', 'warning'); + return; + } + + const update = { dnsServers: {} }; + update.dnsServers[currentDnsId] = { ip, port: String(port) }; + if (name) update.dnsServers[currentDnsId].name = name; + + try { + const response = await secureFetch('/api/v1/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(update) + }); + const result = await response.json(); + if (result.success) { + SITE.dnsServers[currentDnsId] = update.dnsServers[currentDnsId]; + showNotification(`${currentDnsId.toUpperCase()} settings saved`, 'success'); + closeDnsSettings(); + window.refreshAll(); + } else { + showNotification(result.error || 'Failed to save settings', 'error'); + } + } catch (err) { + showNotification('Failed to save: ' + err.message, 'error'); + } + } + + async function removeDnsServer() { + if (!currentDnsId) return; + if (!confirm(`Remove ${currentDnsId.toUpperCase()} from dashboard? This won't affect the actual DNS server.`)) return; + + // Remove by setting to null in dnsServers + try { + const resp = await secureFetch('/api/v1/config'); + const config = await resp.json(); + if (config.dnsServers) { + delete config.dnsServers[currentDnsId]; + } + const saveResp = await secureFetch('/api/v1/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dnsServers: config.dnsServers || {} }) + }); + const result = await saveResp.json(); + if (result.success) { + delete SITE.dnsServers[currentDnsId]; + // Remove card from DOM + const card = document.querySelector(`.top [data-app="${currentDnsId}"]`); + if (card) card.remove(); + showNotification(`${currentDnsId.toUpperCase()} removed from dashboard`, 'success'); + closeDnsSettings(); + } else { + showNotification(result.error || 'Failed to remove', 'error'); + } + } catch (err) { + showNotification('Failed to remove: ' + err.message, 'error'); + } + } + + function closeDnsSettings() { + closeModal('dns-settings-modal'); + currentDnsId = null; + } + + // Event listeners for modal buttons + document.getElementById('dns-settings-cancel')?.addEventListener('click', closeDnsSettings); + document.getElementById('dns-settings-save')?.addEventListener('click', saveDnsSettings); + document.getElementById('dns-settings-delete')?.addEventListener('click', removeDnsServer); + document.getElementById('dns-settings-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'dns-settings-modal') closeDnsSettings(); + }); + // Event delegation for DNS settings buttons (dynamic cards) + document.querySelector('.top')?.addEventListener('click', (e) => { + const settingsBtn = e.target.closest('[id$="-settings"]'); + if (!settingsBtn) return; + const dnsId = settingsBtn.id.replace('-settings', ''); + if (!SITE.dnsServers[dnsId]) return; + e.stopPropagation(); + openDnsSettings(dnsId); + }); + + document.getElementById('refresh')?.addEventListener('click', window.refreshAll); +})(); diff --git a/status/js/core/grid.js b/status/js/core/grid.js new file mode 100644 index 0000000..e8e76c1 --- /dev/null +++ b/status/js/core/grid.js @@ -0,0 +1,384 @@ +// ========== GRID & STATUS HELPERS ========== +(function () { + + /* Enhanced status helpers with response time tracking */ + function setQuick(id, up, responseTime = null) { + const dot = document.getElementById(id + '-dot'); + const pill = document.getElementById(id + '-pill'); + const timeEl = document.getElementById(id + '-time'); + const card = document.querySelector(`[data-app="${id}"]`); + + if (dot) { + dot.classList.toggle('ok', up); + dot.classList.toggle('bad', !up); + } + + if (pill) { + pill.textContent = up ? 'ON' : 'OFF'; + pill.classList.toggle('on', up); + pill.classList.toggle('off', !up); + } + + if (timeEl && responseTime !== null) { + timeEl.textContent = up ? `${responseTime}ms` : 'timeout'; + timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`; + } + + // Update card status for icon coloring + if (card) { + card.setAttribute('data-status', up ? 'on' : 'off'); + } + + // Internet card packet blink effect + if (id === 'internet' && dot && up) { + blinkInternetPacket(dot); + } + } + + // Internet card packet activity blink + let internetBlinkInterval = null; + function blinkInternetPacket(dot) { + // Alternate between rx (green) and tx (blue) to simulate bidirectional traffic + const isRx = Math.random() > 0.5; + dot.classList.add(isRx ? 'packet-rx' : 'packet-tx'); + setTimeout(() => { + dot.classList.remove('packet-rx', 'packet-tx'); + }, 150); + } + + // Continuous packet simulation for Internet card when online + function startInternetPacketSimulation() { + if (internetBlinkInterval) return; + internetBlinkInterval = setInterval(() => { + const dot = document.getElementById('internet-dot'); + const card = document.querySelector('[data-app="internet"]'); + if (dot && card && card.getAttribute('data-status') === 'on') { + blinkInternetPacket(dot); + } + }, 300 + Math.random() * 400); // Random interval 300-700ms + } + + function stopInternetPacketSimulation() { + if (internetBlinkInterval) { + clearInterval(internetBlinkInterval); + internetBlinkInterval = null; + } + } + + // Start simulation on page load + startInternetPacketSimulation(); + + function getResponseTimeClass(time, isUp) { + if (!isUp) return 'timeout'; + if (time < 200) return 'excellent'; + if (time < 500) return 'good'; + if (time < 1000) return 'fair'; + return 'slow'; + } + + async function checkServiceWithTiming(id) { + const startTime = performance.now(); + try { + const r = await fetch('/probe/' + id, { cache: 'no-store' }); + const endTime = performance.now(); + const responseTime = Math.round(endTime - startTime); + const isUp = (r.status >= 200 && r.status < 400) || r.status === 401 || r.status === 403; + return { isUp, responseTime }; + } catch { + const endTime = performance.now(); + const responseTime = Math.round(endTime - startTime); + return { isUp: false, responseTime }; + } + } + + /* App grid - loaded from API */ + window.APPS = []; // Use window.APPS as the main array + + // Load services from API + async function loadServices() { + try { + if (window.SkeletonLoader) window.SkeletonLoader.show(6); + const response = await fetch('/api/v1/services', { cache: 'no-store' }); + if (response.ok) { + window.APPS = await response.json(); + if (window.SkeletonLoader) window.SkeletonLoader.hide(); + } else { + console.error('Failed to load services:', response.status); + if (window.SkeletonLoader) window.SkeletonLoader.hide(); + } + } catch (error) { + console.error('Failed to load services:', error); + if (window.SkeletonLoader) window.SkeletonLoader.hide(); + } + } + + function serviceUrl(id) { return `https://${buildDomain(id)}`; } + function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; } + + function buildGrid() { + const root = document.getElementById('cards'); root.innerHTML = ''; + for (let i = 0; i < window.APPS.length; i++) { + const s = window.APPS[i]; + if (s.id === 'ca') continue; // DashCA lives in top anchor row + const card = el('div', 'card'); + card.setAttribute('data-app', s.id); + card.setAttribute('data-status', 'off'); // Initial status + if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId); + + const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot); + + const row = el('div', 'row'); + const wrap = el('div', 'logo-wrap'); + + // Use reliable PNG images with automatic CDN fallback + const img = document.createElement('img'); + img.src = s.logo; + img.alt = s.name; + img.className = 'logo-img'; + img.onerror = function() { + // Try CDN fallback with multiple naming strategies + // Use id, appTemplate, or derive from name + let appId = s.id || s.appTemplate; + if (!appId && s.name) { + // Derive ID from name (lowercase, remove spaces) + appId = s.name.toLowerCase().replace(/\s+/g, '-'); + } + + if (appId) { + // Try different CDN URL formats + const cdnUrls = [ + `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId}.png`, + `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.toLowerCase()}.png`, + `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.replace(/-/g, '')}.png`, + `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${s.name.toLowerCase().replace(/\s+/g, '-')}.png` + ]; + + // Remove duplicates + const uniqueUrls = [...new Set(cdnUrls)]; + + // Find the next URL to try + const currentIndex = uniqueUrls.indexOf(this.src); + const nextIndex = currentIndex + 1; + + if (nextIndex < uniqueUrls.length) { + this.src = uniqueUrls[nextIndex]; + } else { + this.style.display = 'none'; + } + } else { + this.style.display = 'none'; + } + }; + + wrap.appendChild(img); + row.appendChild(wrap); + + const nameSpan = el('span', 'name', s.name); + row.appendChild(nameSpan); + + // Add Tailscale badge if service is protected + if (s.tailscaleOnly) { + const tsBadge = el('span', 'ts-badge', '🔐'); + tsBadge.title = 'Tailscale-only access'; + tsBadge.style.cssText = 'margin-left: 6px; font-size: 0.75rem; opacity: 0.8;'; + nameSpan.appendChild(tsBadge); + } + + row.appendChild(el('span', 'spacer')); + + const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill); + + // Update available badge (hidden by default, shown when update detected) + const updateBadge = el('span', 'update-available-badge', 'UPDATE'); + updateBadge.id = 'update-badge-' + s.id; + updateBadge.title = 'Update available'; + row.appendChild(updateBadge); + + card.appendChild(row); + + // Add response time row + const responseRow = el('div', 'response-row'); + const timeSpan = el('span', 'response-time', '--'); timeSpan.id = 'time-' + s.id; + responseRow.appendChild(timeSpan); + card.appendChild(responseRow); + + // Add health/uptime row + const healthRow = el('div', 'health-row'); + healthRow.id = 'health-' + s.id; + const uptimeChip = el('span', 'uptime-chip', '--'); + uptimeChip.id = 'uptime-' + s.id; + healthRow.appendChild(uptimeChip); + const uptimeMiniBar = document.createElement('div'); + uptimeMiniBar.className = 'uptime-mini-bar'; + const uptimeFill = document.createElement('div'); + uptimeFill.className = 'fill'; + uptimeFill.id = 'uptime-bar-' + s.id; + uptimeFill.style.width = '0%'; + uptimeMiniBar.appendChild(uptimeFill); + healthRow.appendChild(uptimeMiniBar); + card.appendChild(healthRow); + + const btnRow = el('div', 'btn-row'); + + // Add logs button for services with containerIds + if (s.containerId) { + const logsBtn = el('button', 'logs-btn', '📋'); + logsBtn.title = 'View container logs'; + logsBtn.onclick = (e) => { + e.stopPropagation(); + window.openContainerLogsModal(s.containerId, s.name); + }; + btnRow.appendChild(logsBtn); + + // Add update button for Docker containers + const updateBtn = el('button', 'update-btn', '⬆️'); + updateBtn.title = 'Update container to latest version'; + updateBtn.id = `update-btn-${s.id}`; + updateBtn.onclick = (e) => { + e.stopPropagation(); + window.updateContainer(s.containerId, s.name, s.id); + }; + btnRow.appendChild(updateBtn); + } + + // Add logs button for services with logPath (native apps) + if (s.logPath && !s.containerId) { + const logsBtn = el('button', 'logs-btn', '📋'); + logsBtn.title = 'View application logs'; + logsBtn.onclick = (e) => { + e.stopPropagation(); + window.openFileLogsModal(s.logPath, s.name); + }; + btnRow.appendChild(logsBtn); + } + + // Add credentials button for services that support auto-login + if (s.isExternal || s.appTemplate || s.url) { + const credsBtn = el('button', 'creds-btn', '🔑'); + credsBtn.title = 'Auto-login credentials'; + credsBtn.id = `creds-btn-${s.id}`; + credsBtn.onclick = (e) => { + e.stopPropagation(); + window.openServiceCredsModal(s); + }; + btnRow.appendChild(credsBtn); + } + + // Add options button for all services except 'internet' + if (s.id !== 'internet') { + const optBtn = el('button', 'options-btn', '⚙️'); + optBtn.title = 'Edit service settings'; + optBtn.onclick = (e) => { + e.stopPropagation(); + window.openServiceEditModal(s); + }; + btnRow.appendChild(optBtn); + } + + // Add delete button for all services except Internet + if (s.id !== 'internet') { + const delBtn = el('button', 'delete-btn', '🗑️'); + delBtn.title = 'Delete this service'; + delBtn.onclick = (e) => { + e.stopPropagation(); + window.deleteService(s.id, s.name); + }; + btnRow.appendChild(delBtn); + } + + const btn = el('button', null, 'Open'); + btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener'); + btnRow.appendChild(btn); + card.appendChild(btnRow); + + root.appendChild(card); + + // Staggered loading animation + setTimeout(() => { + card.classList.add('loaded'); + }, i * 100); // 100ms delay between each card + } + + // Group recipe cards visually after grid is built + if (window.groupRecipeCards) setTimeout(window.groupRecipeCards, 50); + } + + function setBadge(id, up, responseTime = null) { + const dot = document.getElementById('dot-' + id + '-grid'); + const pill = document.getElementById('badge-' + id); + const timeEl = document.getElementById('time-' + id); + const card = document.querySelector(`[data-app="${id}"]`); + + if (dot) { + dot.classList.toggle('ok', up); + dot.classList.toggle('bad', !up); + } + + if (pill) { + pill.textContent = up ? 'ON' : 'OFF'; + pill.classList.toggle('on', up); + pill.classList.toggle('off', !up); + } + + if (timeEl && responseTime !== null) { + timeEl.textContent = up ? `${responseTime}ms` : 'timeout'; + timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`; + } + + // Update card status for icon coloring + if (card) { + card.setAttribute('data-status', up ? 'on' : 'off'); + } + } + + async function refreshAll() { + // Check DNS servers dynamically (only those configured in SITE.dnsServers) + const dnsIds = Object.keys(SITE.dnsServers); + const topChecks = dnsIds.map(id => checkServiceWithTiming(id)); + topChecks.push(checkServiceWithTiming('internet')); + const topResults = await Promise.all(topChecks); + + dnsIds.forEach((id, i) => setQuick(id, topResults[i].isUp, topResults[i].responseTime)); + const internetResult = topResults[topResults.length - 1]; + setQuick('internet', internetResult.isUp, internetResult.responseTime); + + // Check app services with timing + const appResults = await Promise.all( + window.APPS.map(async s => { + const result = await checkServiceWithTiming(s.id); + return { id: s.id, ...result }; + }) + ); + + appResults.forEach(result => { + setBadge(result.id, result.isUp, result.responseTime); + }); + + const stamp = document.getElementById('stamp'); + if (stamp) stamp.textContent = 'last check: ' + new Date().toLocaleTimeString(); + } + + // DNS open buttons — use event delegation on .top container + document.querySelector('.top')?.addEventListener('click', (e) => { + const openBtn = e.target.closest('[id$="-open"]'); + if (!openBtn) return; + const id = openBtn.id.replace('-open', ''); + if (SITE.dnsServers[id]) window.open(serviceUrl(id), '_blank', 'noopener'); + }); + document.getElementById('ca-open')?.addEventListener('click', () => window.open(serviceUrl('ca'), '_blank', 'noopener')); + document.getElementById('creds-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceCredsModal) window.openServiceCredsModal(s); }); + document.getElementById('options-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceEditModal) window.openServiceEditModal(s); }); + document.getElementById('delete-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); if (window.deleteService) window.deleteService('ca', 'DashCA'); }); + + // Window exports + window.loadServices = loadServices; + window.buildGrid = buildGrid; + window.refreshAll = refreshAll; + window.setQuick = setQuick; + window.setBadge = setBadge; + window.getResponseTimeClass = getResponseTimeClass; + window.checkServiceWithTiming = checkServiceWithTiming; + window.serviceUrl = serviceUrl; + window.el = el; + +})(); diff --git a/status/js/core/init.js b/status/js/core/init.js new file mode 100644 index 0000000..a93a631 --- /dev/null +++ b/status/js/core/init.js @@ -0,0 +1,204 @@ +// ========== DASHBOARD INITIALIZATION ========== +(function () { + + function loadCustomServices() { + const customServices = safeGet('custom-services'); + if (customServices) { + try { + const services = JSON.parse(customServices); + // Merge with default APPS, avoiding duplicates + services.forEach(service => { + if (!window.APPS.find(app => app.id === service.id)) { + window.APPS.push(service); + } + }); + } catch (e) { + console.warn('Failed to load custom services:', e); + } + } + } + + // Initialize custom services immediately so window.APPS is populated before buildGrid runs + loadCustomServices(); + + // Staggered animation for top cards too + function animateTopCards() { + const topCards = document.querySelectorAll('.top .card'); + topCards.forEach((card, index) => { + card.style.opacity = '0'; + card.style.transform = 'translateY(20px)'; + setTimeout(() => { + card.style.transition = 'opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + card.style.opacity = '1'; + card.style.transform = 'translateY(0)'; + }, index * 150); // 150ms delay between top cards + }); + } + + // Initialize dashboard (called after TOTP gate check or directly if TOTP disabled) + // NOTE: loadServices comes from window.loadServices (exported by grid.js) + let _dashboardInitialized = false; + async function initializeDashboard() { + if (_dashboardInitialized) { + console.warn('[init] initializeDashboard called again, skipping duplicate'); + return; + } + _dashboardInitialized = true; + await window.loadServices(); + window.buildGrid(); + animateTopCards(); + window.refreshAll(); + setInterval(window.refreshAll, DC.POLL.DASHBOARD); + if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons(); + // Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct) + if (typeof window._updateAuthCard === 'function') { + try { + const r = await fetch('/api/v1/totp/config', { cache: 'no-store' }); + const d = await r.json(); + if (d.success) window._updateAuthCard(d.config.enabled && d.config.isSetUp, d.config.sessionDuration); + } catch (e) { /* ignore */ } + } + // Lazy-load onboarding for first-time users, otherwise just add the tour button + addTourButton(); + if (shouldLoadOnboarding()) { + loadOnboarding(); + } + } + + // Lazy-load onboarding bundle (52 KB) — only loaded when needed + function loadOnboarding() { + if (document.querySelector('script[src="/dist/onboarding.js"]')) return; // already loading/loaded + const s = document.createElement('script'); + s.src = '/dist/onboarding.js'; + s.defer = true; + document.head.appendChild(s); + // Also load onboarding CSS if not already present + if (!document.querySelector('link[href="/css/driver.min.css"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/css/driver.min.css'; + document.head.appendChild(link); + } + if (!document.querySelector('link[href="/css/onboarding.css"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/css/onboarding.css'; + document.head.appendChild(link); + } + } + + // Check if onboarding should auto-start (first-time user) + function shouldLoadOnboarding() { + try { + const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding')); + return !data || (!data.tourCompleted && data.currentStep === 0); + } catch (_) { + return true; // No data means first-time user + } + } + + // ===== Collapsible toolbar sections ===== + function initToolbarSections() { + const sections = document.querySelectorAll('.tools-section'); + if (!sections.length) return; + + // Restore saved state from localStorage + let saved = {}; + try { saved = JSON.parse(localStorage.getItem('toolbar-sections') || '{}'); } catch (_) {} + + sections.forEach(section => { + const key = section.dataset.section; + const header = section.querySelector('.tools-section-header'); + if (!header) return; + + // Restore state (default: collapsed) + if (saved[key]) { + section.classList.add('open'); + header.setAttribute('aria-expanded', 'true'); + } + + header.addEventListener('click', (e) => { + e.preventDefault(); + const isOpen = section.classList.toggle('open'); + header.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + + // Save state + const state = {}; + document.querySelectorAll('.tools-section').forEach(s => { + state[s.dataset.section] = s.classList.contains('open'); + }); + localStorage.setItem('toolbar-sections', JSON.stringify(state)); + }); + }); + } + + // Initialize toolbar sections on DOM ready + initToolbarSections(); + + // Add restart tour button (loads bundle on click if not loaded) + // Visible in primary toolbar until tour completed once, then moves to Admin section + function addTourButton() { + if (document.getElementById('restart-tour-btn')) return; + + // Check if tour has been completed before + let tourDone = false; + try { + const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding')); + tourDone = data && data.tourCompleted; + } catch (_) {} + + // Before first completion: show in primary toolbar. After: tuck into Admin section. + const target = tourDone + ? document.querySelector('.tools-section[data-section="admin"] .tools-section-items') + : document.querySelector('.tools-primary'); + if (!target) return; + + const button = document.createElement('button'); + button.id = 'restart-tour-btn'; + button.textContent = tourDone ? 'Help Tour' : '🎓 Help Tour'; + button.title = 'Restart the onboarding tour'; + button.onclick = () => { + if (window.DashCaddyOnboarding) { + window.DashCaddyOnboarding.restartTour(); + } else { + loadOnboarding(); + // Wait for bundle to load, then start + const check = setInterval(() => { + if (window.DashCaddyOnboarding) { + clearInterval(check); + window.DashCaddyOnboarding.restartTour(); + } + }, 100); + setTimeout(() => clearInterval(check), 5000); // give up after 5s + } + }; + target.appendChild(button); + } + + window.initializeDashboard = initializeDashboard; + window.loadCustomServices = loadCustomServices; + + // TOTP-gated initialization + (async () => { + try { + const totpRes = await fetch('/api/v1/totp/config', { cache: 'no-store' }); + const totpData = await totpRes.json(); + + if (totpData.success && totpData.config.enabled) { + // TOTP is enabled - check if we have a valid session + const testRes = await fetch('/api/v1/totp/check-session', { cache: 'no-store' }); + if (testRes.status === 401) { + // Need TOTP verification - show overlay + window._showTotpOverlay(); + return; // initializeDashboard() will be called after successful verification + } + } + } catch (e) { + console.warn('TOTP check failed, proceeding normally:', e); + } + + // TOTP disabled or session valid - initialize immediately + initializeDashboard(); + })(); + +})(); diff --git a/status/js/core/logs.js b/status/js/core/logs.js new file mode 100644 index 0000000..5c73963 --- /dev/null +++ b/status/js/core/logs.js @@ -0,0 +1,672 @@ +// ========== LOG VIEWERS ========== +(function () { + + // Inject logs-modal HTML + injectModal('logs-modal', ` +
+
+
+

DNS Logs

+
+ + + + + +
+
+
+
+
Loading logs...
+
+
+
+
`); + + // ===== State ===== + let currentDnsService = null; + let logsInterval = null; + let logsPaused = false; + + let currentContainerId = null; + let currentContainerName = null; + let containerLogsMode = false; + + let currentLogPath = null; + let currentLogServiceName = null; + let fileLogsMode = false; + + let logEventSource = null; + let isStreaming = false; + + // ===== DNS LOGS ===== + + async function fetchDnsLogs(dnsId, lines = 25) { + try { + const serverIP = getDnsServerAddr(dnsId); + const response = await fetch(`/api/v1/dns/logs?server=${serverIP}&limit=${lines}`, { + cache: 'no-store', + headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' } + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.logs) { + return { logs: result.logs, count: result.count, server: result.server }; + } else { + return { error: result.error || 'Failed to fetch logs' }; + } + } else if (response.status === 401) { + return { error: 'DNS auto-auth failed - check credentials in settings' }; + } else { + return { error: `HTTP ${response.status}` }; + } + } catch (error) { + console.error('DNS logs fetch failed:', error); + return { error: error.message }; + } + } + + function getRcodeColor(rcode) { + const colors = { + 'NoError': 'var(--ok-fg)', + 'NOERROR': 'var(--ok-fg)', + 'NxDomain': 'var(--muted)', + 'NXDOMAIN': 'var(--muted)', + 'Refused': 'var(--bad-fg)', + 'REFUSED': 'var(--bad-fg)', + 'ServerFailure': '#f39c12', + 'SERVFAIL': '#f39c12' + }; + return colors[rcode] || 'var(--fg)'; + } + + function renderDnsLogEntry(log) { + const div = document.createElement('div'); + div.className = 'log-entry'; + div.style.cssText = 'display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;'; + + // If unparsed raw log + if (log.parsed === false) { + div.style.gridTemplateColumns = '1fr'; + div.innerHTML = `${escapeHtml(log.raw)}`; + return div; + } + + const rcodeColor = getRcodeColor(log.rcode); + const isBlocked = log.rcode === 'Refused' || log.rcode === 'REFUSED'; + + div.innerHTML = ` + ${escapeHtml(log.timestamp)} + ${escapeHtml(log.client)} + ${escapeHtml(log.domain)} + ${escapeHtml(log.type)} + ${escapeHtml(log.rcode)} + `; + + return div; + } + + async function updateLogsDisplay() { + // Handle file logs mode + if (fileLogsMode) { + await updateFileLogsDisplay(); + return; + } + + // Handle container logs mode + if (containerLogsMode) { + await updateContainerLogsDisplay(); + return; + } + + // Handle DNS logs mode + if (logsPaused || !currentDnsService) return; + + const lines = parseInt(document.getElementById('log-lines').value); + const logsContent = document.getElementById('logs-content'); + + try { + const result = await fetchDnsLogs(currentDnsService, lines); + + if (result.error) { + logsContent.innerHTML = ` +
+
⚠️ Error
+
${result.error}
+
`; + return; + } + + // Add header row + logsContent.innerHTML = ` +
+ Time + Client + Domain + Type + Status +
`; + + if (result.logs && result.logs.length > 0) { + result.logs.forEach(log => { + const logElement = renderDnsLogEntry(log); + logsContent.appendChild(logElement); + }); + } else { + logsContent.innerHTML += ` +
+ No DNS queries logged yet +
`; + } + } catch (error) { + logsContent.innerHTML = ` +
+ Failed to fetch logs: ${error.message} +
`; + } + } + + function openLogsModal(dnsId) { + currentDnsService = dnsId; + logsPaused = false; + containerLogsMode = false; + + const modal = document.getElementById('logs-modal'); + const title = document.getElementById('logs-title'); + const pauseBtn = document.getElementById('logs-pause'); + const streamBtn = document.getElementById('logs-stream'); + + title.textContent = `${dnsId.toUpperCase()} DNS Logs`; + pauseBtn.textContent = '⏸️ Pause'; + pauseBtn.classList.remove('paused'); + + // Hide stream button for DNS logs (only available for container logs) + if (streamBtn) { + streamBtn.style.display = 'none'; + } + + modal.classList.add('show'); + + // Initial load + updateLogsDisplay(); + + // Start auto-refresh every 3 seconds + logsInterval = setInterval(updateLogsDisplay, DC.POLL.LOGS); + } + + function closeLogsModal() { + const modal = document.getElementById('logs-modal'); + modal.classList.remove('show'); + + if (logsInterval) { + clearInterval(logsInterval); + logsInterval = null; + } + + // Stop SSE streaming if active + stopLogStreaming(); + + // Reset all log modes + currentDnsService = null; + containerLogsMode = false; + currentContainerId = null; + currentContainerName = null; + fileLogsMode = false; + currentLogPath = null; + currentLogServiceName = null; + logsPaused = false; + } + + // ===== SSE LOG STREAMING ===== + + function startLogStreaming(containerId) { + if (logEventSource) { + stopLogStreaming(); + } + + const streamBtn = document.getElementById('logs-stream'); + const pauseBtn = document.getElementById('logs-pause'); + const logsContent = document.getElementById('logs-content'); + + // Stop interval-based refresh + if (logsInterval) { + clearInterval(logsInterval); + logsInterval = null; + } + + try { + logEventSource = new EventSource(`/api/v1/logs/stream/${containerId}`); + isStreaming = true; + + streamBtn.classList.add('active'); + streamBtn.textContent = '🔴 Live'; + streamBtn.title = 'Streaming - click to stop'; + pauseBtn.style.display = 'none'; + + // Add streaming indicator to header + const title = document.getElementById('logs-title'); + if (!title.textContent.includes('🔴')) { + title.innerHTML = title.textContent.replace('📋', '📋 🔴'); + } + + logEventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.error) { + console.error('Stream error:', data.error); + stopLogStreaming(); + return; + } + + // Append new log entry + const entry = document.createElement('div'); + entry.className = 'log-entry'; + entry.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;'; + + const streamType = data.stream || 'stdout'; + const isError = streamType === 'stderr'; + const levelColor = isError ? 'var(--bad-fg)' : 'var(--fg)'; + const bgColor = isError ? 'var(--bad-bg)' : 'var(--ok-bg)'; + const levelBadge = `${isError ? 'STDERR' : 'STDOUT'}`; + + entry.innerHTML = ` +
${levelBadge}
+
${escapeHtml(data.text)}
+ `; + + logsContent.appendChild(entry); + + // Auto-scroll to bottom + logsContent.scrollTop = logsContent.scrollHeight; + + // Limit entries to prevent memory issues (keep last 500) + while (logsContent.children.length > 500) { + logsContent.removeChild(logsContent.firstChild); + } + } catch (err) { + console.error('Error parsing stream data:', err); + } + }; + + logEventSource.onerror = (err) => { + console.error('EventSource error:', err); + stopLogStreaming(); + }; + + } catch (err) { + console.error('Failed to start streaming:', err); + stopLogStreaming(); + } + } + + function stopLogStreaming() { + if (logEventSource) { + logEventSource.close(); + logEventSource = null; + } + isStreaming = false; + + const streamBtn = document.getElementById('logs-stream'); + const pauseBtn = document.getElementById('logs-pause'); + const title = document.getElementById('logs-title'); + + if (streamBtn) { + streamBtn.classList.remove('active'); + streamBtn.textContent = '📡 Live'; + streamBtn.title = 'Enable real-time streaming'; + } + + if (pauseBtn) { + pauseBtn.style.display = ''; + } + + if (title) { + title.textContent = title.textContent.replace(' 🔴', ''); + } + + // Restart interval-based refresh if container logs modal is open + if (containerLogsMode && currentContainerId && !logsInterval) { + logsInterval = setInterval(updateContainerLogsDisplay, DC.POLL.LOGS); + } + } + + // ===== CONTAINER LOGS ===== + + async function fetchContainerLogs(containerId, lines = 100) { + try { + const endpoint = `/api/v1/logs/container/${containerId}?tail=${lines}×tamps=true`; + + const response = await fetch(endpoint, { + cache: 'no-store', + headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' } + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.logs) { + return { + logs: result.logs, + count: result.count, + containerName: result.containerName, + containerId: result.containerId + }; + } else { + return { error: result.error || 'Failed to fetch container logs' }; + } + } else { + return { error: `HTTP ${response.status}: ${response.statusText}` }; + } + } catch (error) { + console.error('Container logs fetch failed:', error); + return { error: error.message }; + } + } + + function renderContainerLogEntry(log) { + const div = document.createElement('div'); + div.className = 'log-entry'; + div.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;'; + + const streamColor = log.stream === 'stderr' ? 'var(--bad-fg)' : 'var(--fg)'; + const streamBadge = log.stream === 'stderr' ? + 'STDERR' : + 'STDOUT'; + + div.innerHTML = ` +
${streamBadge}
+
${escapeHtml(log.text)}
+ `; + + return div; + } + + async function updateContainerLogsDisplay() { + if (logsPaused || !currentContainerId || !containerLogsMode) return; + + const lines = parseInt(document.getElementById('log-lines').value); + const logsContent = document.getElementById('logs-content'); + + try { + const result = await fetchContainerLogs(currentContainerId, lines); + + if (result.error) { + logsContent.innerHTML = ` +
+
⚠️ Error
+
${result.error}
+
`; + return; + } + + // Add header row + logsContent.innerHTML = ` +
+ Stream + Log Output +
`; + + if (result.logs && result.logs.length > 0) { + result.logs.forEach(log => { + const logElement = renderContainerLogEntry(log); + logsContent.appendChild(logElement); + }); + + // Auto-scroll to bottom + logsContent.scrollTop = logsContent.scrollHeight; + } else { + logsContent.innerHTML += ` +
+ No logs available for this container +
`; + } + } catch (error) { + logsContent.innerHTML = ` +
+ Failed to fetch logs: ${error.message} +
`; + } + } + + function openContainerLogsModal(containerId, containerName) { + currentContainerId = containerId; + currentContainerName = containerName; + containerLogsMode = true; + fileLogsMode = false; + logsPaused = false; + + // Stop any existing streaming + stopLogStreaming(); + + const modal = document.getElementById('logs-modal'); + const title = document.getElementById('logs-title'); + const pauseBtn = document.getElementById('logs-pause'); + const streamBtn = document.getElementById('logs-stream'); + + title.textContent = `📋 ${containerName} - Container Logs`; + pauseBtn.textContent = '⏸️ Pause'; + pauseBtn.classList.remove('paused'); + + // Show stream button for container logs + if (streamBtn) { + streamBtn.style.display = ''; + } + + modal.classList.add('show'); + + // Initial load + updateContainerLogsDisplay(); + + // Start auto-refresh every 3 seconds + logsInterval = setInterval(updateContainerLogsDisplay, DC.POLL.LOGS); + } + + // ===== FILE-BASED LOGS (for native apps) ===== + + async function fetchFileLogs(logPath, lines = 100) { + try { + const endpoint = `/api/v1/logs/file?path=${encodeURIComponent(logPath)}&tail=${lines}`; + + const response = await fetch(endpoint, { + cache: 'no-store', + headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' } + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.logs) { + return { + logs: result.logs, + count: result.count, + logPath: result.logPath, + totalLines: result.totalLines + }; + } else { + return { error: result.error || 'Failed to fetch file logs' }; + } + } else { + const result = await response.json().catch(() => ({})); + return { error: result.error || `HTTP ${response.status}` }; + } + } catch (error) { + console.error('File logs fetch failed:', error); + return { error: error.message }; + } + } + + function renderFileLogEntry(log) { + const div = document.createElement('div'); + div.className = 'log-entry'; + div.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;'; + + const text = log.text; + let logLevel = 'INFO'; + let levelColor = 'var(--fg)'; + + if (text.match(/ERROR|FATAL|CRITICAL/i)) { + logLevel = 'ERROR'; + levelColor = 'var(--bad-fg)'; + } else if (text.match(/WARN|WARNING/i)) { + logLevel = 'WARN'; + levelColor = '#f39c12'; + } else if (text.match(/DEBUG/i)) { + logLevel = 'DEBUG'; + levelColor = 'var(--muted)'; + } + + const bgColor = levelColor === 'var(--bad-fg)' ? 'var(--bad-bg)' : 'var(--ok-bg)'; + const levelBadge = `${logLevel}`; + + div.innerHTML = ` +
${levelBadge}
+
${escapeHtml(text)}
+ `; + + return div; + } + + async function updateFileLogsDisplay() { + if (logsPaused || !currentLogPath || !fileLogsMode) return; + + const lines = parseInt(document.getElementById('log-lines').value); + const logsContent = document.getElementById('logs-content'); + + try { + const result = await fetchFileLogs(currentLogPath, lines); + + if (result.error) { + logsContent.innerHTML = ` +
+
⚠️ Error
+
${result.error}
+
`; + return; + } + + logsContent.innerHTML = ` +
+ Log Output (${result.count} of ${result.totalLines} lines) +
`; + + if (result.logs && result.logs.length > 0) { + result.logs.forEach(log => { + const logElement = renderFileLogEntry(log); + logsContent.appendChild(logElement); + }); + logsContent.scrollTop = logsContent.scrollHeight; + } else { + logsContent.innerHTML += ` +
+ No logs available in this file +
`; + } + } catch (error) { + logsContent.innerHTML = ` +
+ Failed to fetch logs: ${error.message} +
`; + } + } + + function openFileLogsModal(logPath, serviceName) { + currentLogPath = logPath; + currentLogServiceName = serviceName; + fileLogsMode = true; + containerLogsMode = false; + logsPaused = false; + + const modal = document.getElementById('logs-modal'); + const title = document.getElementById('logs-title'); + const pauseBtn = document.getElementById('logs-pause'); + const streamBtn = document.getElementById('logs-stream'); + + title.textContent = `📋 ${serviceName} - Application Logs`; + pauseBtn.textContent = '⏸️ Pause'; + pauseBtn.classList.remove('paused'); + + // Hide stream button for file logs (only available for container logs) + if (streamBtn) { + streamBtn.style.display = 'none'; + } + + modal.classList.add('show'); + + updateFileLogsDisplay(); + logsInterval = setInterval(updateFileLogsDisplay, DC.POLL.LOGS); + } + + // ===== EVENT LISTENERS ===== + + // DNS log buttons — event delegation for dynamic cards + document.querySelector('.top')?.addEventListener('click', (e) => { + const logsBtn = e.target.closest('[id$="-logs"]'); + if (!logsBtn) return; + const dnsId = logsBtn.id.replace('-logs', ''); + if (!SITE.dnsServers[dnsId]) return; + openLogsModal(dnsId); + }); + + document.getElementById('logs-close')?.addEventListener('click', closeLogsModal); + + document.getElementById('logs-pause')?.addEventListener('click', () => { + logsPaused = !logsPaused; + const pauseBtn = document.getElementById('logs-pause'); + + if (logsPaused) { + pauseBtn.textContent = '▶️ Resume'; + pauseBtn.classList.add('paused'); + } else { + pauseBtn.textContent = '⏸️ Pause'; + pauseBtn.classList.remove('paused'); + updateLogsDisplay(); + } + }); + + document.getElementById('log-lines')?.addEventListener('change', () => { + if (!logsPaused) { + updateLogsDisplay(); + } + }); + + // Stream button for real-time SSE logs (only works with container logs) + document.getElementById('logs-stream')?.addEventListener('click', () => { + if (!containerLogsMode || !currentContainerId) { + // Streaming only available for container logs + return; + } + + if (isStreaming) { + stopLogStreaming(); + } else { + startLogStreaming(currentContainerId); + } + }); + + // Close modal on outside click + document.getElementById('logs-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'logs-modal') { + closeLogsModal(); + } + }); + + // Close logs-modal on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (document.getElementById('logs-modal')?.classList.contains('show')) { + closeLogsModal(); + } + } + }); + + // ===== EXPORTS ===== + window.openContainerLogsModal = openContainerLogsModal; + window.openFileLogsModal = openFileLogsModal; + window.openLogsModal = openLogsModal; + +})(); diff --git a/status/js/core/service-create.js b/status/js/core/service-create.js new file mode 100644 index 0000000..450f9ee --- /dev/null +++ b/status/js/core/service-create.js @@ -0,0 +1,648 @@ +// ========== SERVICE CREATION ========== +// Add service modal, local/external service creation flows, and event wiring. +(function () { + + // ===== SUBDOMAIN AUTO-DERIVE ===== + + function deriveSubdomain(name) { + return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, ''); + } + + function getSmartSslDefault() { + return SITE.defaults?.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'caddy-managed'); + } + + // ===== SERVICE PREVIEW ===== + + function updateServicePreview() { + const subdomain = document.getElementById('service-subdomain-input').value || 'subdomain'; + const ip = document.getElementById('service-ip-input').value || QUICK_IPS.lan || 'localhost'; + const port = document.getElementById('service-port-input').value || DC.DEFAULTS.SERVICE_PORT; + const sslType = document.getElementById('ssl-type-select').value; + const caName = document.getElementById('ca-name-input').value || 'sami-ca'; + const existingCa = document.getElementById('existing-ca-select').value; + const enableAuth = document.getElementById('enable-auth').checked; + const enableCors = document.getElementById('enable-cors').checked; + const customHeaders = document.getElementById('custom-headers-input').value; + const upstreamPath = document.getElementById('upstream-path-input').value || '/'; + const healthCheck = document.getElementById('health-check-input').value; + const timeout = document.getElementById('timeout-input').value || 30; + + const dnsPreview = document.getElementById('dns-preview'); + if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`; + + const urlPreview = document.getElementById('url-preview'); + if (urlPreview) urlPreview.textContent = `https://${buildDomain(subdomain)}`; + + const config = { + subdomain, port, ip, sslType, caName, existingCa, + enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout + }; + + const caddyConfig = window.generateCaddyConfig(config); + const configPreview = document.getElementById('caddy-config-preview'); + if (configPreview) configPreview.value = caddyConfig; + } + + // ===== QUICK IP CONFIGURATION ===== + + const QUICK_IPS = { + localhost: '127.0.0.1', + lan: '', + tailscale: '' + }; + + async function detectNetworkIPs() { + try { + const response = await fetch('/api/v1/network/ips', { + signal: AbortSignal.timeout(2000) + }); + if (response.ok) { + const data = await response.json(); + if (data.lan) QUICK_IPS.lan = data.lan; + if (data.tailscale) QUICK_IPS.tailscale = data.tailscale; + } + } catch (e) { + // API not available + } + + const lanBtn = document.getElementById('quick-ip-lan'); + const tsBtn = document.getElementById('quick-ip-tailscale'); + if (lanBtn) { + if (QUICK_IPS.lan) { + lanBtn.dataset.ip = QUICK_IPS.lan; + lanBtn.textContent = `LAN (${QUICK_IPS.lan})`; + lanBtn.title = `LAN IP: ${QUICK_IPS.lan}`; + } else { + lanBtn.style.display = 'none'; + } + } + if (tsBtn) { + if (QUICK_IPS.tailscale) { + tsBtn.dataset.ip = QUICK_IPS.tailscale; + tsBtn.textContent = `Tailscale (${QUICK_IPS.tailscale})`; + tsBtn.title = `Tailscale IP: ${QUICK_IPS.tailscale}`; + } else { + tsBtn.style.display = 'none'; + } + } + const ipInput = document.getElementById('service-ip-input'); + if (ipInput && !ipInput.value && QUICK_IPS.lan) ipInput.value = QUICK_IPS.lan; + } + + function initQuickIPButtons() { + document.querySelectorAll('.quick-ip-btn').forEach(btn => { + btn.addEventListener('click', () => { + const ip = btn.dataset.ip; + if (ip) { + document.getElementById('service-ip-input').value = ip; + document.querySelectorAll('.quick-ip-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + updateServicePreview(); + } + }); + }); + + document.getElementById('service-ip-input')?.addEventListener('input', (e) => { + const currentIP = e.target.value; + document.querySelectorAll('.quick-ip-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.ip === currentIP); + }); + }); + } + + // ===== ADD SERVICE MODAL ===== + + async function openAddServiceModal() { + const modal = document.getElementById('add-service-modal'); + modal.classList.add('show'); + + const modalContent = modal.querySelector('.weather-modal-content'); + if (modalContent) modalContent.scrollTop = 0; + + document.body.style.overflow = 'hidden'; + + // Set smart SSL default + const sslSelect = document.getElementById('ssl-type-select'); + if (sslSelect) sslSelect.value = getSmartSslDefault(); + + await detectNetworkIPs(); + + const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE; + await window.loadExistingCAs(caddyfilePath); + + // Check Tailscale status + const tailscaleStatus = document.getElementById('manual-tailscale-status'); + const tailscaleCheckbox = document.getElementById('manual-tailscale-only'); + try { + const response = await fetch('/api/v1/tailscale/status'); + const data = await response.json(); + if (data.success && data.installed && data.connected) { + tailscaleStatus.innerHTML = ` + \u2713 Connected + ${data.self?.hostname} (${data.self?.ip}) + `; + tailscaleCheckbox.disabled = false; + } else if (data.installed) { + tailscaleStatus.innerHTML = `\u26A0 Not connected`; + tailscaleCheckbox.disabled = true; + } else { + tailscaleStatus.innerHTML = `Not available`; + tailscaleCheckbox.disabled = true; + } + } catch (e) { + tailscaleStatus.innerHTML = `Could not check`; + tailscaleCheckbox.disabled = true; + } + tailscaleCheckbox.checked = false; + + updateServicePreview(); + } + + // ===== SERVICE TYPE SWITCHING (TAB STYLE) ===== + + function setupServiceTypeSwitching() { + const localRadio = document.getElementById('service-type-local'); + const externalRadio = document.getElementById('service-type-external'); + const localConfig = document.getElementById('local-service-config'); + const externalConfig = document.getElementById('external-service-config'); + const tabLocal = document.getElementById('tab-local'); + const tabExternal = document.getElementById('tab-external'); + + function switchServiceType() { + if (localRadio.checked) { + localConfig.style.display = 'grid'; + externalConfig.style.display = 'none'; + if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; } + if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; } + } else { + localConfig.style.display = 'none'; + externalConfig.style.display = 'block'; + if (tabExternal) { tabExternal.style.background = 'var(--accent)'; tabExternal.style.color = 'var(--bg)'; } + if (tabLocal) { tabLocal.style.background = 'transparent'; tabLocal.style.color = 'var(--muted)'; } + } + } + + localRadio?.addEventListener('change', switchServiceType); + externalRadio?.addEventListener('change', switchServiceType); + } + + // ===== AUTO-DERIVE SUBDOMAIN FROM NAME ===== + + function setupAutoSubdomain() { + // Local service: name → subdomain + preview + const nameInput = document.getElementById('service-name-input'); + const subdomainInput = document.getElementById('service-subdomain-input'); + const subdomainPreview = document.getElementById('subdomain-preview'); + let userEditedSubdomain = false; + + nameInput?.addEventListener('input', () => { + const derived = deriveSubdomain(nameInput.value); + if (!userEditedSubdomain && subdomainInput) { + subdomainInput.value = derived; + } + if (subdomainPreview) { + subdomainPreview.textContent = derived ? `\u2192 ${buildDomain(derived)}` : ''; + } + updateServicePreview(); + }); + + subdomainInput?.addEventListener('input', () => { + userEditedSubdomain = subdomainInput.value !== deriveSubdomain(nameInput?.value || ''); + const sub = subdomainInput.value.trim() || deriveSubdomain(nameInput?.value || ''); + if (subdomainPreview) { + subdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : ''; + } + updateServicePreview(); + }); + + // External service: name → subdomain + preview + const extNameInput = document.getElementById('external-service-name'); + const extSubdomainInput = document.getElementById('external-service-subdomain'); + const extSubdomainPreview = document.getElementById('external-subdomain-preview'); + const extDomainPreview = document.getElementById('external-domain-preview'); + let userEditedExtSubdomain = false; + + extNameInput?.addEventListener('input', () => { + const derived = deriveSubdomain(extNameInput.value); + if (!userEditedExtSubdomain && extSubdomainInput) { + extSubdomainInput.value = derived; + } + const sub = extSubdomainInput?.value || derived; + if (extSubdomainPreview) { + extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : ''; + } + if (extDomainPreview) { + extDomainPreview.textContent = sub ? buildDomain(sub) : ''; + } + }); + + extSubdomainInput?.addEventListener('input', () => { + userEditedExtSubdomain = extSubdomainInput.value !== deriveSubdomain(extNameInput?.value || ''); + const sub = extSubdomainInput.value.trim() || deriveSubdomain(extNameInput?.value || ''); + if (extSubdomainPreview) { + extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : ''; + } + if (extDomainPreview) { + extDomainPreview.textContent = sub ? buildDomain(sub) : ''; + } + }); + } + + // ===== CREATE EXTERNAL SERVICE ===== + + async function createExternalService() { + const name = document.getElementById('external-service-name').value.trim(); + const externalUrl = document.getElementById('external-service-url').value.trim(); + const subdomain = (document.getElementById('external-service-subdomain').value.trim() || deriveSubdomain(name)).toLowerCase(); + const logo = document.getElementById('external-service-logo').value.trim(); + const icon = document.getElementById('external-service-icon').value.trim(); + const createDns = document.getElementById('external-create-dns').checked; + const createCaddy = document.getElementById('external-create-caddy').checked; + const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost'; + const preserveHost = document.getElementById('external-preserve-host').checked; + const followRedirects = document.getElementById('external-follow-redirects').checked; + + if (!name || !externalUrl) { + showNotification('Please fill in Name and External URL', 'warning'); + return; + } + + if (!subdomain) { + showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning'); + return; + } + + if (!externalUrl.startsWith('http://') && !externalUrl.startsWith('https://')) { + showNotification('External URL must start with http:// or https://', 'warning'); + return; + } + + const domain = buildDomain(subdomain); + + try { + const results = { dns: null, caddy: null, dashboard: false }; + + if (createDns) { + const adminToken = window.getToken('dns2', 'admin'); + if (adminToken) { + try { + const dnsResponse = await secureFetch('/api/v1/dns/record', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + domain: domain, + ip: proxyIp, + ttl: DC.DEFAULTS.TTL, + server: SITE.dnsIp + }) + }); + const dnsResult = await dnsResponse.json(); + results.dns = dnsResult.success ? 'created' : (dnsResult.error || 'failed'); + } catch (e) { + results.dns = e.message; + } + } else { + results.dns = 'no admin token (configure in \uD83D\uDD11 Tokens)'; + } + } + + if (createCaddy) { + try { + const caddyConfig = { + subdomain: subdomain, + externalUrl: externalUrl, + preserveHost: preserveHost, + followRedirects: followRedirects, + sslType: 'caddy-managed', + caddyfilePath: DC.DEFAULTS.CADDYFILE, + reloadCaddy: true + }; + + const caddyResponse = await secureFetch('/api/v1/site/external', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(caddyConfig) + }); + + const caddyResult = await caddyResponse.json(); + results.caddy = caddyResult.success ? 'created' : (caddyResult.error || 'failed'); + } catch (e) { + results.caddy = e.message; + } + } + + const newService = { + id: subdomain, + name: name, + url: `https://${domain}`, + externalUrl: externalUrl, + logo: logo || icon || '\uD83C\uDF10', + isExternal: true, + isCustom: true + }; + + window.APPS.push(newService); + results.dashboard = true; + + const defaultServices = ['plex', 'router', 'chat', 'sync', 'torrent', 'radarr', 'sonarr', 'prowlarr', 'portainer', 'requests', 'jellyfin', 'emby']; + const customServices = window.APPS.filter(app => !defaultServices.includes(app.id)); + safeSet('custom-services', JSON.stringify(customServices)); + + try { + await secureFetch('/api/v1/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(window.APPS) + }); + } catch (e) { + console.warn('Failed to save to services.json:', e); + } + + window.buildGrid(); + window.refreshAll(); + + closeAddServiceModal(); + + const parts = [`External service "${name}" added!`]; + if (createDns) parts.push(`DNS: ${results.dns === 'created' ? '\u2713' : '\u26A0 ' + results.dns}`); + if (createCaddy) parts.push(`Caddy: ${results.caddy === 'created' ? '\u2713' : '\u26A0 ' + results.caddy}`); + parts.push(`Access at: https://${domain}`); + showNotification(parts.join(' | '), 'success', 6000); + + } catch (error) { + console.error('Failed to create external service:', error); + showNotification(`Failed to create external service: ${error.message}`, 'error'); + } + } + + // ===== CLOSE ADD SERVICE MODAL ===== + + function closeAddServiceModal() { + closeModal('add-service-modal'); + + document.body.style.overflow = ''; + + document.getElementById('service-name-input').value = ''; + document.getElementById('service-subdomain-input').value = ''; + document.getElementById('service-port-input').value = ''; + document.getElementById('service-ip-input').value = QUICK_IPS.lan || ''; + document.getElementById('service-logo-input').value = ''; + document.getElementById('dns-ttl-input').value = DC.DEFAULTS.TTL; + document.getElementById('ssl-type-select').value = getSmartSslDefault(); + document.getElementById('ca-name-input').value = ''; + document.getElementById('enable-auth').checked = false; + document.getElementById('enable-cors').checked = false; + document.getElementById('custom-headers-input').value = ''; + document.getElementById('upstream-path-input').value = '/'; + document.getElementById('health-check-input').value = ''; + document.getElementById('timeout-input').value = '30'; + + // Clear subdomain previews + const subPrev = document.getElementById('subdomain-preview'); + if (subPrev) subPrev.textContent = ''; + const extSubPrev = document.getElementById('external-subdomain-preview'); + if (extSubPrev) extSubPrev.textContent = ''; + + // Clear external fields + const extName = document.getElementById('external-service-name'); + if (extName) extName.value = ''; + const extSub = document.getElementById('external-service-subdomain'); + if (extSub) extSub.value = ''; + const extUrl = document.getElementById('external-service-url'); + if (extUrl) extUrl.value = ''; + const extLogo = document.getElementById('external-service-logo'); + if (extLogo) extLogo.value = ''; + const extIcon = document.getElementById('external-service-icon'); + if (extIcon) extIcon.value = ''; + + // Collapse options + const localOpts = document.getElementById('local-advanced-options'); + if (localOpts) localOpts.removeAttribute('open'); + const extOpts = document.getElementById('external-advanced-options'); + if (extOpts) extOpts.removeAttribute('open'); + + // Reset to local tab + const localRadio = document.getElementById('service-type-local'); + if (localRadio) localRadio.checked = true; + const localConfig = document.getElementById('local-service-config'); + const externalConfig = document.getElementById('external-service-config'); + if (localConfig) localConfig.style.display = 'grid'; + if (externalConfig) externalConfig.style.display = 'none'; + const tabLocal = document.getElementById('tab-local'); + const tabExternal = document.getElementById('tab-external'); + if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; } + if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; } + } + + // ===== CREATE NEW SERVICE ===== + + async function createNewService() { + const name = document.getElementById('service-name-input').value.trim(); + const subdomain = (document.getElementById('service-subdomain-input').value.trim() || deriveSubdomain(name)).toLowerCase(); + const port = document.getElementById('service-port-input').value.trim(); + const ip = document.getElementById('service-ip-input').value.trim(); + const logo = document.getElementById('service-logo-input').value.trim(); + const createDns = document.getElementById('create-dns-record').checked; + const ttl = parseInt(document.getElementById('dns-ttl-input').value) || DC.DEFAULTS.TTL; + const tailscaleOnly = document.getElementById('manual-tailscale-only')?.checked || false; + + const sslType = document.getElementById('ssl-type-select')?.value || 'caddy-managed'; + const caName = document.getElementById('ca-name-input')?.value || ''; + const existingCa = document.getElementById('existing-ca-select')?.value || ''; + const enableAuth = document.getElementById('enable-auth')?.checked || false; + const enableCors = document.getElementById('enable-cors')?.checked || false; + const customHeaders = document.getElementById('custom-headers-input')?.value || ''; + const upstreamPath = document.getElementById('upstream-path-input')?.value || '/'; + const healthCheck = document.getElementById('health-check-input')?.value || ''; + const timeout = document.getElementById('timeout-input')?.value || 30; + + const dnsToken = window.getToken('dns2', 'admin'); + + if (!name || !port || !ip) { + showNotification('Please fill in Name, Port, and IP Address', 'warning'); + return; + } + + if (!subdomain) { + showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning'); + return; + } + + if (createDns && !dnsToken) { + showNotification('DNS Admin token required. Configure it in the Tokens menu first.', 'warning'); + return; + } + + const results = { dns: null, caddy: null, dashboard: false }; + + try { + if (createDns) { + try { + await window.createDnsRecord(subdomain, ip, ttl); + results.dns = 'created'; + } catch (error) { + console.error('DNS creation failed:', error); + results.dns = error.message; + throw new Error(`DNS creation failed: ${error.message}`); + } + } else { + results.dns = 'skipped'; + } + + const caddyConfig = window.generateCaddyConfig({ + subdomain, port, ip, sslType, caName, existingCa, + enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout, tailscaleOnly + }); + + try { + const caddyResponse = await secureFetch('/api/v1/site', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + domain: buildDomain(subdomain), + upstream: `${ip}:${port}`, + config: caddyConfig + }) + }); + + const caddyResult = await caddyResponse.json(); + if (caddyResult.success) { + results.caddy = 'added & reloaded'; + } else { + console.error('Caddy configuration failed:', caddyResult.error); + results.caddy = caddyResult.error || 'failed'; + throw new Error(`Caddy configuration failed: ${caddyResult.error}`); + } + } catch (error) { + console.error('Caddy API error:', error); + results.caddy = error.message; + throw new Error(`Caddy API error: ${error.message}`); + } + + const serviceConfig = { + name, subdomain, port, ip, + logo: logo || `/assets/${subdomain}.png`, + tailscaleOnly: tailscaleOnly || false + }; + + await window.addServiceToConfig(serviceConfig); + results.dashboard = true; + + const statusParts = [ + `DNS: ${results.dns === 'created' ? '\u2713' : results.dns === 'skipped' ? '\u25CB' : '\u2717'}`, + `Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`, + `Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}` + ]; + showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 https://${buildDomain(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000); + + closeAddServiceModal(); + + window.buildGrid(); + window.refreshAll(); + + } catch (error) { + console.error('Error creating service:', error); + showNotification(`Error creating "${name}": ${error.message}`, 'error', 6000); + } + } + + // ===== EVENT LISTENERS ===== + + document.getElementById('add-service')?.addEventListener('click', openAddServiceModal); + document.getElementById('add-service-cancel')?.addEventListener('click', closeAddServiceModal); + document.getElementById('add-service-create')?.addEventListener('click', () => { + const serviceType = document.querySelector('input[name="service-type"]:checked')?.value; + if (serviceType === 'external') { + createExternalService(); + } else { + createNewService(); + } + }); + + setupServiceTypeSwitching(); + setupAutoSubdomain(); + initQuickIPButtons(); + + // SSL type change handler + document.getElementById('ssl-type-select')?.addEventListener('change', (e) => { + const existingCaConfig = document.getElementById('existing-ca-config'); + const customCaConfig = document.getElementById('custom-ca-config'); + + existingCaConfig.style.display = 'none'; + customCaConfig.style.display = 'none'; + + if (e.target.value === 'existing-ca') { + existingCaConfig.style.display = 'block'; + } else if (e.target.value === 'custom-ca') { + customCaConfig.style.display = 'block'; + } + + updateServicePreview(); + }); + + // Refresh CAs button + document.getElementById('refresh-cas')?.addEventListener('click', async () => { + const button = document.getElementById('refresh-cas'); + const originalText = button.textContent; + button.textContent = '\u231B Loading...'; + button.disabled = true; + + try { + const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE; + await window.loadExistingCAs(caddyfilePath); + button.textContent = '\u2705 Refreshed'; + } catch (error) { + button.textContent = '\u274C Failed'; + console.error('Failed to refresh CAs:', error); + } + + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + }, 2000); + }); + + // DNS record checkbox handler + document.getElementById('create-dns-record')?.addEventListener('change', (e) => { + const dnsConfig = document.getElementById('dns-config'); + dnsConfig.style.display = e.target.checked ? 'block' : 'none'; + }); + + // Real-time preview updates + ['service-subdomain-input', 'service-ip-input', 'service-port-input', 'ca-name-input', + 'existing-ca-select', 'enable-auth', 'enable-cors', 'custom-headers-input', 'upstream-path-input', + 'health-check-input', 'timeout-input'].forEach(id => { + const element = document.getElementById(id); + if (element) { + element.addEventListener('input', updateServicePreview); + element.addEventListener('change', updateServicePreview); + } + }); + + // ===== CUSTOM SERVICES FROM LOCALSTORAGE ===== + + function loadCustomServices() { + const customServices = safeGet('custom-services'); + if (customServices) { + try { + const services = JSON.parse(customServices); + services.forEach(service => { + if (!window.APPS.find(app => app.id === service.id)) { + window.APPS.push(service); + } + }); + } catch (e) { + console.warn('Failed to load custom services:', e); + } + } + } + + loadCustomServices(); + + // ===== WINDOW EXPORTS ===== + + window.openAddServiceModal = openAddServiceModal; + window.closeAddServiceModal = closeAddServiceModal; + +})(); diff --git a/status/js/core/service-crud.js b/status/js/core/service-crud.js new file mode 100644 index 0000000..b8aa446 --- /dev/null +++ b/status/js/core/service-crud.js @@ -0,0 +1,429 @@ +// ========== SERVICE CRUD ========== +// Edit, delete, and update operations for existing services. +(function () { + + // ===== SERVICE EDIT MODAL ===== + let currentEditService = null; + + function openServiceEditModal(service) { + currentEditService = service; + const modal = document.getElementById('service-edit-modal'); + + document.getElementById('service-edit-title').textContent = `Edit ${service.name}`; + document.getElementById('edit-service-name-display').textContent = service.name; + document.getElementById('edit-service-url-display').textContent = `https://${buildDomain(service.id)}`; + document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`; + document.getElementById('edit-subdomain').value = service.id; + document.getElementById('edit-port').value = service.port || ''; + document.getElementById('edit-ip').value = service.ip || 'localhost'; + document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false; + document.getElementById('edit-logo-url').value = service.logo || ''; + + modal.classList.add('show'); + } + + function closeServiceEditModal() { + closeModal('service-edit-modal'); + currentEditService = null; + } + + async function saveServiceChanges() { + if (!currentEditService) return; + + const newSubdomain = document.getElementById('edit-subdomain').value.trim().toLowerCase(); + const newPort = document.getElementById('edit-port').value.trim(); + const newIp = document.getElementById('edit-ip').value.trim() || 'localhost'; + const tailscaleOnly = document.getElementById('edit-tailscale-only').checked; + const newLogo = document.getElementById('edit-logo-url').value.trim(); + + if (!newSubdomain) { + showNotification('Subdomain is required', 'warning'); + return; + } + + const oldSubdomain = currentEditService.id; + const changes = []; + + if (newSubdomain !== oldSubdomain) changes.push('subdomain'); + if (newPort && newPort !== String(currentEditService.port)) changes.push('port'); + if (newIp !== currentEditService.ip) changes.push('ip'); + if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale'); + if (newLogo !== currentEditService.logo) changes.push('logo'); + + if (changes.length === 0) { + closeServiceEditModal(); + return; + } + + const saveBtn = document.getElementById('service-edit-save'); + saveBtn.textContent = 'Saving...'; + saveBtn.disabled = true; + + try { + if (changes.includes('subdomain') || changes.includes('port') || changes.includes('ip') || changes.includes('tailscale')) { + const response = await secureFetch('/api/v1/services/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + oldSubdomain, + newSubdomain, + port: newPort || currentEditService.port, + ip: newIp, + tailscaleOnly + }) + }); + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to update service'); + } + } + + // Update local APPS array + const appIndex = window.APPS.findIndex(a => a.id === oldSubdomain); + if (appIndex !== -1) { + window.APPS[appIndex] = { + ...window.APPS[appIndex], + id: newSubdomain, + port: newPort || window.APPS[appIndex].port, + ip: newIp, + tailscaleOnly, + logo: newLogo || window.APPS[appIndex].logo + }; + } + + // Update services via API + await secureFetch('/api/v1/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: newSubdomain, + name: currentEditService.name, + port: newPort || currentEditService.port, + ip: newIp, + logo: newLogo || currentEditService.logo, + tailscaleOnly, + containerId: currentEditService.containerId, + appTemplate: currentEditService.appTemplate + }) + }); + + // If subdomain changed, remove old entry + if (newSubdomain !== oldSubdomain) { + await secureFetch(`/api/v1/services/${oldSubdomain}`, { method: 'DELETE' }); + } + + closeServiceEditModal(); + window.buildGrid(); + window.refreshAll(); + + } catch (error) { + console.error('Error saving service changes:', error); + showNotification(`Error saving changes: ${error.message}`, 'error'); + } finally { + saveBtn.textContent = 'Save Changes'; + saveBtn.disabled = false; + } + } + + // Logo file upload handler + document.getElementById('edit-logo-file')?.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + showNotification('Please select an image file', 'warning'); + return; + } + + const reader = new FileReader(); + reader.onload = async (event) => { + const dataUrl = event.target.result; + + document.getElementById('edit-service-logo-preview').src = dataUrl; + document.getElementById('edit-logo-url').value = dataUrl; + + if (currentEditService) { + try { + const response = await secureFetch('/api/v1/assets/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: `${currentEditService.id}.png`, + data: dataUrl + }) + }); + const result = await response.json(); + if (result.success && result.path) { + document.getElementById('edit-logo-url').value = result.path; + } + } catch (err) { + // Fallback to data URL + } + } + }; + reader.readAsDataURL(file); + }); + + // Service edit modal event listeners + document.getElementById('service-edit-cancel')?.addEventListener('click', closeServiceEditModal); + document.getElementById('service-edit-save')?.addEventListener('click', saveServiceChanges); + document.getElementById('service-edit-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'service-edit-modal') closeServiceEditModal(); + }); + + // ===== DELETE SERVICE MODAL ===== + + function showDeleteModal(serviceName, hasContainer, containerId) { + return new Promise((resolve) => { + const modal = document.getElementById('delete-service-modal'); + const title = document.getElementById('delete-modal-title'); + const message = document.getElementById('delete-modal-message'); + const containerInfo = document.getElementById('delete-modal-container-info'); + const containerName = document.getElementById('delete-modal-container-name'); + const help = document.getElementById('delete-modal-help'); + const cancelBtn = document.getElementById('delete-modal-cancel'); + const removeBtn = document.getElementById('delete-modal-remove'); + const deleteBtn = document.getElementById('delete-modal-delete'); + + title.textContent = `Delete "${serviceName}"`; + + if (hasContainer) { + message.innerHTML = 'This service has an associated Docker container.
Choose how to proceed:'; + containerInfo.style.display = 'block'; + containerName.textContent = `Container ID: ${containerId?.slice(0, 12) || 'Unknown'}`; + help.style.display = 'block'; + deleteBtn.style.display = 'block'; + } else { + message.textContent = 'Remove this service from the dashboard?'; + containerInfo.style.display = 'none'; + help.style.display = 'none'; + deleteBtn.style.display = 'none'; + } + + const cleanup = () => { + modal.classList.remove('show'); + cancelBtn.removeEventListener('click', handleCancel); + removeBtn.removeEventListener('click', handleRemove); + deleteBtn.removeEventListener('click', handleDelete); + modal.removeEventListener('click', handleBackdrop); + }; + + const handleCancel = () => { cleanup(); resolve(null); }; + const handleRemove = () => { cleanup(); resolve(false); }; + const handleDelete = () => { cleanup(); resolve(true); }; + const handleBackdrop = (e) => { if (e.target === modal) { cleanup(); resolve(null); } }; + + cancelBtn.addEventListener('click', handleCancel); + removeBtn.addEventListener('click', handleRemove); + deleteBtn.addEventListener('click', handleDelete); + modal.addEventListener('click', handleBackdrop); + + modal.classList.add('show'); + }); + } + + // ===== UPDATE CONTAINER ===== + + async function updateContainer(containerId, serviceName, serviceId) { + const updateBtn = document.getElementById(`update-btn-${serviceId}`); + const originalText = updateBtn?.textContent; + + if (!confirm(`Update ${serviceName} to the latest version?\n\nThis will:\n1. Pull the latest image\n2. Stop the container\n3. Recreate with same settings\n\nThe service will be briefly unavailable.`)) { + return; + } + + try { + if (updateBtn) { + updateBtn.textContent = '\u{1F504}'; + updateBtn.disabled = true; + updateBtn.title = 'Updating...'; + } + + const response = await secureFetch(`/api/v1/containers/${containerId}/update`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.success) { + const service = window.APPS.find(app => app.id === serviceId); + if (service && result.newContainerId) { + service.containerId = result.newContainerId; + } + + if (updateBtn) { + updateBtn.textContent = '\u{2705}'; + updateBtn.title = 'Updated successfully!'; + setTimeout(() => { + updateBtn.textContent = originalText; + updateBtn.disabled = false; + updateBtn.title = 'Update container to latest version'; + }, 3000); + } + + setTimeout(() => window.refreshAll(), 2000); + showNotification(`${serviceName} updated successfully!`, 'success'); + } else { + throw new Error(result.error || 'Update failed'); + } + } catch (error) { + console.error('Update error:', error); + + if (updateBtn) { + updateBtn.textContent = '\u{274C}'; + updateBtn.title = 'Update failed'; + setTimeout(() => { + updateBtn.textContent = originalText; + updateBtn.disabled = false; + updateBtn.title = 'Update container to latest version'; + }, 3000); + } + + showNotification(`Failed to update ${serviceName}: ${error.message}`, 'error'); + } + } + + // ===== DELETE SERVICE ===== + + async function deleteService(serviceId, serviceName) { + const service = window.APPS.find(app => app.id === serviceId); + const domain = service ? buildDomain(service.id) : null; + const hasContainer = service?.containerId; + + const deleteContainer = await showDeleteModal(serviceName || serviceId, hasContainer, service?.containerId); + + if (deleteContainer === null) { + return; // User cancelled + } + + let results = { + dashboard: false, + container: null, + dns: null, + caddy: null, + service: null + }; + + // Full removal with container + if (deleteContainer && hasContainer) { + try { + const params = new URLSearchParams({ + containerId: service.containerId, + subdomain: service.id, + ip: service.ip || 'localhost', + deleteContainer: 'true' + }); + + const response = await secureFetch(`/api/v1/apps/${encodeURIComponent(service.id)}?${params.toString()}`, { + method: 'DELETE' + }); + + const result = await response.json(); + if (result.success) { + results = { ...results, ...result.results, dashboard: false }; + } else { + console.error('App removal failed:', result.error); + } + } catch (error) { + console.error('App removal error:', error); + } + } else if (deleteContainer && domain) { + // Fallback for manually added services + try { + const serviceIP = service?.ip || 'localhost'; + const dnsResponse = await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(domain)}&type=A&ipAddress=${encodeURIComponent(serviceIP)}&server=${SITE.dnsIp}`, { + method: 'DELETE' + }); + const dnsResult = await dnsResponse.json(); + results.dns = dnsResult.success ? 'deleted' : (dnsResult.error || 'failed'); + } catch (e) { + results.dns = e.message; + } + + try { + const caddyResponse = await secureFetch(`/api/v1/site/${encodeURIComponent(domain)}`, { + method: 'DELETE' + }); + const caddyResult = await caddyResponse.json(); + results.caddy = (caddyResult.success || (caddyResult.error && caddyResult.error.includes('not found'))) ? 'removed' : (caddyResult.error || 'failed'); + } catch (e) { + results.caddy = e.message; + } + } + + // Remove from APPS array + const index = window.APPS.findIndex(app => app.id === serviceId); + if (index > -1) { + window.APPS.splice(index, 1); + results.dashboard = true; + } + + // Remove from localStorage + try { + const customApps = safeGetJSON('custom-apps', []); + const localIndex = customApps.findIndex(app => app.id === serviceId); + if (localIndex > -1) { + customApps.splice(localIndex, 1); + safeSet('custom-apps', JSON.stringify(customApps)); + } + } catch (e) { + // Ignore localStorage errors + } + + // Remove from services.json via API + try { + const serviceResponse = await secureFetch(`/api/v1/services/${encodeURIComponent(serviceId)}`, { + method: 'DELETE' + }); + const serviceResult = await serviceResponse.json(); + results.service = serviceResult.success ? 'removed' : (serviceResult.error || 'failed'); + } catch (e) { + results.service = e.message; + } + + window.buildGrid(); + window.refreshAll(); + + // Only show alert if there are actual errors + let hasErrors = false; + let errorMessages = []; + + if (!results.dashboard) { + hasErrors = true; + errorMessages.push('\u{2717} Failed to remove from dashboard'); + } + + const successStates = ['removed', 'already removed', 'not found', 'deleted', 'kept (user choice)', 'skipped', 'no such record', 'does not exist']; + const isSuccess = (val) => !val || successStates.some(s => val.toLowerCase().includes(s.toLowerCase())); + + if (results.container && !isSuccess(results.container)) { + hasErrors = true; + errorMessages.push(`\u{26A0} Container: ${results.container}`); + } + if (results.dns && !isSuccess(results.dns)) { + hasErrors = true; + errorMessages.push(`\u{26A0} DNS Record: ${results.dns}`); + } + if (results.caddy && !isSuccess(results.caddy)) { + hasErrors = true; + errorMessages.push(`\u{26A0} Caddy Config: ${results.caddy}`); + } + if (results.service && !isSuccess(results.service)) { + hasErrors = true; + errorMessages.push(`\u{26A0} Service File: ${results.service}`); + } + + if (hasErrors) { + showNotification(`Error deleting "${serviceName || serviceId}": ${errorMessages.join(', ')}`, 'error', 6000); + } + } + + // ===== WINDOW EXPORTS ===== + + window.openServiceEditModal = openServiceEditModal; + window.showDeleteModal = showDeleteModal; + window.updateContainer = updateContainer; + window.deleteService = deleteService; + +})(); diff --git a/status/js/core/service-infrastructure.js b/status/js/core/service-infrastructure.js new file mode 100644 index 0000000..04c3c73 --- /dev/null +++ b/status/js/core/service-infrastructure.js @@ -0,0 +1,245 @@ +// ========== SERVICE INFRASTRUCTURE ========== +// Caddy config generation, DNS record creation, and service registration. +(function () { + + // ===== LOAD EXISTING CAs ===== + + async function loadExistingCAs(caddyfilePath) { + try { + const response = await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(caddyfilePath)}`); + + if (!response.ok) { + throw new Error(`Failed to load CAs: ${response.status}`); + } + + const result = await response.json(); + + if (result.status === 'success') { + const select = document.getElementById('existing-ca-select'); + select.innerHTML = ''; + + if (result.data.cas.length === 0) { + select.innerHTML = ''; + } else { + select.innerHTML = ''; + result.data.cas.forEach(ca => { + const option = document.createElement('option'); + if (typeof ca === 'object') { + option.value = ca.id; + option.textContent = ca.displayName || ca.name; + } else { + option.value = ca; + option.textContent = ca; + } + select.appendChild(option); + }); + } + return result.data.cas; + } else { + throw new Error(result.message); + } + } catch (error) { + console.error('Error loading CAs:', error); + const select = document.getElementById('existing-ca-select'); + select.innerHTML = ''; + return []; + } + } + + // ===== GENERATE CADDY CONFIG ===== + + function generateCaddyConfig(config) { + const { + subdomain, + port, + ip, + sslType, + caName, + existingCa, + enableAuth, + enableCors, + customHeaders, + upstreamPath, + healthCheck, + timeout, + tailscaleOnly + } = config; + + let caddyConfig = `${buildDomain(subdomain)} {\n`; + + // Tailscale-only access restriction + if (tailscaleOnly) { + caddyConfig += ` @blocked not remote_ip 100.64.0.0/10\n`; + caddyConfig += ` respond @blocked "Access denied. Tailscale connection required." 403\n`; + } + + // SSL Configuration + switch (sslType) { + case 'letsencrypt': + break; + case 'caddy-managed': + caddyConfig += ` tls internal\n`; + break; + case 'existing-ca': + if (existingCa) { + caddyConfig += ` tls {\n ca ${existingCa}\n }\n`; + } + break; + case 'custom-ca': + if (caName) { + caddyConfig += ` tls {\n ca ${caName}\n }\n`; + } + break; + } + + // Authentication + if (enableAuth) { + caddyConfig += ` basicauth {\n admin $2a$14$hashed_password_here\n }\n`; + } + + // CORS Headers + if (enableCors) { + caddyConfig += ` header {\n`; + caddyConfig += ` Access-Control-Allow-Origin "*"\n`; + caddyConfig += ` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"\n`; + caddyConfig += ` Access-Control-Allow-Headers "Content-Type, Authorization"\n`; + caddyConfig += ` }\n`; + } + + // Custom Headers + if (customHeaders) { + try { + const headers = JSON.parse(customHeaders); + caddyConfig += ` header {\n`; + Object.entries(headers).forEach(([key, value]) => { + caddyConfig += ` ${key} "${value}"\n`; + }); + caddyConfig += ` }\n`; + } catch (e) { + console.warn('Invalid JSON in custom headers'); + } + } + + // Health Check + if (healthCheck) { + caddyConfig += ` health_uri ${healthCheck}\n`; + } + + // Reverse Proxy + caddyConfig += ` reverse_proxy ${ip}:${port} {\n`; + if (upstreamPath && upstreamPath !== '/') { + caddyConfig += ` rewrite ${upstreamPath}\n`; + } + if (timeout && timeout !== 30) { + caddyConfig += ` transport http {\n`; + caddyConfig += ` dial_timeout ${timeout}s\n`; + caddyConfig += ` response_header_timeout ${timeout}s\n`; + caddyConfig += ` }\n`; + } + caddyConfig += ` }\n`; + + caddyConfig += `}\n`; + + return caddyConfig; + } + + // ===== CREATE DNS RECORD ===== + + async function createDnsRecord(subdomain, ip, ttl = DC.DEFAULTS.TTL) { + const dnsToken = window.getToken('dns2', 'admin'); + + if (!dnsToken) { + throw new Error('DNS admin token not configured. Please set it in the Tokens menu.'); + } + + const domain = buildDomain(subdomain); + + const response = await secureFetch('/api/v1/dns/record', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + domain: domain, + ip: ip, + ttl: ttl, + token: dnsToken, + server: SITE.dnsIp + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`DNS API Error: ${response.status} - ${errorText}`); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(`DNS Error: ${result.error || 'Unknown error'}`); + } + + return result; + } + + // ===== ADD SERVICE TO CONFIG ===== + + async function addServiceToConfig(serviceConfig) { + const newService = { + id: serviceConfig.subdomain, + name: serviceConfig.name, + logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png` + }; + + try { + const response = await secureFetch('/api/v1/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newService) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to save service'); + } + + await window.loadServices(); + window.buildGrid(); + + return newService; + } catch (error) { + console.error('Failed to add service to config:', error); + throw error; + } + } + + // ===== ADD TO CADDYFILE ===== + + async function addToCaddyfile(config) { + const subdomain = document.getElementById('service-subdomain-input').value.trim(); + const ip = document.getElementById('service-ip-input').value.trim() || 'localhost'; + const port = document.getElementById('service-port-input').value.trim() || '80'; + + const response = await secureFetch('/api/v1/site', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + domain: buildDomain(subdomain), + upstream: `${ip}:${port}`, + config: config + }) + }); + + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(result.error || `Caddy API Error: ${response.status}`); + } + return result; + } + + // ===== WINDOW EXPORTS ===== + + window.loadExistingCAs = loadExistingCAs; + window.generateCaddyConfig = generateCaddyConfig; + window.createDnsRecord = createDnsRecord; + window.addServiceToConfig = addServiceToConfig; + window.addToCaddyfile = addToCaddyfile; + +})(); diff --git a/status/js/core/service-modals.js b/status/js/core/service-modals.js new file mode 100644 index 0000000..47a2e37 --- /dev/null +++ b/status/js/core/service-modals.js @@ -0,0 +1,334 @@ +// ========== SERVICE MODAL TEMPLATES ========== +// Injects the HTML for service edit, delete, and add modals into the DOM. +// Must load before service-crud.js and service-create.js. +(function () { + + injectModal('service-edit-modal', ` +
+
+

Edit Service

+ +
+ +
+ +
+
+
+
+
+ + +
+ +
+ + .home +
+
+ + +
+ + +
+ The port Caddy will proxy to (container's exposed port) +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ Enter a URL or upload an image file (PNG, JPG, SVG) +
+
+
+ +
+ + +
+
+
`); + + injectModal('delete-service-modal', ` +
+
+

Delete Service

+ +
+ +
+ + + +
+ + + +
+ + +
+
`); + + injectModal('add-service-modal', ` +
+
+

Add Service

+ + +
+ + +
+ + + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ + + +
+
+
+ + +
+ Options +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + + +
+ Checking Tailscale... +
+ + + + +
+ +
+
+ + + + +
+
+ + + + + + +
+
+ +
+
+
+ + + + +
+ + +
+
+
`); + +})(); diff --git a/status/js/dns-template-selector.js b/status/js/dns-template-selector.js new file mode 100644 index 0000000..845af68 --- /dev/null +++ b/status/js/dns-template-selector.js @@ -0,0 +1,321 @@ +/** + * DNS Template Selector + * Presents DNS server template options when user chooses to set up DNS + */ + +(function(window) { + 'use strict'; + + class DnsTemplateSelector { + constructor(progressTracker) { + this.progressTracker = progressTracker; + this.modal = null; + this.onTemplateSelected = null; + console.log('[DnsTemplateSelector] Module loaded'); + } + + /** + * Get available DNS server templates from app templates + * @returns {Array} Array of DNS template objects + */ + getDnsTemplates() { + // In a real implementation, this would fetch from app-templates.js + // For now, return hardcoded templates matching what we added + return [ + { + id: 'technitium', + name: 'Technitium DNS Server', + description: 'Modern DNS server with web UI for managing private zones', + icon: '🌐', + difficulty: 'Easy', + features: [ + 'Web-based management interface', + 'Private zone management for .sami domain', + 'DHCP server integration', + 'DNS-over-HTTPS and DNS-over-TLS support' + ], + recommended: true + }, + { + id: 'bind9', + name: 'BIND9 DNS Server', + description: 'Industry-standard DNS server - powerful and flexible', + icon: '🔧', + difficulty: 'Advanced', + features: [ + 'Industry standard DNS server', + 'Full RFC compliance', + 'Advanced zone management', + 'DNSSEC support' + ], + recommended: false + }, + { + id: 'pihole', + name: 'Pi-hole', + description: 'Network-wide ad blocker with DNS capabilities', + icon: '🛡️', + difficulty: 'Intermediate', + features: [ + 'Ad blocking at DNS level', + 'Web interface for management', + 'DHCP server included', + 'Query logging and statistics' + ], + recommended: false + }, + { + id: 'powerdns', + name: 'PowerDNS', + description: 'High-performance DNS server with SQL backend', + icon: '⚡', + difficulty: 'Intermediate', + features: [ + 'SQL database backend', + 'RESTful API for automation', + 'Geographic load balancing', + 'DNSSEC support' + ], + recommended: false + }, + { + id: 'coredns', + name: 'CoreDNS', + description: 'Cloud-native DNS server - lightweight and flexible', + icon: '☁️', + difficulty: 'Intermediate', + features: [ + 'Plugin-based architecture', + 'Kubernetes-native', + 'Lightweight and fast', + 'Prometheus metrics' + ], + recommended: false + } + ]; + } + + /** + * Show DNS template selection modal + */ + showTemplateSelector() { + // Create modal if it doesn't exist + if (!this.modal) { + this.createModal(); + } + + // Populate with templates + this.populateTemplates(); + + // Show modal + this.modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + } + + /** + * Create the modal HTML structure + * @private + */ + createModal() { + const modal = document.createElement('div'); + modal.id = 'dns-template-modal'; + modal.className = 'dns-template-modal'; + modal.innerHTML = ` +
+
+

🌐 Choose a DNS Server

+

Setting up a DNS server is essential for managing your private .sami domain

+ +
+
+ +
+ +
+ `; + + document.body.appendChild(modal); + this.modal = modal; + + // Add event listeners + modal.querySelector('.dns-template-close').addEventListener('click', () => this.close()); + modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater()); + + // Close on overlay click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + this.close(); + } + }); + + // Close on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.style.display === 'flex') { + this.close(); + } + }); + } + + /** + * Populate modal with DNS templates + * @private + */ + populateTemplates() { + const grid = document.getElementById('dns-template-grid'); + if (!grid) return; + + const templates = this.getDnsTemplates(); + grid.innerHTML = ''; + + templates.forEach(template => { + const card = this.createTemplateCard(template); + grid.appendChild(card); + }); + } + + /** + * Create a template card element + * @private + */ + createTemplateCard(template) { + const card = document.createElement('div'); + card.className = 'dns-template-card'; + if (template.recommended) { + card.classList.add('recommended'); + } + + const difficultyClass = template.difficulty.toLowerCase(); + + card.innerHTML = ` + ${template.recommended ? '' : ''} +
${template.icon}
+

${template.name}

+

${template.description}

+
+ ${template.difficulty} +
+
    + ${template.features.slice(0, 3).map(f => `
  • ${f}
  • `).join('')} +
+ + `; + + // Add click handler to select button + const selectBtn = card.querySelector('.dns-template-select-btn'); + selectBtn.addEventListener('click', () => this.handleTemplateSelection(template)); + + return card; + } + + /** + * Handle template selection + * @private + */ + handleTemplateSelection(template) { + console.log(`[DnsTemplateSelector] Template selected: ${template.id}`); + + // Close modal + this.close(); + + // Trigger callback if set + if (this.onTemplateSelected) { + this.onTemplateSelected(template); + } else { + // Default behavior: open app selector with DNS filter + this.openAppSelector(template.id); + } + } + + /** + * Handle "Set up later" button + * @private + */ + handleSetupLater() { + console.log('[DnsTemplateSelector] DNS setup deferred'); + + // Mark as deferred in progress tracker + if (this.progressTracker) { + this.progressTracker.markDnsSetupDeferred(); + } + + // Close modal + this.close(); + + // Show notification + this.showNotification('DNS setup deferred. You can set it up later from the App Selector.'); + } + + /** + * Open app selector with specific template + * @private + */ + openAppSelector(templateId) { + // Try to open the app selector modal if it exists + const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]'); + if (appSelectorBtn) { + appSelectorBtn.click(); + + // Wait a bit then filter to the selected template + setTimeout(() => { + const searchInput = document.querySelector('#app-search'); + if (searchInput) { + searchInput.value = templateId; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); + } else { + // Fallback: show instructions + this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`); + } + } + + /** + * Show notification message + * @private + */ + showNotification(message) { + // Simple notification - could be enhanced + const notification = document.createElement('div'); + notification.className = 'dns-template-notification'; + notification.textContent = message; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: var(--card-base); + color: var(--fg); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 10001; + max-width: 300px; + `; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transition = 'opacity 0.3s'; + setTimeout(() => notification.remove(), 300); + }, 3000); + } + + /** + * Close the modal + */ + close() { + if (this.modal) { + this.modal.style.display = 'none'; + document.body.style.overflow = ''; + } + } + } + + window.DnsTemplateSelector = DnsTemplateSelector; + console.log('[DnsTemplateSelector] Module loaded'); + +})(window); diff --git a/status/js/driver.min.js b/status/js/driver.min.js new file mode 100644 index 0000000..87acd6e --- /dev/null +++ b/status/js/driver.min.js @@ -0,0 +1,2 @@ +this.driver=this.driver||{};this.driver.js=function(D){"use strict";let F={};function z(e={}){F={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?F[e]:F}function W(e,o,t,i){return(e/=i/2)<1?t/2*e*e+o:-t/2*(--e*(e-2)-1)+o}function q(e){const o='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(t=>{const i=t.matches(o),p=Array.from(t.querySelectorAll(o));return[...i?[t]:[],...p]}).filter(t=>getComputedStyle(t).pointerEvents!=="none"&&ae(t))}function V(e){if(!e||se(e))return;const o=a("smoothScroll");e.scrollIntoView({behavior:!o||re(e)?"auto":"smooth",inline:"center",block:"center"})}function re(e){if(!e||!e.parentElement)return;const o=e.parentElement;return o.scrollHeight>o.clientHeight}function se(e){const o=e.getBoundingClientRect();return o.top>=0&&o.left>=0&&o.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&o.right<=(window.innerWidth||document.documentElement.clientWidth)}function ae(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let N={};function b(e,o){N[e]=o}function l(e){return e?N[e]:N}function K(){N={}}let E={};function O(e,o){E[e]=o}function _(e){var o;(o=E[e])==null||o.call(E)}function ce(){E={}}function le(e,o,t,i){let p=l("__activeStagePosition");const n=p||t.getBoundingClientRect(),f=i.getBoundingClientRect(),w=W(e,n.x,f.x-n.x,o),r=W(e,n.y,f.y-n.y,o),v=W(e,n.width,f.width-n.width,o),s=W(e,n.height,f.height-n.height,o);p={x:w,y:r,width:v,height:s},Y(p),b("__activeStagePosition",p)}function X(e){if(!e)return;const o=e.getBoundingClientRect(),t={x:o.x,y:o.y,width:o.width,height:o.height};b("__activeStagePosition",t),Y(t)}function de(){const e=l("__activeStagePosition"),o=l("__overlaySvg");if(!e)return;if(!o){console.warn("No stage svg found.");return}const t=window.innerWidth,i=window.innerHeight;o.setAttribute("viewBox",`0 0 ${t} ${i}`)}function pe(e){const o=ue(e);document.body.appendChild(o),G(o,t=>{t.target.tagName==="path"&&_("overlayClick")}),b("__overlaySvg",o)}function Y(e){const o=l("__overlaySvg");if(!o){pe(e);return}const t=o.firstElementChild;if((t==null?void 0:t.tagName)!=="path")throw new Error("no path element found in stage svg");t.setAttribute("d",j(e))}function ue(e){const o=window.innerWidth,t=window.innerHeight,i=document.createElementNS("http://www.w3.org/2000/svg","svg");i.classList.add("driver-overlay","driver-overlay-animated"),i.setAttribute("viewBox",`0 0 ${o} ${t}`),i.setAttribute("xmlSpace","preserve"),i.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),i.setAttribute("version","1.1"),i.setAttribute("preserveAspectRatio","xMinYMin slice"),i.style.fillRule="evenodd",i.style.clipRule="evenodd",i.style.strokeLinejoin="round",i.style.strokeMiterlimit="2",i.style.zIndex="10000",i.style.position="fixed",i.style.top="0",i.style.left="0",i.style.width="100%",i.style.height="100%";const p=document.createElementNS("http://www.w3.org/2000/svg","path");return p.setAttribute("d",j(e)),p.style.fill=a("overlayColor")||"rgb(0,0,0)",p.style.opacity=`${a("overlayOpacity")}`,p.style.pointerEvents="auto",p.style.cursor="auto",i.appendChild(p),i}function j(e){const o=window.innerWidth,t=window.innerHeight,i=a("stagePadding")||0,p=a("stageRadius")||0,n=e.width+i*2,f=e.height+i*2,w=Math.min(p,n/2,f/2),r=Math.floor(Math.max(w,0)),v=e.x-i+r,s=e.y-i,c=n-r*2,d=f-r*2;return`M${o},0L0,0L0,${t}L${o},${t}L${o},0Z + M${v},${s} h${c} a${r},${r} 0 0 1 ${r},${r} v${d} a${r},${r} 0 0 1 -${r},${r} h-${c} a${r},${r} 0 0 1 -${r},-${r} v-${d} a${r},${r} 0 0 1 ${r},-${r} z`}function ve(){const e=l("__overlaySvg");e&&e.remove()}function fe(){const e=document.getElementById("driver-dummy-element");if(e)return e;let o=document.createElement("div");return o.id="driver-dummy-element",o.style.width="0",o.style.height="0",o.style.pointerEvents="none",o.style.opacity="0",o.style.position="fixed",o.style.top="50%",o.style.left="50%",document.body.appendChild(o),o}function Q(e){const{element:o}=e;let t=typeof o=="string"?document.querySelector(o):o;t||(t=fe()),ge(t,e)}function he(){const e=l("__activeElement"),o=l("__activeStep");e&&(X(e),de(),oe(e,o))}function ge(e,o){const i=Date.now(),p=l("__activeStep"),n=l("__activeElement")||e,f=!n||n===e,w=e.id==="driver-dummy-element",r=n.id==="driver-dummy-element",v=a("animate"),s=o.onHighlightStarted||a("onHighlightStarted"),c=(o==null?void 0:o.onHighlighted)||a("onHighlighted"),d=(p==null?void 0:p.onDeselected)||a("onDeselected"),m=a(),g=l();!f&&d&&d(r?void 0:n,p,{config:m,state:g}),s&&s(w?void 0:e,o,{config:m,state:g});const u=!f&&v;let h=!1;xe(),b("previousStep",p),b("previousElement",n),b("activeStep",o),b("activeElement",e);const P=()=>{if(l("__transitionCallback")!==P)return;const x=Date.now()-i,y=400-x<=400/2;o.popover&&y&&!h&&u&&(J(e,o),h=!0),a("animate")&&x<400?le(x,400,n,e):(X(e),c&&c(w?void 0:e,o,{config:a(),state:l()}),b("__transitionCallback",void 0),b("__previousStep",p),b("__previousElement",n),b("__activeStep",o),b("__activeElement",e)),window.requestAnimationFrame(P)};b("__transitionCallback",P),window.requestAnimationFrame(P),V(e),!u&&o.popover&&J(e,o),n.classList.remove("driver-active-element","driver-no-interaction"),n.removeAttribute("aria-haspopup"),n.removeAttribute("aria-expanded"),n.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function we(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(o=>{o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls")})}function A(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),b("__resizeTimeout",window.requestAnimationFrame(he))}function me(e){var r;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),p=(r=l("popover"))==null?void 0:r.wrapper,n=q([...p?[p]:[],...i?[i]:[]]),f=n[0],w=n[n.length-1];if(e.preventDefault(),e.shiftKey){const v=n[n.indexOf(document.activeElement)-1]||w;v==null||v.focus()}else{const v=n[n.indexOf(document.activeElement)+1]||f;v==null||v.focus()}}function Z(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?_("escapePress"):e.key==="ArrowRight"?_("arrowRightPress"):e.key==="ArrowLeft"&&_("arrowLeftPress"))}function G(e,o,t){const i=(n,f)=>{const w=n.target;e.contains(w)&&((!t||t(w))&&(n.preventDefault(),n.stopPropagation(),n.stopImmediatePropagation()),f==null||f(n))};document.addEventListener("pointerdown",i,!0),document.addEventListener("mousedown",i,!0),document.addEventListener("pointerup",i,!0),document.addEventListener("mouseup",i,!0),document.addEventListener("click",n=>{i(n,o)},!0)}function ye(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",me,!1),window.addEventListener("resize",A),window.addEventListener("scroll",A)}function be(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",A),window.removeEventListener("scroll",A)}function xe(){const e=l("popover");e&&(e.wrapper.style.display="none")}function J(e,o){var C,y;let t=l("popover");t&&document.body.removeChild(t.wrapper),t=Pe(),document.body.appendChild(t.wrapper);const{title:i,description:p,showButtons:n,disableButtons:f,showProgress:w,nextBtnText:r=a("nextBtnText")||"Next →",prevBtnText:v=a("prevBtnText")||"← Previous",progressText:s=a("progressText")||"{current} of {total}"}=o.popover||{};t.nextButton.innerHTML=r,t.previousButton.innerHTML=v,t.progress.innerHTML=s,i?(t.title.innerHTML=i,t.title.style.display="block"):t.title.style.display="none",p?(t.description.innerHTML=p,t.description.style.display="block"):t.description.style.display="none";const c=n||a("showButtons"),d=w||a("showProgress")||!1,m=(c==null?void 0:c.includes("next"))||(c==null?void 0:c.includes("previous"))||d;t.closeButton.style.display=c.includes("close")?"block":"none",m?(t.footer.style.display="flex",t.progress.style.display=d?"block":"none",t.nextButton.style.display=c.includes("next")?"block":"none",t.previousButton.style.display=c.includes("previous")?"block":"none"):t.footer.style.display="none";const g=f||a("disableButtons")||[];g!=null&&g.includes("next")&&(t.nextButton.disabled=!0,t.nextButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("previous")&&(t.previousButton.disabled=!0,t.previousButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("close")&&(t.closeButton.disabled=!0,t.closeButton.classList.add("driver-popover-btn-disabled"));const u=t.wrapper;u.style.display="block",u.style.left="",u.style.top="",u.style.bottom="",u.style.right="",u.id="driver-popover-content",u.setAttribute("role","dialog"),u.setAttribute("aria-labelledby","driver-popover-title"),u.setAttribute("aria-describedby","driver-popover-description");const h=t.arrow;h.className="driver-popover-arrow";const P=((C=o.popover)==null?void 0:C.popoverClass)||a("popoverClass")||"";u.className=`driver-popover ${P}`.trim(),G(t.wrapper,k=>{var M,R,I;const T=k.target,H=((M=o.popover)==null?void 0:M.onNextClick)||a("onNextClick"),$=((R=o.popover)==null?void 0:R.onPrevClick)||a("onPrevClick"),B=((I=o.popover)==null?void 0:I.onCloseClick)||a("onCloseClick");if(T.classList.contains("driver-popover-next-btn"))return H?H(e,o,{config:a(),state:l()}):_("nextClick");if(T.classList.contains("driver-popover-prev-btn"))return $?$(e,o,{config:a(),state:l()}):_("prevClick");if(T.classList.contains("driver-popover-close-btn"))return B?B(e,o,{config:a(),state:l()}):_("closeClick")},k=>!(t!=null&&t.description.contains(k))&&!(t!=null&&t.title.contains(k))&&typeof k.className=="string"&&k.className.includes("driver-popover")),b("popover",t);const S=((y=o.popover)==null?void 0:y.onPopoverRender)||a("onPopoverRender");S&&S(t,{config:a(),state:l()}),oe(e,o),V(u);const L=e.classList.contains("driver-dummy-element"),x=q([u,...L?[]:[e]]);x.length>0&&x[0].focus()}function U(){const e=l("popover");if(!(e!=null&&e.wrapper))return;const o=e.wrapper.getBoundingClientRect(),t=a("stagePadding")||0,i=a("popoverOffset")||0;return{width:o.width+t+i,height:o.height+t+i,realWidth:o.width,realHeight:o.height}}function ee(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.top-p,window.innerHeight-i.realHeight-n.width),n.width):e==="end"?Math.max(Math.min(t.top-(i==null?void 0:i.realHeight)+t.height+p,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):e==="center"?Math.max(Math.min(t.top+t.height/2-(i==null?void 0:i.realHeight)/2,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):0}function te(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.left-p,window.innerWidth-i.realWidth-n.width),n.width):e==="end"?Math.max(Math.min(t.left-(i==null?void 0:i.realWidth)+t.width+p,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):e==="center"?Math.max(Math.min(t.left+t.width/2-(i==null?void 0:i.realWidth)/2,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):0}function oe(e,o){const t=l("popover");if(!t)return;const{align:i="start",side:p="left"}=(o==null?void 0:o.popover)||{},n=i,f=e.id==="driver-dummy-element"?"over":p,w=a("stagePadding")||0,r=U(),v=t.arrow.getBoundingClientRect(),s=e.getBoundingClientRect(),c=s.top-r.height;let d=c>=0;const m=window.innerHeight-(s.bottom+r.height);let g=m>=0;const u=s.left-r.width;let h=u>=0;const P=window.innerWidth-(s.right+r.width);let S=P>=0;const L=!d&&!g&&!h&&!S;let x=f;if(f==="top"&&d?S=h=g=!1:f==="bottom"&&g?S=h=d=!1:f==="left"&&h?S=d=g=!1:f==="right"&&S&&(h=d=g=!1),f==="over"){const C=window.innerWidth/2-r.realWidth/2,y=window.innerHeight/2-r.realHeight/2;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto"}else if(L){const C=window.innerWidth/2-(r==null?void 0:r.realWidth)/2,y=10;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.bottom=`${y}px`,t.wrapper.style.top="auto"}else if(h){const C=Math.min(u,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="left"}else if(S){const C=Math.min(P,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.right=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.left="auto",x="right"}else if(d){const C=Math.min(c,window.innerHeight-r.realHeight-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.top=`${C}px`,t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="top"}else if(g){const C=Math.min(m,window.innerHeight-(r==null?void 0:r.realHeight)-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom=`${C}px`,t.wrapper.style.top="auto",t.wrapper.style.right="auto",x="bottom"}L?t.arrow.classList.add("driver-popover-arrow-none"):Ce(n,x,e)}function Ce(e,o,t){const i=l("popover");if(!i)return;const p=t.getBoundingClientRect(),n=U(),f=i.arrow,w=n.width,r=window.innerWidth,v=p.width,s=p.left,c=n.height,d=window.innerHeight,m=p.top,g=p.height;f.className="driver-popover-arrow";let u=o,h=e;o==="top"?(s+v<=0?(u="right",h="end"):s+v-w<=0&&(u="top",h="start"),s>=r?(u="left",h="end"):s+w>=r&&(u="top",h="end")):o==="bottom"?(s+v<=0?(u="right",h="start"):s+v-w<=0&&(u="bottom",h="start"),s>=r?(u="left",h="start"):s+w>=r&&(u="bottom",h="end")):o==="left"?(m+g<=0?(u="bottom",h="end"):m+g-c<=0&&(u="left",h="start"),m>=d?(u="top",h="end"):m+c>=d&&(u="left",h="end")):o==="right"&&(m+g<=0?(u="bottom",h="start"):m+g-c<=0&&(u="right",h="start"),m>=d?(u="top",h="start"):m+c>=d&&(u="right",h="end")),u?(f.classList.add(`driver-popover-arrow-side-${u}`),f.classList.add(`driver-popover-arrow-align-${h}`)):f.classList.add("driver-popover-arrow-none")}function Pe(){const e=document.createElement("div");e.classList.add("driver-popover");const o=document.createElement("div");o.classList.add("driver-popover-arrow");const t=document.createElement("header");t.id="driver-popover-title",t.classList.add("driver-popover-title"),t.style.display="none",t.innerText="Popover Title";const i=document.createElement("div");i.id="driver-popover-description",i.classList.add("driver-popover-description"),i.style.display="none",i.innerText="Popover description is here";const p=document.createElement("button");p.type="button",p.classList.add("driver-popover-close-btn"),p.setAttribute("aria-label","Close"),p.innerHTML="×";const n=document.createElement("footer");n.classList.add("driver-popover-footer");const f=document.createElement("span");f.classList.add("driver-popover-progress-text"),f.innerText="";const w=document.createElement("span");w.classList.add("driver-popover-navigation-btns");const r=document.createElement("button");r.type="button",r.classList.add("driver-popover-prev-btn"),r.innerHTML="← Previous";const v=document.createElement("button");return v.type="button",v.classList.add("driver-popover-next-btn"),v.innerHTML="Next →",w.appendChild(r),w.appendChild(v),n.appendChild(f),n.appendChild(w),e.appendChild(p),e.appendChild(o),e.appendChild(t),e.appendChild(i),e.appendChild(n),{wrapper:e,arrow:o,title:t,description:i,footer:n,previousButton:r,nextButton:v,closeButton:p,footerButtons:w,progress:f}}function Se(){var o;const e=l("popover");e&&((o=e.wrapper.parentElement)==null||o.removeChild(e.wrapper))}const Le="";function ke(e={}){z(e);function o(){a("allowClose")&&v()}function t(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s+1;c[d]?r(d):v()}function i(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s-1;c[d]?r(d):v()}function p(s){(a("steps")||[])[s]?r(s):v()}function n(){var h;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined"||typeof l("activeIndex")=="undefined")return;const u=((h=d.popover)==null?void 0:h.onPrevClick)||a("onPrevClick");if(u)return u(m,d,{config:a(),state:l()});i()}function f(){var u;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined")return;const g=((u=d.popover)==null?void 0:u.onNextClick)||a("onNextClick");if(g)return g(m,d,{config:a(),state:l()});t()}function w(){l("isInitialized")||(b("isInitialized",!0),document.body.classList.add("driver-active",a("animate")?"driver-fade":"driver-simple"),ye(),O("overlayClick",o),O("escapePress",o),O("arrowLeftPress",n),O("arrowRightPress",f))}function r(s=0){var H,$,B,M,R,I,ie,ne;const c=a("steps");if(!c){console.error("No steps to drive through"),v();return}if(!c[s]){v();return}b("__activeOnDestroyed",document.activeElement),b("activeIndex",s);const d=c[s],m=c[s+1],g=c[s-1],u=((H=d.popover)==null?void 0:H.doneBtnText)||a("doneBtnText")||"Done",h=a("allowClose"),P=typeof(($=d.popover)==null?void 0:$.showProgress)!="undefined"?(B=d.popover)==null?void 0:B.showProgress:a("showProgress"),L=(((M=d.popover)==null?void 0:M.progressText)||a("progressText")||"{{current}} of {{total}}").replace("{{current}}",`${s+1}`).replace("{{total}}",`${c.length}`),x=((R=d.popover)==null?void 0:R.showButtons)||a("showButtons"),C=["next","previous",...h?["close"]:[]].filter(_e=>!(x!=null&&x.length)||x.includes(_e)),y=((I=d.popover)==null?void 0:I.onNextClick)||a("onNextClick"),k=((ie=d.popover)==null?void 0:ie.onPrevClick)||a("onPrevClick"),T=((ne=d.popover)==null?void 0:ne.onCloseClick)||a("onCloseClick");Q({...d,popover:{showButtons:C,nextBtnText:m?void 0:u,disableButtons:[...g?[]:["previous"]],showProgress:P,progressText:L,onNextClick:y||(()=>{m?r(s+1):v()}),onPrevClick:k||(()=>{r(s-1)}),onCloseClick:T||(()=>{v()}),...(d==null?void 0:d.popover)||{}}})}function v(s=!0){const c=l("__activeElement"),d=l("__activeStep"),m=l("__activeOnDestroyed"),g=a("onDestroyStarted");if(s&&g){const P=!c||(c==null?void 0:c.id)==="driver-dummy-element";g(P?void 0:c,d,{config:a(),state:l()});return}const u=(d==null?void 0:d.onDeselected)||a("onDeselected"),h=a("onDestroyed");if(document.body.classList.remove("driver-active","driver-fade","driver-simple"),be(),Se(),we(),ve(),ce(),K(),c&&d){const P=c.id==="driver-dummy-element";u&&u(P?void 0:c,d,{config:a(),state:l()}),h&&h(P?void 0:c,d,{config:a(),state:l()})}m&&m.focus()}return{isActive:()=>l("isInitialized")||!1,refresh:A,drive:(s=0)=>{w(),r(s)},setConfig:z,setSteps:s=>{K(),z({...a(),steps:s})},getConfig:a,getState:l,getActiveIndex:()=>l("activeIndex"),isFirstStep:()=>l("activeIndex")===0,isLastStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&c===s.length-1},getActiveStep:()=>l("activeStep"),getActiveElement:()=>l("activeElement"),getPreviousElement:()=>l("previousElement"),getPreviousStep:()=>l("previousStep"),moveNext:t,movePrevious:i,moveTo:p,hasNextStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c+1]},hasPreviousStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c-1]},highlight:s=>{w(),Q({...s,popover:s.popover?{showButtons:[],showProgress:!1,progressText:"",...s.popover}:void 0})},destroy:()=>{v(!1)}}}return D.driver=ke,Object.defineProperty(D,Symbol.toStringTag,{value:"Module"}),D}({}); diff --git a/status/js/error-handler.js b/status/js/error-handler.js new file mode 100644 index 0000000..6486266 --- /dev/null +++ b/status/js/error-handler.js @@ -0,0 +1,259 @@ +/** + * Error Handler + * Handles errors gracefully without breaking the onboarding tour + */ + +(function(window) { + 'use strict'; + + class ErrorHandler { + constructor() { + this.errors = []; + this.maxErrors = 50; // Keep last 50 errors + } + + /** + * Log an error without breaking the tour + * @param {string} context - Context where error occurred + * @param {Error|string} error - The error object or message + * @param {Object} metadata - Additional metadata + */ + logError(context, error, metadata = {}) { + const errorEntry = { + timestamp: new Date().toISOString(), + context, + message: error instanceof Error ? error.message : error, + stack: error instanceof Error ? error.stack : null, + metadata + }; + + // Add to errors array + this.errors.push(errorEntry); + + // Keep only last maxErrors + if (this.errors.length > this.maxErrors) { + this.errors.shift(); + } + + // Log to console + console.error(`[Onboarding Error] ${context}:`, error, metadata); + + // Optionally send to error tracking service + // this.sendToErrorTracking(errorEntry); + } + + /** + * Attempt to recover from an error and continue tour + * @param {Error} error - The error object + * @param {number} currentStep - Current step index + * @returns {Object} Recovery action + */ + recoverFromError(error, currentStep) { + const errorType = this.classifyError(error); + + switch (errorType) { + case 'ELEMENT_NOT_FOUND': + this.logError('Element Not Found', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Target element not found, skipping to next step' + }; + + case 'STORAGE_UNAVAILABLE': + this.logError('Storage Unavailable', error); + return { + action: 'USE_MEMORY_STORAGE', + message: 'Local storage unavailable, using in-memory storage' + }; + + case 'DRIVER_NOT_LOADED': + this.logError('Driver.js Not Loaded', error); + return { + action: 'ABORT_TOUR', + message: 'Driver.js library not loaded, cannot start tour' + }; + + case 'INVALID_TOOLTIP': + this.logError('Invalid Tooltip Configuration', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Invalid tooltip configuration, skipping' + }; + + case 'THEME_DETECTION_FAILED': + this.logError('Theme Detection Failed', error); + return { + action: 'USE_DEFAULT_THEME', + message: 'Using default dark theme' + }; + + default: + this.logError('Unknown Error', error, { currentStep }); + return { + action: 'ABORT_TOUR', + message: 'Unexpected error occurred, aborting tour' + }; + } + } + + /** + * Classify error type + * @private + * @param {Error} error - The error object + * @returns {string} Error type + */ + classifyError(error) { + const message = error.message || error.toString(); + + if (message.includes('element') && message.includes('not found')) { + return 'ELEMENT_NOT_FOUND'; + } + if (message.includes('storage') || message.includes('quota')) { + return 'STORAGE_UNAVAILABLE'; + } + if (message.includes('driver') || message.includes('undefined')) { + return 'DRIVER_NOT_LOADED'; + } + if (message.includes('invalid') || message.includes('validation')) { + return 'INVALID_TOOLTIP'; + } + if (message.includes('theme')) { + return 'THEME_DETECTION_FAILED'; + } + + return 'UNKNOWN'; + } + + /** + * Get all logged errors + * @returns {Array} Array of error entries + */ + getErrors() { + return [...this.errors]; + } + + /** + * Clear all logged errors + */ + clearErrors() { + this.errors = []; + } + + /** + * Get error statistics + * @returns {Object} Error statistics + */ + getStatistics() { + const stats = { + total: this.errors.length, + byContext: {}, + byType: {}, + recent: this.errors.slice(-10) + }; + + this.errors.forEach(error => { + // Count by context + stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1; + + // Count by type + const type = this.classifyError({ message: error.message }); + stats.byType[type] = (stats.byType[type] || 0) + 1; + }); + + return stats; + } + + /** + * Handle graceful degradation when Driver.js fails to load + * @returns {boolean} Whether fallback was successful + */ + handleDriverLoadFailure() { + this.logError('Driver.js Load Failure', 'Driver.js library failed to load'); + + // Show fallback message + const fallbackMessage = document.createElement('div'); + fallbackMessage.id = 'onboarding-fallback'; + fallbackMessage.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--card-base, #2a2a2a); + color: var(--fg, #ffffff); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 9999; + max-width: 300px; + font-size: 14px; + `; + fallbackMessage.innerHTML = ` + Welcome to DashCaddy!
+

+ The interactive tour is unavailable, but you can explore the dashboard freely. + Check the documentation for help getting started. +

+ `; + + document.body.appendChild(fallbackMessage); + + // Auto-remove after 10 seconds + setTimeout(() => { + if (fallbackMessage.parentNode) { + fallbackMessage.parentNode.removeChild(fallbackMessage); + } + }, 10000); + + return true; + } + + /** + * Handle storage unavailable scenario + * @returns {Object} In-memory storage fallback + */ + handleStorageUnavailable() { + this.logError('Storage Unavailable', 'Local storage is not available'); + + // Create in-memory storage + const memoryStorage = { + data: {}, + getItem(key) { + return this.data[key] || null; + }, + setItem(key, value) { + this.data[key] = value; + }, + removeItem(key) { + delete this.data[key]; + }, + clear() { + this.data = {}; + } + }; + + console.warn('[ErrorHandler] Using in-memory storage - progress will not persist'); + return memoryStorage; + } + + /** + * Send error to tracking service (placeholder) + * @private + * @param {Object} errorEntry - Error entry to send + */ + sendToErrorTracking(errorEntry) { + // Placeholder for error tracking integration + // Could integrate with Sentry, LogRocket, etc. + // Example: + // if (window.Sentry) { + // Sentry.captureException(new Error(errorEntry.message), { + // extra: errorEntry.metadata + // }); + // } + } + } + + window.ErrorHandler = ErrorHandler; + console.log('[ErrorHandler] Module loaded'); + +})(window); diff --git a/status/js/error-logs.js b/status/js/error-logs.js new file mode 100644 index 0000000..8e88515 --- /dev/null +++ b/status/js/error-logs.js @@ -0,0 +1,72 @@ +// ========== ERROR LOG VIEWER ========== +(function() { + // Inject modal HTML + injectModal('error-log-modal', '

📋 Error Logs

Loading error logs...
'); + + const modal = document.getElementById('error-log-modal'); + const content = document.getElementById('error-log-content'); + const viewBtn = document.getElementById('view-error-logs'); + const refreshBtn = document.getElementById('error-log-refresh'); + const clearBtn = document.getElementById('error-log-clear'); + const closeBtn = document.getElementById('error-log-close'); + + async function loadErrorLogs() { + content.innerHTML = '
Loading error logs...
'; + + try { + const response = await fetch('/api/v1/error-logs'); + const data = await response.json(); + + if (data.success && data.logs) { + if (data.logs.length === 0) { + content.innerHTML = '
✅ No errors logged! Everything is working smoothly.
'; + } else { + content.innerHTML = data.logs.map(log => { + const date = new Date(log.timestamp).toLocaleString(); + return ` +
+ ${date} + ERROR +
+ ${escapeHtml(log.context)}: ${escapeHtml(log.error)} + ${log.details ? `
${escapeHtml(log.details)}` : ''} +
+
+ `; + }).join(''); + } + } else { + content.innerHTML = '
❌ Failed to load error logs
'; + } + } catch (error) { + content.innerHTML = `
❌ Error loading logs: ${error.message}
`; + } + } + + async function clearErrorLogs() { + if (!confirm('Clear all error logs?')) return; + + try { + const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' }); + const data = await response.json(); + + if (data.success) { + showNotification('✅ Error logs cleared', 'success', 3000); + loadErrorLogs(); + } else { + showNotification('❌ Failed to clear logs', 'error', 3000); + } + } catch (error) { + showNotification(`❌ Error: ${error.message}`, 'error', 3000); + } + } + + viewBtn?.addEventListener('click', () => { + modal.classList.add('show'); + loadErrorLogs(); + }); + + refreshBtn?.addEventListener('click', loadErrorLogs); + clearBtn?.addEventListener('click', clearErrorLogs); + wireModal(modal, closeBtn); +})(); diff --git a/status/js/globals.js b/status/js/globals.js new file mode 100644 index 0000000..d82bc45 --- /dev/null +++ b/status/js/globals.js @@ -0,0 +1,348 @@ +// ===== DASHBOARD CONSTANTS ===== +const DC = { + NAME: 'DashCaddy', + POLL: { + DASHBOARD: 10000, // 10s — main refreshAll interval + LOGS: 3000, // 3s — log viewer updates + STATS: 5000, // 5s — resource monitor refresh + WEATHER: 600000, // 10m — weather widget refresh + HEALTH: 1000, // 1s — card health badge refresh + DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy + }, + DELAYS: { + BTN_RESET: 2000, // Button text reset after action + RELOAD: 5000, // Page reload after restart + MODAL_CLOSE: 500, // Modal close animation + PORT_CHECK: 500, // Debounce for port availability check + DEPLOY_INIT: 3000, // Initial deploy cert check delay + }, + DEFAULTS: { + DNS_PORT: '5380', + SERVICE_PORT: '8080', + TTL: 300, + CADDYFILE: 'C:\\caddy\\Caddyfile', + }, +}; + +// ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) ===== +const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null'); +const SITE = { + tld: (_cachedCfg && _cachedCfg.tld) || '.home', + dnsIp: (_cachedCfg && _cachedCfg.dnsIp) || '', + dnsPort: (_cachedCfg && _cachedCfg.dnsPort) || DC.DEFAULTS.DNS_PORT, + dnsServers: (_cachedCfg && _cachedCfg.dnsServers) || {}, + configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab', + domain: (_cachedCfg && _cachedCfg.domain) || '', + defaults: (_cachedCfg && _cachedCfg.defaults) || {} +}; +(async function loadSiteConfig() { + try { + const r = await fetch('/api/v1/config'); + if (r.ok) { + const c = await r.json(); + if (c.tld) SITE.tld = c.tld.startsWith('.') ? c.tld : '.' + c.tld; + if (c.dns) { + SITE.dnsIp = c.dns.ip || ''; + SITE.dnsPort = c.dns.port || DC.DEFAULTS.DNS_PORT; + } + if (c.dnsServers) { + Object.assign(SITE.dnsServers, c.dnsServers); + } + if (c.configurationType) SITE.configurationType = c.configurationType; + if (c.domain) SITE.domain = c.domain; + if (c.defaults) SITE.defaults = c.defaults; + // Cache config so next page load uses correct TLD even if API is slow + localStorage.setItem('dashcaddy_site_config', JSON.stringify({ + tld: SITE.tld, dnsIp: SITE.dnsIp, dnsPort: SITE.dnsPort, dnsServers: SITE.dnsServers, + configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults + })); + // Render DNS cards dynamically based on configured servers + renderDnsCards(); + } + } catch (_) {} + // Update static HTML elements with configured TLD + document.querySelectorAll('[data-tld]').forEach(el => el.textContent = SITE.tld); + const tldSuffix = document.getElementById('edit-tld-suffix'); + if (tldSuffix) tldSuffix.textContent = SITE.tld; + const proxyIpInput = document.getElementById('external-proxy-ip'); + if (proxyIpInput && SITE.dnsIp) { proxyIpInput.value = SITE.dnsIp; proxyIpInput.placeholder = SITE.dnsIp; } +})(); +function buildDomain(sub) { return sub + SITE.tld; } +function getDnsServerAddr(dnsId) { + const s = SITE.dnsServers[dnsId]; + return s ? `${s.ip}:${s.port}` : buildDomain(dnsId); +} + +// ===== DYNAMIC DNS CARD RENDERER ===== +function renderDnsCards() { + const topRow = document.querySelector('.top'); + if (!topRow) return; + const dnsIds = Object.keys(SITE.dnsServers); + if (!dnsIds.length) return; // No DNS servers configured — show nothing + + const svgIcon = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + const firstChild = topRow.firstElementChild; + dnsIds.forEach(id => { + const label = (SITE.dnsServers[id].name || id).toUpperCase(); + const card = document.createElement('div'); + card.className = 'card'; + card.setAttribute('data-app', id); + card.setAttribute('data-status', 'off'); + card.innerHTML = + `` + + `
${svgIcon}
` + + `${label}` + + `OFF
` + + `
--
` + + `
` + + `` + + `` + + `` + + `` + + `` + + `
`; + topRow.insertBefore(card, firstChild); + }); +} +window.renderDnsCards = renderDnsCards; + +// ===== CSRF PROTECTION ===== +let csrfToken = null; + +/** + * Get CSRF token from server (cached) + * @returns {Promise} CSRF token + */ +async function getCSRFToken() { + if (csrfToken) return csrfToken; + + try { + const response = await fetch('/api/v1/csrf-token'); + if (!response.ok) { + throw new Error('Failed to fetch CSRF token'); + } + const data = await response.json(); + csrfToken = data.token; + return csrfToken; + } catch (error) { + console.error('Failed to get CSRF token:', error); + throw error; + } +} + +/** + * Secure fetch wrapper that automatically adds CSRF token to state-changing requests + * @param {string} url - URL to fetch + * @param {Object} options - Fetch options + * @returns {Promise} Fetch response + */ +async function secureFetch(url, options = {}) { + const method = (options.method || 'GET').toUpperCase(); + + // Add CSRF token for state-changing methods + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + try { + const token = await getCSRFToken(); + options.headers = { + ...options.headers, + 'X-CSRF-Token': token + }; + } catch (error) { + console.error('Failed to add CSRF token to request:', error); + } + } + + // Default 15s timeout if no signal provided (prevents hanging requests) + if (!options.signal) { + options = { ...options, signal: AbortSignal.timeout(15000) }; + } + + return fetch(url, options); +} + +// ===== API CALL HELPERS ===== + +/** POST JSON and return parsed response. Throws on HTTP or API error. */ +async function postJSON(url, data) { + const resp = await secureFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const result = await resp.json(); + if (!resp.ok || result.success === false) { + throw new Error(result.error || `Request failed (${resp.status})`); + } + return result; +} + +/** GET JSON and return parsed response. Throws on HTTP error with server message. */ +async function getJSON(url) { + const resp = await secureFetch(url); + if (!resp.ok) { + let msg = `Request failed (${resp.status})`; + try { const body = await resp.json(); msg = body.error || msg; } catch (_) {} + throw new Error(msg); + } + return resp.json(); +} + +/** DELETE request. Returns parsed JSON response. */ +async function deleteAPI(url) { + const resp = await secureFetch(url, { method: 'DELETE' }); + const result = await resp.json(); + if (!resp.ok || result.success === false) { + throw new Error(result.error || `Delete failed (${resp.status})`); + } + return result; +} + +/** + * Run an async operation with button loading state. + * Disables the button, shows loading text, restores on complete. + * @param {HTMLElement} btn - Button element + * @param {string} loadingText - Text to show while loading (e.g. 'Saving...') + * @param {function} asyncFn - Async function to execute + * @param {Object} opts - Options: { successText, resetDelay } + */ +async function withButton(btn, loadingText, asyncFn, opts = {}) { + const original = btn.innerHTML; + const { successText = '✅', resetDelay = DC.DELAYS.BTN_RESET } = opts; + btn.disabled = true; + btn.innerHTML = loadingText; + try { + const result = await asyncFn(); + btn.innerHTML = successText; + setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, resetDelay); + return result; + } catch (e) { + btn.innerHTML = original; + btn.disabled = false; + throw e; + } +} + +/** Show/hide a modal by ID */ +function openModal(id) { + document.getElementById(id)?.classList.add('show'); +} +function closeModal(id) { + document.getElementById(id)?.classList.remove('show'); +} + +/** Wire backdrop-click close + optional close buttons for a modal element */ +function wireModal(modal, ...closeBtns) { + if (!modal) return; + modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('show'); }); + closeBtns.forEach(btn => btn?.addEventListener('click', () => modal.classList.remove('show'))); +} + +/** Toast-style notification (replaces all alert() usage) */ +function showNotification(text, type = 'info', duration = 3000) { + const existingNotif = document.querySelector('.deploy-notification'); + if (existingNotif) existingNotif.remove(); + + const colors = { + info: { bg: '#2196F3', fg: '#fff' }, + success: { bg: 'var(--ok-bg)', fg: 'var(--ok-fg)' }, + error: { bg: '#f44336', fg: '#fff' }, + warning: { bg: '#ff9800', fg: '#fff' } + }; + + const c = colors[type] || colors.info; + const msg = document.createElement('div'); + msg.className = 'deploy-notification'; + msg.textContent = text; + msg.style.cssText = ` + position: fixed; top: 20px; right: 20px; + background: ${c.bg}; color: ${c.fg}; + padding: 16px 24px; border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,.3); + z-index: 10000; animation: slideIn 0.3s ease-out; + max-width: 400px; white-space: pre-line; font-size: 14px; + `; + document.body.appendChild(msg); + if (duration > 0) setTimeout(() => msg.remove(), duration); +} + +/** Relative time display (e.g. "5m ago", "2h ago") */ +function timeAgo(ts) { + const diff = Date.now() - new Date(ts).getTime(); + if (diff < 60000) return 'just now'; + if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; + if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; + return Math.floor(diff / 86400000) + 'd ago'; +} + +// ===== SAFE STORAGE WRAPPERS ===== +// Prevents crashes in Safari private browsing, quota exceeded, or restricted environments +function safeGet(key, fallback = null) { + try { const v = localStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; } +} +function safeSet(key, value) { + try { localStorage.setItem(key, value); } catch (_) { /* quota exceeded or private mode */ } +} +function safeRemove(key) { + try { localStorage.removeItem(key); } catch (_) {} +} +function safeSessionGet(key, fallback = null) { + try { const v = sessionStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; } +} +function safeSessionSet(key, value) { + try { sessionStorage.setItem(key, value); } catch (_) {} +} +/** Parse JSON from localStorage with fallback — avoids try/catch at every call site */ +function safeGetJSON(key, fallback = null) { + try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch (_) { return fallback; } +} + +/** Escape HTML entities for safe innerHTML insertion (handles both content and attributes) */ +function escapeHtml(text) { + return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +/** Inject modal HTML into DOM (idempotent — skips if element already exists) */ +function injectModal(id, html) { + if (document.getElementById(id)) return; + document.body.insertAdjacentHTML('beforeend', html); +} + +// ===== EVENT BUS ===== +// Lightweight pub/sub for cross-module communication without window globals. +// Usage: DC_BUS.on('services:loaded', handler); DC_BUS.emit('services:loaded', data); +const DC_BUS = { + _handlers: {}, + on(event, fn) { (this._handlers[event] ||= []).push(fn); }, + off(event, fn) { this._handlers[event] = this._handlers[event]?.filter(h => h !== fn); }, + emit(event, data) { this._handlers[event]?.forEach(fn => fn(data)); } +}; + +// ===== CENTRALIZED APP STATE ===== +// Single source of truth for the services array. Modules should use AppState +// instead of mutating window.APPS directly. Emits 'apps:changed' on updates. +const AppState = { + _apps: [], + getApps() { return this._apps; }, + setApps(apps) { this._apps = apps; window.APPS = apps; DC_BUS.emit('apps:changed', apps); }, + findApp(id) { return this._apps.find(a => a.id === id); }, + addApp(app) { this._apps.push(app); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); }, + removeApp(id) { + const idx = this._apps.findIndex(a => a.id === id); + if (idx > -1) { this._apps.splice(idx, 1); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); } + return idx > -1; + }, + updateApp(id, changes) { + const app = this._apps.find(a => a.id === id); + if (app) { Object.assign(app, changes); DC_BUS.emit('apps:changed', this._apps); } + return app; + } +}; diff --git a/status/js/health-check.js b/status/js/health-check.js new file mode 100644 index 0000000..05b2341 --- /dev/null +++ b/status/js/health-check.js @@ -0,0 +1,371 @@ +// ========== HEALTH CHECK DASHBOARD ========== +(function() { + // Inject modal HTML + injectModal('health-modal', `
+
+

🏥 Health Check Dashboard

+ + +
+ + + +
+ + +
+
+
Loading health status...
+
+
+ + +
+
+
🚨 Loading incidents...
+
+
+ + +
+
+
⚙️ Loading configuration...
+
+ + + + +
+ +
+
+ +
+ + +
+ + +
+
`); + + const modal = document.getElementById('health-modal'); + const openBtn = document.getElementById('health-check-btn'); + const cancelBtn = document.getElementById('health-cancel'); + const refreshBtn = document.getElementById('health-refresh-btn'); + const statusContainer = document.getElementById('health-status-container'); + const incidentsContainer = document.getElementById('health-incidents-container'); + const configContainer = document.getElementById('health-config-container'); + const lastUpdateSpan = document.getElementById('health-last-update'); + const addBtn = document.getElementById('health-add-btn'); + const formEl = document.getElementById('health-config-form'); + const formTitle = document.getElementById('health-form-title'); + const formCancel = document.getElementById('health-form-cancel'); + const formSave = document.getElementById('health-form-save'); + + let editingId = null; + + function uptimeColor(pct) { + if (pct >= 99.9) return 'var(--ok-fg)'; + if (pct >= 95) return '#f39c12'; + return 'var(--bad-fg)'; + } + + function severityBadge(sev) { + const colors = { critical: 'var(--bad-fg)', high: '#ff6b6b', medium: '#f39c12', low: 'var(--muted)' }; + return `${sev}`; + } + + async function loadStatus() { + try { + const res = await fetch('/api/v1/health-checks/status'); + const data = await res.json(); + if (!data.success || !data.status || Object.keys(data.status).length === 0) { + statusContainer.innerHTML = '
🏥No health checks configured. Go to the Configure tab to add services.
'; + return; + } + const services = Object.values(data.status); + let html = ''; + html += ''; + html += ''; + html += ''; + html += ''; + for (const s of services) { + const isUp = s.status === 'up'; + const dotColor = isUp ? 'var(--dot-ok)' : 'var(--dot-bad)'; + const u24 = s.uptime?.['24h'] ?? '-'; + const u7d = s.uptime?.['7d'] ?? '-'; + const avgRt = s.avgResponseTime != null ? Math.round(s.avgResponseTime) + 'ms' : '-'; + const lastCheck = s.timestamp ? timeAgo(s.timestamp) : '-'; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + html += ``; + } + html += '
ServiceStatusUptime 24hUptime 7dAvg ResponseLast Check
${s.name || s.serviceId}${isUp ? 'Up' : 'Down'}${typeof u24 === 'number' ? u24.toFixed(1) + '%' : u24}${typeof u7d === 'number' ? u7d.toFixed(1) + '%' : u7d}${avgRt}${lastCheck}
'; + statusContainer.innerHTML = html; + lastUpdateSpan.textContent = 'Updated ' + new Date().toLocaleTimeString(); + + // Row click to expand details + statusContainer.querySelectorAll('tr[data-health-id]').forEach(row => { + row.addEventListener('click', async () => { + const id = row.dataset.healthId; + const detailRow = document.getElementById('health-detail-' + id); + if (!detailRow) return; + if (detailRow.style.display !== 'none') { + detailRow.style.display = 'none'; + return; + } + detailRow.style.display = ''; + try { + const r = await fetch(`/api/v1/health-checks/${id}/stats?hours=24`); + const d = await r.json(); + if (d.success && d.stats) { + const st = d.stats; + const rt = st.responseTime || {}; + detailRow.querySelector('td').innerHTML = ` +
+
Total Checks
${st.totalChecks || 0}
+
Uptime
${(st.uptime || 0).toFixed(2)}%
+
Avg Response
${Math.round(rt.avg || 0)}ms
+
P95 / P99
${Math.round(rt.p95 || 0)}ms / ${Math.round(rt.p99 || 0)}ms
+
Min Response
${Math.round(rt.min || 0)}ms
+
Max Response
${Math.round(rt.max || 0)}ms
+
Up Checks
${st.upChecks || 0}
+
Down Checks
${st.downChecks || 0}
+
`; + } else { + detailRow.querySelector('td').innerHTML = '
No detailed stats available for this period.
'; + } + } catch (e) { + detailRow.querySelector('td').innerHTML = `
Failed: ${e.message}
`; + } + }); + }); + } catch (e) { + statusContainer.innerHTML = `
Failed to load health status: ${e.message}
`; + } + } + + async function loadIncidents() { + try { + const [openRes, histRes] = await Promise.all([ + fetch('/api/v1/health-checks/incidents'), + fetch('/api/v1/health-checks/incidents/history?limit=50') + ]); + const openData = await openRes.json(); + const histData = await histRes.json(); + let html = ''; + + // Open incidents + const open = (openData.success && openData.incidents) ? openData.incidents : []; + if (open.length > 0) { + html += '

Open Incidents (' + open.length + ')

'; + for (const inc of open) { + html += `
+
+ ${inc.serviceId} + ${severityBadge(inc.severity)} +
+
${inc.message}
+
Started ${timeAgo(inc.createdAt)} · ${inc.occurrences || 1} occurrence(s)
+
`; + } + html += '
'; + } else { + html += '
All services operational — no open incidents
'; + } + + // Incident history + const history = (histData.success && histData.history) ? histData.history : []; + if (history.length > 0) { + html += '

Incident History

'; + html += ''; + html += ''; + for (const inc of history) { + const resolved = inc.status === 'resolved'; + const dur = resolved && inc.duration ? (inc.duration < 60000 ? Math.round(inc.duration / 1000) + 's' : Math.round(inc.duration / 60000) + 'm') : '-'; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + } + html += '
ServiceTypeSeverityStatusDurationWhen
${inc.serviceId}${inc.type}${severityBadge(inc.severity)}${inc.status}${dur}${timeAgo(inc.createdAt)}
'; + } + + incidentsContainer.innerHTML = html || '
🚨No incidents recorded yet.
'; + } catch (e) { + incidentsContainer.innerHTML = `
Failed: ${e.message}
`; + } + } + + async function loadConfig() { + try { + const res = await fetch('/api/v1/health-checks/status'); + const data = await res.json(); + const services = data.success && data.status ? Object.values(data.status) : []; + if (services.length === 0) { + configContainer.innerHTML = '
⚙️No health checks configured yet. Click "Add Health Check" below.
'; + return; + } + let html = ''; + html += ''; + for (const s of services) { + const isUp = s.status === 'up'; + html += ``; + html += ``; + html += ``; + html += ``; + html += `'; + } + html += '
ServiceStatusSLA TargetActions
${s.name || s.serviceId}${isUp ? 'Up' : 'Down'}${s.sla?.target ? s.sla.target + '%' : '-'}`; + html += ``; + html += ``; + html += '
'; + configContainer.innerHTML = html; + } catch (e) { + configContainer.innerHTML = `
Failed: ${e.message}
`; + } + } + + function showForm(id, name, url, timeout, codes, sla, slow) { + editingId = id || null; + formTitle.textContent = id ? 'Edit Health Check' : 'Add Health Check'; + document.getElementById('health-form-id').value = id || ''; + document.getElementById('health-form-id').disabled = !!id; + document.getElementById('health-form-name').value = name || ''; + document.getElementById('health-form-url').value = url || ''; + document.getElementById('health-form-timeout').value = timeout || 10000; + document.getElementById('health-form-codes').value = codes || '200'; + document.getElementById('health-form-sla').value = sla || 99.9; + document.getElementById('health-form-slow').value = slow || 5000; + formEl.style.display = ''; + addBtn.style.display = 'none'; + } + + function hideForm() { + formEl.style.display = 'none'; + addBtn.style.display = ''; + editingId = null; + } + + addBtn?.addEventListener('click', () => showForm('', '', '', 10000, '200', 99.9, 5000)); + formCancel?.addEventListener('click', hideForm); + + formSave?.addEventListener('click', async () => { + const id = editingId || document.getElementById('health-form-id').value.trim(); + if (!id) return showNotification('Service ID is required', 'warning'); + const url = document.getElementById('health-form-url').value.trim(); + if (!url) return showNotification('URL is required', 'warning'); + + const codes = document.getElementById('health-form-codes').value.split(',').map(c => parseInt(c.trim())).filter(Boolean); + const body = { + name: document.getElementById('health-form-name').value.trim() || id, + url, + timeout: parseInt(document.getElementById('health-form-timeout').value) || 10000, + expectedStatusCodes: codes.length ? codes : [200], + sla: { target: parseFloat(document.getElementById('health-form-sla').value) || 99.9 }, + slowResponseThreshold: parseInt(document.getElementById('health-form-slow').value) || 5000 + }; + + try { + formSave.textContent = 'Saving...'; + formSave.disabled = true; + const res = await secureFetch(`/api/v1/health-checks/${encodeURIComponent(id)}/configure`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Save failed'); + hideForm(); + loadConfig(); + loadStatus(); + } catch (e) { + showNotification('Error: ' + e.message, 'error'); + } finally { + formSave.textContent = 'Save'; + formSave.disabled = false; + } + }); + + document.addEventListener('health-edit', async (e) => { + const id = e.detail; + // Load existing config to populate form — use current status data as fallback + showForm(id, '', '', 10000, '200', 99.9, 5000); + }); + + document.addEventListener('health-delete', async (e) => { + const id = e.detail; + if (!confirm(`Delete health check for "${id}"?`)) return; + try { + const res = await secureFetch(`/api/v1/health-checks/${encodeURIComponent(id)}/configure`, { method: 'DELETE' }); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + loadConfig(); + loadStatus(); + } catch (err) { + showNotification('Error: ' + err.message, 'error'); + } + }); + + openBtn?.addEventListener('click', () => { + modal?.classList.add('show'); + loadStatus(); + }); + wireModal(modal, cancelBtn); + refreshBtn?.addEventListener('click', loadStatus); + + // Lazy-load tabs + document.querySelector('[data-panel="health-incidents"]')?.addEventListener('click', loadIncidents); + document.querySelector('[data-panel="health-config"]')?.addEventListener('click', loadConfig); +})(); diff --git a/status/js/import-export.js b/status/js/import-export.js new file mode 100644 index 0000000..ff4d9e8 --- /dev/null +++ b/status/js/import-export.js @@ -0,0 +1,169 @@ +// ========== IMPORT/EXPORT FUNCTIONALITY ========== +(function() { + // Export dashboard configuration + async function exportDashboard() { + // Fetch themes from server (source of truth) with localStorage fallback + let userThemes = safeGetJSON('user-themes', {}); + try { + const res = await secureFetch('/api/v1/themes'); + const data = await res.json(); + if (data.success && data.themes) userThemes = data.themes; + } catch (e) {} + + const exportData = { + version: '1.0.0', + exportDate: new Date().toISOString(), + services: window.APPS || [], + customServices: safeGetJSON('custom-services', []), + customApps: safeGetJSON('custom-apps', []), + weatherZip: safeGet('weather-zip') || '', + theme: safeGet('theme') || 'dark', + userThemes: userThemes, + // Note: API tokens are intentionally NOT exported for security + }; + + const dataStr = JSON.stringify(exportData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + + const link = document.createElement('a'); + link.href = url; + link.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showNotification('Dashboard exported successfully! Note: API tokens are not included for security reasons.', 'success'); + } + + // Import dashboard configuration + function importDashboard() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const importData = JSON.parse(text); + + // Validate import data + if (!importData.version || !importData.services) { + throw new Error('Invalid dashboard backup file'); + } + + // Confirm import + const confirmed = confirm( + `Import dashboard configuration?\n\n` + + `Export Date: ${new Date(importData.exportDate).toLocaleString()}\n` + + `Services: ${importData.services.length}\n` + + `Custom Apps: ${(importData.customApps || []).length}\n\n` + + `⚠️ This will replace your current dashboard configuration.\n` + + `API tokens will need to be reconfigured.` + ); + + if (!confirmed) return; + + // Import services to API + try { + const response = await secureFetch('/api/v1/services', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(importData.services) + }); + + if (!response.ok) { + console.warn('Could not save services to API, saving locally only'); + } + } catch (err) { + console.warn('API not available, saving locally only:', err); + } + + // Import to localStorage + if (importData.customServices) { + safeSet('custom-services', JSON.stringify(importData.customServices)); + } + if (importData.customApps) { + safeSet('custom-apps', JSON.stringify(importData.customApps)); + } + if (importData.weatherZip) { + safeSet('weather-zip', importData.weatherZip); + } + if (importData.theme) { + safeSet('theme', importData.theme); + } + if (importData.userThemes && Object.keys(importData.userThemes).length) { + safeSet('user-themes', JSON.stringify(importData.userThemes)); + // Push imported themes to server + Object.keys(importData.userThemes).forEach(function (slug) { + var t = importData.userThemes[slug]; + var colors = {}; + (window.THEME_PROPS || []).forEach(function (p) { if (t[p]) colors[p] = t[p]; }); + secureFetch('/api/v1/themes/' + slug, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: t.name || slug, colors: colors }) + }).catch(function () {}); + }); + } + + // Update APPS array + window.APPS = importData.services; + + showNotification('Dashboard imported successfully! The page will now reload.', 'success'); + + // Reload page to apply changes + window.location.reload(); + + } catch (err) { + showNotification(`Import failed: ${err.message}. Please check the file and try again.`, 'error'); + console.error('Import error:', err); + } + }; + + input.click(); + } + + // Add event listeners for import/export buttons + document.getElementById('export-dashboard')?.addEventListener('click', exportDashboard); + document.getElementById('import-dashboard')?.addEventListener('click', importDashboard); + + // Reload Caddy button handler + document.getElementById('reload-caddy-top')?.addEventListener('click', async () => { + const button = document.getElementById('reload-caddy-top'); + const originalText = button.textContent; + + try { + button.textContent = '⏳ Reloading...'; + button.disabled = true; + + const response = await secureFetch('/api/v1/caddy/reload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + button.textContent = '✅ Reloaded!'; + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + }, 2000); + } else { + throw new Error(result.error || 'Reload failed'); + } + } catch (error) { + button.textContent = '❌ Failed'; + showNotification(`Failed to reload Caddy: ${error.message}`, 'error'); + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + }, 2000); + } + }); +})(); diff --git a/status/js/keyboard-shortcuts.js b/status/js/keyboard-shortcuts.js new file mode 100644 index 0000000..2080750 --- /dev/null +++ b/status/js/keyboard-shortcuts.js @@ -0,0 +1,622 @@ +/** + * DashCaddy Keyboard Shortcuts System + * Provides global keyboard shortcuts for improved navigation + */ + +(function() { + 'use strict'; + + // All modal selectors that can be closed with Escape + const MODAL_SELECTORS = [ + '#app-selector-modal', + '#app-deploy-modal', + '#weather-modal', + '#token-management-modal', + '#service-edit-modal', + '#notifications-modal', + '#backup-modal', + '#stats-modal', + '#arr-setup-modal', + '#add-service-modal', + '#error-log-modal', + '#logs-modal', + '#dns-template-modal' + ]; + + // Quick search state + let quickSearchModal = null; + let quickSearchInput = null; + let quickSearchResults = null; + + /** + * Initialize the keyboard shortcuts system + */ + function init() { + try { + // Create quick search modal + createQuickSearchModal(); + + // Add global keyboard listener + document.addEventListener('keydown', handleKeyDown); + + console.log('[Keyboard Shortcuts] Initialized'); + console.log('[Keyboard Shortcuts] Press Ctrl+K to open quick search'); + console.log('[Keyboard Shortcuts] Press Escape to close modals'); + } catch (e) { + console.warn('[Keyboard Shortcuts] Failed to initialize:', e.message); + } + } + + /** + * Create the quick search modal + */ + function createQuickSearchModal() { + quickSearchModal = document.createElement('div'); + quickSearchModal.id = 'quick-search-modal'; + quickSearchModal.className = 'quick-search-modal'; + quickSearchModal.innerHTML = ` +
+
+ 🔍 + + ESC +
+
+ +
+ `; + + // Add styles + const style = document.createElement('style'); + style.textContent = ` + .quick-search-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 100000; + justify-content: center; + align-items: flex-start; + padding-top: 15vh; + backdrop-filter: blur(4px); + } + + .quick-search-modal.show { + display: flex; + } + + .quick-search-content { + background: var(--card-base, #1a1a2e); + border: 1px solid var(--border, #333); + border-radius: 12px; + width: 90%; + max-width: 600px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + overflow: hidden; + } + + .quick-search-input-wrapper { + display: flex; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border, #333); + gap: 12px; + } + + .quick-search-icon { + font-size: 20px; + opacity: 0.6; + } + + #quick-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--fg, #fff); + font-size: 18px; + font-family: inherit; + } + + #quick-search-input::placeholder { + color: var(--muted, #666); + } + + .quick-search-shortcut { + background: var(--card-hover, #2a2a4e); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + color: var(--muted, #666); + font-family: monospace; + } + + .quick-search-results { + max-height: 400px; + overflow-y: auto; + } + + .quick-search-category { + padding: 8px 20px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--muted, #666); + background: var(--card-hover, #2a2a4e); + letter-spacing: 0.5px; + } + + .quick-search-item { + display: flex; + align-items: center; + padding: 12px 20px; + cursor: pointer; + transition: background 0.15s; + gap: 12px; + } + + .quick-search-item:hover, + .quick-search-item.selected { + background: var(--accent, #3498db); + } + + .quick-search-item-icon { + font-size: 24px; + width: 32px; + text-align: center; + } + + .quick-search-item-content { + flex: 1; + } + + .quick-search-item-title { + font-weight: 500; + color: var(--fg, #fff); + } + + .quick-search-item-description { + font-size: 12px; + color: var(--muted, #aaa); + margin-top: 2px; + } + + .quick-search-item-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + background: var(--card-hover, #2a2a4e); + color: var(--muted, #888); + } + + .quick-search-empty { + padding: 40px 20px; + text-align: center; + color: var(--muted, #666); + } + + .quick-search-footer { + display: flex; + justify-content: center; + gap: 24px; + padding: 12px 20px; + border-top: 1px solid var(--border, #333); + background: var(--card-hover, #2a2a4e); + font-size: 12px; + color: var(--muted, #666); + } + + .quick-search-footer kbd { + background: var(--card-base, #1a1a2e); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + margin-right: 4px; + } + `; + + document.head.appendChild(style); + document.body.appendChild(quickSearchModal); + + quickSearchInput = document.getElementById('quick-search-input'); + quickSearchResults = document.getElementById('quick-search-results'); + + // Input handling + quickSearchInput.addEventListener('input', handleSearchInput); + quickSearchInput.addEventListener('keydown', handleSearchKeyDown); + + // Close on backdrop click + quickSearchModal.addEventListener('click', (e) => { + if (e.target === quickSearchModal) { + closeQuickSearch(); + } + }); + } + + /** + * Handle global keydown events + */ + function handleKeyDown(e) { + try { + // Ctrl+K or Cmd+K to open quick search + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + openQuickSearch(); + return; + } + + // Escape to close modals + if (e.key === 'Escape') { + // First check if quick search is open + if (quickSearchModal && quickSearchModal.classList.contains('show')) { + closeQuickSearch(); + return; + } + + // Then close any other open modal + closeTopModal(); + } + } catch (e2) { + console.warn('[Keyboard Shortcuts] Error handling keydown:', e2.message); + } + } + + /** + * Open quick search modal + */ + function openQuickSearch() { + try { + quickSearchModal.classList.add('show'); + quickSearchInput.value = ''; + quickSearchInput.focus(); + showDefaultResults(); + } catch (e) { + console.warn('[Keyboard Shortcuts] Error opening quick search:', e.message); + } + } + + /** + * Close quick search modal + */ + function closeQuickSearch() { + try { + quickSearchModal.classList.remove('show'); + quickSearchInput.value = ''; + quickSearchResults.innerHTML = ''; + } catch (e) { + console.warn('[Keyboard Shortcuts] Error closing quick search:', e.message); + } + } + + /** + * Close the topmost open modal + */ + function closeTopModal() { + for (const selector of MODAL_SELECTORS) { + const modal = document.querySelector(selector); + if (modal && (modal.classList.contains('show') || modal.style.display === 'flex')) { + modal.classList.remove('show'); + modal.style.display = 'none'; + return true; + } + } + return false; + } + + /** + * Show default/suggested results + */ + function showDefaultResults() { + const html = ` +
Quick Actions
+
+ 🔄 +
+
Refresh Dashboard
+
Refresh all service statuses
+
+
+
+ +
+
Reload Caddy
+
Reload Caddy configuration
+
+
+
+ +
+
Add Service
+
Open service configuration modal
+
+
+
+ 📱 +
+
App Selector
+
Deploy new applications
+
+
+ +
Services
+ ${getServiceItems()} + `; + + quickSearchResults.innerHTML = html; + attachResultListeners(); + } + + /** + * Get service items from the dashboard + */ + function getServiceItems() { + const cards = document.querySelectorAll('.card[data-app], #cards .card'); + let html = ''; + + cards.forEach(card => { + const name = card.querySelector('.name')?.textContent || 'Unknown'; + const status = card.dataset.status || 'unknown'; + const app = card.dataset.app || ''; + + if (name && name !== '--') { + html += ` +
+ ${status === 'on' ? '🟢' : '🔴'} +
+
${name}
+
Click to open service
+
+ ${status.toUpperCase()} +
+ `; + } + }); + + return html || '
No services found
'; + } + + /** + * Handle search input + */ + function handleSearchInput(e) { + try { + const query = e.target.value.toLowerCase().trim(); + + if (!query) { + showDefaultResults(); + return; + } + + // Search through services and actions + const results = searchAll(query); + displaySearchResults(results); + } catch (e2) { + console.warn('[Keyboard Shortcuts] Error handling search input:', e2.message); + } + } + + /** + * Search all items + */ + function searchAll(query) { + const results = { + actions: [], + services: [] + }; + + // Search actions + const actions = [ + { id: 'refresh', title: 'Refresh Dashboard', icon: '🔄', keywords: 'refresh reload update status' }, + { id: 'reload-caddy', title: 'Reload Caddy', icon: '⚡', keywords: 'reload caddy proxy config' }, + { id: 'add-service', title: 'Add Service', icon: '➕', keywords: 'add new service create' }, + { id: 'app-selector', title: 'App Selector', icon: '📱', keywords: 'app deploy install docker container' }, + { id: 'backup', title: 'Backup & Restore', icon: '💾', keywords: 'backup restore export import' }, + { id: 'stats', title: 'Container Stats', icon: '📊', keywords: 'stats resources cpu memory' }, + { id: 'logs', title: 'View Logs', icon: '📋', keywords: 'logs error debug' }, + { id: 'tokens', title: 'Manage Tokens', icon: '🔑', keywords: 'tokens api keys credentials' }, + { id: 'notifications', title: 'Notifications', icon: '🔔', keywords: 'alerts notifications discord telegram' }, + { id: 'theme', title: 'Change Theme', icon: '🎨', keywords: 'theme dark light appearance' }, + { id: 'tour', title: 'Help Tour', icon: '🎓', keywords: 'help tour guide onboarding' } + ]; + + actions.forEach(action => { + if (action.title.toLowerCase().includes(query) || action.keywords.includes(query)) { + results.actions.push(action); + } + }); + + // Search services + const cards = document.querySelectorAll('.card[data-app], #cards .card'); + cards.forEach(card => { + const name = card.querySelector('.name')?.textContent || ''; + const app = card.dataset.app || ''; + const status = card.dataset.status || 'unknown'; + + if (name.toLowerCase().includes(query) || app.toLowerCase().includes(query)) { + results.services.push({ + id: app, + title: name, + status: status, + icon: status === 'on' ? '🟢' : '🔴' + }); + } + }); + + return results; + } + + /** + * Display search results + */ + function displaySearchResults(results) { + let html = ''; + + if (results.actions.length > 0) { + html += '
Actions
'; + results.actions.forEach(action => { + html += ` +
+ ${action.icon} +
+
${action.title}
+
+
+ `; + }); + } + + if (results.services.length > 0) { + html += '
Services
'; + results.services.forEach(service => { + html += ` +
+ ${service.icon} +
+
${service.title}
+
+ ${service.status.toUpperCase()} +
+ `; + }); + } + + if (!html) { + html = '
No results found
'; + } + + quickSearchResults.innerHTML = html; + attachResultListeners(); + } + + /** + * Attach click listeners to result items + */ + function attachResultListeners() { + const items = quickSearchResults.querySelectorAll('.quick-search-item'); + items.forEach((item, index) => { + item.addEventListener('click', () => executeAction(item)); + + // Select first item by default + if (index === 0) { + item.classList.add('selected'); + } + }); + } + + /** + * Handle keyboard navigation in search + */ + function handleSearchKeyDown(e) { + try { + const items = quickSearchResults.querySelectorAll('.quick-search-item'); + const selected = quickSearchResults.querySelector('.quick-search-item.selected'); + const selectedIndex = Array.from(items).indexOf(selected); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (selected) selected.classList.remove('selected'); + const nextIndex = (selectedIndex + 1) % items.length; + items[nextIndex]?.classList.add('selected'); + items[nextIndex]?.scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (selected) selected.classList.remove('selected'); + const prevIndex = selectedIndex <= 0 ? items.length - 1 : selectedIndex - 1; + items[prevIndex]?.classList.add('selected'); + items[prevIndex]?.scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selected) { + executeAction(selected); + } + } + } catch (e2) { + console.warn('[Keyboard Shortcuts] Error handling search navigation:', e2.message); + } + } + + /** + * Execute an action from quick search + */ + function executeAction(item) { + try { + const action = item.dataset.action; + const service = item.dataset.service; + + closeQuickSearch(); + + switch (action) { + case 'refresh': + document.getElementById('refresh')?.click(); + break; + case 'reload-caddy': + document.getElementById('reload-caddy-top')?.click(); + break; + case 'add-service': + document.getElementById('add-service')?.click(); + break; + case 'app-selector': + document.getElementById('add-service-btn')?.click(); + break; + case 'backup': + document.getElementById('backup-restore-btn')?.click(); + break; + case 'stats': + document.getElementById('container-stats-btn')?.click(); + break; + case 'logs': + document.getElementById('view-error-logs')?.click(); + break; + case 'tokens': + document.getElementById('manage-tokens')?.click(); + break; + case 'notifications': + document.getElementById('manage-notifications')?.click(); + break; + case 'theme': + document.getElementById('theme')?.click(); + break; + case 'tour': + document.getElementById('restart-tour-btn')?.click(); + break; + case 'open-service': + if (service) { + const openBtn = document.querySelector(`[data-app="${service}"] [id$="-open"], [data-app="${service}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`); + if (openBtn) { + openBtn.click(); + } else { + // Try to find the card and click it + const card = document.querySelector(`[data-app="${service}"]`); + if (card) card.click(); + } + } + break; + default: + console.log('[Keyboard Shortcuts] Unknown action:', action); + } + } catch (e) { + console.warn('[Keyboard Shortcuts] Error executing action:', e.message); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Expose to global scope + window.DashCaddyKeyboardShortcuts = { + openQuickSearch, + closeQuickSearch + }; + +})(); diff --git a/status/js/license.js b/status/js/license.js new file mode 100644 index 0000000..c83cd82 --- /dev/null +++ b/status/js/license.js @@ -0,0 +1,278 @@ +// License Management UI +(function() { + // Inject license modal HTML + injectModal('license-modal', `
+
+

DashCaddy License

+

+ Activate a license code to unlock premium features. +

+ +
+
+ + +
+
+
+ +
+ + +

Enter your license code to activate premium features

+
+ +
+ +
+
+ + + + +
+ + + +
+
+
`); + + const modal = document.getElementById('license-modal'); + const codeInput = document.getElementById('license-code-input'); + const activateBtn = document.getElementById('license-activate'); + const deactivateBtn = document.getElementById('license-deactivate'); + const errorEl = document.getElementById('license-error'); + const successEl = document.getElementById('license-success'); + const badgeIcon = document.getElementById('license-badge-icon'); + const badgeText = document.getElementById('license-badge-text'); + const badge = document.getElementById('license-badge'); + const detailsEl = document.getElementById('license-details'); + const featureList = document.getElementById('license-feature-list'); + const activateSection = document.getElementById('license-activate-section'); + + let currentStatus = null; + + function hideMessages() { + errorEl.style.display = 'none'; + successEl.style.display = 'none'; + } + + function showError(msg) { + hideMessages(); + errorEl.textContent = msg; + errorEl.style.display = 'block'; + } + + function showSuccess(msg) { + hideMessages(); + successEl.textContent = msg; + successEl.style.display = 'block'; + } + + function renderStatus(status) { + currentStatus = status; + + if (status.active) { + badge.style.background = 'rgba(46,204,113,0.15)'; + badge.style.color = 'var(--ok-fg)'; + badgeIcon.textContent = '\u2605'; + badgeText.textContent = 'Premium Active'; + const expiryLine = status.lifetime + ? '
License: LIFETIME
' + : `
Expires: ${new Date(status.expiresAt).toLocaleDateString()} (${status.daysRemaining} days remaining)
`; + detailsEl.innerHTML = ` +
Code: ${status.code || '***'}
+ ${expiryLine} + `; + activateSection.style.display = 'none'; + activateBtn.style.display = 'none'; + deactivateBtn.style.display = ''; + } else { + badge.style.background = 'rgba(149,165,166,0.15)'; + badge.style.color = 'var(--muted)'; + badgeIcon.textContent = '\u2606'; + badgeText.textContent = status.expired ? 'License Expired' : 'Free Tier'; + detailsEl.innerHTML = status.expired + ? '
Your license has expired. Enter a new code to renew.
' + : '
Enter a license code to unlock premium features.
'; + activateSection.style.display = ''; + activateBtn.style.display = ''; + deactivateBtn.style.display = 'none'; + } + + // Render feature list + const features = status.premiumFeatures || {}; + const activeFeatures = new Set(status.features || []); + featureList.innerHTML = Object.entries(features).map(([key, info]) => { + const active = activeFeatures.has(key); + return `
+ ${active ? '\u2705' : '\uD83D\uDD12'} +
+
${info.name}
+
${info.description}
+
+
`; + }).join(''); + } + + async function loadStatus() { + try { + const resp = await fetch('/api/v1/license/status'); + const data = await resp.json(); + if (data.success) { + renderStatus(data.license); + updateHeaderBadge(data.license); + } + } catch (e) { + console.warn('Failed to load license status:', e.message); + } + } + + async function activateLicense() { + const code = codeInput.value.trim(); + if (!code) { + showError('Please enter a license code.'); + return; + } + + hideMessages(); + activateBtn.disabled = true; + activateBtn.textContent = 'Activating...'; + + try { + const resp = await secureFetch('/api/v1/license/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }) + }); + const data = await resp.json(); + + if (data.success) { + showSuccess(data.message); + codeInput.value = ''; + renderStatus(data.license); + showNotification('License activated! Premium features unlocked.', 'success', 5000); + updateHeaderBadge(data.license); + } else { + showError(data.error || 'Activation failed'); + } + } catch (e) { + showError('Network error: ' + e.message); + } finally { + activateBtn.disabled = false; + activateBtn.textContent = 'Activate'; + } + } + + async function deactivateLicense() { + if (!confirm('Deactivate your license? You can reuse the code on another machine.')) return; + + deactivateBtn.disabled = true; + deactivateBtn.textContent = 'Deactivating...'; + + try { + const resp = await secureFetch('/api/v1/license/deactivate', { method: 'POST' }); + const data = await resp.json(); + + if (data.success) { + showSuccess(data.message); + await loadStatus(); + showNotification('License deactivated.', 'info', 3000); + updateHeaderBadge({ active: false }); + } else { + showError(data.error || 'Deactivation failed'); + } + } catch (e) { + showError('Network error: ' + e.message); + } finally { + deactivateBtn.disabled = false; + deactivateBtn.textContent = 'Deactivate'; + } + } + + function updateHeaderBadge(status) { + const topbar = document.getElementById('license-status-topbar'); + const iconEl = document.getElementById('license-topbar-icon'); + const textEl = document.getElementById('license-topbar-text'); + const timeEl = document.getElementById('license-topbar-time'); + if (!topbar) return; + + topbar.className = 'license-status-topbar ' + (status.active ? 'premium' : 'free'); + + if (status.active) { + iconEl.textContent = '\u2605'; + textEl.textContent = 'PREMIUM'; + if (status.lifetime) { + timeEl.textContent = '\u00b7 LIFETIME'; + } else { + const days = status.daysRemaining; + timeEl.textContent = days != null ? '\u00b7 ' + days + 'd remaining' : ''; + } + } else { + iconEl.textContent = '\u2606'; + textEl.textContent = status.expired ? 'EXPIRED' : 'FREE TIER'; + timeEl.textContent = ''; + } + } + + function openLicenseModal() { + hideMessages(); + loadStatus(); + modal.classList.add('show'); + } + + // Format code input as user types (auto-add dashes) + codeInput.addEventListener('input', function() { + let val = this.value.toUpperCase().replace(/[^A-Z0-9-]/g, ''); + // Don't auto-format if user is deleting + if (val.length > this._prevLength) { + val = val.replace(/-/g, ''); + if (val.length > 2 && !val.startsWith('DC')) { + val = 'DC' + val; + } + // Add dashes after DC and every 5 chars + if (val.startsWith('DC') && val.length > 2) { + const parts = ['DC']; + const rest = val.substring(2); + for (let i = 0; i < rest.length; i += 5) { + parts.push(rest.substring(i, i + 5)); + } + val = parts.join('-'); + } + } + this._prevLength = val.length; + this.value = val; + }); + + // Wire up events + activateBtn.addEventListener('click', activateLicense); + deactivateBtn.addEventListener('click', deactivateLicense); + codeInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') activateLicense(); + }); + wireModal(modal, document.getElementById('license-cancel')); + const topbarEl = document.getElementById('license-status-topbar'); + if (topbarEl) topbarEl.addEventListener('click', () => window.openLicenseModal && window.openLicenseModal()); + + // Expose for other modules to open + window.openLicenseModal = openLicenseModal; + + // Expose feature check for frontend gating + window.checkPremiumFeature = async function(feature) { + try { + const resp = await fetch(`/api/v1/license/feature/${feature}`); + const data = await resp.json(); + return data.available; + } catch { + return false; + } + }; + + // Load status on page load + loadStatus().then(status => { + if (currentStatus) updateHeaderBadge(currentStatus); + }); +})(); diff --git a/status/js/logo-customization.js b/status/js/logo-customization.js new file mode 100644 index 0000000..353bd8b --- /dev/null +++ b/status/js/logo-customization.js @@ -0,0 +1,453 @@ +// Logo Customization System +(function() { + // Inject modal HTML + injectModal('logo-modal', `
+
+

Dashboard Settings

+

+ Customize your dashboard's appearance and system preferences. +

+ +
+ + +

Shown in browser tab and header (max 50 characters)

+
+ +
+ +
+ +

Separate logos for dark and light themes, or use the same for both.

+
+ +
+
+ Dark theme logo +

Dark themes

+
+
+ Light theme logo +

Light themes

+
+
+

Using default logos

+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + + +
+ +
+ + + +
+
+ +
+ +
+ +
+ Current favicon + Using DashCaddy favicon +
+ +

Upload PNG or SVG - automatically converted to ICO

+
+ +
+ +
+ + +

Used by all deployed containers. Changes apply to new deployments.

+
+ +
+ + + +
+
+
`); + + const logoModal = document.getElementById('logo-modal'); + const previewDark = document.getElementById('logo-preview-dark'); + const previewLight = document.getElementById('logo-preview-light'); + const logoStatus = document.getElementById('logo-status'); + const sameBothCheckbox = document.getElementById('logo-same-both'); + const dualUploads = document.getElementById('logo-dual-uploads'); + const singleUpload = document.getElementById('logo-single-upload'); + const uploadDark = document.getElementById('logo-upload-dark'); + const uploadLight = document.getElementById('logo-upload-light'); + const uploadSingle = document.getElementById('logo-upload-single'); + const brandLogoDark = document.querySelector('#brand .brand-logo-dark'); + const brandLogoLight = document.querySelector('#brand .brand-logo-light'); + const topRow = document.querySelector('.top-row'); + const dashboardTitleInput = document.getElementById('dashboard-title'); + const DEFAULT_TITLE = DC.NAME; + + let pendingDarkData = null; + let pendingLightData = null; + let pendingSingleData = null; + let currentPosition = 'left'; + let currentTitle = DEFAULT_TITLE; + + // Toggle between dual and single upload mode + sameBothCheckbox?.addEventListener('change', () => { + if (sameBothCheckbox.checked) { + dualUploads.style.display = 'none'; + singleUpload.style.display = ''; + // Clear individual pending data + pendingDarkData = null; + pendingLightData = null; + } else { + dualUploads.style.display = 'flex'; + singleUpload.style.display = 'none'; + pendingSingleData = null; + } + }); + + // Read file as data URL helper + function readFileAsDataURL(file, callback) { + if (!file || !file.type.startsWith('image/')) { + showNotification('Please select an image file', 'warning'); + return; + } + const reader = new FileReader(); + reader.onload = (e) => callback(e.target.result); + reader.readAsDataURL(file); + } + + // Upload handlers + uploadDark?.addEventListener('change', (e) => { + readFileAsDataURL(e.target.files[0], (data) => { + pendingDarkData = data; + previewDark.src = data; + logoStatus.textContent = 'New dark logo ready to save'; + }); + }); + + uploadLight?.addEventListener('change', (e) => { + readFileAsDataURL(e.target.files[0], (data) => { + pendingLightData = data; + previewLight.src = data; + logoStatus.textContent = 'New light logo ready to save'; + }); + }); + + uploadSingle?.addEventListener('change', (e) => { + readFileAsDataURL(e.target.files[0], (data) => { + pendingSingleData = data; + previewDark.src = data; + previewLight.src = data; + logoStatus.textContent = 'New logo ready to save (both themes)'; + }); + }); + + // Apply logo position + function applyLogoPosition(pos) { + topRow.setAttribute('data-logo-pos', pos); + document.querySelectorAll('.logo-pos-btn').forEach(btn => { + btn.style.background = btn.dataset.pos === pos ? 'var(--accent)' : 'var(--card-bg)'; + btn.style.color = btn.dataset.pos === pos ? 'white' : 'var(--fg)'; + }); + } + + // Apply dashboard title + function applyDashboardTitle(title) { + currentTitle = title || DEFAULT_TITLE; + document.title = currentTitle; + const headerTitle = document.querySelector('.dashboard-title'); + if (headerTitle) headerTitle.textContent = currentTitle; + } + + // Load custom logo, position, and title on startup + async function loadCustomLogo() { + try { + const response = await fetch('/api/v1/logo'); + if (response.ok) { + const data = await response.json(); + // Apply dark/light variants + if (data.customLogoDark) { + brandLogoDark.src = data.customLogoDark; + previewDark.src = data.customLogoDark; + } + if (data.customLogoLight) { + brandLogoLight.src = data.customLogoLight; + previewLight.src = data.customLogoLight; + } + // Legacy single-logo fallback + if (!data.customLogoDark && !data.customLogoLight && data.customLogo) { + brandLogoDark.src = data.customLogo; + brandLogoLight.src = data.customLogo; + previewDark.src = data.customLogo; + previewLight.src = data.customLogo; + } + if (!data.isDefault) { + logoStatus.textContent = 'Using custom logo'; + } + if (data.position) { + currentPosition = data.position; + applyLogoPosition(data.position); + } + if (data.dashboardTitle) { + applyDashboardTitle(data.dashboardTitle); + } + } + } catch (error) { + console.warn('Could not load custom logo:', error.message); + } + } + + // Position button handlers + document.querySelectorAll('.logo-pos-btn').forEach(btn => { + btn.addEventListener('click', () => { + currentPosition = btn.dataset.pos; + applyLogoPosition(currentPosition); + }); + }); + + // Open logo modal on brand click + document.getElementById('brand')?.addEventListener('click', () => { + pendingDarkData = null; + pendingLightData = null; + pendingSingleData = null; + if (uploadDark) uploadDark.value = ''; + if (uploadLight) uploadLight.value = ''; + if (uploadSingle) uploadSingle.value = ''; + // Reset checkbox to dual mode + if (sameBothCheckbox) sameBothCheckbox.checked = false; + dualUploads.style.display = 'flex'; + singleUpload.style.display = 'none'; + // Reset previews to current header logos + previewDark.src = brandLogoDark.src; + previewLight.src = brandLogoLight.src; + const isCustom = brandLogoDark.src.includes('custom-logo') || brandLogoLight.src.includes('custom-logo'); + logoStatus.textContent = isCustom ? 'Using custom logo' : 'Using default logos'; + applyLogoPosition(currentPosition); + dashboardTitleInput.value = currentTitle; + logoModal.classList.add('show'); + }); + + // Save logo, position, and title + document.getElementById('logo-save')?.addEventListener('click', async () => { + try { + const newTitle = dashboardTitleInput.value.trim() || DEFAULT_TITLE; + const payload = { + position: currentPosition, + dashboardTitle: newTitle + }; + + // Determine which logo data to send + if (sameBothCheckbox?.checked && pendingSingleData) { + // Single logo for both variants + payload.dataDark = pendingSingleData; + payload.dataLight = pendingSingleData; + } else { + if (pendingDarkData) payload.dataDark = pendingDarkData; + if (pendingLightData) payload.dataLight = pendingLightData; + } + + const response = await secureFetch('/api/v1/logo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + const data = await response.json(); + const t = '?t=' + Date.now(); + if (data.pathDark) { + brandLogoDark.src = data.pathDark + t; + previewDark.src = data.pathDark + t; + } + if (data.pathLight) { + brandLogoLight.src = data.pathLight + t; + previewLight.src = data.pathLight + t; + } + applyLogoPosition(currentPosition); + applyDashboardTitle(newTitle); + logoModal.classList.remove('show'); + } else { + const error = await response.json(); + showNotification('Failed to save: ' + error.error, 'error'); + } + } catch (error) { + showNotification('Error saving: ' + error.message, 'error'); + } + }); + + // Reset all branding to defaults + document.getElementById('logo-reset')?.addEventListener('click', async () => { + if (!confirm('Reset all branding to DashCaddy defaults?\n\nThis will reset the logo, favicon, title, and position.')) return; + + try { + const logoResponse = await secureFetch('/api/v1/logo', { method: 'DELETE' }); + if (logoResponse.ok) { + brandLogoDark.src = '/assets/dashcaddy-logo-dark.png'; + brandLogoLight.src = '/assets/dashcaddy-logo-light.png'; + previewDark.src = '/assets/dashcaddy-logo-dark.png'; + previewLight.src = '/assets/dashcaddy-logo-light.png'; + logoStatus.textContent = 'Using default logos'; + pendingDarkData = null; + pendingLightData = null; + pendingSingleData = null; + dashboardTitleInput.value = DEFAULT_TITLE; + applyDashboardTitle(DEFAULT_TITLE); + currentPosition = 'left'; + applyLogoPosition('left'); + } + + const faviconResponse = await secureFetch('/api/v1/favicon', { method: 'DELETE' }); + if (faviconResponse.ok) { + const faviconLink = document.querySelector('link[rel="icon"]'); + const faviconPreview = document.getElementById('favicon-preview'); + const faviconStatus = document.getElementById('favicon-status'); + if (faviconLink) faviconLink.href = '/assets/dashcaddy-favicon.ico?t=' + Date.now(); + if (faviconPreview) faviconPreview.src = '/assets/dashcaddy-favicon.ico?t=' + Date.now(); + if (faviconStatus) faviconStatus.textContent = 'Using DashCaddy favicon'; + pendingFaviconData = null; + } + } catch (error) { + showNotification('Error resetting branding: ' + error.message, 'error'); + } + }); + + wireModal(logoModal, document.getElementById('logo-cancel')); + + // ===== FAVICON HANDLING ===== + const faviconPreview = document.getElementById('favicon-preview'); + const faviconStatus = document.getElementById('favicon-status'); + const faviconUpload = document.getElementById('favicon-upload'); + const faviconLink = document.querySelector('link[rel="icon"]') || document.createElement('link'); + let pendingFaviconData = null; + + if (!document.querySelector('link[rel="icon"]')) { + faviconLink.rel = 'icon'; + faviconLink.href = '/assets/dashcaddy-favicon.ico'; + document.head.appendChild(faviconLink); + } + + async function loadCustomFavicon() { + try { + const response = await fetch('/api/v1/favicon'); + if (response.ok) { + const data = await response.json(); + if (data.customFavicon) { + faviconLink.href = data.customFavicon + '?t=' + Date.now(); + faviconPreview.src = data.customFavicon + '?t=' + Date.now(); + faviconStatus.textContent = 'Using custom favicon'; + } + } + } catch (error) { + console.warn('Could not load custom favicon:', error.message); + } + } + + faviconUpload?.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + if (!file.type.match(/^image\/(png|svg\+xml)$/)) { + showNotification('Please select a PNG or SVG file', 'warning'); + faviconUpload.value = ''; + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + pendingFaviconData = event.target.result; + faviconPreview.src = pendingFaviconData; + faviconStatus.textContent = 'New favicon ready to save'; + }; + reader.readAsDataURL(file); + }); + + // Save favicon alongside logo save + document.getElementById('logo-save')?.addEventListener('click', async () => { + if (pendingFaviconData) { + try { + const response = await secureFetch('/api/v1/favicon', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: pendingFaviconData }) + }); + if (response.ok) { + const data = await response.json(); + faviconLink.href = data.path + '?t=' + Date.now(); + faviconPreview.src = data.path + '?t=' + Date.now(); + faviconStatus.textContent = 'Using custom favicon'; + pendingFaviconData = null; + } else { + const error = await response.json(); + showNotification('Failed to save favicon: ' + error.error, 'error'); + } + } catch (error) { + showNotification('Error saving favicon: ' + error.message, 'error'); + } + } + }); + + loadCustomFavicon(); + loadCustomLogo(); + + // ===== TIMEZONE SETTING ===== + const settingsTzSelect = document.getElementById('settings-timezone'); + if (settingsTzSelect) { + const observer = new MutationObserver(() => { + if (logoModal.classList.contains('show') && settingsTzSelect.options.length === 0) { + (async () => { + let currentTz; + try { + const res = await fetch('/api/v1/config'); + if (res.ok) { const cfg = await res.json(); currentTz = cfg.timezone; } + } catch (e) { /* ignore */ } + window.populateTimezoneSelect(settingsTzSelect, currentTz); + })(); + } + }); + observer.observe(logoModal, { attributes: true, attributeFilter: ['class'] }); + + document.getElementById('logo-save')?.addEventListener('click', async () => { + const tz = settingsTzSelect.value; + if (!tz) return; + try { + const res = await fetch('/api/v1/config'); + if (!res.ok) return; + const cfg = await res.json(); + cfg.timezone = tz; + cfg.updatedAt = new Date().toISOString(); + await secureFetch('/api/v1/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg) + }); + } catch (e) { + console.warn('Failed to save timezone:', e.message); + } + }); + } +})(); diff --git a/status/js/notification-settings.js b/status/js/notification-settings.js new file mode 100644 index 0000000..3056b69 --- /dev/null +++ b/status/js/notification-settings.js @@ -0,0 +1,344 @@ +// ========== NOTIFICATION SETTINGS ========== +(function() { + // Inject modal HTML + injectModal('notifications-modal', `
+
+

🔔 Notification Settings

+ + +
+ +
+ + +

Notification Providers

+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +

Health Monitoring

+
+ +
+ + + +
+
+ Last check: Never +
+
+ + +

Events to Notify

+
+ + + + +
+ + +

Notification History

+
+
No notifications yet
+
+ + + +
+
`); + + const modal = document.getElementById('notifications-modal'); + const openBtn = document.getElementById('manage-notifications'); + const saveBtn = document.getElementById('notifications-save'); + const cancelBtn = document.getElementById('notifications-cancel'); + + // Provider toggle handlers + ['discord', 'telegram', 'ntfy'].forEach(provider => { + const checkbox = document.getElementById(`${provider}-enabled`); + const config = document.getElementById(`${provider}-config`); + + checkbox?.addEventListener('change', () => { + config.style.display = checkbox.checked ? 'block' : 'none'; + }); + }); + + // Health check toggle + const healthCheckEnabled = document.getElementById('health-check-enabled'); + const healthCheckConfig = document.getElementById('health-check-config'); + healthCheckEnabled?.addEventListener('change', () => { + healthCheckConfig.style.opacity = healthCheckEnabled.checked ? '1' : '0.5'; + }); + + // Load notification config from API + async function loadNotificationConfig() { + try { + const response = await fetch('/api/v1/notifications/config'); + const data = await response.json(); + + if (data.success) { + const config = data.config; + + // Master toggle + document.getElementById('notifications-enabled').checked = config.enabled; + + // Providers + document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false; + document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false; + document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false; + + // Show/hide config sections + document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none'; + document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none'; + document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none'; + + // ntfy server URL + if (config.providers?.ntfy?.serverUrl) { + document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl; + } + + // Health check + document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false; + if (config.healthCheck?.intervalMinutes) { + document.getElementById('health-check-interval').value = config.healthCheck.intervalMinutes; + } + if (config.healthCheck?.lastCheck) { + document.getElementById('health-check-status').textContent = + `Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`; + } + + // Events + document.getElementById('event-container-down').checked = config.events?.containerDown !== false; + document.getElementById('event-container-up').checked = config.events?.containerUp !== false; + document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false; + document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false; + } + } catch (error) { + console.error('Failed to load notification config:', error); + } + } + + // Load notification history + async function loadNotificationHistory() { + try { + const response = await fetch('/api/v1/notifications/history?limit=10'); + const data = await response.json(); + + const container = document.getElementById('notification-history'); + if (data.success && data.history?.length > 0) { + container.innerHTML = data.history.map(item => { + const date = new Date(item.timestamp).toLocaleString(); + const typeColors = { + success: 'var(--ok-fg)', + error: 'var(--bad-fg)', + warning: '#f39c12', + info: 'var(--accent)' + }; + return ` +
+ ${item.type === 'success' ? '✓' : item.type === 'error' ? '✗' : 'ℹ'} +
+
${escapeHtml(item.title)}
+
${date}
+
+
+ `; + }).join(''); + } else { + container.innerHTML = '
No notifications yet
'; + } + } catch (error) { + console.error('Failed to load notification history:', error); + } + } + + // Save notification config + async function saveNotificationConfig() { + try { + const config = { + enabled: document.getElementById('notifications-enabled').checked, + providers: { + discord: { + enabled: document.getElementById('discord-enabled').checked, + webhookUrl: document.getElementById('discord-webhook').value.trim() + }, + telegram: { + enabled: document.getElementById('telegram-enabled').checked, + botToken: document.getElementById('telegram-bot-token').value.trim(), + chatId: document.getElementById('telegram-chat-id').value.trim() + }, + ntfy: { + enabled: document.getElementById('ntfy-enabled').checked, + serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh', + topic: document.getElementById('ntfy-topic').value.trim() + } + }, + events: { + containerDown: document.getElementById('event-container-down').checked, + containerUp: document.getElementById('event-container-up').checked, + deploymentSuccess: document.getElementById('event-deploy-success').checked, + deploymentFailed: document.getElementById('event-deploy-failed').checked + }, + healthCheck: { + enabled: document.getElementById('health-check-enabled').checked, + intervalMinutes: parseInt(document.getElementById('health-check-interval').value) || 5 + } + }; + + const response = await secureFetch('/api/v1/notifications/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + const data = await response.json(); + if (data.success) { + showNotification('Notification settings saved', 'success', 3000); + modal.classList.remove('show'); + } else { + showNotification(`Failed to save: ${data.error}`, 'error', 3000); + } + } catch (error) { + showNotification(`Error: ${error.message}`, 'error', 3000); + } + } + + // Test notification handlers + async function testProvider(provider) { + try { + const response = await secureFetch('/api/v1/notifications/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider }) + }); + + const data = await response.json(); + if (data.success) { + showNotification(`Test ${provider} notification sent!`, 'success', 3000); + } else { + showNotification(`Test failed: ${data.error}`, 'error', 3000); + } + } catch (error) { + showNotification(`Error: ${error.message}`, 'error', 3000); + } + } + + document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord')); + document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram')); + document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy')); + + // Health check now button + document.getElementById('health-check-now')?.addEventListener('click', async () => { + try { + const response = await secureFetch('/api/v1/notifications/health-check', { method: 'POST' }); + const data = await response.json(); + + if (data.success) { + document.getElementById('health-check-status').textContent = + `Last check: ${new Date(data.lastCheck).toLocaleString()} (${data.containersMonitored} containers)`; + showNotification('Health check completed', 'success', 2000); + } + } catch (error) { + showNotification(`Error: ${error.message}`, 'error', 3000); + } + }); + + // Modal handlers + openBtn?.addEventListener('click', () => { + modal.classList.add('show'); + loadNotificationConfig(); + loadNotificationHistory(); + }); + + saveBtn?.addEventListener('click', saveNotificationConfig); + wireModal(modal, cancelBtn); +})(); diff --git a/status/js/onboarding.js b/status/js/onboarding.js new file mode 100644 index 0000000..638294d --- /dev/null +++ b/status/js/onboarding.js @@ -0,0 +1,177 @@ +/** + * DashCaddy User Onboarding System + * Main entry point for the tooltip-based onboarding experience + * + * This file initializes the onboarding system and coordinates between + * the various components (TourManager, ProgressTracker, ThemeAdapter, etc.) + */ + +(function() { + 'use strict'; + + let progressTracker; + let themeAdapter; + let tourManager; + let dnsTemplateSelector; + let errorHandler; + + /** + * Initialize the onboarding system + */ + async function initializeOnboarding() { + try { + console.log('[Onboarding] Initializing system...'); + + // Initialize Error Handler first + errorHandler = new ErrorHandler(); + console.log('[Onboarding] Error Handler initialized'); + + // Initialize Progress Tracker + progressTracker = new ProgressTracker('dashcaddy_onboarding'); + console.log('[Onboarding] Progress Tracker initialized'); + + // Initialize Theme Adapter + themeAdapter = new ThemeAdapter(); + console.log('[Onboarding] Theme Adapter initialized'); + + // Initialize DNS Template Selector + dnsTemplateSelector = new DnsTemplateSelector(progressTracker); + console.log('[Onboarding] DNS Template Selector initialized'); + + // Initialize Tour Manager + tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); + console.log('[Onboarding] Tour Manager initialized'); + + // Check if tour should auto-start + if (tourManager.shouldAutoStart()) { + console.log('[Onboarding] Auto-starting tour for first-time user'); + // Wait a bit for page to fully load + setTimeout(() => { + tourManager.startTour(); + }, 1000); + } else { + const tourCompleted = progressTracker.isTourCompleted(); + const currentStep = progressTracker.getCurrentStep(); + console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); + + // If tour is in progress, offer to resume + if (!tourCompleted && currentStep > 0) { + console.log('[Onboarding] Tour in progress, can be resumed manually'); + } + } + + // Add restart tour button to tools row + addRestartTourButton(); + + // Expose to global scope for manual triggering + window.DashCaddyOnboarding = { + startTour: () => tourManager.startTour(), + restartTour: () => tourManager.restartTour(), + showTooltip: (id) => tourManager.showTooltip(id), + showWhatsNew: () => tourManager.showWhatsNew(), + resetProgress: () => progressTracker.resetProgress(), + getErrors: () => errorHandler.getErrors(), + getErrorStats: () => errorHandler.getStatistics() + }; + + console.log('[Onboarding] System initialized successfully'); + } catch (error) { + console.error('[Onboarding] Initialization error:', error); + + // Use error handler if available + if (errorHandler) { + errorHandler.logError('Initialization', error); + } + + // Graceful degradation - don't break the dashboard + console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding'); + } + } + + /** + * Add restart tour button to tools row + */ + function addRestartTourButton() { + const toolsRow = document.querySelector('.tools-primary') || document.querySelector('.tools'); + if (!toolsRow) return; + + const clickHandler = () => { + if (tourManager) { + console.log('[Onboarding] Starting tour via button click'); + tourManager.restartTour(); + } else { + console.error('[Onboarding] Tour manager not initialized'); + alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); + } + }; + + // If button already exists in the HTML, just attach the handler + const existing = document.getElementById('restart-tour-btn'); + if (existing) { + existing.onclick = clickHandler; + return; + } + + const button = document.createElement('button'); + button.id = 'restart-tour-btn'; + button.textContent = 'Help Tour'; + button.title = 'Restart the onboarding tour'; + button.onclick = clickHandler; + toolsRow.appendChild(button); + } + + /** + * Check if Driver.js is loaded + */ + function checkDriverLoaded() { + // Driver.js v1.x IIFE: window.driver.js.driver is the factory function + const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; + if (typeof driverFactory !== 'function') { + console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver); + return false; + } + return true; + } + + /** + * Wait for Driver.js to load, then initialize + */ + function waitForDriver() { + let retries = 0; + const maxRetries = 10; + + function attemptInit() { + if (checkDriverLoaded()) { + initializeOnboarding(); + } else { + retries++; + if (retries < maxRetries) { + // Retry after a short delay + setTimeout(attemptInit, 500); + } else { + // Max retries reached, show fallback + console.error('[Onboarding] Driver.js failed to load after multiple attempts'); + if (errorHandler) { + errorHandler.handleDriverLoadFailure(); + } else { + // Create temporary error handler for fallback + const tempHandler = new ErrorHandler(); + tempHandler.handleDriverLoadFailure(); + } + } + } + } + + attemptInit(); + } + + // Start initialization when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', waitForDriver); + } else { + waitForDriver(); + } + + console.log('[Onboarding] System loaded'); + +})(); diff --git a/status/js/panel-tabs.js b/status/js/panel-tabs.js new file mode 100644 index 0000000..cd68092 --- /dev/null +++ b/status/js/panel-tabs.js @@ -0,0 +1,16 @@ +// ========== PANEL TAB SWITCHING (shared utility) ========== +(function() { + document.addEventListener('click', (e) => { + const tab = e.target.closest('.panel-tab'); + if (!tab) return; + const panelId = tab.dataset.panel; + if (!panelId) return; + const tabBar = tab.closest('.panel-tabs'); + const modalContent = tabBar.closest('.weather-modal-content'); + tabBar.querySelectorAll('.panel-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + modalContent.querySelectorAll('.panel-section').forEach(s => s.classList.remove('active')); + const target = modalContent.querySelector('#' + panelId); + if (target) target.classList.add('active'); + }); +})(); diff --git a/status/js/progress-tracker.js b/status/js/progress-tracker.js new file mode 100644 index 0000000..9ed9d8c --- /dev/null +++ b/status/js/progress-tracker.js @@ -0,0 +1,282 @@ +/** + * Progress Tracker + * Manages persistent storage of user progress through the onboarding flow + * using browser local storage. + * + * Storage Schema: + * { + * "version": "1.0", + * "tourCompleted": false, + * "completedTooltips": ["welcome", "dns-priority", ...], + * "currentStep": 3, + * "completionTimestamp": "2024-01-15T10:30:00Z", + * "dnsSetupDeferred": false, + * "lastVisit": "2024-01-15T10:30:00Z" + * } + */ + +(function(window) { + 'use strict'; + + /** + * ProgressTracker class + * Manages persistent storage of onboarding progress + * + * @class + * @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding') + */ + class ProgressTracker { + constructor(storageKey = 'dashcaddy_onboarding') { + this.storageKey = storageKey; + this.storageVersion = '1.0'; + + // Initialize storage if it doesn't exist + this._initializeStorage(); + + // Update last visit timestamp + this._updateLastVisit(); + } + + /** + * Initialize storage with default values if it doesn't exist + * @private + */ + _initializeStorage() { + const existing = this._getStorage(); + if (!existing || existing.version !== this.storageVersion) { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + } + + /** + * Get the current storage state + * @private + * @returns {Object|null} The storage state or null if unavailable + */ + _getStorage() { + try { + const data = localStorage.getItem(this.storageKey); + return data ? JSON.parse(data) : null; + } catch (error) { + console.error('[ProgressTracker] Error reading from storage:', error); + return null; + } + } + + /** + * Set the storage state + * @private + * @param {Object} state - The state to save + */ + _setStorage(state) { + try { + localStorage.setItem(this.storageKey, JSON.stringify(state)); + } catch (error) { + console.error('[ProgressTracker] Error writing to storage:', error); + // Handle quota exceeded or storage unavailable + // Fall back to session storage or in-memory storage + this._handleStorageError(error); + } + } + + /** + * Handle storage errors (quota exceeded, unavailable, etc.) + * @private + * @param {Error} error - The error that occurred + */ + _handleStorageError(error) { + // Try session storage as fallback + try { + sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); + console.warn('[ProgressTracker] Falling back to session storage'); + } catch (sessionError) { + console.error('[ProgressTracker] Session storage also unavailable:', sessionError); + // Could implement in-memory fallback here if needed + } + } + + /** + * Update the last visit timestamp + * @private + */ + _updateLastVisit() { + const state = this._getStorage(); + if (state) { + state.lastVisit = new Date().toISOString(); + this._setStorage(state); + } + } + + /** + * Check if a specific tooltip has been completed + * @param {string} tooltipId - The ID of the tooltip to check + * @returns {boolean} True if the tooltip has been completed + */ + isTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return false; + return state.completedTooltips.includes(tooltipId); + } + + /** + * Mark a tooltip as completed with timestamp + * @param {string} tooltipId - The ID of the tooltip to mark as completed + */ + markTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return; + + // Add tooltip to completed list if not already there + if (!state.completedTooltips.includes(tooltipId)) { + state.completedTooltips.push(tooltipId); + + // Store timestamp for this specific tooltip + if (!state.tooltipTimestamps) { + state.tooltipTimestamps = {}; + } + state.tooltipTimestamps[tooltipId] = new Date().toISOString(); + + this._setStorage(state); + } + } + + /** + * Check if the entire tour has been completed + * @returns {boolean} True if the tour is completed + */ + isTourCompleted() { + const state = this._getStorage(); + if (!state) return false; + return state.tourCompleted === true; + } + + /** + * Mark the entire tour as completed + */ + markTourCompleted() { + const state = this._getStorage(); + if (!state) return; + + state.tourCompleted = true; + state.completionTimestamp = new Date().toISOString(); + this._setStorage(state); + } + + /** + * Get the current step index + * @returns {number} The current step index (0-based) + */ + getCurrentStep() { + const state = this._getStorage(); + if (!state) return 0; + return state.currentStep || 0; + } + + /** + * Set the current step index + * @param {number} stepIndex - The step index to set (0-based) + */ + setCurrentStep(stepIndex) { + const state = this._getStorage(); + if (!state) return; + + state.currentStep = stepIndex; + this._setStorage(state); + } + + /** + * Reset all progress and clear storage + */ + resetProgress() { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + + /** + * Get the completion timestamp + * @returns {Date|null} The completion timestamp or null if not completed + */ + getCompletionTimestamp() { + const state = this._getStorage(); + if (!state || !state.completionTimestamp) return null; + return new Date(state.completionTimestamp); + } + + /** + * Check if DNS setup was deferred + * @returns {boolean} True if DNS setup was deferred + */ + isDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return false; + return state.dnsSetupDeferred === true; + } + + /** + * Mark DNS setup as deferred + */ + markDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return; + + state.dnsSetupDeferred = true; + this._setStorage(state); + } + + /** + * Get the timestamp for a specific tooltip completion + * @param {string} tooltipId - The ID of the tooltip + * @returns {Date|null} The timestamp or null if not completed + */ + getTooltipTimestamp(tooltipId) { + const state = this._getStorage(); + if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) { + return null; + } + return new Date(state.tooltipTimestamps[tooltipId]); + } + + /** + * Get all completed tooltip IDs + * @returns {string[]} Array of completed tooltip IDs + */ + getCompletedTooltips() { + const state = this._getStorage(); + if (!state) return []; + return state.completedTooltips || []; + } + + /** + * Get the last visit timestamp + * @returns {Date|null} The last visit timestamp + */ + getLastVisit() { + const state = this._getStorage(); + if (!state || !state.lastVisit) return null; + return new Date(state.lastVisit); + } + } + + // Export to global scope + window.ProgressTracker = ProgressTracker; + + console.log('[ProgressTracker] Module loaded'); + +})(window); diff --git a/status/js/recipes.js b/status/js/recipes.js new file mode 100644 index 0000000..f51ecd5 --- /dev/null +++ b/status/js/recipes.js @@ -0,0 +1,590 @@ +// Recipe System — multi-container stack deployment +(function() { + // === RECIPE DEPLOY WIZARD MODAL === + injectModal('recipe-deploy-modal', `
+
+

Deploy Recipe

+ + +
+
1 Components
+
2 Configuration
+
3 Review
+
4 Progress
+
+ + +
+ +
+
+ + + + + + + + + + +
+ + + +
+
+
`); + + // === STATE === + let recipeTemplates = null; + let recipeCategories = null; + let currentRecipe = null; + let currentStep = 1; + let isPremium = false; + + const deployModal = document.getElementById('recipe-deploy-modal'); + const cancelBtn = document.getElementById('recipe-cancel'); + const prevBtn = document.getElementById('recipe-prev'); + const nextBtn = document.getElementById('recipe-next'); + + wireModal(deployModal, cancelBtn); + + // === FETCH RECIPE TEMPLATES === + async function fetchRecipeTemplates() { + try { + const resp = await fetch('/api/v1/recipes/templates'); + const data = await resp.json(); + if (data.success) { + recipeTemplates = data.templates; + recipeCategories = data.categories; + return true; + } + // Premium required — templates API gated + if (resp.status === 403) { + isPremium = false; + return false; + } + } catch (e) { + console.warn('Failed to fetch recipe templates:', e.message); + } + return false; + } + + // === CHECK PREMIUM === + async function checkRecipePremium() { + try { + const resp = await fetch('/api/v1/license/feature/recipes'); + const data = await resp.json(); + isPremium = data.available; + } catch { + isPremium = false; + } + return isPremium; + } + + // === RENDER RECIPE CARDS INTO APP SELECTOR === + // This function is called by the app selector to inject recipe cards + window.renderRecipeCards = async function(grid) { + await checkRecipePremium(); + + // Build recipe data — we can show card metadata even without premium + // (cards just show a lock and prompt to upgrade) + let recipes; + if (isPremium && recipeTemplates) { + recipes = recipeTemplates; + } else { + // Show hardcoded preview cards when not premium + recipes = getPreviewRecipes(); + } + + if (!recipes || recipes.length === 0) return; + + // Category header + const header = document.createElement('div'); + header.className = 'app-category-header'; + header.innerHTML = `\uD83E\uDDEA Recipes`; + header.style.borderBottomColor = '#8e44ad'; + grid.appendChild(header); + + const recipeList = Array.isArray(recipes) ? recipes : Object.values(recipes); + recipeList.sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); + + for (const recipe of recipeList) { + const option = document.createElement('div'); + option.className = 'app-option'; + option.style.position = 'relative'; + + const componentBadge = `
${recipe.componentCount || recipe.components?.length || '?'} apps
`; + + const lockOverlay = !isPremium ? `
PREMIUM
` : ''; + + option.innerHTML = ` + ${lockOverlay} +
${escapeHtml(recipe.icon || '\uD83E\uDDEA')}
+
${escapeHtml(recipe.name)}
+
${escapeHtml(recipe.description || '')}
+ ${componentBadge} + `; + + option.onclick = () => { + if (!isPremium) { + showNotification('Recipes require a DashCaddy Premium license. Click the License button to activate.', 'warning', 5000); + if (window.openLicenseModal) window.openLicenseModal(); + return; + } + openRecipeDeployWizard(recipe); + }; + + grid.appendChild(option); + } + }; + + function getPreviewRecipes() { + return [ + { id: 'htpc-suite', name: 'HTPC Suite', icon: '\uD83C\uDFAC', description: 'Complete media automation: find, download, organize, and stream', componentCount: 6, popularity: 98 }, + { id: 'nextcloud-complete', name: 'Nextcloud Complete', icon: '\u2601\uFE0F', description: 'Full productivity suite: cloud storage, office editing, and collaboration', componentCount: 4, popularity: 90 }, + { id: 'smart-home', name: 'Smart Home Hub', icon: '\uD83C\uDFE0', description: 'Home automation: control, automate, and monitor IoT devices', componentCount: 4, popularity: 88 }, + { id: 'dev-environment', name: 'Dev Environment', icon: '\uD83D\uDCBB', description: 'Self-hosted development workflow: Git, CI/CD, IDE, and database', componentCount: 4, popularity: 82 } + ]; + } + + // === RECIPE DEPLOY WIZARD === + function openRecipeDeployWizard(recipe) { + currentRecipe = recipe; + currentStep = 1; + + // Close app selector + const appSelectorModal = document.getElementById('app-selector-modal'); + if (appSelectorModal) appSelectorModal.classList.remove('show'); + + document.getElementById('recipe-deploy-title').textContent = `Deploy ${recipe.name}`; + + // Reset steps + updateStepUI(); + renderStep1(); + + deployModal.classList.add('show'); + } + + function updateStepUI() { + // Step indicators + document.querySelectorAll('#recipe-steps .recipe-step').forEach(el => { + const step = parseInt(el.dataset.step); + el.classList.toggle('active', step === currentStep); + el.classList.toggle('completed', step < currentStep); + }); + + // Step panels + for (let i = 1; i <= 4; i++) { + const panel = document.getElementById(`recipe-step-${i}`); + if (panel) panel.style.display = i === currentStep ? '' : 'none'; + } + + // Navigation buttons + prevBtn.style.display = currentStep > 1 && currentStep < 4 ? '' : 'none'; + if (currentStep === 4) { + nextBtn.style.display = 'none'; + cancelBtn.textContent = 'Close'; + } else if (currentStep === 3) { + nextBtn.textContent = '\uD83D\uDE80 Deploy'; + nextBtn.style.display = ''; + cancelBtn.textContent = 'Cancel'; + } else { + nextBtn.textContent = 'Next'; + nextBtn.style.display = ''; + cancelBtn.textContent = 'Cancel'; + } + } + + // Step 1: Component selection + function renderStep1() { + const list = document.getElementById('recipe-component-list'); + list.innerHTML = ''; + + const components = currentRecipe.components || []; + for (const comp of components) { + const el = document.createElement('div'); + el.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);'; + + const isRequired = comp.required; + const isInternal = comp.internal; + + el.innerHTML = ` + +
+
${escapeHtml(comp.role || comp.id)}
+
+ ${comp.templateRef ? escapeHtml(comp.templateRef) : 'Built-in'} + ${isRequired ? 'Required' : 'Optional'} + ${isInternal ? '(Internal)' : ''} +
+ ${comp.note ? `
\u26A0 ${escapeHtml(comp.note)}
` : ''} +
+ `; + list.appendChild(el); + } + } + + // Step 2: Shared configuration + function renderStep2() { + const volumeSection = document.getElementById('recipe-volumes-section'); + const volumeList = document.getElementById('recipe-volume-list'); + + const sharedVolumes = currentRecipe.sharedVolumes; + if (sharedVolumes && Object.keys(sharedVolumes).length > 0) { + volumeSection.style.display = ''; + volumeList.innerHTML = ''; + + for (const [key, vol] of Object.entries(sharedVolumes)) { + const el = document.createElement('div'); + el.style.cssText = 'display: grid; gap: 4px;'; + el.innerHTML = ` + + +
${escapeHtml(vol.description || '')}
+ `; + volumeList.appendChild(el); + } + } else { + volumeSection.style.display = 'none'; + } + } + + // Step 3: Review + function renderStep3() { + const content = document.getElementById('recipe-review-content'); + const selectedComponents = getSelectedComponents(); + + const volumeInputs = document.querySelectorAll('#recipe-volume-list input[data-volume-key]'); + const volumes = {}; + volumeInputs.forEach(input => { + volumes[input.dataset.volumeKey] = input.value; + }); + + const tz = document.getElementById('recipe-timezone').value || 'UTC'; + const ip = document.getElementById('recipe-ip').value || 'host.docker.internal'; + const tailscale = document.getElementById('recipe-tailscale').checked; + + content.innerHTML = ` +
${escapeHtml(currentRecipe.name)}
+
${escapeHtml(currentRecipe.description || '')}
+ +
+ Components (${selectedComponents.length}): +
+ ${selectedComponents.map(c => `
+ \u2022 ${escapeHtml(c.role || c.id)} ${c.internal ? '(internal)' : ''} +
`).join('')} +
+
+ + ${Object.keys(volumes).length > 0 ? `
+ Volumes: + ${Object.entries(volumes).map(([k, v]) => `
${k}: ${escapeHtml(v)}
`).join('')} +
` : ''} + +
+ Timezone: ${escapeHtml(tz)} • IP: ${escapeHtml(ip)} ${tailscale ? '• Tailscale only' : ''} +
+ + ${currentRecipe.network ? `
Docker network: ${escapeHtml(currentRecipe.network.name)}
` : ''} + `; + } + + function getSelectedComponents() { + const checkboxes = document.querySelectorAll('#recipe-component-list input[data-component-id]'); + const selectedIds = new Set(); + checkboxes.forEach(cb => { + if (cb.checked) selectedIds.add(cb.dataset.componentId); + }); + + // Always include required components + const components = currentRecipe.components || []; + components.filter(c => c.required).forEach(c => selectedIds.add(c.id)); + + return components.filter(c => selectedIds.has(c.id)); + } + + // Step 4: Deploy + async function executeDeploy() { + const progressList = document.getElementById('recipe-progress-list'); + const resultEl = document.getElementById('recipe-deploy-result'); + resultEl.style.display = 'none'; + progressList.innerHTML = ''; + + const selectedComponents = getSelectedComponents(); + + // Show progress items + for (const comp of selectedComponents) { + const el = document.createElement('div'); + el.id = `recipe-progress-${comp.id}`; + el.style.cssText = 'display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;'; + el.innerHTML = ` + \u23F3 + ${escapeHtml(comp.role || comp.id)} + Queued + `; + progressList.appendChild(el); + } + + // Collect config + const volumeInputs = document.querySelectorAll('#recipe-volume-list input[data-volume-key]'); + const volumes = {}; + volumeInputs.forEach(input => { + volumes[input.dataset.volumeKey] = input.value; + }); + + const config = { + selectedComponents: selectedComponents.map(c => c.id), + sharedConfig: { + ip: document.getElementById('recipe-ip').value || 'host.docker.internal', + timezone: document.getElementById('recipe-timezone').value || 'UTC', + tailscaleOnly: document.getElementById('recipe-tailscale').checked, + volumes + }, + componentOverrides: {} + }; + + // Mark all as deploying + for (const comp of selectedComponents) { + updateProgressItem(comp.id, 'deploying', 'Deploying...'); + } + + try { + const resp = await secureFetch('/api/v1/recipes/deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipeId: currentRecipe.id, config }) + }); + const data = await resp.json(); + + if (data.success) { + // Mark deployed components + for (const deployed of (data.deployed || [])) { + updateProgressItem(deployed.id, 'success', deployed.url ? `Running \u2192 ${deployed.url}` : 'Running'); + } + // Mark errors + for (const err of (data.errors || [])) { + updateProgressItem(err.componentId, 'error', err.error); + } + + resultEl.style.display = ''; + resultEl.innerHTML = ` +
+
${escapeHtml(data.message || 'Deployed!')}
+ ${data.setupInstructions ? `
+ Setup tips: +
    ${data.setupInstructions.map(s => `
  • ${escapeHtml(s)}
  • `).join('')}
+
` : ''} +
+ `; + + showNotification(`${currentRecipe.name} recipe deployed successfully!`, 'success', 5000); + + // Refresh dashboard + if (window.loadServices) window.loadServices(); + } else { + resultEl.style.display = ''; + resultEl.innerHTML = `
+ Deployment failed: ${escapeHtml(data.error || 'Unknown error')} +
`; + showNotification(`Recipe deployment failed: ${data.error}`, 'error', 5000); + } + } catch (e) { + resultEl.style.display = ''; + resultEl.innerHTML = `
+ Network error: ${escapeHtml(e.message)} +
`; + } + } + + function updateProgressItem(componentId, status, text) { + const el = document.getElementById(`recipe-progress-${componentId}`); + if (!el) return; + const icon = el.querySelector('.recipe-progress-icon'); + const statusEl = el.querySelector('.recipe-progress-status'); + + if (status === 'deploying') { + icon.textContent = '\u23F3'; + statusEl.style.color = 'var(--accent)'; + } else if (status === 'success') { + icon.textContent = '\u2705'; + statusEl.style.color = 'var(--ok-fg)'; + } else if (status === 'error') { + icon.textContent = '\u274C'; + statusEl.style.color = 'var(--bad-fg)'; + } + statusEl.textContent = text; + } + + // === STEP NAVIGATION === + nextBtn.addEventListener('click', () => { + if (currentStep === 3) { + currentStep = 4; + updateStepUI(); + executeDeploy(); + return; + } + if (currentStep < 3) { + currentStep++; + updateStepUI(); + if (currentStep === 2) renderStep2(); + if (currentStep === 3) renderStep3(); + } + }); + + prevBtn.addEventListener('click', () => { + if (currentStep > 1 && currentStep < 4) { + currentStep--; + updateStepUI(); + } + }); + + // === RECIPE CARD GROUPING ON DASHBOARD === + // After dashboard loads, group cards that share a recipeId + window.groupRecipeCards = function() { + const cards = document.querySelectorAll('.service-card[data-recipe-id]'); + if (cards.length === 0) return; + + const groups = {}; + cards.forEach(card => { + const recipeId = card.dataset.recipeId; + if (!groups[recipeId]) groups[recipeId] = []; + groups[recipeId].push(card); + }); + + for (const [recipeId, groupCards] of Object.entries(groups)) { + if (groupCards.length < 2) continue; + + // Apply subtle visual grouping + groupCards.forEach((card, i) => { + card.style.borderLeft = '3px solid rgba(142,68,173,0.5)'; + if (i === 0) { + // Add a recipe label to the first card + let label = card.querySelector('.recipe-group-label'); + if (!label) { + label = document.createElement('div'); + label.className = 'recipe-group-label'; + label.style.cssText = 'position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;'; + label.textContent = recipeId.replace(/-/g, ' '); + card.style.position = 'relative'; + card.appendChild(label); + } + } + }); + } + }; + + // === RECIPE MANAGEMENT ACTIONS === + window.manageRecipe = async function(recipeId, action) { + const endpoint = `/api/v1/recipes/${recipeId}/${action}`; + const method = action === 'remove' ? 'DELETE' : 'POST'; + const url = action === 'remove' ? `/api/v1/recipes/${recipeId}` : endpoint; + + if (action === 'remove' && !confirm(`Remove the entire ${recipeId} recipe? This will delete all containers and configuration.`)) { + return; + } + + try { + const resp = await secureFetch(url, { method }); + const data = await resp.json(); + if (data.success) { + showNotification(`Recipe ${action}: ${data.results?.filter(r => r.status !== 'failed').length || 0} components processed`, 'success', 4000); + if (window.loadServices) window.loadServices(); + } else { + showNotification(`Recipe ${action} failed: ${data.error}`, 'error', 5000); + } + } catch (e) { + showNotification(`Network error: ${e.message}`, 'error', 5000); + } + }; + + // === INJECT CSS === + const style = document.createElement('style'); + style.textContent = ` + .recipe-step { + flex: 1; + text-align: center; + padding: 8px 4px; + font-size: 0.78rem; + color: var(--muted); + border-bottom: 2px solid var(--border); + transition: all 0.2s; + } + .recipe-step span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--border); + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + margin-right: 4px; + } + .recipe-step.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + .recipe-step.active span { + background: var(--accent); + color: #fff; + } + .recipe-step.completed { + color: var(--ok-fg); + border-bottom-color: var(--ok-fg); + } + .recipe-step.completed span { + background: var(--ok-fg); + color: #fff; + } + .recipe-step-panel { + min-height: 180px; + } + `; + document.head.appendChild(style); + + // === INIT === + // Pre-fetch premium status on load + checkRecipePremium(); +})(); diff --git a/status/js/resource-monitor.js b/status/js/resource-monitor.js new file mode 100644 index 0000000..a686f4a --- /dev/null +++ b/status/js/resource-monitor.js @@ -0,0 +1,328 @@ +// ========== RESOURCE MONITOR (Enhanced) ========== +(function() { + // Inject modal HTML + injectModal('stats-modal', `
+
+

📊 Resource Monitor

+ + + +
+ + + +
+ + +
+
+
+ Loading container stats... +
+
+
+ + +
+
+
+ 📈 + Loading 24-hour aggregated metrics... +
+
+
+ + +
+
+
+ 🔔 + Loading alert configurations... +
+
+
+ + +
+ + + +
+ + + +
+
`); + + const modal = document.getElementById('stats-modal'); + const openBtn = document.getElementById('container-stats-btn'); + const cancelBtn = document.getElementById('stats-cancel'); + const refreshBtn = document.getElementById('stats-refresh-btn'); + const autoRefreshCheckbox = document.getElementById('stats-auto-refresh'); + const container = document.getElementById('stats-container'); + const aggregatedContainer = document.getElementById('stats-aggregated-container'); + const alertsContainer = document.getElementById('stats-alerts-container'); + const lastUpdateSpan = document.getElementById('stats-last-update'); + + let refreshInterval = null; + let cachedMonitoringData = null; + + function formatBytes(bytes) { + if (bytes === 0 || !bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + function getCpuColor(percent) { + if (percent < 30) return '#2ecc71'; + if (percent < 70) return '#f39c12'; + return '#e74c3c'; + } + + function getMemColor(percent) { + if (percent < 50) return '#2ecc71'; + if (percent < 80) return '#f39c12'; + return '#e74c3c'; + } + + async function loadStats() { + try { + // Try new monitoring API first, fall back to old + let stats = null; + let isNewApi = false; + try { + const res = await fetch('/api/v1/monitoring/stats'); + const data = await res.json(); + if (data.success && data.stats) { stats = data.stats; isNewApi = true; cachedMonitoringData = data.stats; } + } catch (_) {} + + if (!isNewApi) { + const response = await fetch('/api/v1/stats/containers'); + const data = await response.json(); + if (data.success && data.stats) { + // Convert array format to object format + stats = {}; + for (const s of data.stats) { + stats[s.name] = { name: s.name, current: { cpu: s.cpu, memory: { percent: s.memory.percent, usage: s.memory.used, limit: s.memory.limit, usageMB: Math.round(s.memory.used / 1048576), limitMB: Math.round(s.memory.limit / 1048576) }, network: { rxBytes: s.network.rx, txBytes: s.network.tx, rxMB: (s.network.rx / 1048576).toFixed(1), txMB: (s.network.tx / 1048576).toFixed(1) }, disk: { readMB: 0, writeMB: 0 } }, status: s.status }; + } + cachedMonitoringData = stats; + } + } + + if (!stats || Object.keys(stats).length === 0) { + container.innerHTML = '
No running containers found
'; + return; + } + + let html = '
'; + + for (const [id, info] of Object.entries(stats)) { + const cur = info.current || info; + const cpu = cur.cpu?.percent || 0; + const mem = cur.memory?.percent || 0; + const cpuColor = getCpuColor(cpu); + const memColor = getMemColor(mem); + const memUsed = cur.memory?.usage || cur.memory?.used || 0; + const memLimit = cur.memory?.limit || 0; + const netRx = cur.network?.rxBytes || cur.network?.rx || 0; + const netTx = cur.network?.txBytes || cur.network?.tx || 0; + const agg = info.aggregated; + + html += ` +
+
+ ${info.name || id} + ${agg ? `avg ${agg.cpu?.avg?.toFixed(0) || 0}% cpu` : ''} + ${info.status || 'running'} +
+
+
+
CPU
+
+
+
+
+ ${cpu.toFixed(1)}% +
+
+
+
Memory
+
+
+
+
+ ${mem.toFixed(1)}% +
+
${formatBytes(memUsed)} / ${formatBytes(memLimit)}
+
+
+
Network
+
+ ↓ ${formatBytes(netRx)} + / + ↑ ${formatBytes(netTx)} +
+
+
+
`; + } + html += '
'; + container.innerHTML = html; + lastUpdateSpan.textContent = 'Updated: ' + new Date().toLocaleTimeString(); + } catch (e) { + container.innerHTML = `
❌ Failed to load stats: ${e.message}
`; + } + } + + // === 24h Aggregated Tab === + async function loadAggregated() { + if (!aggregatedContainer) return; + const data = cachedMonitoringData; + if (!data || Object.keys(data).length === 0) { + aggregatedContainer.innerHTML = '
📈No monitoring data available. Open the Live Stats tab first.
'; + return; + } + let html = '
'; + for (const [id, info] of Object.entries(data)) { + const agg = info.aggregated; + if (!agg) continue; + html += `
+
${info.name || id}
+
+
${agg.cpu?.avg?.toFixed(1) || 0}%Avg CPU
+
${agg.cpu?.max?.toFixed(1) || 0}%Max CPU
+
${agg.memory?.avg?.toFixed(1) || 0}%Avg Mem
+
${agg.memory?.max?.toFixed(1) || 0}%Max Mem
+
+ ${agg.dataPoints ? `
${agg.dataPoints} data points over ${agg.timeRange || 24}h
` : ''} +
`; + } + html += '
'; + aggregatedContainer.innerHTML = html; + } + + // === Alerts Tab === + async function loadAlerts() { + if (!alertsContainer) return; + alertsContainer.innerHTML = '
Loading alerts...
'; + const data = cachedMonitoringData; + if (!data || Object.keys(data).length === 0) { + alertsContainer.innerHTML = '
🔔No containers found. Open the Live Stats tab first.
'; + return; + } + let html = '
'; + for (const [id, info] of Object.entries(data)) { + const alertCfg = info.alertConfig || {}; + html += `
+
+ ${info.name || id} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
`; + } + html += '
'; + alertsContainer.innerHTML = html; + + // Wire up save buttons + alertsContainer.querySelectorAll('.alert-save-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const cId = btn.dataset.container; + const enabled = alertsContainer.querySelector(`.alert-enabled[data-container="${cId}"]`)?.checked || false; + const cpuThreshold = parseInt(alertsContainer.querySelector(`.alert-cpu[data-container="${cId}"]`)?.value) || 80; + const memoryThreshold = parseInt(alertsContainer.querySelector(`.alert-mem[data-container="${cId}"]`)?.value) || 85; + const cooldownMinutes = parseInt(alertsContainer.querySelector(`.alert-cooldown[data-container="${cId}"]`)?.value) || 15; + const autoRestart = alertsContainer.querySelector(`.alert-autorestart[data-container="${cId}"]`)?.checked || false; + try { + const res = await secureFetch(`/api/v1/monitoring/alerts/${cId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled, cpuThreshold, memoryThreshold, cooldownMinutes, autoRestart }) + }); + const data = await res.json(); + btn.textContent = data.success ? '✅ Saved' : '⚠️ Failed'; + setTimeout(() => { btn.textContent = 'Save'; }, 2000); + } catch (e) { + btn.textContent = '❌ Error'; + setTimeout(() => { btn.textContent = 'Save'; }, 2000); + } + }); + }); + } + + function startAutoRefresh() { + if (refreshInterval) clearInterval(refreshInterval); + if (autoRefreshCheckbox?.checked) { + refreshInterval = setInterval(loadStats, DC.POLL.STATS); + } + } + + function stopAutoRefresh() { + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } + } + + // Open modal + openBtn?.addEventListener('click', () => { + modal.classList.add('show'); + loadStats(); + startAutoRefresh(); + }); + + // Close modal + cancelBtn?.addEventListener('click', () => { + modal.classList.remove('show'); + stopAutoRefresh(); + }); + + modal?.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.remove('show'); + stopAutoRefresh(); + } + }); + + refreshBtn?.addEventListener('click', loadStats); + + autoRefreshCheckbox?.addEventListener('change', () => { + if (autoRefreshCheckbox.checked) startAutoRefresh(); + else stopAutoRefresh(); + }); + + // Lazy-load tabs + document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener('click', loadAggregated); + document.querySelector('[data-panel="stats-alerts"]')?.addEventListener('click', loadAlerts); +})(); diff --git a/status/js/service-credentials.js b/status/js/service-credentials.js new file mode 100644 index 0000000..964e3c2 --- /dev/null +++ b/status/js/service-credentials.js @@ -0,0 +1,284 @@ +// ===== SERVICE CREDENTIALS ===== +(function() { + injectModal('folder-browser-modal', `
+
+

📂 Browse for Media Folders

+ +
+ / +
+ +
+
Loading...
+
+ + + +
+ +
+ + +
+
+
+
`); + + injectModal('service-creds-modal', `
+
+

Service Credentials

+

Credentials are injected automatically when accessing this service.

+ + +
+ + No credentials stored +
+ + + + + + + + + + + +
+ + + +
+
+
`); + + const modal = document.getElementById('service-creds-modal'); + let currentService = null; + const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr']; + + window.openServiceCredsModal = async function(service) { + currentService = service; + const title = document.getElementById('svc-creds-title'); + const desc = document.getElementById('svc-creds-desc'); + const seedhostSection = document.getElementById('svc-creds-seedhost'); + const apikeySection = document.getElementById('svc-creds-apikey'); + const basicSection = document.getElementById('svc-creds-basic'); + + title.textContent = service.name + ' Credentials'; + // Determine which sections to show + const isExt = !!service.isExternal; + const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate); + + seedhostSection.style.display = isExt ? '' : 'none'; + apikeySection.style.display = isArr ? '' : 'none'; + basicSection.style.display = !isExt ? '' : 'none'; + + if (isExt) { + desc.textContent = 'Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.'; + // Update password placeholder with service name + document.getElementById('svc-seedhost-pass').placeholder = `Password for ${service.name}`; + } else if (isArr) { + desc.textContent = 'API key bypasses the app login screen automatically.'; + } else { + desc.textContent = 'Credentials are injected automatically when accessing this service.'; + } + + // Load existing credentials + await loadServiceCreds(service); + modal.classList.add('show'); + }; + + async function loadServiceCreds(service) { + const dot = document.getElementById('svc-creds-dot'); + const status = document.getElementById('svc-creds-status'); + const clearBtn = document.getElementById('svc-creds-clear'); + let hasCreds = false; + + // Load seedhost creds (shared username + per-service password) + if (service.isExternal) { + try { + const res = await fetch(`/api/v1/seedhost-creds?serviceId=${service.id}`); + const data = await res.json(); + if (data.success) { + document.getElementById('svc-seedhost-user').value = data.username || ''; + if (data.hasCredentials) hasCreds = true; + } else { + document.getElementById('svc-seedhost-user').value = ''; + } + } catch (e) { /* ignore */ } + document.getElementById('svc-seedhost-pass').value = ''; + } + + // Load per-service creds + try { + const res = await fetch(`/api/v1/services/${service.id}/credentials`); + const data = await res.json(); + if (data.success) { + if (data.hasApiKey) { + document.getElementById('svc-apikey-input').value = '••••••••'; + hasCreds = true; + } else { + document.getElementById('svc-apikey-input').value = ''; + } + if (data.hasBasicAuth && !service.isExternal) { + document.getElementById('svc-basic-user').value = data.username || ''; + hasCreds = true; + } else { + document.getElementById('svc-basic-user').value = ''; + } + } + } catch (e) { /* ignore */ } + if (document.getElementById('svc-basic-pass')) document.getElementById('svc-basic-pass').value = ''; + + if (hasCreds) { + dot.style.background = 'var(--ok-fg, #74dfc4)'; + status.style.color = 'var(--ok-fg, #74dfc4)'; + status.textContent = 'Credentials stored'; + clearBtn.style.display = ''; + // Update the card button + const btn = document.getElementById(`creds-btn-${service.id}`); + if (btn) btn.classList.add('has-creds'); + } else { + dot.style.background = 'var(--muted)'; + status.style.color = 'var(--muted)'; + status.textContent = 'No credentials stored'; + clearBtn.style.display = 'none'; + } + } + + // Save button + document.getElementById('svc-creds-save')?.addEventListener('click', async () => { + if (!currentService) return; + const saveBtn = document.getElementById('svc-creds-save'); + saveBtn.textContent = 'Saving...'; + saveBtn.disabled = true; + + try { + // Save seedhost creds (shared username + per-service password) + if (currentService.isExternal) { + const user = document.getElementById('svc-seedhost-user').value.trim(); + const pass = document.getElementById('svc-seedhost-pass').value; + if (user) { + await secureFetch('/api/v1/seedhost-creds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id }) + }); + } + } + + // Save API key + const apiKeyInput = document.getElementById('svc-apikey-input'); + const apiKey = apiKeyInput.value.trim(); + if (apiKey && apiKey !== '••••••••') { + await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey }) + }); + } + + // Save per-service basic auth + if (!currentService.isExternal) { + const user = document.getElementById('svc-basic-user').value.trim(); + const pass = document.getElementById('svc-basic-pass').value; + if (user && pass) { + await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password: pass }) + }); + } + } + + await loadServiceCreds(currentService); + } catch (e) { + console.error('Failed to save credentials:', e); + } + saveBtn.textContent = 'Save'; + saveBtn.disabled = false; + }); + + // Clear button + document.getElementById('svc-creds-clear')?.addEventListener('click', async () => { + if (!currentService) return; + if (!confirm(`Remove stored credentials for ${currentService.name}?`)) return; + try { + if (currentService.isExternal) { + await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' }); + } + await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' }); + const btn = document.getElementById(`creds-btn-${currentService.id}`); + if (btn) btn.classList.remove('has-creds'); + await loadServiceCreds(currentService); + } catch (e) { + console.error('Failed to clear credentials:', e); + } + }); + + // Close button / backdrop + document.getElementById('svc-creds-close')?.addEventListener('click', () => { + modal.classList.remove('show'); + currentService = null; + }); + modal?.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.remove('show'); + currentService = null; + } + }); + + // Check credential status for all services on page load (update key button highlights) + window.refreshCredsButtons = async function() { + try { + for (const app of (window.APPS || [])) { + if (!app.isExternal && !app.appTemplate && !app.url) continue; + let hasCreds = false; + if (app.isExternal) { + try { + const seedRes = await fetch(`/api/v1/seedhost-creds?serviceId=${app.id}`); + const seedData = await seedRes.json(); + if (seedData.success && seedData.hasCredentials) hasCreds = true; + } catch (e) { /* ignore */ } + } + try { + const r = await fetch(`/api/v1/services/${app.id}/credentials`); + const d = await r.json(); + if (d.success && (d.hasApiKey || d.hasBasicAuth)) hasCreds = true; + } catch (e) { /* ignore */ } + const btn = document.getElementById(`creds-btn-${app.id}`); + if (btn) btn.classList.toggle('has-creds', hasCreds); + } + } catch (e) { /* ignore */ } + }; +})(); diff --git a/status/js/setup-wizard.js b/status/js/setup-wizard.js new file mode 100644 index 0000000..21175f2 --- /dev/null +++ b/status/js/setup-wizard.js @@ -0,0 +1,414 @@ +// Shared timezone utility — used by setup wizard and settings modal +window.populateTimezoneSelect = function(selectEl, selectedTz) { + const timezones = Intl.supportedValuesOf('timeZone'); + const detected = selectedTz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + selectEl.innerHTML = ''; + for (const tz of timezones) { + const opt = document.createElement('option'); + opt.value = tz; + opt.textContent = tz.replace(/_/g, ' '); + if (tz === detected) opt.selected = true; + selectEl.appendChild(opt); + } +}; + +// Setup Wizard System - Server-side config storage +(function () { + let currentConfigType = 'homelab'; + let serverConfig = null; + + // Check server for existing config on page load + async function checkServerConfig() { + try { + const response = await fetch('/api/v1/config'); + if (response.ok) { + serverConfig = await response.json(); + if (serverConfig && serverConfig.setupComplete) { + // Config exists on server - don't show wizard + document.getElementById('setup-wizard').style.display = 'none'; + return true; + } + } + } catch (error) { + console.warn('Could not fetch server config, checking localStorage fallback:', error.message); + } + + // Fallback: check localStorage for backwards compatibility + const localSetup = safeGet('dashcaddy-setup'); + if (localSetup) { + document.getElementById('setup-wizard').style.display = 'none'; + return true; + } + + // No config found - show wizard + document.getElementById('setup-wizard').style.display = 'flex'; + return false; + } + + // Initialize on page load + checkServerConfig(); + + // Populate timezone dropdown with auto-detection + const setupTzSelect = document.getElementById('setup-timezone'); + if (setupTzSelect) window.populateTimezoneSelect(setupTzSelect); + + // Step navigation + function showStep(stepId) { + document.querySelectorAll('.setup-step').forEach(step => { + step.style.display = 'none'; + }); + const targetStep = document.getElementById(stepId); + if (targetStep) { + targetStep.style.display = 'block'; + } + } + + // Show summary + function showSummary() { + const summaryContent = document.getElementById('setup-summary-content'); + if (!summaryContent) return; + + let html = '
'; + + if (currentConfigType === 'homelab') { + const tld = document.getElementById('setup-tld')?.value?.trim() || '.home'; + const caName = document.getElementById('setup-ca-name')?.value?.trim() || ''; + const dnsIP = document.getElementById('setup-dns-ip')?.value?.trim() || ''; + const dnsPort = document.getElementById('setup-dns-port')?.value?.trim() || DC.DEFAULTS.DNS_PORT; + + html += ` +
+

Home Lab Configuration

+
+
TLD: ${tld}
+
Certificate Authority: ${caName}
+
DNS Server: ${dnsIP}:${dnsPort}
+
Example URLs: https://uptime${tld}, https://nextcloud${tld}
+
+
+ `; + } else if (currentConfigType === 'simple') { + const ip = document.getElementById('setup-simple-ip')?.value?.trim() || 'localhost'; + + html += ` +
+

Simple Setup

+
+
Access Method: IP:Port only
+
Default IP: ${ip}
+
SSL: None (HTTP only)
+
Example URLs: http://${ip}:8080, http://${ip}:3000
+
+
+ `; + } else if (currentConfigType === 'public') { + const domain = document.getElementById('setup-public-domain')?.value?.trim() || ''; + const email = document.getElementById('setup-public-email')?.value?.trim() || ''; + + html += ` +
+

Public Server

+
+
Domain: ${domain}
+
SSL: Let's Encrypt
+
Email: ${email}
+
Example URLs: https://app.${domain}, https://cloud.${domain}
+
+
+ `; + } + + // Timezone (universal across all config types) + const tz = document.getElementById('setup-timezone')?.value || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + html += ` +
+
Timezone: ${tz.replace(/_/g, ' ')}
+
+ `; + + html += '
'; + summaryContent.innerHTML = html; + showStep('setup-step-summary'); + } + + // Save config to server + async function saveConfigToServer(config) { + try { + const response = await secureFetch('/api/v1/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + if (response.ok) { + await response.json(); + return true; + } else { + console.error('Failed to save config to server:', response.status); + return false; + } + } catch (error) { + console.error('Error saving config to server:', error); + return false; + } + } + + // Finish setup handler + async function finishSetup() { + const config = { + setupComplete: true, + configurationType: currentConfigType, + timestamp: new Date().toISOString(), + timezone: document.getElementById('setup-timezone')?.value || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + }; + + if (currentConfigType === 'homelab') { + config.tld = document.getElementById('setup-tld')?.value?.trim() || '.home'; + config.caName = document.getElementById('setup-ca-name')?.value?.trim() || ''; + config.dns = { + provider: 'technitium', + ip: document.getElementById('setup-dns-ip')?.value?.trim() || '', + port: document.getElementById('setup-dns-port')?.value?.trim() || DC.DEFAULTS.DNS_PORT, + token: document.getElementById('setup-dns-token')?.value?.trim() || '' + }; + config.defaults = { + dnsType: 'private', + sslType: 'internal', + targetIP: 'localhost' + }; + } else if (currentConfigType === 'simple') { + config.defaultIP = document.getElementById('setup-simple-ip')?.value?.trim() || 'localhost'; + config.defaults = { + dnsType: 'none', + sslType: 'none', + targetIP: config.defaultIP + }; + } else if (currentConfigType === 'public') { + config.domain = document.getElementById('setup-public-domain')?.value?.trim() || ''; + config.email = document.getElementById('setup-public-email')?.value?.trim() || ''; + config.defaults = { + dnsType: 'public', + sslType: 'letsencrypt', + targetIP: 'localhost' + }; + } + + // Save to server (primary) and localStorage (fallback) + const savedToServer = await saveConfigToServer(config); + safeSet('dashcaddy-config', JSON.stringify(config)); + safeSet('dashcaddy-setup', 'completed'); + + // Hide wizard + document.getElementById('setup-wizard').style.display = 'none'; + + // Show success notification + const configName = currentConfigType === 'homelab' ? 'Professional Home Lab' : + currentConfigType === 'simple' ? 'Simple Setup' : 'Public Server'; + const saveLocation = savedToServer ? 'server (shared across all devices)' : 'locally (this browser only)'; + + showNotification(`Setup Complete! Configured for: ${configName}. Settings saved to: ${saveLocation}`, 'success', 5000); + + // Reload page to apply configuration + setTimeout(() => location.reload(), 500); + } + + // ===== Event Handlers using direct onclick for reliability ===== + + // Step 1: Continue button + const step1Next = document.getElementById('setup-step-1-next'); + if (step1Next) { + step1Next.onclick = function(e) { + e.preventDefault(); + const selected = document.querySelector('input[name="config-type"]:checked'); + if (selected) { + currentConfigType = selected.value; + } + if (currentConfigType === 'homelab') { + showStep('setup-step-homelab'); + } else if (currentConfigType === 'simple') { + showStep('setup-step-simple'); + } else if (currentConfigType === 'public') { + showStep('setup-step-public'); + } else { + showStep('setup-step-homelab'); + } + }; + } + + // Skip setup button + const skipBtn = document.getElementById('setup-skip'); + if (skipBtn) { + skipBtn.onclick = async function(e) { + e.preventDefault(); + if (confirm('Skip setup? You can run it later from Settings.')) { + // Save skip status to server + await saveConfigToServer({ setupComplete: true, skipped: true, timestamp: new Date().toISOString() }); + safeSet('dashcaddy-setup', 'skipped'); + document.getElementById('setup-wizard').style.display = 'none'; + } + }; + } + + // Home Lab: TLD Preview + const tldInput = document.getElementById('setup-tld'); + if (tldInput) { + tldInput.oninput = function(e) { + const tld = e.target.value || '.home'; + const preview1 = document.getElementById('tld-preview'); + const preview2 = document.getElementById('tld-preview-2'); + if (preview1) preview1.textContent = tld; + if (preview2) preview2.textContent = tld; + }; + } + + // Home Lab navigation + const homelabBack = document.getElementById('setup-homelab-back'); + if (homelabBack) { + homelabBack.onclick = function(e) { + e.preventDefault(); + showStep('setup-step-1'); + }; + } + + const homelabNext = document.getElementById('setup-homelab-next'); + if (homelabNext) { + homelabNext.onclick = function(e) { + e.preventDefault(); + const tld = document.getElementById('setup-tld')?.value?.trim() || ''; + const caName = document.getElementById('setup-ca-name')?.value?.trim() || ''; + const dnsIP = document.getElementById('setup-dns-ip')?.value?.trim() || ''; + + if (!tld || !tld.startsWith('.')) { + showNotification('Please enter a valid TLD starting with a dot (e.g., .home)', 'warning'); + return; + } + if (!caName) { + showNotification('Please enter a Certificate Authority name', 'warning'); + return; + } + if (!dnsIP) { + showNotification('Please enter your DNS server IP address', 'warning'); + return; + } + showSummary(); + }; + } + + // Simple navigation + const simpleBack = document.getElementById('setup-simple-back'); + if (simpleBack) { + simpleBack.onclick = function(e) { + e.preventDefault(); + showStep('setup-step-1'); + }; + } + + const simpleNext = document.getElementById('setup-simple-next'); + if (simpleNext) { + simpleNext.onclick = function(e) { + e.preventDefault(); + showSummary(); + }; + } + + // Public navigation + const publicBack = document.getElementById('setup-public-back'); + if (publicBack) { + publicBack.onclick = function(e) { + e.preventDefault(); + showStep('setup-step-1'); + }; + } + + const publicNext = document.getElementById('setup-public-next'); + if (publicNext) { + publicNext.onclick = function(e) { + e.preventDefault(); + const domain = document.getElementById('setup-public-domain')?.value?.trim() || ''; + const email = document.getElementById('setup-public-email')?.value?.trim() || ''; + + if (!domain) { + showNotification('Please enter your domain name', 'warning'); + return; + } + if (!email || !email.includes('@')) { + showNotification('Please enter a valid email address', 'warning'); + return; + } + showSummary(); + }; + } + + // Summary navigation + const summaryBack = document.getElementById('setup-summary-back'); + if (summaryBack) { + summaryBack.onclick = function(e) { + e.preventDefault(); + if (currentConfigType === 'homelab') { + showStep('setup-step-homelab'); + } else if (currentConfigType === 'simple') { + showStep('setup-step-simple'); + } else if (currentConfigType === 'public') { + showStep('setup-step-public'); + } + }; + } + + // Finish setup button + const finishBtn = document.getElementById('setup-finish'); + if (finishBtn) { + finishBtn.onclick = function(e) { + e.preventDefault(); + finishSetup(); + }; + } + + // Expose function to get global config (from server or localStorage) + window.getGlobalConfig = async function() { + // Try server first + try { + const response = await fetch('/api/v1/config'); + if (response.ok) { + const config = await response.json(); + if (config && config.setupComplete) { + return config; + } + } + } catch (e) { + console.warn('Could not fetch config from server'); + } + + // Fallback to localStorage + const configStr = safeGet('dashcaddy-config'); + if (configStr) { + return JSON.parse(configStr); + } + + // Return default config if not set + return { + setupComplete: false, + configurationType: 'homelab', + tld: '.home', + caName: '', + defaults: { + dnsType: 'private', + sslType: 'internal', + targetIP: 'localhost' + } + }; + }; + + // Expose reset function for settings + window.resetSetupWizard = async function() { + if (confirm('Reset DashCaddy configuration? This will show the setup wizard again.')) { + try { + await secureFetch('/api/v1/config', { method: 'DELETE' }); + } catch (e) { + console.warn('Could not delete server config'); + } + safeRemove('dashcaddy-setup'); + safeRemove('dashcaddy-config'); + location.reload(); + } + }; +})(); diff --git a/status/js/skeleton-loader.js b/status/js/skeleton-loader.js new file mode 100644 index 0000000..7e610cd --- /dev/null +++ b/status/js/skeleton-loader.js @@ -0,0 +1,56 @@ +// ========== SKELETON LOADING PLACEHOLDERS ========== +(function () { + + function createSkeletonCard() { + const card = document.createElement('div'); + card.className = 'skeleton-card'; + card.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + return card; + } + + function showSkeletons(count) { + const grid = document.getElementById('cards'); + if (!grid || grid.querySelector('.card')) return; // Don't show if real cards exist + count = count || 6; + + for (let i = 0; i < count; i++) { + const sk = createSkeletonCard(); + grid.appendChild(sk); + // Stagger fade-in + setTimeout(function () { sk.classList.add('loaded'); }, i * 60); + } + } + + function hideSkeletons() { + const grid = document.getElementById('cards'); + if (!grid) return; + var skeletons = grid.querySelectorAll('.skeleton-card'); + if (!skeletons.length) return; + + skeletons.forEach(function (sk, i) { + setTimeout(function () { + sk.style.opacity = '0'; + sk.style.transform = 'translateY(-10px)'; + }, i * 25); + }); + // Remove after animation + setTimeout(function () { + skeletons.forEach(function (sk) { if (sk.parentNode) sk.remove(); }); + }, skeletons.length * 25 + 300); + } + + window.SkeletonLoader = { show: showSkeletons, hide: hideSkeletons }; +})(); diff --git a/status/js/smart-arr-connect.js b/status/js/smart-arr-connect.js new file mode 100644 index 0000000..29a2e95 --- /dev/null +++ b/status/js/smart-arr-connect.js @@ -0,0 +1,431 @@ +// ========== SMART ARR CONNECT ========== +(function() { + injectModal('arr-setup-modal', `
+
+

🎬 Smart Arr Connect

+

+ Auto-discover and connect your entire media stack. +

+ + +
+
+ +
Scanning for services...
+
+ +
+ + + + + + + + + + + +
+ Where to find API keys:
+ Radarr/Sonarr/Prowlarr: Settings -> General -> Security -> API Key +
+ + + +
+
`); + + const modal = document.getElementById('arr-setup-modal'); + const openBtn = document.getElementById('arr-setup-btn'); + const cancelBtn = document.getElementById('arr-setup-cancel'); + const connectBtn = document.getElementById('smart-connect-btn'); + + // Phase elements + const phaseDetect = document.getElementById('smart-phase-detect'); + const phaseCredentials = document.getElementById('smart-phase-credentials'); + const phaseProgress = document.getElementById('smart-phase-progress'); + const phaseResults = document.getElementById('smart-phase-results'); + + const detectResults = document.getElementById('smart-detect-results'); + const credentialInputs = document.getElementById('smart-credential-inputs'); + const progressSteps = document.getElementById('smart-progress-steps'); + const resultsContent = document.getElementById('smart-results-content'); + const plexLibraries = document.getElementById('smart-plex-libraries'); + const retryBtn = document.getElementById('smart-retry-btn'); + + let detectedData = null; // Store detection results for smart-connect + + const serviceIcons = { plex: '🎬', radarr: '🎬', sonarr: '📺', prowlarr: '🔍', seerr: '📋' }; + const serviceLabels = { plex: 'Plex', radarr: 'Radarr (Movies)', sonarr: 'Sonarr (TV)', prowlarr: 'Prowlarr (Indexers)', seerr: 'Seerr' }; + + function showPhase(phase) { + phaseDetect.style.display = phase === 'detect' ? 'block' : 'none'; + phaseCredentials.style.display = phase === 'credentials' ? 'block' : 'none'; + phaseProgress.style.display = phase === 'progress' ? 'block' : 'none'; + phaseResults.style.display = phase === 'results' ? 'block' : 'none'; + } + + function statusBadge(status) { + const colors = { + connected: { bg: 'var(--ok-fg)', icon: '✓', text: 'Connected' }, + needs_key: { bg: '#f39c12', icon: '🔑', text: 'Needs API Key' }, + not_found: { bg: 'var(--muted)', icon: '—', text: 'Not Found' }, + error: { bg: 'var(--bad-fg)', icon: '✗', text: 'Error' } + }; + const c = colors[status] || colors.not_found; + return `${c.icon} ${c.text}`; + } + + // Phase 1: Smart Detection + async function smartDetect() { + showPhase('detect'); + detectResults.style.display = 'none'; + + try { + const response = await fetch('/api/v1/arr/smart-detect'); + detectedData = await response.json(); + + if (!detectedData.success) { + detectResults.innerHTML = `
Detection failed: ${escapeHtml(detectedData.error)}
`; + detectResults.style.display = 'block'; + return; + } + + // Render detection results + let html = '
'; + + for (const [svc, info] of Object.entries(detectedData.services)) { + const icon = serviceIcons[svc] || '📦'; + const label = serviceLabels[svc] || svc; + const source = info.source ? `${escapeHtml(info.source)}` : ''; + const version = info.version ? `v${escapeHtml(info.version)}` : ''; + const keySaved = (info.hasApiKey || info.hasToken) && info.status === 'connected' + ? 'Key saved' : ''; + + html += `
+ ${icon} +
+
${label}
+
+ ${source} ${version} ${keySaved} +
+
+ ${statusBadge(info.status)} +
`; + } + + html += '
'; + + // Summary + const s = detectedData.summary; + html += `
+ ${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected + (5 - s.totalDetected)))} services detected · + ${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey > 0 ? ` · ${escapeHtml(String(s.needsApiKey))} needs API key` : ''} +
`; + + detectResults.innerHTML = html; + detectResults.style.display = 'block'; + + // Build credential inputs for services that need keys + buildCredentialInputs(detectedData); + + // Auto-advance after a short delay + setTimeout(() => { + showPhase('credentials'); + }, 800); + + } catch (e) { + detectResults.innerHTML = `
Error: ${escapeHtml(e.message)}
`; + detectResults.style.display = 'block'; + } + } + + // Build credential input fields + function buildCredentialInputs(data) { + let html = ''; + const services = data.services; + const arrServices = ['radarr', 'sonarr', 'prowlarr']; + + for (const svc of arrServices) { + const info = services[svc]; + if (!info || info.status === 'not_found' && !info.url) continue; + + const icon = serviceIcons[svc]; + const label = serviceLabels[svc]; + const isConnected = info.status === 'connected'; + const borderColor = isConnected ? 'var(--ok-fg)' : 'var(--border)'; + + html += `
+
+ ${icon} + ${label} + + ${isConnected ? '✓ Connected' : ''} + +
+
+
+ + +
+
+ + +
+
+ +
`; + } + + // Plex status (non-editable, just shows status) + const plex = services.plex; + if (plex) { + const plexConnected = plex.status === 'connected'; + html += `
+
+ 🎬 + Plex + ${statusBadge(plex.status)} + ${escapeHtml(plex.source || '')} +
+
`; + } + + // Seerr status + const seerr = services.seerr; + if (seerr) { + const seerrOk = seerr.status === 'connected'; + let configuredHtml = ''; + if (seerr.configuredServices) { + const cs = seerr.configuredServices; + configuredHtml = `
+ Configured: ${cs.radarr ? '✓ Radarr' : '✗ Radarr'} · + ${cs.sonarr ? '✓ Sonarr' : '✗ Sonarr'} · + ${cs.plex ? '✓ Plex' : '✗ Plex'} +
`; + } + html += `
+
+ 📋 + Seerr + ${statusBadge(seerr.status)} +
+ ${configuredHtml} +
`; + } + + credentialInputs.innerHTML = html; + } + + // Test connection (global for onclick) + window.smartTestConnection = async function(service) { + const urlInput = document.getElementById(`smart-${service}-url`); + const keyInput = document.getElementById(`smart-${service}-key`); + const statusSpan = document.getElementById(`smart-${service}-status`); + + const url = urlInput?.value.trim(); + const apiKey = keyInput?.value.trim(); + + if (!url || !apiKey) { + statusSpan.innerHTML = 'Enter URL and API key'; + return; + } + + statusSpan.innerHTML = ''; + + try { + const response = await secureFetch('/api/v1/arr/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service, url, apiKey }) + }); + const data = await response.json(); + if (data.success) { + statusSpan.innerHTML = `✓ ${escapeHtml(data.appName || 'Connected')} v${escapeHtml(data.version || '')}`; + } else { + statusSpan.innerHTML = `✗ ${escapeHtml(data.error)}`; + } + } catch (e) { + statusSpan.innerHTML = `✗ ${escapeHtml(e.message)}`; + } + }; + + // Phase 3: Smart Connect + async function smartConnect() { + showPhase('progress'); + progressSteps.innerHTML = '
Connecting services...
'; + + // Gather input + const services = {}; + for (const svc of ['radarr', 'sonarr', 'prowlarr']) { + const url = document.getElementById(`smart-${svc}-url`)?.value.trim(); + const apiKey = document.getElementById(`smart-${svc}-key`)?.value.trim(); + if (apiKey && url) { + services[svc] = { apiKey, url }; + } else if (apiKey) { + // Key provided without URL - let backend resolve + services[svc] = { apiKey }; + } + // If no key entered but service was already connected, backend uses stored credentials + } + + const payload = { + services: Object.keys(services).length > 0 ? services : undefined, + configurePlex: document.getElementById('smart-opt-plex')?.checked, + configureProwlarr: document.getElementById('smart-opt-prowlarr')?.checked, + configureSeerr: document.getElementById('smart-opt-seerr')?.checked, + saveCredentials: document.getElementById('smart-opt-save')?.checked + }; + + try { + const response = await secureFetch('/api/v1/arr/smart-connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + // Render progress steps + let stepsHtml = ''; + for (const step of (data.steps || [])) { + const icon = step.status === 'success' ? '' + : ''; + const detailColor = step.status === 'success' ? 'var(--muted)' : 'var(--bad-fg)'; + stepsHtml += `
+ ${icon} + ${escapeHtml(step.step)} + ${escapeHtml(step.details || '')} +
`; + } + progressSteps.innerHTML = stepsHtml; + + // Show results after brief delay + setTimeout(() => showResults(data), 500); + + } catch (e) { + progressSteps.innerHTML = `
Connection error: ${escapeHtml(e.message)}
`; + } + } + + // Phase 4: Results + function showResults(data) { + showPhase('results'); + + const s = data.summary || {}; + const allGood = s.failed === 0 && s.succeeded > 0; + const headerColor = allGood ? 'var(--ok-fg)' : '#f39c12'; + const headerIcon = allGood ? '✓' : '⚠'; + const headerText = allGood ? 'All Connected!' : `${escapeHtml(String(s.succeeded))}/${escapeHtml(String(s.totalSteps))} Steps Succeeded`; + + let html = `
+
${headerIcon}
+
${headerText}
+
${escapeHtml(String(s.succeeded))} succeeded, ${escapeHtml(String(s.failed))} failed
+
`; + + // Steps detail + html += '
'; + for (const step of (data.steps || [])) { + const icon = step.status === 'success' ? '' + : ''; + html += `
+ ${icon} ${escapeHtml(step.step)} ${escapeHtml(step.details || '')} +
`; + } + html += '
'; + + resultsContent.innerHTML = html; + + // Show retry button if any failures + retryBtn.style.display = s.failed > 0 ? 'block' : 'none'; + + // Fetch Plex libraries if Plex was connected + if (data.steps?.some(st => st.step.includes('Plex') && st.status === 'success')) { + fetchPlexLibraries(); + } + } + + async function fetchPlexLibraries() { + try { + const res = await fetch('/api/v1/plex/libraries'); + const data = await res.json(); + if (data.success && data.libraries?.length > 0) { + let html = `
+

🎬 ${escapeHtml(data.serverName)} Libraries

+
`; + for (const lib of data.libraries) { + const typeIcon = lib.type === 'movie' ? '🎬' : lib.type === 'show' ? '📺' : '🎵'; + html += `
+ ${typeIcon} ${escapeHtml(lib.title)} + ${escapeHtml(String(lib.count))} items +
`; + } + html += '
'; + plexLibraries.innerHTML = html; + plexLibraries.style.display = 'block'; + } + } catch (e) { + // Ignore Plex library fetch errors + } + } + + // Event listeners + openBtn?.addEventListener('click', () => { + modal.classList.add('show'); + plexLibraries.style.display = 'none'; + smartDetect(); + }); + + wireModal(modal, cancelBtn); + connectBtn?.addEventListener('click', smartConnect); + retryBtn?.addEventListener('click', smartConnect); +})(); diff --git a/status/js/theme-adapter.js b/status/js/theme-adapter.js new file mode 100644 index 0000000..e79ae88 --- /dev/null +++ b/status/js/theme-adapter.js @@ -0,0 +1,307 @@ +/** + * Theme Adapter + * Ensures tooltips match the current dashboard theme + * Integrates with Driver.js to apply theme-specific styling + */ + +(function(window) { + 'use strict'; + + /** + * Theme configuration mapping for Driver.js + * Maps dashboard themes to Driver.js styling + */ + const THEME_CONFIGS = { + dark: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(0, 0, 0, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + light: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent-strong)', + overlayColor: 'rgba(0, 0, 0, 0.5)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent-strong)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + blue: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(25, 8, 172, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + nord: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(46, 52, 64, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + dracula: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(40, 42, 54, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + 'solarized-dark': { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(0, 43, 54, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + 'solarized-light': { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(253, 246, 227, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + } + }; + + /** + * ThemeAdapter class + * Manages theme integration for the tooltip system + */ + class ThemeAdapter { + constructor() { + this.currentTheme = this.getCurrentTheme(); + this.themeChangeCallbacks = []; + this._setupThemeChangeListener(); + } + + /** + * Get the current theme name from document root class + * @returns {string} Current theme name (e.g., 'dark', 'light', 'blue') + */ + getCurrentTheme() { + const root = document.documentElement; + const classList = Array.from(root.classList); + + // Check all known themes (built-in + user) except 'dark' (no class = dark) + const allThemes = (window.THEMES || []).filter(t => t !== 'dark'); + const foundTheme = allThemes.find(theme => classList.includes(theme)); + + return foundTheme || 'dark'; + } + + /** + * Get Driver.js theme configuration for current theme + * @returns {Object} Theme configuration object + */ + getDriverTheme() { + const themeName = this.getCurrentTheme(); + const config = THEME_CONFIGS[themeName] || THEME_CONFIGS.dark; + + // Resolve CSS variables to actual values + const resolvedConfig = {}; + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'string' && value.startsWith('var(')) { + // Extract CSS variable name + const varName = value.match(/var\((--[^)]+)\)/)?.[1]; + if (varName) { + const computedValue = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + resolvedConfig[key] = computedValue || value; + } else { + resolvedConfig[key] = value; + } + } else { + resolvedConfig[key] = value; + } + } + + return resolvedConfig; + } + + /** + * Register a callback for theme changes + * @param {Function} callback - Function to call when theme changes + */ + onThemeChange(callback) { + if (typeof callback === 'function') { + this.themeChangeCallbacks.push(callback); + } + } + + /** + * Setup theme change listener using MutationObserver + * @private + */ + _setupThemeChangeListener() { + const root = document.documentElement; + + // Create observer to watch for class changes on root element + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + const newTheme = this.getCurrentTheme(); + if (newTheme !== this.currentTheme) { + const oldTheme = this.currentTheme; + this.currentTheme = newTheme; + this._notifyThemeChange(newTheme, oldTheme); + } + } + }); + }); + + // Start observing + observer.observe(root, { + attributes: true, + attributeFilter: ['class'] + }); + + console.log('[ThemeAdapter] Theme change listener initialized'); + } + + /** + * Notify all registered callbacks of theme change + * @private + * @param {string} newTheme - New theme name + * @param {string} oldTheme - Old theme name + */ + _notifyThemeChange(newTheme, oldTheme) { + console.log(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); + + this.themeChangeCallbacks.forEach(callback => { + try { + callback(newTheme, oldTheme); + } catch (error) { + console.error('[ThemeAdapter] Error in theme change callback:', error); + } + }); + } + + /** + * Apply theme to Driver.js instance + * @param {Object} driver - Driver.js instance + */ + applyTheme(driver) { + if (!driver) { + console.warn('[ThemeAdapter] No driver instance provided'); + return; + } + + const themeConfig = this.getDriverTheme(); + + // Apply theme configuration to driver + // Note: Driver.js v1.0+ uses CSS variables, so we inject a style element + this._injectDriverStyles(themeConfig); + + console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme); + } + + /** + * Inject custom styles for Driver.js based on theme + * @private + * @param {Object} themeConfig - Theme configuration + */ + _injectDriverStyles(themeConfig) { + // Remove existing theme styles + const existingStyle = document.getElementById('driver-theme-styles'); + if (existingStyle) { + existingStyle.remove(); + } + + // Create new style element + const style = document.createElement('style'); + style.id = 'driver-theme-styles'; + style.textContent = ` + .driver-popover { + background: ${themeConfig.backgroundColor} !important; + color: ${themeConfig.textColor} !important; + border: 1px solid ${themeConfig.borderColor} !important; + border-radius: 12px !important; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-title { + color: ${themeConfig.textColor} !important; + font-weight: 600 !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-description { + color: ${themeConfig.textColor} !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-footer button { + background: ${themeConfig.primaryColor} !important; + color: ${themeConfig.backgroundColor} !important; + border: none !important; + font-family: ${themeConfig.fontFamily} !important; + font-weight: 500 !important; + } + + .driver-popover-footer button:hover { + opacity: 0.9 !important; + } + + .driver-popover-close-btn { + color: ${themeConfig.textColor} !important; + } + + .driver-overlay { + background: ${themeConfig.overlayColor} !important; + } + + .driver-highlighted-element { + outline: 2px solid ${themeConfig.highlightColor} !important; + outline-offset: 4px !important; + } + + .driver-popover-progress-text { + color: ${themeConfig.textColor} !important; + opacity: 0.7 !important; + font-family: ${themeConfig.fontFamily} !important; + } + `; + + document.head.appendChild(style); + } + + /** + * Get all available theme names + * @returns {string[]} Array of theme names + */ + getAvailableThemes() { + return Object.keys(THEME_CONFIGS); + } + + /** + * Check if a theme is available + * @param {string} themeName - Theme name to check + * @returns {boolean} True if theme is available + */ + isThemeAvailable(themeName) { + return THEME_CONFIGS.hasOwnProperty(themeName); + } + } + + // Export to global scope + window.ThemeAdapter = ThemeAdapter; + + console.log('[ThemeAdapter] Module loaded'); + +})(window); diff --git a/status/js/theme-builder.js b/status/js/theme-builder.js new file mode 100644 index 0000000..a4777fb --- /dev/null +++ b/status/js/theme-builder.js @@ -0,0 +1,637 @@ +// ========== THEME PICKER + THEME BUILDER ========== +(function () { + var themeBeforeBuilder = null; + var editingSlug = null; + var advancedOverrides = {}; // tracks which advanced fields user manually changed + + // Display names for built-in themes + var THEME_LABELS = { + dark: 'Dark', + light: 'Light', + blue: 'Blue', + black: 'Black', + nord: 'Nord', + dracula: 'Dracula', + 'solarized-dark': 'Solarized Dark', + 'solarized-light': 'Solarized Light', + taxi: 'Taxi', + ocean: 'Ocean', + }; + + // Color picker field definitions: [css-prop, label, section] + var FIELDS = [ + ['bg', 'Background', 'base'], + ['card-base', 'Card', 'base'], + ['fg', 'Text', 'base'], + ['muted', 'Muted Text', 'base'], + ['border', 'Border', 'base'], + ['accent', 'Accent', 'accent'], + ['accent-strong', 'Accent Strong', 'accent'], + ['ok-bg', 'OK Background', 'status'], + ['ok-fg', 'OK Text', 'status'], + ['bad-bg', 'Error Bg', 'status'], + ['bad-fg', 'Error Text', 'status'], + ['dot-ok', 'Dot OK', 'status'], + ['dot-bad', 'Dot Error', 'status'], + ['uptime', 'Uptime Bar', 'status'], + ['hover', 'Hover', 'advanced'], + ['card-hover', 'Card Hover', 'advanced'], + ['base', 'Tags/Badges', 'advanced'], + ['fg-muted', 'Dim Text', 'advanced'], + ['success', 'Success', 'advanced'], + ['error', 'Error', 'advanced'], + ['warning', 'Warning', 'advanced'], + ]; + + // ─── Theme Cycle Button ─────────────────────────────── + + var themeBtn = document.getElementById('theme'); + if (!themeBtn) return; + var themeLabel = document.getElementById('theme-label'); + + function getLabelFor(t) { + if (THEME_LABELS[t]) return THEME_LABELS[t]; + var userThemes = safeGetJSON(window.USER_THEMES_KEY, {}); + if (userThemes[t]) return userThemes[t].name || t; + return t; + } + + function updateLabel() { + if (themeLabel) themeLabel.textContent = getLabelFor(window.getActiveTheme()); + } + + themeBtn.addEventListener('click', function () { + var list = window.THEMES.slice(); + var current = window.getActiveTheme(); + var idx = list.indexOf(current); + var next = list[(idx + 1) % list.length]; + window.applyTheme(next); + updateLabel(); + }); + + updateLabel(); + + // ─── Theme Builder Modal ─────────────────────────────── + + function buildFieldsHTML() { + var sections = { base: 'Base Colors', accent: 'Accent', status: 'Status', advanced: 'Advanced (auto-derived)' }; + var grouped = {}; + FIELDS.forEach(function (f) { + if (!grouped[f[2]]) grouped[f[2]] = []; + grouped[f[2]].push(f); + }); + + var html = ''; + Object.keys(sections).forEach(function (key) { + if (key === 'advanced') { + html += '
Show advanced colors ▼
'; + html += '