ui rebuild

This commit is contained in:
2025-11-24 19:52:35 +00:00
parent c9beae93c8
commit 9aa6906ca5
73 changed files with 8514 additions and 4537 deletions

View File

@@ -5,6 +5,8 @@
### NEVER GUESS - ALWAYS READ THE ACTUAL CODE
**FUCKING ALWAYS look at the dependency actual code. Don't start fucking guessing stuff.**
run "pnpm run watch" when starting to do stuff, so the UI gets recompiled and the server automatically restarts on file changes.
When working with any dependency:
1. **READ the actual source code** in `node_modules/` or check the package documentation
2. **CHECK the exact API** - don't assume based on similar libraries

View File

@@ -7,7 +7,7 @@
"test": "deno test --allow-all test/",
"test:watch": "deno test --allow-all --watch test/",
"compile": "bash scripts/compile-all.sh",
"dev": "deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor"
"dev": "pnpm run watch"
},
"imports": {
"@std/path": "jsr:@std/path@^1.1.2",
@@ -17,7 +17,7 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.1.0",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.0.2",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^1.8.0",

View File

@@ -8,7 +8,8 @@
"onebox": "./bin/onebox-wrapper.js"
},
"scripts": {
"postinstall": "node scripts/install-binary.js"
"postinstall": "node scripts/install-binary.js",
"watch": "concurrently --kill-others --names \"BACKEND,UI\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor\" \"cd ui && pnpm run watch\""
},
"keywords": [
"docker",
@@ -50,5 +51,8 @@
"arm64"
],
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"dependencies": {}
"dependencies": {},
"devDependencies": {
"concurrently": "^9.1.2"
}
}

206
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,206 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
concurrently:
specifier: ^9.1.2
version: 9.2.1
packages:
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
hasBin: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
require-directory@2.1.1:
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
snapshots:
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.8.3
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
emoji-regex@8.0.0: {}
escalade@3.2.0: {}
get-caller-file@2.0.5: {}
has-flag@4.0.0: {}
is-fullwidth-code-point@3.0.0: {}
require-directory@2.1.1: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
shell-quote@1.8.3: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
tree-kill@1.2.2: {}
tslib@2.8.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1

491
readme.md
View File

@@ -1,59 +1,88 @@
# @serve.zone/onebox
> Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers
> 🚀 Self-hosted Docker Swarm platform with native reverse proxy, automatic SSL, and real-time WebSocket updates
**Onebox** is a single-executable tool that transforms any Linux server into a simple container hosting platform. Deploy Docker containers with automatic HTTPS, DNS configuration, and Nginx reverse proxy - all managed through a beautiful Angular web interface or powerful CLI.
**Onebox** transforms any Linux server into a powerful container hosting platform. Deploy Docker Swarm services with automatic HTTPS, DNS configuration, and a native Deno reverse proxy - all managed through a beautiful Angular web interface with real-time updates.
## Features
## Issue Reporting and Security
- 🐳 **Docker Container Management** - Deploy, start, stop, and manage containers
- 🌐 **Automatic Nginx Reverse Proxy** - Traffic routing with zero configuration
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration via SmartACME
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record management
- 📊 **Metrics & Monitoring** - Historical CPU, memory, and network stats
- 📝 **Log Aggregation** - Centralized container logs
- 🎨 **Angular Web UI** - Modern, responsive interface
- 👥 **Multi-user Support** - Role-based access control
- 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What Makes Onebox Different? 🎯
- **Native Deno Reverse Proxy** - Built from scratch in Deno (no Nginx required!), featuring HTTP/HTTPS servers with SNI support and bidirectional WebSocket proxying
- **Docker Swarm First** - All workloads run as Swarm services, not standalone containers, for built-in orchestration
- **Real-time Everything** - WebSocket-powered live updates for service status, logs, and metrics across all connected clients
- **Single Executable** - Compiles to a standalone binary - just run it, no dependencies
- **Private Registry Included** - Built-in Docker registry with auto-deploy on push
- **Zero Config SSL** - Automatic Let's Encrypt certificates with hot-reload
- **Cloudflare Integration** - Automatic DNS record management
- **Modern Stack** - Deno runtime + SQLite database + Angular 18 UI
## Features ✨
### Core Platform
- 🐳 **Docker Swarm Management** - Deploy, scale, and orchestrate services with Swarm mode
- 🌐 **Native Reverse Proxy** - Deno-based HTTP/HTTPS proxy with dynamic routing from database
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and synchronization
- 📦 **Built-in Registry** - Private Docker registry with per-service tokens and auto-update
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events
### Monitoring & Management
- 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s)
- 📝 **Centralized Logging** - Container logs with retention policies
- 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates
- 👥 **Multi-user Support** - Role-based access control (admin/user)
- 💾 **SQLite Database** - Embedded, zero-configuration storage
- 📦 **Single Executable** - No dependencies, no installation hassle
- 🔄 **Systemd Integration** - Run as a daemon with auto-restart
## Quick Start
### Developer Experience
- 🚀 **Auto-update on Push** - Push to registry and services update automatically
- 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries
- 🔄 **Systemd Integration** - Run as a daemon with auto-restart
- 🎛️ **Full CLI & API** - Manage everything from terminal or HTTP API
## Quick Start 🏁
### Installation
```bash
# Install via shell script
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
# Download the latest release for your platform
curl -sSL https://code.foss.global/serve.zone/onebox/releases/latest/download/onebox-linux-x64 -o onebox
chmod +x onebox
sudo mv onebox /usr/local/bin/
# Or via npm/pnpm
# Or install from npm
pnpm install -g @serve.zone/onebox
```
### Deploy Your First Service
### First Run
```bash
# Add a registry (optional, for private images)
onebox registry add --url registry.example.com --username myuser --password mypass
# Start the server in development mode
onebox server --ephemeral
# Deploy a service
# In another terminal, deploy your first service
onebox service add myapp \
--image nginx:latest \
--domain app.example.com \
--env PORT=80
# Check status
onebox service list
# View logs
onebox service logs myapp
--port 80
```
### Install as Daemon
### Access the Web UI
Open `http://localhost:3000` in your browser.
**Default credentials:**
- Username: `admin`
- Password: `admin`
⚠️ **Change the default password immediately after first login!**
### Production Setup
```bash
# Install systemd service
# Install as systemd service
sudo onebox daemon install
# Start the daemon
@@ -63,147 +92,395 @@ sudo onebox daemon start
sudo onebox daemon logs
```
### Access Web UI
## Architecture 🏗️
The web UI is available at `http://localhost:3000` (or configured port).
Onebox is built with modern technologies for performance and developer experience:
Default credentials:
- Username: `admin`
- Password: `admin` (change immediately!)
```
┌─────────────────────────────────────────────────┐
│ Angular 18 Web UI │
│ (Real-time WebSocket Updates) │
└─────────────────┬───────────────────────────────┘
│ HTTP/WS
┌─────────────────▼───────────────────────────────┐
│ Deno HTTP Server (Port 3000) │
│ REST API + WebSocket Broadcast │
└─────────────────┬───────────────────────────────┘
┌─────────────────▼───────────────────────────────┐
│ Native Reverse Proxy │
│ HTTP (80) + HTTPS (443) + SNI + WS Proxy │
└─────┬───────────────────────────────────────────┘
├──► Docker Swarm Services
├──► SSL Certificate Manager (Let's Encrypt)
├──► Cloudflare DNS Manager
├──► Built-in Docker Registry
└──► SQLite Database
```
## CLI Reference
### Core Components
- **Deno Runtime** - Modern TypeScript with built-in security
- **Native Reverse Proxy** - Custom HTTP/HTTPS proxy with TLS SNI support
- **Docker Swarm** - Container orchestration (NOT standalone containers)
- **SQLite Database** - Configuration, metrics, and user data
- **WebSocket Server** - Real-time bidirectional communication
- **Let's Encrypt** - Automatic SSL certificate management
- **Cloudflare API** - DNS record automation
## CLI Reference 📖
### Service Management
```bash
onebox service add <name> --image <image> --domain <domain> [--env KEY=VALUE]
onebox service remove <name>
# Deploy a service
onebox service add <name> --image <image> --domain <domain> [--port <port>] [--env KEY=VALUE]
# Deploy with Onebox Registry (auto-update on push)
onebox service add myapp --use-onebox-registry --domain myapp.example.com
# List services
onebox service list
# Control services
onebox service start <name>
onebox service stop <name>
onebox service restart <name>
onebox service list
onebox service logs <name> [--follow]
# Remove service
onebox service remove <name>
# View logs
onebox service logs <name>
```
### Server Management
```bash
# Start server (development)
onebox server --ephemeral # Runs in foreground with monitoring
# Start server (production)
onebox daemon install # Install systemd service
onebox daemon start # Start daemon
onebox daemon stop # Stop daemon
onebox daemon logs # View logs
```
### Registry Management
```bash
onebox registry add --url <url> --username <user> --password <pass>
onebox registry remove <url>
# Add external registry credentials
onebox registry add --url registry.example.com --username user --password pass
# List registries
onebox registry list
# Remove registry
onebox registry remove <url>
```
### DNS Management
```bash
onebox dns add <domain> --ip <ip>
onebox dns remove <domain>
# Add DNS record (requires Cloudflare config)
onebox dns add <domain>
# List DNS records
onebox dns list
# Sync from Cloudflare
onebox dns sync
# Remove DNS record
onebox dns remove <domain>
```
### SSL Management
```bash
onebox ssl renew [domain]
onebox ssl list
# Renew expiring certificates
onebox ssl renew
# Force renew specific domain
onebox ssl force-renew <domain>
```
### Nginx Management
```bash
onebox nginx reload
onebox nginx test
onebox nginx status
```
### Daemon Management
```bash
onebox daemon install
onebox daemon start
onebox daemon stop
onebox daemon restart
onebox daemon logs
```
### User Management
```bash
onebox user add <username> --password <password> [--role admin|user]
onebox user remove <username>
onebox user list
onebox user passwd <username>
# List certificates
onebox ssl list
```
### Configuration
```bash
# Show all settings
onebox config show
# Set configuration value
onebox config set <key> <value>
# Example: Configure Cloudflare
onebox config set cloudflareAPIKey your-api-key
onebox config set cloudflareEmail your@email.com
onebox config set cloudflareZoneID your-zone-id
```
### Metrics
### System Status
```bash
onebox metrics [service-name]
# Get full system status
onebox status
```
## Architecture
## Configuration 🔧
Onebox is built with Deno and compiles to a standalone binary for each platform:
### System Requirements
- **Deno Runtime** - Modern TypeScript with built-in security
- **SQLite** - Embedded database for configuration and metrics
- **Docker Engine** - Container runtime (required on host)
- **Nginx** - Reverse proxy and SSL termination
- **Cloudflare API** - DNS management
- **Let's Encrypt** - Free SSL certificates
- **Angular 18+** - Modern web interface
## Requirements
- **Linux** x64 or ARM64 (primary target)
- **Linux** (x64 or ARM64)
- **Docker** installed and running
- **Nginx** installed
- **Root/sudo access** (for nginx, Docker, ports 80/443)
- **(Optional) Cloudflare account** for DNS management
- **Docker Swarm** initialized (`docker swarm init`)
- **Root/sudo access** for ports 80/443
- **(Optional) Cloudflare account** for DNS automation
## Development
### Data Locations
- **Database**: `./onebox.db` (or custom path)
- **SSL Certificates**: Managed by CertManager
- **Registry Data**: `./.nogit/registry-data`
### Environment Variables
```bash
# Database location
ONEBOX_DB_PATH=/path/to/onebox.db
# HTTP server port (default: 3000)
ONEBOX_HTTP_PORT=3000
# Enable debug logging
ONEBOX_DEBUG=true
```
## Development 💻
### Setup
```bash
# Clone repository
git clone https://code.foss.global/serve.zone/onebox
cd onebox
# Run in development mode
# Install dependencies (Deno handles this automatically)
deno task dev
```
### Tasks
```bash
# Development server (auto-restart on changes)
deno task dev
# Run tests
deno task test
# Compile for all platforms
# Watch mode for tests
deno task test:watch
# Compile binaries for all platforms
deno task compile
```
## Configuration
### Project Structure
Onebox stores configuration in:
- **Database**: `/var/lib/onebox/onebox.db`
- **Nginx configs**: `/etc/nginx/sites-available/onebox-*`
- **SSL certificates**: `/etc/letsencrypt/live/`
```
onebox/
├── ts/
│ ├── classes/ # Core implementations
│ │ ├── onebox.ts # Main coordinator
│ │ ├── reverseproxy.ts # Native HTTP/HTTPS proxy
│ │ ├── docker.ts # Docker Swarm API
│ │ ├── database.ts # SQLite storage
│ │ ├── httpserver.ts # REST API + WebSocket
│ │ ├── services.ts # Service orchestration
│ │ ├── certmanager.ts # SSL certificate management
│ │ ├── registry.ts # Built-in Docker registry
│ │ └── ...
│ ├── cli.ts # CLI router
│ ├── types.ts # TypeScript interfaces
│ └── plugins.ts # Dependency imports
├── ui/ # Angular web interface
├── test/ # Test files
├── mod.ts # Main entry point
└── deno.json # Deno configuration
```
## Contributing
### API Endpoints
Contributions welcome! Please read the contributing guidelines first.
The HTTP server exposes the following endpoints:
## License
- `POST /api/auth/login` - User authentication (returns token)
- `GET /api/status` - System status (requires auth)
- `GET /api/services` - List all services (requires auth)
- `POST /api/services` - Create service (requires auth)
- `PUT /api/services/:id` - Update service (requires auth)
- `DELETE /api/services/:id` - Delete service (requires auth)
- `GET /api/ws` - WebSocket connection for real-time updates
MIT © Lossless GmbH
See `ts/classes/httpserver.ts` for complete API documentation.
## Links
### WebSocket Messages
- [Documentation](https://code.foss.global/serve.zone/onebox/src/branch/main/docs)
- [Issue Tracker](https://code.foss.global/serve.zone/onebox/issues)
- [Changelog](./changelog.md)
Real-time updates are broadcast via WebSocket:
```typescript
// Service lifecycle updates
{
type: 'service_update',
action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped',
service: { id, name, status, ... }
}
// Service status changes
{
type: 'service_status',
service: { id, name, status, ... }
}
// System status updates
{
type: 'system_status',
status: { docker, reverseProxy, services, ... }
}
```
## Advanced Usage 🚀
### Using the Built-in Registry
```bash
# Deploy a service with Onebox Registry
onebox service add myapp \
--use-onebox-registry \
--domain myapp.example.com \
--auto-update-on-push
# Get the registry token for pushing images
# (Token is automatically created and stored in database)
# Push your image
docker tag myimage:latest localhost:4000/myapp:latest
docker push localhost:4000/myapp:latest
# Service automatically updates! 🎉
```
### Cloudflare DNS Integration
```bash
# Configure Cloudflare (one-time setup)
onebox config set cloudflareAPIKey your-api-key
onebox config set cloudflareEmail your@email.com
onebox config set cloudflareZoneID your-zone-id
# Deploy with automatic DNS
onebox service add myapp \
--image nginx:latest \
--domain myapp.example.com
# DNS record is automatically created!
```
### SSL Certificate Management
SSL certificates are automatically obtained and renewed:
- Certificates are requested when a service with a domain is deployed
- Renewal happens automatically 30 days before expiry
- Certificates are hot-reloaded without downtime
- Force renewal: `onebox ssl force-renew <domain>`
### Monitoring and Metrics
Metrics are collected every 60 seconds (configurable):
```bash
# Set metrics interval (milliseconds)
onebox config set metricsInterval 30000
# View in web UI or query database directly
sqlite3 onebox.db "SELECT * FROM metrics WHERE service_id = 1 ORDER BY timestamp DESC LIMIT 10"
```
## Troubleshooting 🔧
### Docker Swarm Not Initialized
```bash
# Initialize Docker Swarm
docker swarm init
# Verify swarm mode
docker info | grep "Swarm: active"
```
### Port Already in Use
```bash
# Check what's using port 80/443
sudo lsof -i :80
sudo lsof -i :443
# Kill the process or change Onebox ports
onebox config set httpPort 8080
```
### SSL Certificate Issues
```bash
# Check certificate status
onebox ssl list
# Verify DNS is pointing to your server
dig +short yourdomain.com
# Force certificate renewal
onebox ssl force-renew yourdomain.com
```
### WebSocket Connection Issues
- Ensure firewall allows WebSocket connections
- Check browser console for connection errors
- Verify `/api/ws` endpoint is accessible
### Service Not Starting
```bash
# Check Docker logs
docker service logs <service-name>
# Check Onebox logs
onebox daemon logs
# Verify image exists
docker images | grep <image-name>
```
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -57,8 +57,8 @@ export class CloudflareDomainSync {
try {
logger.info('Starting Cloudflare zone synchronization...');
// Fetch all zones from Cloudflare
const zones = await this.cloudflareAccount!.getZones();
// Fetch all zones from Cloudflare (v6+ API uses convenience.listZones())
const zones = await this.cloudflareAccount!.convenience.listZones();
logger.info(`Found ${zones.length} Cloudflare zone(s)`);
const now = Date.now();

View File

@@ -651,7 +651,13 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM services WHERE name = ?', [name]);
return rows.length > 0 ? this.rowToService(rows[0]) : null;
if (rows.length > 0) {
logger.info(`getServiceByName: raw row data: ${JSON.stringify(rows[0])}`);
const service = this.rowToService(rows[0]);
logger.info(`getServiceByName: service object containerID: ${service.containerID}`);
return service;
}
return null;
}
getServiceByID(id: number): IService | null {

View File

@@ -40,8 +40,8 @@ export class OneboxDockerManager {
*/
private async ensureNetwork(): Promise<void> {
try {
const networks = await this.dockerClient!.getNetworks();
const existingNetwork = networks.find((n: any) => n.name === this.networkName);
const networks = await this.dockerClient!.listNetworks();
const existingNetwork = networks.find((n: any) => n.Name === this.networkName);
if (!existingNetwork) {
logger.info(`Creating Docker network: ${this.networkName}`);
@@ -228,14 +228,12 @@ export class OneboxDockerManager {
* Get network ID by name
*/
private async getNetworkID(networkName: string): Promise<string> {
const networks = await this.dockerClient!.getNetworks();
const network = networks.find((n: any) =>
(n.name || n.Name) === networkName
);
const networks = await this.dockerClient!.listNetworks();
const network = networks.find((n: any) => n.Name === networkName);
if (!network) {
throw new Error(`Network not found: ${networkName}`);
}
return network.id || network.Id;
return network.Id;
}
/**
@@ -578,19 +576,32 @@ export class OneboxDockerManager {
*/
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
try {
const container = this.dockerClient!.getContainer(containerID);
const container = await this.dockerClient!.getContainerById(containerID);
if (!container) {
// Container not found - this is expected for Swarm services where we have service ID instead of container ID
// Return null silently
return null;
}
const stats = await container.stats({ stream: false });
// Validate stats structure
if (!stats || !stats.cpu_stats || !stats.cpu_stats.cpu_usage) {
logger.warn(`Invalid stats structure for container ${containerID}`);
return null;
}
// Calculate CPU percentage
const cpuDelta =
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage || 0);
const cpuPercent =
systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0;
// Memory stats
const memoryUsed = stats.memory_stats.usage || 0;
const memoryLimit = stats.memory_stats.limit || 0;
const memoryUsed = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 0;
const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0;
// Network stats
@@ -612,49 +623,88 @@ export class OneboxDockerManager {
networkTx,
};
} catch (error) {
// Don't log errors for container not found - this is expected for Swarm services
if (!error.message.includes('No such container') && !error.message.includes('not found')) {
logger.error(`Failed to get container stats ${containerID}: ${error.message}`);
}
return null;
}
}
/**
* Helper: Get actual container ID for a Swarm service
* For Swarm services, we need to find the task/container that's actually running
*/
private async getContainerIdForService(serviceId: string): Promise<string | null> {
try {
// List all containers and find one with the service label matching our service ID
const containers = await this.dockerClient!.listContainers();
// Find a container that belongs to this service
const serviceContainer = containers.find((container: any) => {
const labels = container.Labels || {};
// Swarm services have a com.docker.swarm.service.id label
return labels['com.docker.swarm.service.id'] === serviceId;
});
if (serviceContainer) {
return serviceContainer.Id;
}
return null;
} catch (error) {
logger.warn(`Failed to get container ID for service ${serviceId}: ${error.message}`);
return null;
}
}
/**
* Get container logs
* Handles both regular containers and Swarm services
*/
async getContainerLogs(
containerID: string,
tail = 100
): Promise<{ stdout: string; stderr: string }> {
try {
const container = this.dockerClient!.getContainer(containerID);
let actualContainerId = containerID;
// Try to get container directly first
let container = await this.dockerClient!.getContainerById(containerID);
// If not found, it might be a service ID - try to get the actual container ID
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
actualContainerId = serviceContainerId;
container = await this.dockerClient!.getContainerById(serviceContainerId);
}
}
if (!container) {
throw new Error(`Container not found: ${containerID}`);
}
// Get logs as string (v5 handles demultiplexing automatically)
const logs = await container.logs({
stdout: true,
stderr: true,
tail,
tail: tail,
timestamps: true,
});
// Parse logs (Docker returns them in a special format)
const stdout: string[] = [];
const stderr: string[] = [];
const lines = logs.toString().split('\n');
for (const line of lines) {
if (line.length === 0) continue;
// Docker log format: first byte indicates stream (1=stdout, 2=stderr)
const streamType = line.charCodeAt(0);
const content = line.slice(8); // Skip header (8 bytes)
if (streamType === 1) {
stdout.push(content);
} else if (streamType === 2) {
stderr.push(content);
}
// v5 should return a string, but let's handle edge cases
if (typeof logs !== 'string') {
logger.error(`Unexpected logs type: ${typeof logs}, constructor: ${logs?.constructor?.name}`);
logger.error(`Logs content: ${JSON.stringify(logs).slice(0, 500)}`);
// If it's not a string, something went wrong
throw new Error(`Unexpected log format: expected string, got ${typeof logs}`);
}
// v5 returns already-parsed logs as a string
return {
stdout: stdout.join('\n'),
stderr: stderr.join('\n'),
stdout: logs,
stderr: '', // v5 combines stdout/stderr into single string
};
} catch (error) {
logger.error(`Failed to get container logs ${containerID}: ${error.message}`);
@@ -662,47 +712,15 @@ export class OneboxDockerManager {
}
}
/**
* Stream container logs (real-time)
*/
async streamContainerLogs(
containerID: string,
callback: (line: string, isError: boolean) => void
): Promise<void> {
try {
const container = this.dockerClient!.getContainer(containerID);
const stream = await container.logs({
stdout: true,
stderr: true,
follow: true,
tail: 0,
timestamps: true,
});
stream.on('data', (chunk: Buffer) => {
const streamType = chunk[0];
const content = chunk.slice(8).toString();
callback(content, streamType === 2);
});
stream.on('error', (error: Error) => {
logger.error(`Log stream error for ${containerID}: ${error.message}`);
});
} catch (error) {
logger.error(`Failed to stream container logs ${containerID}: ${error.message}`);
throw error;
}
}
/**
* List all onebox-managed containers
*/
async listContainers(): Promise<any[]> {
try {
const containers = await this.dockerClient!.getContainers();
const containers = await this.dockerClient!.listContainers();
// Filter for onebox-managed containers
return containers.filter((c: any) =>
c.labels && c.labels['managed-by'] === 'onebox'
c.Labels && c.Labels['managed-by'] === 'onebox'
);
} catch (error) {
logger.error(`Failed to list containers: ${error.message}`);
@@ -724,14 +742,16 @@ export class OneboxDockerManager {
/**
* Get Docker version info
* Note: v5 API doesn't expose version() method, so we return a placeholder
*/
async getDockerVersion(): Promise<any> {
try {
return await this.dockerClient!.version();
} catch (error) {
logger.error(`Failed to get Docker version: ${error.message}`);
return null;
}
// v5 API doesn't have a version() method
// Return a basic structure for compatibility
return {
Version: 'N/A',
ApiVersion: 'N/A',
Note: 'Version info not available in @apiclient.xyz/docker v5'
};
}
/**
@@ -753,7 +773,12 @@ export class OneboxDockerManager {
*/
async getContainerIP(containerID: string): Promise<string | null> {
try {
const container = this.dockerClient!.getContainer(containerID);
const container = await this.dockerClient!.getContainerById(containerID);
if (!container) {
throw new Error(`Container not found: ${containerID}`);
}
const info = await container.inspect();
const networks = info.NetworkSettings.Networks;
@@ -776,7 +801,11 @@ export class OneboxDockerManager {
cmd: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const container = this.dockerClient!.getContainer(containerID);
const container = await this.dockerClient!.getContainerById(containerID);
if (!container) {
throw new Error(`Container not found: ${containerID}`);
}
const exec = await container.exec({
Cmd: cmd,

View File

@@ -76,6 +76,12 @@ export class OneboxHttpServer {
return this.handleWebSocketUpgrade(req);
}
// Log streaming WebSocket
if (path.startsWith('/api/services/') && path.endsWith('/logs/stream') && req.headers.get('upgrade') === 'websocket') {
const serviceName = path.split('/')[3];
return this.handleLogStreamUpgrade(req, serviceName);
}
// Docker Registry v2 API (no auth required - registry handles it)
if (path.startsWith('/v2/')) {
return await this.oneboxRef.registry.handleRequest(req);
@@ -107,25 +113,31 @@ export class OneboxHttpServer {
filePath = '/index.html';
}
const fullPath = `./ui/dist${filePath}`;
const fullPath = `./ui/dist/ui/browser${filePath}`;
// Read file
const file = await Deno.readFile(fullPath);
// Determine content type
const contentType = this.getContentType(filePath);
// Prevent stale bundles in dev (no hashed filenames) while allowing long-lived caching for hashed prod assets
const isHashedAsset = /\.[a-f0-9]{8,}\./i.test(filePath);
const cacheControl =
filePath === '/index.html' || !isHashedAsset
? 'no-cache'
: 'public, max-age=31536000, immutable';
return new Response(file, {
headers: {
'Content-Type': contentType,
'Cache-Control': filePath === '/index.html' ? 'no-cache' : 'public, max-age=3600',
'Cache-Control': cacheControl,
},
});
} catch (error) {
// File not found - serve index.html for Angular routing
if (error instanceof Deno.errors.NotFound) {
try {
const indexFile = await Deno.readFile('./ui/dist/index.html');
const indexFile = await Deno.readFile('./ui/dist/ui/browser/index.html');
return new Response(indexFile, {
headers: {
'Content-Type': 'text/html',
@@ -450,6 +462,8 @@ export class OneboxHttpServer {
private async handleGetLogsRequest(name: string): Promise<Response> {
try {
const logs = await this.oneboxRef.services.getServiceLogs(name);
logger.log(`handleGetLogsRequest: logs type = ${typeof logs}, constructor = ${logs?.constructor?.name}`);
logger.log(`handleGetLogsRequest: logs value = ${String(logs).slice(0, 100)}`);
return this.jsonResponse({ success: true, data: logs });
} catch (error) {
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
@@ -824,6 +838,135 @@ export class OneboxHttpServer {
return response;
}
/**
* Handle WebSocket upgrade for log streaming
*/
private handleLogStreamUpgrade(req: Request, serviceName: string): Response {
const { socket, response } = Deno.upgradeWebSocket(req);
socket.onopen = async () => {
logger.info(`Log stream WebSocket connected for service: ${serviceName}`);
try {
// Get the service from database
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
socket.send(JSON.stringify({ error: 'Service not found' }));
socket.close();
return;
}
// Get the container (handle both direct container IDs and service IDs)
logger.info(`Looking up container for service ${serviceName}, containerID: ${service.containerID}`);
let container = await this.oneboxRef.docker.dockerClient!.getContainerById(service.containerID!);
logger.info(`Direct lookup result: ${container ? 'found' : 'null'}`);
// If not found, it might be a service ID - try to get the actual container ID
if (!container) {
logger.info('Listing all containers to find matching service...');
const containers = await this.oneboxRef.docker.dockerClient!.listContainers();
logger.info(`Found ${containers.length} containers`);
const serviceContainer = containers.find((c: any) => {
const labels = c.Labels || {};
return labels['com.docker.swarm.service.id'] === service.containerID;
});
if (serviceContainer) {
logger.info(`Found matching container: ${serviceContainer.Id}`);
container = await this.oneboxRef.docker.dockerClient!.getContainerById(serviceContainer.Id);
logger.info(`Second lookup result: ${container ? 'found' : 'null'}`);
} else {
logger.error(`No container found with service label matching ${service.containerID}`);
}
}
if (!container) {
logger.error(`Container not found for service ${serviceName}, containerID: ${service.containerID}`);
socket.send(JSON.stringify({ error: 'Container not found' }));
socket.close();
return;
}
// Start streaming logs
const logStream = await container.streamLogs({
stdout: true,
stderr: true,
timestamps: true,
tail: 100, // Start with last 100 lines
});
// Send initial connection message
socket.send(JSON.stringify({
type: 'connected',
serviceName: service.name,
}));
// Demultiplex and pipe log data to WebSocket
// Docker streams use 8-byte headers: [STREAM_TYPE, 0, 0, 0, SIZE_BYTE1, SIZE_BYTE2, SIZE_BYTE3, SIZE_BYTE4]
let buffer = Buffer.alloc(0);
logStream.on('data', (chunk: Buffer) => {
if (socket.readyState !== WebSocket.OPEN) return;
// Append new data to buffer
buffer = Buffer.concat([buffer, chunk]);
// Process complete frames
while (buffer.length >= 8) {
// Read frame size from header (bytes 4-7, big-endian)
const frameSize = buffer.readUInt32BE(4);
// Check if we have the complete frame
if (buffer.length < 8 + frameSize) {
break; // Wait for more data
}
// Extract the frame data (skip 8-byte header)
const frameData = buffer.slice(8, 8 + frameSize);
// Send the clean log line
socket.send(frameData.toString('utf8'));
// Remove processed frame from buffer
buffer = buffer.slice(8 + frameSize);
}
});
logStream.on('error', (error: Error) => {
logger.error(`Log stream error for ${serviceName}: ${error.message}`);
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ error: error.message }));
}
});
logStream.on('end', () => {
logger.info(`Log stream ended for ${serviceName}`);
socket.close();
});
// Clean up on close
socket.onclose = () => {
logger.info(`Log stream WebSocket closed for ${serviceName}`);
logStream.destroy();
};
} catch (error) {
logger.error(`Failed to start log stream for ${serviceName}: ${error.message}`);
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ error: error.message }));
socket.close();
}
}
};
socket.onerror = (error) => {
logger.error(`Log stream WebSocket error: ${error}`);
};
return response;
}
/**
* Broadcast message to all connected WebSocket clients
*/

View File

@@ -157,13 +157,22 @@ export class RegistryManager {
}
try {
// Check if getManifest method exists (API may have changed)
if (typeof this.registry.getManifest !== 'function') {
// Method not available in current API version
return null;
}
const manifest = await this.registry.getManifest(repository, tag);
if (manifest && manifest.digest) {
return manifest.digest;
}
return null;
} catch (error) {
// Only log if it's not a "not a function" error
if (!error.message.includes('not a function')) {
logger.warn(`Failed to get digest for ${repository}:${tag}: ${error.message}`);
}
return null;
}
}

View File

@@ -333,7 +333,13 @@ export class OneboxServicesManager {
const logs = await this.docker.getContainerLogs(service.containerID, tail);
return `=== STDOUT ===\n${logs.stdout}\n\n=== STDERR ===\n${logs.stderr}`;
// Debug: check what we got
logger.log(`getServiceLogs: logs type = ${typeof logs}, constructor = ${logs?.constructor?.name}`);
logger.log(`getServiceLogs: logs.stdout type = ${typeof logs.stdout}`);
logger.log(`getServiceLogs: logs.stdout value = ${String(logs.stdout).slice(0, 100)}`);
// v5 API returns combined stdout/stderr with proper formatting
return logs.stdout;
} catch (error) {
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
throw error;

17
ui/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

46
ui/.gitignore vendored
View File

@@ -1,14 +1,42 @@
# Dependencies
node_modules/
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Build outputs
dist/
.angular/
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# IDE
.vscode/
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Misc
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
*.log
Thumbs.db

59
ui/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Ui
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.19.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -3,29 +3,26 @@
"version": 1,
"newProjectRoot": "projects",
"projects": {
"onebox-ui": {
"ui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "css",
"standalone": true
}
},
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist",
"outputPath": "dist/ui",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": ["zone.js"],
"browser": "src/main.ts",
"polyfills": [],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
@@ -37,39 +34,59 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "onebox-ui:build:production"
"buildTarget": "ui:build:production"
},
"development": {
"buildTarget": "onebox-ui:build:development"
"buildTarget": "ui:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
}
}
}

View File

@@ -1,37 +1,41 @@
{
"name": "onebox-ui",
"version": "1.0.0",
"name": "ui",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build --configuration production",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.0.0",
"@angular/common": "^18.0.0",
"@angular/compiler": "^18.0.0",
"@angular/core": "^18.0.0",
"@angular/forms": "^18.0.0",
"@angular/platform-browser": "^18.0.0",
"@angular/platform-browser-dynamic": "^18.0.0",
"@angular/router": "^18.0.0",
"chart.js": "^4.4.0",
"ng2-charts": "^6.0.0",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",
"tailwindcss": "^3.4.18",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.0",
"@angular/cli": "^18.0.0",
"@angular/compiler-cli": "^18.0.0",
"@types/node": "^20.11.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "~5.4.0"
"@angular-devkit/build-angular": "^19.2.19",
"@angular/cli": "^19.2.19",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"@types/node": "^24.10.1",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

4105
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
ui/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -2,6 +2,7 @@
"/api": {
"target": "http://localhost:3000",
"secure": false,
"ws": true,
"changeOrigin": true
}
}

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

View File

@@ -0,0 +1,336 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 3),
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://twitter.com/angular"
aria-label="Twitter"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Twitter"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'ui' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('ui');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, ui');
});
});

View File

@@ -1,23 +1,14 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { WebSocketService } from './core/services/websocket.service';
import { ToasterComponent } from './ui/toast/toaster.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`,
imports: [RouterOutlet, ToasterComponent],
template: `
<router-outlet />
<ui-toaster />
`,
})
export class AppComponent implements OnInit, OnDestroy {
private wsService = inject(WebSocketService);
ngOnInit(): void {
// Connect to WebSocket when app starts
this.wsService.connect();
}
ngOnDestroy(): void {
// Disconnect when app is destroyed
this.wsService.disconnect();
}
}
export class AppComponent {}

13
ui/src/app/app.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};

View File

@@ -9,9 +9,11 @@ export const routes: Routes = [
},
{
path: '',
canActivate: [authGuard],
loadComponent: () =>
import('./shared/components/layout.component').then((m) => m.LayoutComponent),
import('./shared/components/layout/layout.component').then(
(m) => m.LayoutComponent
),
canActivate: [authGuard],
children: [
{
path: '',
@@ -21,29 +23,60 @@ export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
import('./features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'services',
children: [
{
path: '',
loadComponent: () =>
import('./features/services/services-list.component').then(
(m) => m.ServicesListComponent
),
},
{
path: 'services/new',
path: 'create',
loadComponent: () =>
import('./features/services/service-create.component').then(
(m) => m.ServiceCreateComponent
),
},
{
path: 'services/:name',
path: ':name',
loadComponent: () =>
import('./features/services/service-detail.component').then(
(m) => m.ServiceDetailComponent
),
},
],
},
{
path: 'domains',
children: [
{
path: '',
loadComponent: () =>
import('./features/domains/domains.component').then(
(m) => m.DomainsComponent
),
},
{
path: ':domain',
loadComponent: () =>
import('./features/domains/domain-detail.component').then(
(m) => m.DomainDetailComponent
),
},
],
},
{
path: 'dns',
loadComponent: () =>
import('./features/dns/dns.component').then((m) => m.DnsComponent),
},
{
path: 'registries',
loadComponent: () =>
@@ -51,28 +84,17 @@ export const routes: Routes = [
(m) => m.RegistriesComponent
),
},
{
path: 'dns',
loadComponent: () =>
import('./features/dns/dns.component').then((m) => m.DnsComponent),
},
{
path: 'domains',
loadComponent: () =>
import('./features/domains/domains.component').then((m) => m.DomainsComponent),
},
{
path: 'domains/:domain',
loadComponent: () =>
import('./features/domains/domain-detail.component').then(
(m) => m.DomainDetailComponent
),
},
{
path: 'settings',
loadComponent: () =>
import('./features/settings/settings.component').then((m) => m.SettingsComponent),
import('./features/settings/settings.component').then(
(m) => m.SettingsComponent
),
},
],
},
{
path: '**',
redirectTo: 'dashboard',
},
];

View File

@@ -3,10 +3,10 @@ import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const auth = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
if (auth.isAuthenticated()) {
return true;
}

View File

@@ -1,17 +1,26 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
export const authInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
) => {
const auth = inject(AuthService);
const token = auth.getToken();
if (token && !req.url.includes('/api/auth/login')) {
req = req.clone({
// Skip auth header for login request
if (req.url.includes('/api/auth/login')) {
return next(req);
}
if (token) {
const authReq = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(authReq);
}
return next(req);

View File

@@ -1,186 +1,141 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import {
IApiResponse,
IService,
IServiceCreate,
IServiceUpdate,
ISystemStatus,
IDomain,
IDomainDetail,
IDnsRecord,
IRegistry,
IRegistryCreate,
ISetting,
ISettings,
} from '../types/api.types';
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface Service {
id: number;
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
containerID?: string;
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
createdAt: number;
updatedAt: number;
// Onebox Registry fields
useOneboxRegistry?: boolean;
registryRepository?: string;
registryToken?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
}
export interface Registry {
id: number;
url: string;
username: string;
createdAt: number;
}
export interface SystemStatus {
docker: {
running: boolean;
version: any;
};
reverseProxy: {
http: {
running: boolean;
port: number;
};
https: {
running: boolean;
port: number;
certificates: number;
};
routes: number;
};
dns: {
configured: boolean;
};
ssl: {
configured: boolean;
certbotInstalled: boolean;
};
services: {
total: number;
running: number;
stopped: number;
};
}
@Injectable({
providedIn: 'root',
})
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private baseUrl = '/api';
// System
getStatus(): Observable<ApiResponse<SystemStatus>> {
return this.http.get<ApiResponse<SystemStatus>>(`${this.baseUrl}/status`);
// System Status
async getStatus(): Promise<IApiResponse<ISystemStatus>> {
return firstValueFrom(this.http.get<IApiResponse<ISystemStatus>>('/api/status'));
}
// Services
getServices(): Observable<ApiResponse<Service[]>> {
return this.http.get<ApiResponse<Service[]>>(`${this.baseUrl}/services`);
async getServices(): Promise<IApiResponse<IService[]>> {
return firstValueFrom(this.http.get<IApiResponse<IService[]>>('/api/services'));
}
getService(name: string): Observable<ApiResponse<Service>> {
return this.http.get<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`);
async getService(name: string): Promise<IApiResponse<IService>> {
return firstValueFrom(this.http.get<IApiResponse<IService>>(`/api/services/${name}`));
}
createService(data: any): Observable<ApiResponse<Service>> {
return this.http.post<ApiResponse<Service>>(`${this.baseUrl}/services`, data);
async createService(data: IServiceCreate): Promise<IApiResponse<IService>> {
return firstValueFrom(this.http.post<IApiResponse<IService>>('/api/services', data));
}
deleteService(name: string): Observable<ApiResponse> {
return this.http.delete<ApiResponse>(`${this.baseUrl}/services/${name}`);
async updateService(name: string, data: IServiceUpdate): Promise<IApiResponse<IService>> {
return firstValueFrom(this.http.put<IApiResponse<IService>>(`/api/services/${name}`, data));
}
startService(name: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/start`, {});
async deleteService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/services/${name}`));
}
stopService(name: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/stop`, {});
async startService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/start`, {}));
}
restartService(name: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/restart`, {});
async stopService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/stop`, {}));
}
getServiceLogs(name: string): Observable<ApiResponse<string>> {
return this.http.get<ApiResponse<string>>(`${this.baseUrl}/services/${name}/logs`);
async restartService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/restart`, {}));
}
updateService(name: string, updates: {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
}): Observable<ApiResponse<Service>> {
return this.http.put<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`, updates);
async getServiceLogs(name: string): Promise<IApiResponse<string>> {
return firstValueFrom(this.http.get<IApiResponse<string>>(`/api/services/${name}/logs`));
}
// Registries
getRegistries(): Observable<ApiResponse<Registry[]>> {
return this.http.get<ApiResponse<Registry[]>>(`${this.baseUrl}/registries`);
async getRegistries(): Promise<IApiResponse<IRegistry[]>> {
return firstValueFrom(this.http.get<IApiResponse<IRegistry[]>>('/api/registries'));
}
createRegistry(data: any): Observable<ApiResponse<Registry>> {
return this.http.post<ApiResponse<Registry>>(`${this.baseUrl}/registries`, data);
async createRegistry(data: IRegistryCreate): Promise<IApiResponse<IRegistry>> {
return firstValueFrom(this.http.post<IApiResponse<IRegistry>>('/api/registries', data));
}
deleteRegistry(url: string): Observable<ApiResponse> {
return this.http.delete<ApiResponse>(`${this.baseUrl}/registries/${encodeURIComponent(url)}`);
async deleteRegistry(id: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registries/${id}`));
}
// DNS
getDnsRecords(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/dns`);
// DNS Records
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
return firstValueFrom(this.http.get<IApiResponse<IDnsRecord[]>>('/api/dns'));
}
createDnsRecord(data: any): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/dns`, data);
async createDnsRecord(domain: string, ip?: string): Promise<IApiResponse<IDnsRecord>> {
return firstValueFrom(this.http.post<IApiResponse<IDnsRecord>>('/api/dns', { domain, ip }));
}
deleteDnsRecord(domain: string): Observable<ApiResponse> {
return this.http.delete<ApiResponse>(`${this.baseUrl}/dns/${domain}`);
async deleteDnsRecord(domain: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/dns/${domain}`));
}
syncDnsRecords(): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/dns/sync`, {});
}
// SSL
getSslCertificates(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/ssl`);
}
renewSslCertificate(domain: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/ssl/${domain}/renew`, {});
async syncDnsRecords(): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>('/api/dns/sync', {}));
}
// Domains
getDomains(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/domains`);
async getDomains(): Promise<IApiResponse<IDomainDetail[]>> {
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail[]>>('/api/domains'));
}
getDomainDetail(domain: string): Observable<ApiResponse<any>> {
return this.http.get<ApiResponse<any>>(`${this.baseUrl}/domains/${domain}`);
async getDomainDetail(domain: string): Promise<IApiResponse<IDomainDetail>> {
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail>>(`/api/domains/${domain}`));
}
syncCloudflareDomains(): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/domains/sync`, {});
async syncCloudflareDomains(): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>('/api/domains/sync', {}));
}
// SSL Certificates
async obtainCertificate(domain: string, includeWildcard?: boolean): Promise<IApiResponse<void>> {
return firstValueFrom(
this.http.post<IApiResponse<void>>('/api/ssl/obtain', { domain, includeWildcard })
);
}
async renewCertificate(domain: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/ssl/${domain}/renew`, {}));
}
// Settings
getSettings(): Observable<ApiResponse<Record<string, string>>> {
return this.http.get<ApiResponse<Record<string, string>>>(`${this.baseUrl}/settings`);
async getSettings(): Promise<IApiResponse<ISetting[]>> {
return firstValueFrom(this.http.get<IApiResponse<ISetting[]>>('/api/settings'));
}
updateSetting(key: string, value: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/settings`, { key, value });
async updateSettings(settings: Record<string, string> | ISettings): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.put<IApiResponse<void>>('/api/settings', settings));
}
async updateSetting(key: string, value: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.put<IApiResponse<void>>('/api/settings', { key, value }));
}
// Auth
async changePassword(currentPassword: string, newPassword: string): Promise<IApiResponse<void>> {
return firstValueFrom(
this.http.post<IApiResponse<void>>('/api/auth/change-password', {
currentPassword,
newPassword,
})
);
}
}

View File

@@ -1,69 +1,54 @@
import { Injectable, inject, signal } from '@angular/core';
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, tap } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { IApiResponse, ILoginResponse, IUser } from '../types/api.types';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
success: boolean;
data?: {
token: string;
user: {
username: string;
role: string;
};
};
error?: string;
}
@Injectable({
providedIn: 'root',
})
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
isAuthenticated = signal(false);
currentUser = signal<{ username: string; role: string } | null>(null);
private token = signal<string | null>(this.loadToken());
currentUser = signal<IUser | null>(null);
isAuthenticated = computed(() => !!this.token());
constructor() {
// Check if already authenticated
const token = this.getToken();
if (token) {
this.isAuthenticated.set(true);
// TODO: Decode JWT to get user info
this.currentUser.set({ username: 'admin', role: 'admin' });
}
private loadToken(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem('onebox_token');
}
login(credentials: LoginRequest): Observable<LoginResponse> {
return this.http.post<LoginResponse>('/api/auth/login', credentials).pipe(
tap((response) => {
if (response.success && response.data) {
this.setToken(response.data.token);
this.currentUser.set(response.data.user);
this.isAuthenticated.set(true);
}
})
async login(username: string, password: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await firstValueFrom(
this.http.post<IApiResponse<ILoginResponse>>('/api/auth/login', { username, password })
);
if (response?.success && response.data) {
this.token.set(response.data.token);
this.currentUser.set(response.data.user);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('onebox_token', response.data.token);
}
return { success: true };
}
return { success: false, error: response?.error || 'Login failed' };
} catch (err: any) {
const errorMessage = err?.error?.error || err?.message || 'Login failed';
return { success: false, error: errorMessage };
}
}
logout(): void {
localStorage.removeItem('onebox_token');
this.isAuthenticated.set(false);
this.token.set(null);
this.currentUser.set(null);
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('onebox_token');
}
this.router.navigate(['/login']);
}
getToken(): string | null {
return localStorage.getItem('onebox_token');
}
private setToken(token: string): void {
localStorage.setItem('onebox_token', token);
return this.token();
}
}

View File

@@ -0,0 +1,140 @@
import { Injectable, signal } from '@angular/core';
export interface ILogStreamState {
connected: boolean;
error: string | null;
serviceName: string | null;
}
@Injectable({ providedIn: 'root' })
export class LogStreamService {
private ws: WebSocket | null = null;
private currentService: string | null = null;
// Signals for reactive state
state = signal<ILogStreamState>({
connected: false,
error: null,
serviceName: null,
});
logs = signal<string[]>([]);
isStreaming = signal(false);
/**
* Connect to log stream for a service
*/
connect(serviceName: string): void {
// Disconnect any existing stream
this.disconnect();
this.currentService = serviceName;
this.isStreaming.set(true);
this.logs.set([]);
this.state.set({
connected: false,
error: null,
serviceName,
});
if (typeof window === 'undefined') return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const url = `${protocol}//${host}/api/services/${serviceName}/logs/stream`;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
// Connection established, waiting for 'connected' message from server
};
this.ws.onmessage = (event) => {
const data = event.data;
// Try to parse as JSON (for control messages)
try {
const json = JSON.parse(data);
if (json.type === 'connected') {
this.state.set({
connected: true,
error: null,
serviceName: json.serviceName,
});
return;
}
if (json.error) {
this.state.update((s) => ({ ...s, error: json.error }));
return;
}
} catch {
// Not JSON - it's a log line
this.logs.update((lines) => {
const newLines = [...lines, data];
// Keep last 1000 lines to prevent memory issues
if (newLines.length > 1000) {
return newLines.slice(-1000);
}
return newLines;
});
}
};
this.ws.onclose = () => {
this.state.update((s) => ({ ...s, connected: false }));
this.isStreaming.set(false);
this.ws = null;
};
this.ws.onerror = () => {
this.state.update((s) => ({
...s,
connected: false,
error: 'WebSocket connection failed',
}));
this.isStreaming.set(false);
};
} catch (error) {
this.state.set({
connected: false,
error: 'Failed to connect to log stream',
serviceName,
});
this.isStreaming.set(false);
}
}
/**
* Disconnect from log stream
*/
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.currentService = null;
this.isStreaming.set(false);
this.state.set({
connected: false,
error: null,
serviceName: null,
});
}
/**
* Clear logs buffer
*/
clearLogs(): void {
this.logs.set([]);
}
/**
* Get current service name being streamed
*/
getCurrentService(): string | null {
return this.currentService;
}
}

View File

@@ -0,0 +1,64 @@
import { Injectable, signal, effect } from '@angular/core';
export type Theme = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeService {
theme = signal<Theme>(this.loadTheme());
constructor() {
effect(() => {
this.applyTheme(this.theme());
});
// Listen for system preference changes
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.theme() === 'system') {
this.applyTheme('system');
}
});
}
}
private loadTheme(): Theme {
if (typeof localStorage === 'undefined') return 'system';
const stored = localStorage.getItem('onebox-theme');
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
}
return 'system';
}
setTheme(theme: Theme): void {
this.theme.set(theme);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('onebox-theme', theme);
}
}
toggle(): void {
const resolved = this.resolvedTheme();
this.setTheme(resolved === 'dark' ? 'light' : 'dark');
}
isDark(): boolean {
return this.resolvedTheme() === 'dark';
}
resolvedTheme(): 'light' | 'dark' {
if (this.theme() === 'system') {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return this.theme() as 'light' | 'dark';
}
private applyTheme(theme: Theme): void {
if (typeof document === 'undefined') return;
const resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.classList.toggle('dark', resolved === 'dark');
}
}

View File

@@ -1,53 +1,48 @@
import { Injectable, signal } from '@angular/core';
import { IToast, ToastType } from '../types/api.types';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
@Injectable({ providedIn: 'root' })
export class ToastService {
toasts = signal<IToast[]>([]);
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
private generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
@Injectable({
providedIn: 'root'
})
export class ToastService {
toasts = signal<Toast[]>([]);
private nextId = 0;
show(type: ToastType, message: string, duration: number = 5000) {
const id = `toast-${this.nextId++}`;
const toast: Toast = { id, type, message, duration };
show(type: ToastType, message: string, duration = 5000): string {
const id = this.generateId();
const toast: IToast = { id, type, message, duration };
this.toasts.update(toasts => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => this.remove(id), duration);
}
setTimeout(() => this.dismiss(id), duration);
}
success(message: string, duration?: number) {
this.show('success', message, duration);
return id;
}
error(message: string, duration?: number) {
this.show('error', message, duration);
success(message: string, duration?: number): string {
return this.show('success', message, duration);
}
info(message: string, duration?: number) {
this.show('info', message, duration);
error(message: string, duration?: number): string {
return this.show('error', message, duration);
}
warning(message: string, duration?: number) {
this.show('warning', message, duration);
info(message: string, duration?: number): string {
return this.show('info', message, duration);
}
remove(id: string) {
warning(message: string, duration?: number): string {
return this.show('warning', message, duration);
}
dismiss(id: string): void {
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
}
clear() {
dismissAll(): void {
this.toasts.set([]);
}
}

View File

@@ -1,101 +1,109 @@
import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { Injectable, signal, computed, effect, inject } from '@angular/core';
import { IWebSocketMessage } from '../types/api.types';
import { AuthService } from './auth.service';
export interface WebSocketMessage {
type: string;
action?: string;
serviceName?: string;
data?: any;
status?: string;
timestamp: number;
message?: string;
}
@Injectable({
providedIn: 'root'
})
@Injectable({ providedIn: 'root' })
export class WebSocketService {
private auth = inject(AuthService);
private ws: WebSocket | null = null;
private messageSubject = new Subject<WebSocketMessage>();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000;
private reconnectTimer: any = null;
private reconnectDelay = 1000;
constructor() {}
isConnected = signal(false);
lastMessage = signal<IWebSocketMessage | null>(null);
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
console.log('WebSocket already connected');
return;
// Computed signals for specific message types
serviceUpdates = computed(() => {
const msg = this.lastMessage();
return msg?.type === 'service_update' ? msg : null;
});
serviceStatus = computed(() => {
const msg = this.lastMessage();
return msg?.type === 'service_status' ? msg : null;
});
systemStatus = computed(() => {
const msg = this.lastMessage();
return msg?.type === 'system_status' ? msg : null;
});
constructor() {
// Auto-connect when authenticated
effect(() => {
if (this.auth.isAuthenticated()) {
this.connect();
} else {
this.disconnect();
}
});
}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) return;
if (typeof window === 'undefined') return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
const host = window.location.host;
const url = `${protocol}//${host}/api/ws`;
console.log('Connecting to WebSocket:', wsUrl);
this.ws = new WebSocket(wsUrl);
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('✓ WebSocket connected');
this.isConnected.set(true);
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
};
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
console.log('📨 WebSocket message:', message);
this.messageSubject.next(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
const message: IWebSocketMessage = JSON.parse(event.data);
this.lastMessage.set(message);
} catch {
console.error('Failed to parse WebSocket message');
}
};
this.ws.onerror = (error) => {
console.error('✖ WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('⚠ WebSocket closed');
this.isConnected.set(false);
this.ws = null;
this.attemptReconnect();
};
this.ws.onerror = () => {
this.isConnected.set(false);
};
} catch {
this.isConnected.set(false);
}
}
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max WebSocket reconnect attempts reached');
return;
}
if (!this.auth.isAuthenticated()) return;
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
setTimeout(() => {
this.connect();
}, delay);
}, this.reconnectDelay);
// Exponential backoff
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
}
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnected.set(false);
}
getMessages(): Observable<WebSocketMessage> {
return this.messageSubject.asObservable();
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
send(message: any): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
}

View File

@@ -0,0 +1,175 @@
export interface IApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface IUser {
username: string;
role: 'admin' | 'user';
}
export interface ILoginResponse {
token: string;
user: IUser;
}
export interface IService {
id?: number;
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
containerID?: string;
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
createdAt: number;
updatedAt: number;
useOneboxRegistry?: boolean;
registryRepository?: string;
registryToken?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
}
export interface IServiceCreate {
name: string;
image: string;
port: number;
domain?: string;
envVars?: Record<string, string>;
useOneboxRegistry?: boolean;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
}
export interface IServiceUpdate {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
}
export interface ISystemStatus {
docker: {
running: boolean;
version: any;
};
reverseProxy: {
http: { running: boolean; port: number };
https: { running: boolean; port: number; certificates: number };
routes: number;
};
dns: { configured: boolean };
ssl: { configured: boolean; certbotInstalled: boolean };
services: { total: number; running: number; stopped: number };
}
export interface IDomain {
id?: number;
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
cloudflareZoneId?: string;
isObsolete: boolean;
defaultWildcard: boolean;
createdAt: number;
updatedAt: number;
}
export interface ICertificate {
id?: number;
domainId: number;
certDomain: string;
isWildcard: boolean;
certPath: string;
keyPath: string;
fullChainPath: string;
expiryDate: number;
issuer: string;
isValid: boolean;
createdAt: number;
updatedAt: number;
}
export interface ICertRequirement {
id?: number;
domainId: number;
serviceId: number;
subdomain: string;
status: 'pending' | 'active' | 'renewing' | 'failed';
certificateId?: number;
createdAt: number;
updatedAt: number;
}
export interface IDomainDetail {
domain: IDomain;
certificates: ICertificate[];
requirements: ICertRequirement[];
serviceCount: number;
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
daysRemaining: number | null;
}
export interface IDnsRecord {
id?: number;
domain: string;
type: 'A' | 'AAAA' | 'CNAME';
value: string;
cloudflareID?: string;
createdAt: number;
updatedAt: number;
}
export interface IRegistry {
id?: number;
url: string;
username: string;
createdAt: number;
}
export interface IRegistryCreate {
url: string;
username: string;
password: string;
}
export interface ISetting {
key: string;
value: string;
updatedAt: number;
}
export interface ISettings {
cloudflareToken: string;
cloudflareZoneId: string;
autoRenewCerts: boolean;
renewalThreshold: number;
acmeEmail: string;
httpPort: number;
httpsPort: number;
forceHttps: boolean;
}
export interface IWebSocketMessage {
type: 'connected' | 'service_update' | 'service_status' | 'system_status';
action?: 'created' | 'updated' | 'deleted' | 'started' | 'stopped';
serviceName?: string;
status?: string;
data?: any;
message?: string;
timestamp: number;
}
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface IToast {
id: string;
type: ToastType;
message: string;
duration?: number;
}

View File

@@ -1,242 +1,262 @@
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ApiService, SystemStatus } from '../../core/services/api.service';
import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { Subscription } from 'rxjs';
import { ToastService } from '../../core/services/toast.service';
import { ISystemStatus } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<div class="flex items-center gap-4">
@if (lastUpdated()) {
<span class="text-sm text-gray-500">
Last updated: {{ lastUpdated()!.toLocaleTimeString() }}
</span>
}
<button (click)="refresh()" class="btn btn-secondary text-sm" [disabled]="loading()">
<svg class="w-4 h-4 mr-1" [class.animate-spin]="loading()" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
<p class="text-muted-foreground">System overview and quick actions</p>
</div>
<button uiButton variant="outline" (click)="loadStatus()" [disabled]="loading()">
@if (loading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
}
Refresh
</button>
</div>
</div>
@if (loading()) {
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
@if (loading() && !status()) {
<!-- Loading skeleton -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@for (_ of [1,2,3,4]; track $index) {
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-skeleton class="h-4 w-24" />
</ui-card-header>
<ui-card-content>
<ui-skeleton class="h-8 w-16" />
</ui-card-content>
</ui-card>
}
</div>
} @else if (status()) {
<!-- Stats Grid -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<!-- Total Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-primary-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Total Services</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Services</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.total }}</dd>
</dl>
</div>
</div>
</div>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ status()!.services.total }}</div>
</ui-card-content>
</ui-card>
<!-- Running Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Running</ui-card-title>
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Running</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.running }}</dd>
</dl>
</div>
</div>
</div>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold text-success">{{ status()!.services.running }}</div>
</ui-card-content>
</ui-card>
<!-- Stopped Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-gray-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Stopped</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Stopped</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.stopped }}</dd>
</dl>
</div>
</div>
</div>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ status()!.services.stopped }}</div>
</ui-card-content>
</ui-card>
<!-- Docker Status -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 rounded-md p-3" [ngClass]="status()!.docker.running ? 'bg-green-500' : 'bg-red-500'">
<svg class="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338 0-.676.03-1.01.09-.458-1.314-1.605-2.16-2.898-2.16h-.048c-.328 0-.654.06-.969.18-.618-2.066-2.215-3.073-4.752-3.073-2.538 0-4.135 1.007-4.753 3.073-.315-.12-.64-.18-.969-.18h-.048c-1.293 0-2.44.846-2.898 2.16a8.39 8.39 0 00-1.01-.09c-1.282 0-1.889.459-1.954.51L0 10.2l.08.31s.935 3.605 4.059 4.794v.003c.563.215 1.156.322 1.756.322.71 0 1.423-.129 2.112-.385a8.804 8.804 0 002.208.275c.877 0 1.692-.165 2.411-.49a4.71 4.71 0 001.617.28c.606 0 1.201-.11 1.773-.328.572.219 1.167.327 1.772.327.71 0 1.423-.129 2.112-.385.79.251 1.57.376 2.315.376.606 0 1.2-.107 1.766-.322v-.003c3.124-1.189 4.059-4.794 4.059-4.794l.08-.31-.237-.31z"/>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Docker</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Docker</dt>
<dd class="text-lg font-semibold text-gray-900">
</ui-card-header>
<ui-card-content>
<ui-badge [variant]="status()!.docker.running ? 'success' : 'destructive'">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</dd>
</dl>
</div>
</div>
</div>
</ui-badge>
</ui-card-content>
</ui-card>
</div>
<!-- System Status -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<!-- Docker -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">Docker</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span [ngClass]="status()!.docker.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</span>
</div>
@if (status()!.docker.version) {
<div class="flex justify-between">
<span class="text-sm text-gray-600">Version</span>
<span class="text-sm text-gray-900">{{ status()!.docker.version.Version }}</span>
</div>
}
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Reverse Proxy -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">Reverse Proxy</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">HTTP (Port {{ status()!.reverseProxy.http.port }})</span>
<span [ngClass]="status()!.reverseProxy.http.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.reverseProxy.http.running ? 'Running' : 'Stopped' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">HTTPS (Port {{ status()!.reverseProxy.https.port }})</span>
<span [ngClass]="status()!.reverseProxy.https.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.reverseProxy.https.running ? 'Running' : 'Stopped' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">SSL Certificates</span>
<span class="badge badge-info">{{ status()!.reverseProxy.https.certificates }}</span>
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Reverse Proxy</ui-card-title>
<ui-card-description>HTTP/HTTPS proxy status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">HTTP ({{ status()!.reverseProxy.http.port }})</span>
<ui-badge [variant]="status()!.reverseProxy.http.running ? 'success' : 'secondary'">
{{ status()!.reverseProxy.http.running ? 'Active' : 'Inactive' }}
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">HTTPS ({{ status()!.reverseProxy.https.port }})</span>
<ui-badge [variant]="status()!.reverseProxy.https.running ? 'success' : 'secondary'">
{{ status()!.reverseProxy.https.running ? 'Active' : 'Inactive' }}
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Certificates</span>
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span>
</div>
</ui-card-content>
</ui-card>
<!-- DNS & SSL -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">DNS & SSL</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">DNS Configured</span>
<span [ngClass]="status()!.dns.configured ? 'badge-success' : 'badge-warning'" class="badge">
{{ status()!.dns.configured ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">SSL Configured</span>
<span [ngClass]="status()!.ssl.configured ? 'badge-success' : 'badge-warning'" class="badge">
{{ status()!.ssl.configured ? 'Yes' : 'No' }}
</span>
<!-- DNS -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>DNS</ui-card-title>
<ui-card-description>DNS configuration status</ui-card-description>
</ui-card-header>
<ui-card-content>
<div class="flex items-center justify-between">
<span class="text-sm">Cloudflare</span>
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
</ui-card-content>
</ui-card>
<!-- SSL -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL/TLS</ui-card-title>
<ui-card-description>Certificate management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">ACME</span>
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Certbot</span>
<ui-badge [variant]="status()!.ssl.certbotInstalled ? 'success' : 'secondary'">
{{ status()!.ssl.certbotInstalled ? 'Installed' : 'Not installed' }}
</ui-badge>
</div>
</ui-card-content>
</ui-card>
</div>
<!-- Quick Actions -->
<div class="mt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div class="flex space-x-4">
<a routerLink="/services/new" class="btn btn-primary">
Deploy New Service
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Quick Actions</ui-card-title>
<ui-card-description>Common tasks and shortcuts</ui-card-description>
</ui-card-header>
<ui-card-content class="flex gap-4">
<a routerLink="/services/create">
<button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Deploy Service
</button>
</a>
<a routerLink="/services" class="btn btn-secondary">
View All Services
<a routerLink="/services">
<button uiButton variant="outline">View All Services</button>
</a>
</div>
</div>
<a routerLink="/domains">
<button uiButton variant="outline">Manage Domains</button>
</a>
</ui-card-content>
</ui-card>
}
</div>
`,
})
export class DashboardComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private wsService = inject(WebSocketService);
private api = inject(ApiService);
private ws = inject(WebSocketService);
private toast = inject(ToastService);
status = signal<SystemStatus | null>(null);
loading = signal(true);
lastUpdated = signal<Date | null>(null);
private wsSubscription?: Subscription;
private refreshInterval?: number;
status = signal<ISystemStatus | null>(null);
loading = signal(false);
ngOnInit(): void {
this.loadStatus();
private refreshInterval: any;
// Subscribe to WebSocket updates
this.wsSubscription = this.wsService.getMessages().subscribe((message: any) => {
// Reload status on any service or system update
if (message.type === 'service_update' || message.type === 'service_status' || message.type === 'system_status') {
constructor() {
// React to WebSocket updates
effect(() => {
const update = this.ws.serviceUpdates();
const systemStatus = this.ws.systemStatus();
if (update || systemStatus) {
this.loadStatus();
}
});
}
// Auto-refresh every 30 seconds
this.refreshInterval = window.setInterval(() => {
ngOnInit(): void {
this.loadStatus();
}, 30000);
// Auto-refresh every 30 seconds
this.refreshInterval = setInterval(() => this.loadStatus(), 30000);
}
ngOnDestroy(): void {
this.wsSubscription?.unsubscribe();
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
loadStatus(): void {
async loadStatus(): Promise<void> {
this.loading.set(true);
this.apiService.getStatus().subscribe({
next: (response) => {
try {
const response = await this.api.getStatus();
if (response.success && response.data) {
this.status.set(response.data);
this.lastUpdated.set(new Date());
} else {
this.toast.error(response.error || 'Failed to load status');
}
} catch (err) {
this.toast.error('Failed to load status');
} finally {
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
refresh(): void {
this.loadStatus();
}
}
}

View File

@@ -1,105 +1,207 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Component, inject, signal, OnInit } from '@angular/core';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { IDnsRecord } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component';
import {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from '../../ui/table/table.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
import {
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
} from '../../ui/dialog/dialog.component';
@Component({
selector: 'app-dns',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">DNS Records</h1>
<button
(click)="syncRecords()"
[disabled]="syncing()"
class="btn btn-primary"
>
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">DNS Records</h1>
<p class="text-muted-foreground">Manage DNS records synced with Cloudflare</p>
</div>
<button uiButton (click)="syncRecords()" [disabled]="syncing()">
@if (syncing()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Syncing...
} @else {
Sync Cloudflare
}
</button>
</div>
@if (records().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (record of records(); track record.domain) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.domain }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ record.value }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="deleteRecord(record)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
<ui-card>
<ui-card-content class="p-0">
@if (loading() && records().length === 0) {
<div class="p-6 space-y-4">
@for (_ of [1,2,3]; track $index) {
<ui-skeleton class="h-12 w-full" />
}
</tbody>
</table>
</div>
} @else if (records().length === 0) {
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<h3 class="mt-4 text-lg font-semibold">No DNS records</h3>
<p class="mt-2 text-sm text-muted-foreground">DNS records are created automatically when you deploy services with domains.</p>
<button uiButton class="mt-4" (click)="syncRecords()">Sync from Cloudflare</button>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">No DNS records configured</p>
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
<p class="text-sm text-gray-400 mt-2">Or click "Sync Cloudflare" to import existing DNS records from Cloudflare</p>
</div>
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Domain</ui-table-head>
<ui-table-head>Type</ui-table-head>
<ui-table-head>Value</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (record of records(); track record.id) {
<ui-table-row>
<ui-table-cell class="font-medium">{{ record.domain }}</ui-table-cell>
<ui-table-cell>
<ui-badge variant="secondary">{{ record.type }}</ui-badge>
</ui-table-cell>
<ui-table-cell class="font-mono text-sm">{{ record.value }}</ui-table-cell>
<ui-table-cell class="text-right">
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(record)">
Delete
</button>
</ui-table-cell>
</ui-table-row>
}
</ui-table-body>
</ui-table>
}
</ui-card-content>
</ui-card>
</div>
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
<ui-dialog-header>
<ui-dialog-title>Delete DNS Record</ui-dialog-title>
<ui-dialog-description>
Are you sure you want to delete the record for "{{ recordToDelete()?.domain }}"?
</ui-dialog-description>
</ui-dialog-header>
<ui-dialog-footer>
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
<button uiButton variant="destructive" (click)="deleteRecord()">Delete</button>
</ui-dialog-footer>
</ui-dialog>
`,
})
export class DnsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
records = signal<any[]>([]);
private api = inject(ApiService);
private toast = inject(ToastService);
records = signal<IDnsRecord[]>([]);
loading = signal(false);
syncing = signal(false);
deleteDialogOpen = signal(false);
recordToDelete = signal<IDnsRecord | null>(null);
ngOnInit(): void {
this.loadRecords();
}
loadRecords(): void {
this.apiService.getDnsRecords().subscribe({
next: (response) => {
async loadRecords(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getDnsRecords();
if (response.success && response.data) {
this.records.set(response.data);
}
},
});
} catch {
this.toast.error('Failed to load DNS records');
} finally {
this.loading.set(false);
}
}
syncRecords(): void {
async syncRecords(): Promise<void> {
this.syncing.set(true);
this.apiService.syncDnsRecords().subscribe({
next: (response) => {
try {
const response = await this.api.syncDnsRecords();
if (response.success) {
this.toastService.success('Cloudflare DNS records synced successfully');
this.toast.success('DNS records synced');
this.loadRecords();
} else {
this.toastService.error(response.error || 'Failed to sync DNS records');
this.toast.error(response.error || 'Failed to sync DNS records');
}
} catch {
this.toast.error('Failed to sync DNS records');
} finally {
this.syncing.set(false);
},
error: () => {
this.toastService.error('Failed to sync DNS records');
this.syncing.set(false);
},
});
}
}
deleteRecord(record: any): void {
if (confirm(`Delete DNS record for ${record.domain}?`)) {
this.apiService.deleteDnsRecord(record.domain).subscribe({
next: () => this.loadRecords(),
});
confirmDelete(record: IDnsRecord): void {
this.recordToDelete.set(record);
this.deleteDialogOpen.set(true);
}
async deleteRecord(): Promise<void> {
const record = this.recordToDelete();
if (!record) return;
try {
const response = await this.api.deleteDnsRecord(record.domain);
if (response.success) {
this.toast.success('DNS record deleted');
this.loadRecords();
} else {
this.toast.error(response.error || 'Failed to delete record');
}
} catch {
this.toast.error('Failed to delete record');
} finally {
this.deleteDialogOpen.set(false);
this.recordToDelete.set(null);
}
}
}

View File

@@ -1,356 +1,289 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService } from '../../core/services/api.service';
interface DomainDetail {
domain: {
id: number;
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
cloudflareZoneId?: string;
isObsolete: boolean;
defaultWildcard: boolean;
createdAt: number;
updatedAt: number;
};
certificates: Array<{
id: number;
certDomain: string;
isWildcard: boolean;
expiryDate: number;
issuer: string;
isValid: boolean;
createdAt: number;
}>;
requirements: Array<{
id: number;
serviceId: number;
subdomain: string;
status: 'pending' | 'active' | 'renewing';
certificateId?: number;
}>;
services: Array<{
id: number;
name: string;
domain: string;
status: string;
}>;
}
import { ToastService } from '../../core/services/toast.service';
import { IDomainDetail, IService } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component';
import {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from '../../ui/table/table.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-domain-detail',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center mb-4">
<a
routerLink="/domains"
class="text-primary-600 hover:text-primary-900 mr-4"
>
← Back to Domains
<div class="space-y-6">
<div>
<a routerLink="/domains" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Domains
</a>
</div>
@if (loading()) {
<div class="card text-center py-12">
<p class="text-gray-500">Loading domain details...</p>
</div>
} @else if (domainDetail()) {
<div>
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900">
{{ domainDetail()!.domain.domain }}
</h1>
<div class="mt-2 flex items-center gap-3">
@if (domainDetail()!.domain.dnsProvider === 'cloudflare') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Cloudflare
</span>
} @else if (domainDetail()!.domain.dnsProvider === 'manual') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Manual DNS
</span>
@if (loading() && !domain()) {
<ui-skeleton class="h-9 w-64" />
} @else if (domain()) {
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold tracking-tight">{{ domain()!.domain.domain }}</h1>
<ui-badge [variant]="domain()!.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
{{ domain()!.domain.dnsProvider || 'Manual' }}
</ui-badge>
@if (domain()!.domain.defaultWildcard) {
<ui-badge variant="outline">Wildcard</ui-badge>
}
@if (domainDetail()!.domain.defaultWildcard) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Wildcard Enabled
</span>
}
@if (domainDetail()!.domain.isObsolete) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Obsolete
</span>
@if (domain()!.domain.isObsolete) {
<ui-badge variant="destructive">Obsolete</ui-badge>
}
</div>
</div>
}
</div>
<!-- Stats Grid -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Certificates</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.certificates.length }}
</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Requirements</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.requirements.length }}
</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Services</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.services.length }}
</dd>
</div>
@if (domain()) {
<!-- Stats -->
<div class="grid gap-4 md:grid-cols-3">
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Certificates</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ domain()!.certificates.length }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Requirements</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ domain()!.requirements.length }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Services</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ domain()!.serviceCount }}</div>
</ui-card-content>
</ui-card>
</div>
<!-- Certificates Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">SSL Certificates</h2>
@if (domainDetail()!.certificates.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (cert of domainDetail()!.certificates; track cert.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ cert.certDomain }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (cert.isWildcard) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Wildcard
</span>
<!-- Certificates -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL Certificates</ui-card-title>
<ui-card-description>Active certificates for this domain</ui-card-description>
</ui-card-header>
<ui-card-content class="p-0">
@if (domain()!.certificates.length === 0) {
<div class="p-6 text-center text-muted-foreground">No certificates</div>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Standard
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (getCertStatus(cert) === 'valid') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Valid
</span>
} @else if (getCertStatus(cert) === 'expiring') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Expiring Soon
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Expired/Invalid
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Domain</ui-table-head>
<ui-table-head>Type</ui-table-head>
<ui-table-head>Status</ui-table-head>
<ui-table-head>Expires</ui-table-head>
<ui-table-head>Issuer</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (cert of domain()!.certificates; track cert.id) {
<ui-table-row>
<ui-table-cell class="font-medium">{{ cert.certDomain }}</ui-table-cell>
<ui-table-cell>
<ui-badge variant="outline">{{ cert.isWildcard ? 'Wildcard' : 'Standard' }}</ui-badge>
</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="getCertStatusVariant(cert)">
{{ getCertStatus(cert) }}
</ui-badge>
</ui-table-cell>
<ui-table-cell>
{{ formatDate(cert.expiryDate) }}
<span class="text-gray-500">({{ getDaysRemaining(cert.expiryDate) }} days)</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ cert.issuer }}
</td>
</tr>
<span class="text-xs text-muted-foreground ml-1">
({{ getDaysRemaining(cert.expiryDate) }} days)
</span>
</ui-table-cell>
<ui-table-cell>{{ cert.issuer }}</ui-table-cell>
<ui-table-cell class="text-right">
<button uiButton variant="outline" size="sm" (click)="renewCertificate(cert.certDomain)">
Renew
</button>
</ui-table-cell>
</ui-table-row>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No certificates for this domain</p>
</div>
</ui-table-body>
</ui-table>
}
</div>
</ui-card-content>
</ui-card>
<!-- Certificate Requirements Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">Certificate Requirements</h2>
@if (domainDetail()!.requirements.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Subdomain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate ID</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (req of domainDetail()!.requirements; track req.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ getServiceName(req.serviceId) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ req.subdomain || '(root)' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (req.status === 'active') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
} @else if (req.status === 'pending') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Pending
</span>
} @else if (req.status === 'renewing') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Renewing
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ req.certificateId || '—' }}
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Services using this domain -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Services</ui-card-title>
<ui-card-description>Services using this domain</ui-card-description>
</ui-card-header>
<ui-card-content class="p-0">
@if (services().length === 0) {
<div class="p-6 text-center text-muted-foreground">No services using this domain</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No certificate requirements</p>
</div>
}
</div>
<!-- Services Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">Services Using This Domain</h2>
@if (domainDetail()!.services.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (service of domainDetail()!.services; track service.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ service.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ service.domain }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (service.status === 'running') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Running
</span>
} @else if (service.status === 'stopped') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Stopped
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ service.status }}
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<a
[routerLink]="['/services', service.name]"
class="text-primary-600 hover:text-primary-900"
>
View Service
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Service</ui-table-head>
<ui-table-head>Domain</ui-table-head>
<ui-table-head>Status</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (svc of services(); track svc.name) {
<ui-table-row>
<ui-table-cell class="font-medium">{{ svc.name }}</ui-table-cell>
<ui-table-cell>{{ svc.domain }}</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="svc.status === 'running' ? 'success' : 'secondary'">
{{ svc.status }}
</ui-badge>
</ui-table-cell>
<ui-table-cell class="text-right">
<a [routerLink]="['/services', svc.name]">
<button uiButton variant="outline" size="sm">View</button>
</a>
</td>
</tr>
</ui-table-cell>
</ui-table-row>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No services using this domain</p>
</div>
</ui-table-body>
</ui-table>
}
</div>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">Domain not found</p>
</div>
</ui-card-content>
</ui-card>
}
</div>
</div>
`,
})
export class DomainDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(ApiService);
private api = inject(ApiService);
private toast = inject(ToastService);
domainDetail = signal<DomainDetail | null>(null);
loading = signal(true);
domain = signal<IDomainDetail | null>(null);
services = signal<IService[]>([]);
loading = signal(false);
ngOnInit(): void {
const domain = this.route.snapshot.paramMap.get('domain');
if (domain) {
this.loadDomainDetail(domain);
const domainName = this.route.snapshot.paramMap.get('domain');
if (domainName) {
this.loadDomain(domainName);
this.loadServices(domainName);
}
}
loadDomainDetail(domain: string): void {
async loadDomain(name: string): Promise<void> {
this.loading.set(true);
this.apiService.getDomainDetail(domain).subscribe({
next: (response) => {
try {
const response = await this.api.getDomainDetail(name);
if (response.success && response.data) {
this.domainDetail.set(response.data);
this.domain.set(response.data);
}
} catch {
this.toast.error('Failed to load domain');
} finally {
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}
async loadServices(domainName: string): Promise<void> {
try {
const response = await this.api.getServices();
if (response.success && response.data) {
this.services.set(response.data.filter(s => s.domain?.includes(domainName)));
}
} catch {
// Silent fail
}
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return new Date(timestamp).toLocaleDateString();
}
getDaysRemaining(expiryDate: number): number {
getDaysRemaining(timestamp: number): number {
const now = Date.now();
const diff = expiryDate - now;
return Math.floor(diff / (24 * 60 * 60 * 1000));
return Math.floor((timestamp - now) / (1000 * 60 * 60 * 24));
}
getCertStatus(cert: any): 'valid' | 'expiring' | 'invalid' {
if (!cert.isValid) return 'invalid';
const daysRemaining = this.getDaysRemaining(cert.expiryDate);
if (daysRemaining < 0) return 'invalid';
if (daysRemaining <= 30) return 'expiring';
return 'valid';
getCertStatus(cert: any): string {
if (!cert.isValid) return 'Invalid';
const days = this.getDaysRemaining(cert.expiryDate);
if (days < 0) return 'Expired';
if (days <= 30) return 'Expiring';
return 'Valid';
}
getServiceName(serviceId: number): string {
const service = this.domainDetail()?.services.find((s) => s.id === serviceId);
return service?.name || `Service #${serviceId}`;
getCertStatusVariant(cert: any): 'success' | 'warning' | 'destructive' {
const status = this.getCertStatus(cert);
switch (status) {
case 'Valid': return 'success';
case 'Expiring': return 'warning';
default: return 'destructive';
}
}
async renewCertificate(domain: string): Promise<void> {
try {
const response = await this.api.renewCertificate(domain);
if (response.success) {
this.toast.success('Certificate renewal initiated');
} else {
this.toast.error(response.error || 'Failed to renew certificate');
}
} catch {
this.toast.error('Failed to renew certificate');
}
}
}

View File

@@ -1,216 +1,242 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
interface DomainView {
domain: {
id: number;
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
isObsolete: boolean;
defaultWildcard: boolean;
};
serviceCount: number;
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
daysRemaining: number | null;
certificates: any[];
requirements: any[];
}
import { IDomainDetail } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component';
import {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from '../../ui/table/table.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-domains',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Domains</h1>
<button
(click)="syncDomains()"
[disabled]="syncing()"
class="btn btn-primary"
>
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Domains</h1>
<p class="text-muted-foreground">Manage domains and SSL certificates</p>
</div>
<button uiButton (click)="syncDomains()" [disabled]="syncing()">
@if (syncing()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Syncing...
} @else {
Sync Cloudflare
}
</button>
</div>
@if (loading()) {
<div class="card text-center py-12">
<p class="text-gray-500">Loading domains...</p>
<!-- Stats -->
<div class="grid gap-4 md:grid-cols-4">
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Total Domains</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ domains().length }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Valid Certificates</ui-card-title>
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold text-success">{{ countByStatus('valid') }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Expiring Soon</ui-card-title>
<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold text-warning">{{ countByStatus('expiring-soon') }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Expired/Pending</ui-card-title>
<svg class="h-4 w-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold text-destructive">{{ countByStatus('expired') + countByStatus('pending') }}</div>
</ui-card-content>
</ui-card>
</div>
} @else if (domains().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Provider</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Services</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expiry</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (domainView of domains(); track domainView.domain.id) {
<tr [class.opacity-50]="domainView.domain.isObsolete">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ domainView.domain.domain }}</div>
@if (domainView.domain.isObsolete) {
<span class="text-xs text-red-600">Obsolete</span>
<!-- Domains Table -->
<ui-card>
<ui-card-content class="p-0">
@if (loading() && domains().length === 0) {
<div class="p-6 space-y-4">
@for (_ of [1,2,3]; track $index) {
<ui-skeleton class="h-12 w-full" />
}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (domainView.domain.dnsProvider === 'cloudflare') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Cloudflare
</span>
} @else if (domainView.domain.dnsProvider === 'manual') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Manual
</div>
} @else if (domains().length === 0) {
<div class="p-12 text-center">
<h3 class="text-lg font-semibold">No domains found</h3>
<p class="mt-2 text-sm text-muted-foreground">Sync domains from Cloudflare to get started.</p>
<button uiButton class="mt-4" (click)="syncDomains()">Sync Cloudflare Domains</button>
</div>
} @else {
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Domain</ui-table-head>
<ui-table-head>Provider</ui-table-head>
<ui-table-head>Services</ui-table-head>
<ui-table-head>Certificate</ui-table-head>
<ui-table-head>Expires</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (d of domains(); track d.domain.id) {
<ui-table-row [class.opacity-50]="d.domain.isObsolete">
<ui-table-cell>
<div class="flex items-center gap-2">
<span class="font-medium">{{ d.domain.domain }}</span>
@if (d.domain.isObsolete) {
<ui-badge variant="destructive">Obsolete</ui-badge>
}
</div>
</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="d.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
{{ d.domain.dnsProvider || 'None' }}
</ui-badge>
</ui-table-cell>
<ui-table-cell>{{ d.serviceCount }}</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="getCertStatusVariant(d.certificateStatus)">
{{ d.certificateStatus }}
</ui-badge>
</ui-table-cell>
<ui-table-cell>
@if (d.daysRemaining !== null) {
<span [class.text-destructive]="d.daysRemaining <= 30">
{{ d.daysRemaining }} days
</span>
} @else {
<span class="text-sm text-gray-400">None</span>
<span class="text-muted-foreground">-</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ domainView.serviceCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@switch (domainView.certificateStatus) {
@case ('valid') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Valid
</span>
}
@case ('expiring-soon') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Expiring Soon
</span>
}
@case ('expired') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Expired
</span>
}
@case ('pending') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Pending
</span>
}
@default {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
None
</span>
}
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
@if (domainView.daysRemaining !== null) {
<span [ngClass]="domainView.daysRemaining <= 30 ? 'text-red-600 font-medium' : 'text-gray-500'">
{{ domainView.daysRemaining }} days
</span>
} @else {
<span class="text-gray-400">—</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<a
[routerLink]="['/domains', domainView.domain.domain]"
class="text-primary-600 hover:text-primary-900"
>
View Details
</ui-table-cell>
<ui-table-cell class="text-right">
<a [routerLink]="['/domains', d.domain.domain]">
<button uiButton variant="outline" size="sm">View</button>
</a>
</td>
</tr>
</ui-table-cell>
</ui-table-row>
}
</tbody>
</table>
</div>
<!-- Summary Stats -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-4">
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Total Domains</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{{ domains().length }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Valid Certificates</dt>
<dd class="mt-1 text-3xl font-semibold text-green-600">{{ getStatusCount('valid') }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Expiring Soon</dt>
<dd class="mt-1 text-3xl font-semibold text-yellow-600">{{ getStatusCount('expiring-soon') }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Expired/Pending</dt>
<dd class="mt-1 text-3xl font-semibold text-red-600">{{ getStatusCount('expired') + getStatusCount('pending') }}</dd>
</div>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">No domains found</p>
<p class="text-sm text-gray-400 mt-2">
Sync your Cloudflare zones or manually add domains to get started
</p>
<button
(click)="syncDomains()"
class="mt-4 btn btn-primary"
>
Sync Cloudflare Domains
</button>
</div>
</ui-table-body>
</ui-table>
}
</ui-card-content>
</ui-card>
</div>
`,
})
export class DomainsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
private api = inject(ApiService);
private toast = inject(ToastService);
domains = signal<DomainView[]>([]);
loading = signal(true);
domains = signal<IDomainDetail[]>([]);
loading = signal(false);
syncing = signal(false);
ngOnInit(): void {
this.loadDomains();
}
loadDomains(): void {
async loadDomains(): Promise<void> {
this.loading.set(true);
this.apiService.getDomains().subscribe({
next: (response) => {
try {
const response = await this.api.getDomains();
if (response.success && response.data) {
this.domains.set(response.data);
}
} catch {
this.toast.error('Failed to load domains');
} finally {
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}
syncDomains(): void {
async syncDomains(): Promise<void> {
this.syncing.set(true);
this.apiService.syncCloudflareDomains().subscribe({
next: (response) => {
try {
const response = await this.api.syncCloudflareDomains();
if (response.success) {
this.toastService.success('Cloudflare domains synced successfully');
this.toast.success('Domains synced');
this.loadDomains();
} else {
this.toast.error(response.error || 'Failed to sync domains');
}
} catch {
this.toast.error('Failed to sync domains');
} finally {
this.syncing.set(false);
},
error: (error) => {
this.toastService.error('Failed to sync Cloudflare domains: ' + (error.error?.error || error.message));
this.syncing.set(false);
},
});
}
}
getStatusCount(status: string): number {
countByStatus(status: string): number {
return this.domains().filter(d => d.certificateStatus === status).length;
}
getCertStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'secondary' {
switch (status) {
case 'valid': return 'success';
case 'expiring-soon': return 'warning';
case 'expired': return 'destructive';
case 'pending': return 'secondary';
default: return 'secondary';
}
}
}

View File

@@ -1,103 +1,163 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../core/services/auth.service';
import { ThemeService } from '../../core/services/theme.service';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
ButtonComponent,
InputComponent,
LabelComponent,
AlertComponent,
AlertDescriptionComponent,
],
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Onebox
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Sign in to your account
</p>
</div>
<form class="mt-8 space-y-6" (ngSubmit)="onSubmit()">
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="username" class="sr-only">Username</label>
<input
id="username"
name="username"
type="text"
[(ngModel)]="username"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Username"
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
[(ngModel)]="password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
@if (error) {
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{ error }}</p>
</div>
}
<div>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<div class="absolute top-4 right-4">
<button
type="submit"
[disabled]="loading"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
uiButton
variant="ghost"
size="icon"
(click)="theme.toggle()"
>
{{ loading ? 'Signing in...' : 'Sign in' }}
@if (theme.resolvedTheme() === 'dark') {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
} @else {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
}
</button>
</div>
<div class="text-sm text-gray-600 text-center">
<p>Default credentials: admin / admin</p>
<p class="text-xs text-gray-500 mt-1">Please change after first login</p>
<ui-card class="w-full max-w-md">
<ui-card-header class="text-center">
<div class="mx-auto mb-4">
<svg class="h-12 w-12 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
</div>
<ui-card-title>Welcome to Onebox</ui-card-title>
<ui-card-description>Enter your credentials to sign in</ui-card-description>
</ui-card-header>
<form (ngSubmit)="onSubmit()">
<ui-card-content class="space-y-4">
@if (error()) {
<ui-alert variant="destructive">
<ui-alert-description>{{ error() }}</ui-alert-description>
</ui-alert>
}
<div class="space-y-2">
<label uiLabel for="username">Username</label>
<input
uiInput
id="username"
type="text"
[(ngModel)]="username"
name="username"
placeholder="Enter username"
autocomplete="username"
required
/>
</div>
<div class="space-y-2">
<label uiLabel for="password">Password</label>
<input
uiInput
id="password"
type="password"
[(ngModel)]="password"
name="password"
placeholder="Enter password"
autocomplete="current-password"
required
/>
</div>
</ui-card-content>
<ui-card-footer>
<button
uiButton
type="submit"
class="w-full"
[disabled]="loading()"
>
@if (loading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
} @else {
Sign in
}
</button>
</ui-card-footer>
</form>
<div class="px-6 pb-6">
<p class="text-xs text-center text-muted-foreground">
Default credentials: admin / admin
</p>
</div>
</ui-card>
</div>
`,
})
export class LoginComponent {
private authService = inject(AuthService);
private auth = inject(AuthService);
private router = inject(Router);
theme = inject(ThemeService);
username = '';
password = '';
loading = false;
error = '';
loading = signal(false);
error = signal<string | null>(null);
onSubmit(): void {
this.error = '';
this.loading = true;
async onSubmit(): Promise<void> {
if (!this.username || !this.password) {
this.error.set('Please enter username and password');
return;
}
this.authService.login({ username: this.username, password: this.password }).subscribe({
next: (response) => {
this.loading = false;
if (response.success) {
this.loading.set(true);
this.error.set(null);
const result = await this.auth.login(this.username, this.password);
this.loading.set(false);
if (result.success) {
this.router.navigate(['/dashboard']);
} else {
this.error = response.error || 'Login failed';
}
},
error: (err) => {
this.loading = false;
this.error = err.error?.error || 'An error occurred during login';
},
});
this.error.set(result.error || 'Invalid credentials');
}
}
}

View File

@@ -1,99 +1,229 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ApiService, Registry } from '../../core/services/api.service';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from '../../ui/table/table.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
import {
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
} from '../../ui/dialog/dialog.component';
@Component({
selector: 'app-registries',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
InputComponent,
LabelComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Docker Registries</h1>
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold tracking-tight">Docker Registries</h1>
<p class="text-muted-foreground">Manage Docker registry credentials</p>
</div>
<!-- Add Registry Form -->
<div class="card mb-8 max-w-2xl">
<h2 class="text-lg font-medium text-gray-900 mb-4">Add Registry</h2>
<form (ngSubmit)="addRegistry()" class="space-y-4">
<div>
<label for="url" class="label">Registry URL</label>
<input type="text" id="url" [(ngModel)]="newRegistry.url" name="url" required placeholder="registry.example.com" class="input" />
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Add Registry</ui-card-title>
<ui-card-description>Add credentials for a private Docker registry</ui-card-description>
</ui-card-header>
<ui-card-content>
<form (ngSubmit)="addRegistry()" class="grid gap-4 md:grid-cols-4">
<div class="space-y-2">
<label uiLabel>Registry URL</label>
<input uiInput [(ngModel)]="form.url" name="url" placeholder="registry.example.com" required />
</div>
<div>
<label for="username" class="label">Username</label>
<input type="text" id="username" [(ngModel)]="newRegistry.username" name="username" required class="input" />
<div class="space-y-2">
<label uiLabel>Username</label>
<input uiInput [(ngModel)]="form.username" name="username" required />
</div>
<div>
<label for="password" class="label">Password</label>
<input type="password" id="password" [(ngModel)]="newRegistry.password" name="password" required class="input" />
<div class="space-y-2">
<label uiLabel>Password</label>
<input uiInput type="password" [(ngModel)]="form.password" name="password" required />
</div>
<div class="flex items-end">
<button uiButton type="submit" [disabled]="loading()">Add Registry</button>
</div>
<button type="submit" class="btn btn-primary">Add Registry</button>
</form>
</div>
</ui-card-content>
</ui-card>
<!-- Registries List -->
@if (registries().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Registered Registries</ui-card-title>
</ui-card-header>
<ui-card-content class="p-0">
@if (loading() && registries().length === 0) {
<div class="p-6 space-y-4">
@for (_ of [1,2]; track $index) {
<ui-skeleton class="h-12 w-full" />
}
</div>
} @else if (registries().length === 0) {
<div class="p-12 text-center">
<p class="text-muted-foreground">No registries configured</p>
</div>
} @else {
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>URL</ui-table-head>
<ui-table-head>Username</ui-table-head>
<ui-table-head>Created</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (registry of registries(); track registry.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.url }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.username }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(registry.createdAt) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="deleteRegistry(registry)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
<ui-table-row>
<ui-table-cell class="font-medium">{{ registry.url }}</ui-table-cell>
<ui-table-cell>{{ registry.username }}</ui-table-cell>
<ui-table-cell>{{ formatDate(registry.createdAt) }}</ui-table-cell>
<ui-table-cell class="text-right">
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(registry)">
Delete
</button>
</ui-table-cell>
</ui-table-row>
}
</tbody>
</table>
</div>
</ui-table-body>
</ui-table>
}
</ui-card-content>
</ui-card>
</div>
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
<ui-dialog-header>
<ui-dialog-title>Delete Registry</ui-dialog-title>
<ui-dialog-description>
Are you sure you want to delete "{{ registryToDelete()?.url }}"?
</ui-dialog-description>
</ui-dialog-header>
<ui-dialog-footer>
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
<button uiButton variant="destructive" (click)="deleteRegistry()">Delete</button>
</ui-dialog-footer>
</ui-dialog>
`,
})
export class RegistriesComponent implements OnInit {
private apiService = inject(ApiService);
registries = signal<Registry[]>([]);
newRegistry = { url: '', username: '', password: '' };
private api = inject(ApiService);
private toast = inject(ToastService);
registries = signal<IRegistry[]>([]);
loading = signal(false);
deleteDialogOpen = signal(false);
registryToDelete = signal<IRegistry | null>(null);
form: IRegistryCreate = { url: '', username: '', password: '' };
ngOnInit(): void {
this.loadRegistries();
}
loadRegistries(): void {
this.apiService.getRegistries().subscribe({
next: (response) => {
async loadRegistries(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getRegistries();
if (response.success && response.data) {
this.registries.set(response.data);
}
},
});
} catch {
this.toast.error('Failed to load registries');
} finally {
this.loading.set(false);
}
}
addRegistry(): void {
this.apiService.createRegistry(this.newRegistry).subscribe({
next: () => {
this.newRegistry = { url: '', username: '', password: '' };
async addRegistry(): Promise<void> {
if (!this.form.url || !this.form.username || !this.form.password) {
this.toast.error('Please fill in all fields');
return;
}
this.loading.set(true);
try {
const response = await this.api.createRegistry(this.form);
if (response.success) {
this.toast.success('Registry added');
this.form = { url: '', username: '', password: '' };
this.loadRegistries();
},
});
} else {
this.toast.error(response.error || 'Failed to add registry');
}
} catch {
this.toast.error('Failed to add registry');
} finally {
this.loading.set(false);
}
}
deleteRegistry(registry: Registry): void {
if (confirm(`Delete registry ${registry.url}?`)) {
this.apiService.deleteRegistry(registry.url).subscribe({
next: () => this.loadRegistries(),
});
confirmDelete(registry: IRegistry): void {
this.registryToDelete.set(registry);
this.deleteDialogOpen.set(true);
}
async deleteRegistry(): Promise<void> {
const registry = this.registryToDelete();
if (!registry?.id) return;
try {
const response = await this.api.deleteRegistry(registry.id);
if (response.success) {
this.toast.success('Registry deleted');
this.loadRegistries();
} else {
this.toast.error(response.error || 'Failed to delete registry');
}
} catch {
this.toast.error('Failed to delete registry');
} finally {
this.deleteDialogOpen.set(false);
this.registryToDelete.set(null);
}
}

View File

@@ -1,354 +1,317 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Component, inject, signal, OnInit } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { IServiceCreate, IDomainDetail } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import { CheckboxComponent } from '../../ui/checkbox/checkbox.component';
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
import { SeparatorComponent } from '../../ui/separator/separator.component';
interface EnvVar {
key: string;
value: string;
}
interface Domain {
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
isObsolete: boolean;
}
@Component({
selector: 'app-service-create',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
imports: [
FormsModule,
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
ButtonComponent,
InputComponent,
LabelComponent,
CheckboxComponent,
AlertComponent,
AlertDescriptionComponent,
SeparatorComponent,
],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
<div class="card max-w-3xl">
<form (ngSubmit)="onSubmit()">
<!-- Name -->
<div class="mb-6">
<label for="name" class="label">Service Name *</label>
<input
type="text"
id="name"
[(ngModel)]="name"
name="name"
required
placeholder="myapp"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only</p>
</div>
<!-- Image -->
<div class="mb-6">
<label for="image" class="label">Docker Image *</label>
<input
type="text"
id="image"
[(ngModel)]="image"
name="image"
[required]="!useOneboxRegistry"
[disabled]="useOneboxRegistry"
placeholder="nginx:latest"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
</div>
<!-- Onebox Registry Option -->
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<input
type="checkbox"
id="useOneboxRegistry"
[(ngModel)]="useOneboxRegistry"
name="useOneboxRegistry"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="useOneboxRegistry" class="ml-2 block text-sm font-medium text-gray-900">
Use Onebox Registry
</label>
</div>
<p class="text-sm text-gray-600 mb-3">
Store your container image in the local Onebox registry instead of using an external image.
</p>
@if (useOneboxRegistry) {
<div class="space-y-3">
<div class="max-w-2xl mx-auto space-y-6">
<!-- Header -->
<div>
<label for="registryImageTag" class="label text-sm">Image Tag</label>
<input
type="text"
id="registryImageTag"
[(ngModel)]="registryImageTag"
name="registryImageTag"
placeholder="latest"
class="input text-sm"
/>
<p class="mt-1 text-xs text-gray-500">Tag to use (e.g., latest, v1.0, develop)</p>
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Services
</a>
<h1 class="text-3xl font-bold tracking-tight">Deploy Service</h1>
<p class="text-muted-foreground">Deploy a new Docker service</p>
</div>
<div class="flex items-center">
<form (ngSubmit)="onSubmit()">
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Service Configuration</ui-card-title>
<ui-card-description>Configure your service settings</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-6">
<!-- Basic Configuration -->
<div class="grid gap-4">
<div class="space-y-2">
<label uiLabel for="name">Service Name</label>
<input
type="checkbox"
id="autoUpdateOnPush"
[(ngModel)]="autoUpdateOnPush"
name="autoUpdateOnPush"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoUpdateOnPush" class="ml-2 block text-sm text-gray-700">
Auto-restart on new image push
</label>
</div>
<p class="text-xs text-gray-500 ml-6">
Automatically pull and restart the service when a new image is pushed to the registry
</p>
</div>
}
</div>
<!-- Port -->
<div class="mb-6">
<label for="port" class="label">Container Port *</label>
<input
type="number"
id="port"
[(ngModel)]="port"
name="port"
uiInput
id="name"
[(ngModel)]="form.name"
name="name"
placeholder="my-service"
required
placeholder="80"
class="input"
pattern="[a-z0-9-]+"
/>
<p class="mt-1 text-sm text-gray-500">Port that your application listens on</p>
<p class="text-xs text-muted-foreground">Lowercase letters, numbers, and hyphens only</p>
</div>
<!-- Domain -->
<div class="mb-6">
<label for="domain" class="label">Domain (Optional)</label>
<div class="space-y-2">
<label uiLabel for="image">Docker Image</label>
<input
type="text"
uiInput
id="image"
[(ngModel)]="form.image"
name="image"
placeholder="nginx:latest"
required
/>
<p class="text-xs text-muted-foreground">e.g., nginx:latest, registry.example.com/image:tag</p>
</div>
<div class="space-y-2">
<label uiLabel for="port">Container Port</label>
<input
uiInput
id="port"
type="number"
[(ngModel)]="form.port"
name="port"
placeholder="80"
required
min="1"
max="65535"
/>
</div>
<div class="space-y-2">
<label uiLabel for="domain">Domain (optional)</label>
<input
uiInput
id="domain"
[(ngModel)]="domain"
(ngModelChange)="onDomainChange()"
[(ngModel)]="form.domain"
name="domain"
placeholder="app.example.com"
list="domainList"
class="input"
[class.border-red-300]="domainWarning()"
list="domains-list"
/>
<datalist id="domainList">
@for (domain of availableDomains(); track domain.domain) {
<option [value]="domain.domain">{{ domain.domain }}</option>
<datalist id="domains-list">
@for (d of domains(); track d.domain.domain) {
<option [value]="d.domain.domain">{{ d.domain.domain }}</option>
}
</datalist>
@if (domainWarning()) {
<div class="mt-2 rounded-md bg-yellow-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
<strong>{{ domainWarningTitle() }}</strong>
</p>
<p class="mt-1 text-sm text-yellow-700">{{ domainWarningMessage() }}</p>
<div class="mt-2">
<a routerLink="/domains" class="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline">
View domains &rarr;
</a>
</div>
</div>
</div>
</div>
} @else {
<p class="mt-1 text-sm text-gray-500">
Leave empty to skip automatic DNS & SSL.
@if (availableDomains().length > 0) {
<span>Or select from {{ availableDomains().length }} available domain(s).</span>
}
</p>
<ui-alert variant="warning" class="mt-2">
<ui-alert-description>{{ domainWarning() }}</ui-alert-description>
</ui-alert>
}
</div>
</div>
<ui-separator />
<!-- Environment Variables -->
<div class="mb-6">
<label class="label">Environment Variables</label>
@for (env of envVars(); track $index) {
<div class="flex gap-2 mb-2">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">Environment Variables</h3>
<p class="text-xs text-muted-foreground">Configure environment variables for your service</p>
</div>
<button uiButton variant="outline" size="sm" type="button" (click)="addEnvVar()">
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
@if (envVars().length > 0) {
<div class="space-y-2">
@for (env of envVars(); track $index; let i = $index) {
<div class="flex gap-2">
<input
type="text"
uiInput
[(ngModel)]="env.key"
[name]="'envKey' + $index"
[name]="'env-key-' + i"
placeholder="KEY"
class="input flex-1"
class="flex-1"
/>
<input
type="text"
uiInput
[(ngModel)]="env.value"
[name]="'envValue' + $index"
[name]="'env-value-' + i"
placeholder="value"
class="input flex-1"
class="flex-1"
/>
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
Remove
<button
uiButton
variant="ghost"
size="icon"
type="button"
(click)="removeEnvVar(i)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
}
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
Add Environment Variable
</button>
</div>
<!-- Options -->
<div class="mb-6">
<div class="flex items-center mb-2">
<input
type="checkbox"
id="autoDNS"
[(ngModel)]="autoDNS"
name="autoDNS"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoDNS" class="ml-2 block text-sm text-gray-900">
Configure DNS automatically
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="autoSSL"
[(ngModel)]="autoSSL"
name="autoSSL"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoSSL" class="ml-2 block text-sm text-gray-900">
Obtain SSL certificate automatically
</label>
</div>
</div>
@if (error()) {
<div class="rounded-md bg-red-50 p-4 mb-6">
<p class="text-sm text-red-800">{{ error() }}</p>
</div>
}
<!-- Actions -->
<div class="flex justify-end space-x-4">
<button type="button" (click)="cancel()" class="btn btn-secondary">
Cancel
</button>
<button type="submit" [disabled]="loading()" class="btn btn-primary">
{{ loading() ? 'Deploying...' : 'Deploy Service' }}
</button>
</div>
<ui-separator />
<!-- Onebox Registry -->
<div class="space-y-4">
<div class="flex items-center gap-3">
<ui-checkbox
[checked]="form.useOneboxRegistry ?? false"
(checkedChange)="form.useOneboxRegistry = $event"
/>
<div>
<label uiLabel class="cursor-pointer">Use Onebox Registry</label>
<p class="text-xs text-muted-foreground">Push images directly to this Onebox instance</p>
</div>
</div>
@if (form.useOneboxRegistry) {
<div class="pl-7 space-y-4">
<div class="space-y-2">
<label uiLabel for="registryImageTag">Image Tag</label>
<input
uiInput
id="registryImageTag"
[(ngModel)]="form.registryImageTag"
name="registryImageTag"
placeholder="latest"
/>
</div>
<div class="flex items-center gap-3">
<ui-checkbox
[checked]="form.autoUpdateOnPush ?? false"
(checkedChange)="form.autoUpdateOnPush = $event"
/>
<label uiLabel class="cursor-pointer">Auto-restart on push</label>
</div>
</div>
}
</div>
</ui-card-content>
<ui-card-footer class="flex justify-between">
<a routerLink="/services">
<button uiButton variant="outline" type="button">Cancel</button>
</a>
<button uiButton type="submit" [disabled]="loading()">
@if (loading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Deploying...
} @else {
Deploy Service
}
</button>
</ui-card-footer>
</ui-card>
</form>
</div>
</div>
`,
})
export class ServiceCreateComponent implements OnInit {
private apiService = inject(ApiService);
private api = inject(ApiService);
private router = inject(Router);
private toast = inject(ToastService);
form: IServiceCreate = {
name: '',
image: '',
port: 80,
domain: '',
useOneboxRegistry: false,
registryImageTag: 'latest',
autoUpdateOnPush: false,
};
name = '';
image = '';
port = 80;
domain = '';
autoDNS = true;
autoSSL = true;
envVars = signal<EnvVar[]>([]);
domains = signal<IDomainDetail[]>([]);
loading = signal(false);
error = signal('');
// Onebox Registry
useOneboxRegistry = false;
registryImageTag = 'latest';
autoUpdateOnPush = false;
// Domain validation
availableDomains = signal<Domain[]>([]);
domainWarning = signal(false);
domainWarningTitle = signal('');
domainWarningMessage = signal('');
domainWarning = signal<string | null>(null);
ngOnInit(): void {
this.loadDomains();
}
loadDomains(): void {
this.apiService.getDomains().subscribe({
next: (response) => {
async loadDomains(): Promise<void> {
try {
const response = await this.api.getDomains();
if (response.success && response.data) {
const domains: Domain[] = response.data.map((d: any) => ({
domain: d.domain.domain,
dnsProvider: d.domain.dnsProvider,
isObsolete: d.domain.isObsolete,
}));
this.availableDomains.set(domains);
this.domains.set(response.data);
}
},
error: () => {
// Silently fail - domains list not critical
},
});
}
onDomainChange(): void {
if (!this.domain) {
this.domainWarning.set(false);
return;
}
// Extract base domain from entered domain
const parts = this.domain.split('.');
if (parts.length < 2) {
// Not a valid domain format
this.domainWarning.set(false);
return;
}
const baseDomain = parts.slice(-2).join('.');
// Check if base domain exists in available domains
const matchingDomain = this.availableDomains().find(
(d) => d.domain === baseDomain
);
if (!matchingDomain) {
this.domainWarning.set(true);
this.domainWarningTitle.set('Domain not found');
this.domainWarningMessage.set(
`The base domain "${baseDomain}" is not in the Domain table. The service will deploy, but certificate management may not work. Sync your Cloudflare domains or manually add the domain first.`
);
} else if (matchingDomain.isObsolete) {
this.domainWarning.set(true);
this.domainWarningTitle.set('Domain is obsolete');
this.domainWarningMessage.set(
`The domain "${baseDomain}" is marked as obsolete (likely removed from Cloudflare). Certificate management may not work properly.`
);
} else {
this.domainWarning.set(false);
} catch {
// Silent fail - domain autocomplete is optional
}
}
addEnvVar(): void {
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
this.envVars.update(vars => [...vars, { key: '', value: '' }]);
}
removeEnvVar(index: number): void {
this.envVars.update((vars) => vars.filter((_, i) => i !== index));
this.envVars.update(vars => vars.filter((_, i) => i !== index));
}
validateDomain(): void {
if (!this.form.domain) {
this.domainWarning.set(null);
return;
}
const domain = this.domains().find(d => d.domain.domain === this.form.domain);
if (!domain) {
this.domainWarning.set('This domain is not in your domain list. DNS and SSL may not be configured automatically.');
} else if (domain.domain.isObsolete) {
this.domainWarning.set('This domain is marked as obsolete.');
} else {
this.domainWarning.set(null);
}
}
async onSubmit(): Promise<void> {
if (!this.form.name || !this.form.image || !this.form.port) {
this.toast.error('Please fill in all required fields');
return;
}
onSubmit(): void {
this.error.set('');
this.loading.set(true);
// Convert env vars to object
// Build env vars object
const envVarsObj: Record<string, string> = {};
for (const env of this.envVars()) {
if (env.key && env.value) {
@@ -356,36 +319,23 @@ export class ServiceCreateComponent implements OnInit {
}
}
const data = {
name: this.name,
image: this.image,
port: this.port,
domain: this.domain || undefined,
envVars: envVarsObj,
autoDNS: this.autoDNS,
autoSSL: this.autoSSL,
useOneboxRegistry: this.useOneboxRegistry,
registryImageTag: this.useOneboxRegistry ? this.registryImageTag : undefined,
autoUpdateOnPush: this.useOneboxRegistry ? this.autoUpdateOnPush : undefined,
const data: IServiceCreate = {
...this.form,
envVars: Object.keys(envVarsObj).length > 0 ? envVarsObj : undefined,
};
this.apiService.createService(data).subscribe({
next: (response) => {
this.loading.set(false);
try {
const response = await this.api.createService(data);
if (response.success) {
this.toast.success(`Service "${this.form.name}" deployed successfully`);
this.router.navigate(['/services']);
} else {
this.error.set(response.error || 'Failed to deploy service');
this.toast.error(response.error || 'Failed to deploy service');
}
},
error: (err) => {
} catch {
this.toast.error('Failed to deploy service');
} finally {
this.loading.set(false);
this.error.set(err.error?.error || 'An error occurred');
},
});
}
cancel(): void {
this.router.navigate(['/services']);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,180 +1,332 @@
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, effect, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ApiService, Service } from '../../core/services/api.service';
import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { Subscription } from 'rxjs';
import { ToastService } from '../../core/services/toast.service';
import { IService } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component';
import {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from '../../ui/table/table.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
import {
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
} from '../../ui/dialog/dialog.component';
@Component({
selector: 'app-services-list',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="sm:flex sm:items-center sm:justify-between mb-8">
<h1 class="text-3xl font-bold text-gray-900">Services</h1>
<div class="mt-4 sm:mt-0">
<a routerLink="/services/new" class="btn btn-primary">
Deploy New Service
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Services</h1>
<p class="text-muted-foreground">Manage your deployed services</p>
</div>
<a routerLink="/services/create">
<button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Deploy Service
</button>
</a>
</div>
</div>
@if (loading()) {
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<!-- Services Table -->
<ui-card>
<ui-card-content class="p-0">
@if (loading() && services().length === 0) {
<div class="p-6 space-y-4">
@for (_ of [1,2,3]; track $index) {
<ui-skeleton class="h-12 w-full" />
}
</div>
} @else if (services().length === 0) {
<div class="card text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No services</h3>
<p class="mt-1 text-sm text-gray-500">Get started by deploying a new service.</p>
<div class="mt-6">
<a routerLink="/services/new" class="btn btn-primary">
Deploy Service
<h3 class="mt-4 text-lg font-semibold">No services</h3>
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
<a routerLink="/services/create" class="mt-4 inline-block">
<button uiButton>Deploy Service</button>
</a>
</div>
</div>
} @else {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (service of services(); track service.id) {
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<a [routerLink]="['/services', service.name]" class="text-sm font-medium text-primary-600 hover:text-primary-900">
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Name</ui-table-head>
<ui-table-head>Image</ui-table-head>
<ui-table-head>Domain</ui-table-head>
<ui-table-head>Status</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (service of services(); track service.name) {
<ui-table-row>
<ui-table-cell>
<a [routerLink]="['/services', service.name]" class="font-medium hover:underline">
{{ service.name }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ service.image }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ service.domain || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span [ngClass]="{
'badge-success': service.status === 'running',
'badge-danger': service.status === 'stopped' || service.status === 'failed',
'badge-warning': service.status === 'starting' || service.status === 'stopping'
}" class="badge">
</ui-table-cell>
<ui-table-cell class="text-muted-foreground">{{ service.image }}</ui-table-cell>
<ui-table-cell>
@if (service.domain) {
<a [href]="'https://' + service.domain" target="_blank" class="text-primary hover:underline">
{{ service.domain }}
</a>
} @else {
<span class="text-muted-foreground">-</span>
}
</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="getStatusVariant(service.status)">
{{ service.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
@if (service.status === 'stopped') {
<button (click)="startService(service)" class="text-green-600 hover:text-green-900">Start</button>
</ui-badge>
</ui-table-cell>
<ui-table-cell class="text-right">
<div class="flex items-center justify-end gap-2">
@if (service.status === 'stopped' || service.status === 'failed') {
<button
uiButton
variant="outline"
size="sm"
(click)="startService(service.name)"
[disabled]="actionLoading() === service.name"
>
Start
</button>
}
@if (service.status === 'running') {
<button (click)="stopService(service)" class="text-yellow-600 hover:text-yellow-900">Stop</button>
<button (click)="restartService(service)" class="text-blue-600 hover:text-blue-900">Restart</button>
<button
uiButton
variant="outline"
size="sm"
(click)="stopService(service.name)"
[disabled]="actionLoading() === service.name"
>
Stop
</button>
<button
uiButton
variant="outline"
size="sm"
(click)="restartService(service.name)"
[disabled]="actionLoading() === service.name"
>
Restart
</button>
}
<button (click)="deleteService(service)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
}
</tbody>
</table>
<button
uiButton
variant="destructive"
size="sm"
(click)="confirmDelete(service)"
>
Delete
</button>
</div>
</ui-table-cell>
</ui-table-row>
}
</ui-table-body>
</ui-table>
}
</ui-card-content>
</ui-card>
</div>
<!-- Delete Confirmation Dialog -->
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
<ui-dialog-header>
<ui-dialog-title>Delete Service</ui-dialog-title>
<ui-dialog-description>
Are you sure you want to delete "{{ serviceToDelete()?.name }}"? This action cannot be undone.
</ui-dialog-description>
</ui-dialog-header>
<ui-dialog-footer>
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
<button uiButton variant="destructive" (click)="deleteService()" [disabled]="!!actionLoading()">
Delete
</button>
</ui-dialog-footer>
</ui-dialog>
`,
})
export class ServicesListComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private wsService = inject(WebSocketService);
private wsSubscription?: Subscription;
export class ServicesListComponent implements OnInit {
private api = inject(ApiService);
private ws = inject(WebSocketService);
private toast = inject(ToastService);
services = signal<Service[]>([]);
loading = signal(true);
services = signal<IService[]>([]);
loading = signal(false);
actionLoading = signal<string | null>(null);
deleteDialogOpen = signal(false);
serviceToDelete = signal<IService | null>(null);
ngOnInit(): void {
// Initial load
constructor() {
// React to WebSocket updates
effect(() => {
const update = this.ws.serviceUpdates();
const status = this.ws.serviceStatus();
if (update || status) {
this.loadServices();
// Subscribe to WebSocket updates
this.wsSubscription = this.wsService.getMessages().subscribe((message) => {
this.handleWebSocketMessage(message);
}
});
}
ngOnDestroy(): void {
this.wsSubscription?.unsubscribe();
}
private handleWebSocketMessage(message: any): void {
if (message.type === 'service_update') {
// Reload the full service list on any service update
ngOnInit(): void {
this.loadServices();
} else if (message.type === 'service_status') {
// Update individual service status
const currentServices = this.services();
const updatedServices = currentServices.map(s =>
s.name === message.serviceName
? { ...s, status: message.status }
: s
);
this.services.set(updatedServices);
}
}
loadServices(): void {
async loadServices(): Promise<void> {
this.loading.set(true);
this.apiService.getServices().subscribe({
next: (response) => {
try {
const response = await this.api.getServices();
if (response.success && response.data) {
this.services.set(response.data);
}
} catch {
this.toast.error('Failed to load services');
} finally {
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}
startService(service: Service): void {
this.apiService.startService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
switch (status) {
case 'running':
return 'success';
case 'stopped':
return 'secondary';
case 'failed':
return 'destructive';
case 'starting':
case 'stopping':
return 'warning';
default:
return 'secondary';
}
}
stopService(service: Service): void {
this.apiService.stopService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
async startService(name: string): Promise<void> {
this.actionLoading.set(name);
try {
const response = await this.api.startService(name);
if (response.success) {
this.toast.success(`Service "${name}" started`);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to start service');
}
} catch {
this.toast.error('Failed to start service');
} finally {
this.actionLoading.set(null);
}
}
restartService(service: Service): void {
this.apiService.restartService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
async stopService(name: string): Promise<void> {
this.actionLoading.set(name);
try {
const response = await this.api.stopService(name);
if (response.success) {
this.toast.success(`Service "${name}" stopped`);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to stop service');
}
} catch {
this.toast.error('Failed to stop service');
} finally {
this.actionLoading.set(null);
}
}
deleteService(service: Service): void {
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
this.apiService.deleteService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
async restartService(name: string): Promise<void> {
this.actionLoading.set(name);
try {
const response = await this.api.restartService(name);
if (response.success) {
this.toast.success(`Service "${name}" restarted`);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to restart service');
}
} catch {
this.toast.error('Failed to restart service');
} finally {
this.actionLoading.set(null);
}
}
confirmDelete(service: IService): void {
this.serviceToDelete.set(service);
this.deleteDialogOpen.set(true);
}
async deleteService(): Promise<void> {
const service = this.serviceToDelete();
if (!service) return;
this.actionLoading.set(service.name);
try {
const response = await this.api.deleteService(service.name);
if (response.success) {
this.toast.success(`Service "${service.name}" deleted`);
this.deleteDialogOpen.set(false);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to delete service');
}
} catch {
this.toast.error('Failed to delete service');
} finally {
this.actionLoading.set(null);
this.serviceToDelete.set(null);
}
}
}

View File

@@ -1,100 +1,337 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { ThemeService } from '../../core/services/theme.service';
import { AuthService } from '../../core/services/auth.service';
import { ISettings } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import { SwitchComponent } from '../../ui/switch/switch.component';
import { SeparatorComponent } from '../../ui/separator/separator.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
InputComponent,
LabelComponent,
SwitchComponent,
SeparatorComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
<div class="space-y-6">
<!-- Cloudflare Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">Cloudflare DNS</h2>
<div>
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
<p class="text-muted-foreground">Manage system configuration</p>
</div>
@if (loading()) {
<div class="space-y-6">
@for (_ of [1,2,3]; track $index) {
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-skeleton class="h-6 w-48" />
</ui-card-header>
<ui-card-content>
<ui-skeleton class="h-20 w-full" />
</ui-card-content>
</ui-card>
}
</div>
} @else {
<!-- Appearance -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Appearance</ui-card-title>
<ui-card-description>Customize the look and feel</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label uiLabel>Dark Mode</label>
<p class="text-sm text-muted-foreground">Toggle dark mode on or off</p>
</div>
<ui-switch [ngModel]="theme.isDark()" (ngModelChange)="theme.toggle()" />
</div>
</ui-card-content>
</ui-card>
<!-- Cloudflare Integration -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Cloudflare Integration</ui-card-title>
<ui-card-description>Configure Cloudflare API for DNS management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label uiLabel>API Token</label>
<input
uiInput
type="password"
[(ngModel)]="settings.cloudflareToken"
placeholder="Enter Cloudflare API token"
/>
</div>
<div class="space-y-2">
<label uiLabel>Zone ID (Optional)</label>
<input
uiInput
[(ngModel)]="settings.cloudflareZoneId"
placeholder="Default zone ID"
/>
</div>
</div>
<p class="text-sm text-muted-foreground">
Get your API token from the Cloudflare dashboard with DNS edit permissions.
</p>
</ui-card-content>
</ui-card>
<!-- SSL/TLS Settings -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL/TLS Settings</ui-card-title>
<ui-card-description>Configure certificate management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label uiLabel>Auto-Renew Certificates</label>
<p class="text-sm text-muted-foreground">Automatically renew certificates before expiry</p>
</div>
<ui-switch [(ngModel)]="settings.autoRenewCerts" />
</div>
<ui-separator />
<div class="space-y-2">
<label uiLabel>Renewal Threshold (days)</label>
<input
uiInput
type="number"
[(ngModel)]="settings.renewalThreshold"
min="1"
max="90"
class="w-32"
/>
<p class="text-sm text-muted-foreground">
Renew certificates when they have fewer than this many days remaining.
</p>
</div>
<div class="space-y-2">
<label uiLabel>ACME Email</label>
<input
uiInput
type="email"
[(ngModel)]="settings.acmeEmail"
placeholder="admin@example.com"
/>
<p class="text-sm text-muted-foreground">
Email address for Let's Encrypt notifications.
</p>
</div>
</ui-card-content>
</ui-card>
<!-- Network Settings -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Network Settings</ui-card-title>
<ui-card-description>Configure network and proxy settings</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label uiLabel>HTTP Port</label>
<input
uiInput
type="number"
[(ngModel)]="settings.httpPort"
min="1"
max="65535"
/>
</div>
<div class="space-y-2">
<label uiLabel>HTTPS Port</label>
<input
uiInput
type="number"
[(ngModel)]="settings.httpsPort"
min="1"
max="65535"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label uiLabel>Force HTTPS</label>
<p class="text-sm text-muted-foreground">Redirect all HTTP traffic to HTTPS</p>
</div>
<ui-switch [(ngModel)]="settings.forceHttps" />
</div>
</ui-card-content>
</ui-card>
<!-- Account Settings -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Account</ui-card-title>
<ui-card-description>Manage your account settings</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="space-y-2">
<label uiLabel>Current User</label>
<p class="text-sm font-medium">{{ auth.currentUser()?.username || 'Unknown' }}</p>
</div>
<ui-separator />
<div class="space-y-4">
<div>
<label class="label">API Key</label>
<input type="password" [(ngModel)]="settings.cloudflareAPIKey" class="input" />
<h4 class="text-sm font-medium">Change Password</h4>
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-2">
<label uiLabel>Current Password</label>
<input uiInput type="password" [(ngModel)]="passwordForm.current" />
</div>
<div>
<label class="label">Email</label>
<input type="email" [(ngModel)]="settings.cloudflareEmail" class="input" />
<div class="space-y-2">
<label uiLabel>New Password</label>
<input uiInput type="password" [(ngModel)]="passwordForm.new" />
</div>
<div>
<label class="label">Zone ID</label>
<input type="text" [(ngModel)]="settings.cloudflareZoneID" class="input" />
<div class="space-y-2">
<label uiLabel>Confirm Password</label>
<input uiInput type="password" [(ngModel)]="passwordForm.confirm" />
</div>
</div>
</div>
<!-- Server Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">Server</h2>
<div class="space-y-4">
<div>
<label class="label">Server IP</label>
<input type="text" [(ngModel)]="settings.serverIP" class="input" placeholder="1.2.3.4" />
</div>
<div>
<label class="label">HTTP Port</label>
<input type="number" [(ngModel)]="settings.httpPort" class="input" placeholder="3000" />
</div>
</div>
</div>
<!-- SSL Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">SSL / ACME</h2>
<div>
<label class="label">ACME Email</label>
<input type="email" [(ngModel)]="settings.acmeEmail" class="input" placeholder="admin@example.com" />
</div>
</div>
<!-- Save Button -->
<div class="flex justify-end">
<button (click)="saveSettings()" class="btn btn-primary">
Save Settings
<button uiButton variant="outline" (click)="changePassword()">
Update Password
</button>
</div>
</ui-card-content>
</ui-card>
<!-- Save Button -->
<div class="flex justify-end gap-4">
<button uiButton variant="outline" (click)="loadSettings()">Reset</button>
<button uiButton (click)="saveSettings()" [disabled]="saving()">
@if (saving()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Saving...
} @else {
Save Settings
}
</button>
</div>
}
</div>
`,
})
export class SettingsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
settings: any = {};
private api = inject(ApiService);
private toast = inject(ToastService);
protected theme = inject(ThemeService);
protected auth = inject(AuthService);
loading = signal(false);
saving = signal(false);
settings: ISettings = {
cloudflareToken: '',
cloudflareZoneId: '',
autoRenewCerts: true,
renewalThreshold: 30,
acmeEmail: '',
httpPort: 80,
httpsPort: 443,
forceHttps: true,
};
passwordForm = {
current: '',
new: '',
confirm: '',
};
ngOnInit(): void {
this.loadSettings();
}
loadSettings(): void {
this.apiService.getSettings().subscribe({
next: (response) => {
async loadSettings(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getSettings();
if (response.success && response.data) {
this.settings = response.data;
this.settings = { ...this.settings, ...response.data };
}
} catch {
this.toast.error('Failed to load settings');
} finally {
this.loading.set(false);
}
},
});
}
saveSettings(): void {
// Save each setting individually
const promises = Object.entries(this.settings).map(([key, value]) =>
this.apiService.updateSetting(key, value as string).toPromise()
async saveSettings(): Promise<void> {
this.saving.set(true);
try {
const response = await this.api.updateSettings(this.settings);
if (response.success) {
this.toast.success('Settings saved');
} else {
this.toast.error(response.error || 'Failed to save settings');
}
} catch {
this.toast.error('Failed to save settings');
} finally {
this.saving.set(false);
}
}
async changePassword(): Promise<void> {
if (!this.passwordForm.current || !this.passwordForm.new) {
this.toast.error('Please fill in all password fields');
return;
}
if (this.passwordForm.new !== this.passwordForm.confirm) {
this.toast.error('New passwords do not match');
return;
}
if (this.passwordForm.new.length < 6) {
this.toast.error('Password must be at least 6 characters');
return;
}
try {
const response = await this.api.changePassword(
this.passwordForm.current,
this.passwordForm.new
);
Promise.all(promises).then(() => {
this.toastService.success('Settings saved successfully');
}).catch((error) => {
this.toastService.error('Failed to save settings: ' + (error.message || 'Unknown error'));
});
if (response.success) {
this.toast.success('Password changed');
this.passwordForm = { current: '', new: '', confirm: '' };
} else {
this.toast.error(response.error || 'Failed to change password');
}
} catch {
this.toast.error('Failed to change password');
}
}
}

View File

@@ -1,93 +0,0 @@
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../core/services/auth.service';
import { ToastComponent } from './toast.component';
@Component({
selector: 'app-layout',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, ToastComponent],
template: `
<div class="min-h-screen bg-gray-50">
<!-- Navigation -->
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<span class="text-2xl font-bold text-primary-600">Onebox</span>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a
routerLink="/dashboard"
routerLinkActive="border-primary-500 text-gray-900"
[routerLinkActiveOptions]="{ exact: true }"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Dashboard
</a>
<a
routerLink="/services"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Services
</a>
<a
routerLink="/registries"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Registries
</a>
<a
routerLink="/dns"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
DNS
</a>
<a
routerLink="/domains"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Domains
</a>
<a
routerLink="/settings"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Settings
</a>
</div>
</div>
<div class="flex items-center">
<span class="text-sm text-gray-700 mr-4">{{ authService.currentUser()?.username }}</span>
<button (click)="logout()" class="btn btn-secondary text-sm">
Logout
</button>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<router-outlet></router-outlet>
</main>
<!-- Toast Notifications -->
<app-toast></app-toast>
</div>
`,
})
export class LayoutComponent {
authService = inject(AuthService);
logout(): void {
this.authService.logout();
}
}

View File

@@ -0,0 +1,126 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
import { ThemeService } from '../../../core/services/theme.service';
import { ToasterComponent } from '../../../ui/toast/toaster.component';
import { ButtonComponent } from '../../../ui/button/button.component';
import { SeparatorComponent } from '../../../ui/separator/separator.component';
interface NavItem {
label: string;
path: string;
icon: string;
}
@Component({
selector: 'app-layout',
standalone: true,
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
ToasterComponent,
ButtonComponent,
SeparatorComponent,
],
template: `
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container flex h-14 items-center">
<!-- Logo -->
<a routerLink="/dashboard" class="mr-6 flex items-center space-x-2">
<svg class="h-6 w-6 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
<span class="font-bold">Onebox</span>
</a>
<!-- Navigation -->
<nav class="flex items-center space-x-6 text-sm font-medium">
@for (item of navItems; track item.path) {
<a
[routerLink]="item.path"
routerLinkActive="text-foreground"
[routerLinkActiveOptions]="{ exact: item.path === '/dashboard' }"
class="transition-colors hover:text-foreground/80 text-foreground/60"
>
{{ item.label }}
</a>
}
</nav>
<!-- Right side -->
<div class="ml-auto flex items-center space-x-4">
<!-- Theme toggle -->
<button
uiButton
variant="ghost"
size="icon"
(click)="theme.toggle()"
class="h-9 w-9"
>
@if (theme.resolvedTheme() === 'dark') {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
} @else {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
}
</button>
<ui-separator orientation="vertical" class="h-6" />
<!-- User info -->
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{ auth.currentUser()?.username || 'User' }}
</span>
<button
uiButton
variant="ghost"
size="sm"
(click)="auth.logout()"
>
Logout
</button>
</div>
</div>
</div>
</header>
<!-- Main content -->
<main class="container pt-8 pb-6">
<router-outlet />
</main>
<!-- Toaster -->
<ui-toaster />
</div>
`,
styles: [`
.container {
width: 100%;
max-width: 1400px;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
`],
})
export class LayoutComponent {
auth = inject(AuthService);
theme = inject(ThemeService);
navItems: NavItem[] = [
{ label: 'Dashboard', path: '/dashboard', icon: 'home' },
{ label: 'Services', path: '/services', icon: 'server' },
{ label: 'Registries', path: '/registries', icon: 'database' },
{ label: 'DNS', path: '/dns', icon: 'globe' },
{ label: 'Domains', path: '/domains', icon: 'link' },
{ label: 'Settings', path: '/settings', icon: 'settings' },
];
}

View File

@@ -1,48 +0,0 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-loading-spinner',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex items-center justify-center" [class]="containerClass">
<div
class="spinner border-t-transparent rounded-full animate-spin"
[ngClass]="{
'w-4 h-4 border-2': size === 'sm',
'w-6 h-6 border-2': size === 'md',
'w-8 h-8 border-3': size === 'lg',
'w-12 h-12 border-4': size === 'xl',
'border-primary-600': color === 'primary',
'border-white': color === 'white',
'border-gray-600': color === 'gray'
}"
></div>
@if (text) {
<span class="ml-3 text-sm text-gray-600">{{ text }}</span>
}
</div>
`,
styles: [`
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.border-3 {
border-width: 3px;
}
`]
})
export class LoadingSpinnerComponent {
@Input() size: 'sm' | 'md' | 'lg' | 'xl' = 'md';
@Input() color: 'primary' | 'white' | 'gray' = 'primary';
@Input() text?: string;
@Input() containerClass?: string;
}

View File

@@ -1,91 +0,0 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
imports: [CommonModule],
template: `
<div class="fixed top-4 right-4 z-50 space-y-2">
@for (toast of toastService.toasts(); track toast.id) {
<div
class="toast-item animate-slide-in-right shadow-lg rounded-lg px-4 py-3 flex items-start gap-3 min-w-[320px] max-w-md"
[ngClass]="{
'bg-green-50 border-l-4 border-green-500 text-green-900': toast.type === 'success',
'bg-red-50 border-l-4 border-red-500 text-red-900': toast.type === 'error',
'bg-blue-50 border-l-4 border-blue-500 text-blue-900': toast.type === 'info',
'bg-yellow-50 border-l-4 border-yellow-500 text-yellow-900': toast.type === 'warning'
}"
>
<!-- Icon -->
<div class="flex-shrink-0 mt-0.5">
@if (toast.type === 'success') {
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
}
@if (toast.type === 'error') {
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
}
@if (toast.type === 'info') {
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}
@if (toast.type === 'warning') {
<svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
}
</div>
<!-- Message -->
<div class="flex-1 text-sm font-medium">
{{ toast.message }}
</div>
<!-- Close button -->
<button
type="button"
(click)="toastService.remove(toast.id)"
class="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
}
</div>
`,
styles: [`
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
.toast-item {
transition: all 0.3s ease-out;
}
.toast-item:hover {
transform: translateY(-2px);
}
`]
})
export class ToastComponent {
toastService = inject(ToastService);
}

View File

@@ -0,0 +1,62 @@
import { Component, Input } from '@angular/core';
export type AlertVariant = 'default' | 'destructive' | 'success' | 'warning';
@Component({
selector: 'ui-alert',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
role: 'alert',
},
})
export class AlertComponent {
@Input() variant: AlertVariant = 'default';
@Input() class = '';
private baseClasses =
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground';
private variantClasses: Record<AlertVariant, string> = {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
success: 'border-success/50 text-success dark:border-success [&>svg]:text-success',
warning: 'border-warning/50 text-warning dark:border-warning [&>svg]:text-warning',
};
get computedClasses(): string {
return `${this.baseClasses} ${this.variantClasses[this.variant]} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-alert-title',
standalone: true,
template: `<h5 [class]="computedClasses"><ng-content /></h5>`,
})
export class AlertTitleComponent {
@Input() class = '';
private baseClasses = 'mb-1 font-medium leading-none tracking-tight';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-alert-description',
standalone: true,
template: `<div [class]="computedClasses"><ng-content /></div>`,
})
export class AlertDescriptionComponent {
@Input() class = '';
private baseClasses = 'text-sm [&_p]:leading-relaxed';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,32 @@
import { Component, Input } from '@angular/core';
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'outline';
@Component({
selector: 'ui-badge',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
})
export class BadgeComponent {
@Input() variant: BadgeVariant = 'default';
@Input() class = '';
private baseClasses =
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2';
private variantClasses: Record<BadgeVariant, string> = {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
success: 'border-transparent bg-success text-success-foreground hover:bg-success/80',
warning: 'border-transparent bg-warning text-warning-foreground hover:bg-warning/80',
outline: 'text-foreground',
};
get computedClasses(): string {
return `${this.baseClasses} ${this.variantClasses[this.variant]} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,45 @@
import { Component, Input } from '@angular/core';
export type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
@Component({
selector: 'ui-button, button[uiButton]',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
'[disabled]': 'disabled',
'[type]': 'type',
},
})
export class ButtonComponent {
@Input() variant: ButtonVariant = 'default';
@Input() size: ButtonSize = 'default';
@Input() disabled = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Input() class = '';
private baseClasses =
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0';
private variantClasses: Record<ButtonVariant, string> = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
};
private sizeClasses: Record<ButtonSize, string> = {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
};
get computedClasses(): string {
return `${this.baseClasses} ${this.variantClasses[this.variant]} ${this.sizeClasses[this.size]} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,112 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-card',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [':host { display: block; }'],
})
export class CardComponent {
@Input() class = '';
private baseClasses = 'rounded-lg border bg-card text-card-foreground shadow-sm';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-card-header',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
})
export class CardHeaderComponent {
@Input() class = '';
private baseClasses = 'p-6';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-card-title',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
})
export class CardTitleComponent {
@Input() class = '';
private baseClasses = 'text-lg font-semibold leading-none tracking-tight';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-card-description',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [':host { display: block; }'],
})
export class CardDescriptionComponent {
@Input() class = '';
private baseClasses = 'text-sm text-muted-foreground';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-card-content',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
})
export class CardContentComponent {
@Input() class = '';
private baseClasses = 'block p-6 pt-0';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-card-footer',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [':host { display: block; }'],
})
export class CardFooterComponent {
@Input() class = '';
private baseClasses = 'flex items-center p-6 pt-0';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,79 @@
import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'ui-checkbox',
standalone: true,
template: `
<button
type="button"
role="checkbox"
[attr.aria-checked]="checked"
[class]="computedClasses"
[disabled]="disabled"
(click)="toggle()"
>
@if (checked) {
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
}
</button>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CheckboxComponent),
multi: true,
},
],
})
export class CheckboxComponent implements ControlValueAccessor {
@Input() checked = false;
@Input() disabled = false;
@Input() class = '';
@Output() checkedChange = new EventEmitter<boolean>();
private onChange: (value: boolean) => void = () => {};
private onTouched: () => void = () => {};
private baseClasses =
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground flex items-center justify-center';
get computedClasses(): string {
const stateClass = this.checked ? 'bg-primary text-primary-foreground' : 'bg-background';
return `${this.baseClasses} ${stateClass} ${this.class}`.trim();
}
toggle(): void {
if (this.disabled) return;
this.checked = !this.checked;
this.checkedChange.emit(this.checked);
this.onChange(this.checked);
this.onTouched();
}
writeValue(value: boolean): void {
this.checked = value;
}
registerOnChange(fn: (value: boolean) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}

View File

@@ -0,0 +1,67 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'ui-dialog',
standalone: true,
template: `
@if (open) {
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Overlay -->
<div
class="fixed inset-0 bg-black/80 animate-fade-in"
(click)="onOverlayClick()"
></div>
<!-- Content -->
<div class="relative z-50 grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg animate-fade-in sm:rounded-lg">
<ng-content />
</div>
</div>
}
`,
})
export class DialogComponent {
@Input() open = false;
@Input() closeOnOverlay = true;
@Output() openChange = new EventEmitter<boolean>();
onOverlayClick(): void {
if (this.closeOnOverlay) {
this.open = false;
this.openChange.emit(false);
}
}
close(): void {
this.open = false;
this.openChange.emit(false);
}
}
@Component({
selector: 'ui-dialog-header',
standalone: true,
template: `<div class="flex flex-col space-y-1.5 text-center sm:text-left"><ng-content /></div>`,
})
export class DialogHeaderComponent {}
@Component({
selector: 'ui-dialog-title',
standalone: true,
template: `<h2 class="text-lg font-semibold leading-none tracking-tight"><ng-content /></h2>`,
})
export class DialogTitleComponent {}
@Component({
selector: 'ui-dialog-description',
standalone: true,
template: `<p class="text-sm text-muted-foreground"><ng-content /></p>`,
})
export class DialogDescriptionComponent {}
@Component({
selector: 'ui-dialog-footer',
standalone: true,
template: `<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2"><ng-content /></div>`,
})
export class DialogFooterComponent {}

62
ui/src/app/ui/index.ts Normal file
View File

@@ -0,0 +1,62 @@
// Button
export { ButtonComponent, ButtonVariant, ButtonSize } from './button/button.component';
// Input
export { InputComponent } from './input/input.component';
// Label
export { LabelComponent } from './label/label.component';
// Card
export {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
} from './card/card.component';
// Badge
export { BadgeComponent, BadgeVariant } from './badge/badge.component';
// Table
export {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from './table/table.component';
// Skeleton
export { SkeletonComponent } from './skeleton/skeleton.component';
// Separator
export { SeparatorComponent } from './separator/separator.component';
// Alert
export { AlertComponent, AlertTitleComponent, AlertDescriptionComponent, AlertVariant } from './alert/alert.component';
// Checkbox
export { CheckboxComponent } from './checkbox/checkbox.component';
// Switch
export { SwitchComponent } from './switch/switch.component';
// Toast
export { ToastComponent } from './toast/toast.component';
export { ToasterComponent } from './toast/toaster.component';
// Dialog
export {
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
} from './dialog/dialog.component';
// Select
export { SelectComponent, SelectOption } from './select/select.component';

View File

@@ -0,0 +1,26 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-input, input[uiInput]',
standalone: true,
template: ``,
host: {
'[class]': 'computedClasses',
'[disabled]': 'disabled',
'[type]': 'type',
'[placeholder]': 'placeholder',
},
})
export class InputComponent {
@Input() type: string = 'text';
@Input() placeholder: string = '';
@Input() disabled = false;
@Input() class = '';
private baseClasses =
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-label, label[uiLabel]',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
})
export class LabelComponent {
@Input() class = '';
private baseClasses =
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,121 @@
import { Component, Input, Output, EventEmitter, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
export interface SelectOption {
label: string;
value: string;
}
@Component({
selector: 'ui-select',
standalone: true,
template: `
<div class="relative">
<button
type="button"
role="combobox"
[attr.aria-expanded]="isOpen()"
[class]="buttonClasses"
[disabled]="disabled"
(click)="toggleOpen()"
>
<span class="flex-1 text-left truncate">{{ selectedLabel || placeholder }}</span>
<svg
class="h-4 w-4 opacity-50 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (isOpen()) {
<div
class="absolute z-50 mt-1 w-full min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-fade-in"
>
<div class="p-1 max-h-60 overflow-auto">
@for (option of options; track option.value) {
<div
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
[class.bg-accent]="option.value === value"
(click)="selectOption(option)"
>
{{ option.label }}
</div>
}
</div>
</div>
}
</div>
`,
host: {
'(document:click)': 'onDocumentClick($event)',
},
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectComponent),
multi: true,
},
],
})
export class SelectComponent implements ControlValueAccessor {
@Input() options: SelectOption[] = [];
@Input() placeholder = 'Select...';
@Input() value: string = '';
@Input() disabled = false;
@Input() class = '';
@Output() valueChange = new EventEmitter<string>();
isOpen = signal(false);
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
get selectedLabel(): string {
const selected = this.options.find((o) => o.value === this.value);
return selected?.label || '';
}
get buttonClasses(): string {
return `flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${this.class}`.trim();
}
toggleOpen(): void {
if (this.disabled) return;
this.isOpen.update((v) => !v);
}
selectOption(option: SelectOption): void {
this.value = option.value;
this.valueChange.emit(option.value);
this.onChange(option.value);
this.onTouched();
this.isOpen.set(false);
}
onDocumentClick(event: Event): void {
const target = event.target as HTMLElement;
if (!target.closest('ui-select')) {
this.isOpen.set(false);
}
}
writeValue(value: string): void {
this.value = value;
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}

View File

@@ -0,0 +1,21 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-separator',
standalone: true,
template: ``,
host: {
'[class]': 'computedClasses',
role: 'separator',
},
})
export class SeparatorComponent {
@Input() orientation: 'horizontal' | 'vertical' = 'horizontal';
@Input() class = '';
get computedClasses(): string {
const orientationClasses =
this.orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]';
return `shrink-0 bg-border ${orientationClasses} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-skeleton',
standalone: true,
template: ``,
host: {
'[class]': 'computedClasses',
},
})
export class SkeletonComponent {
@Input() class = '';
private baseClasses = 'animate-pulse rounded-md bg-muted';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,69 @@
import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'ui-switch',
standalone: true,
template: `
<button
type="button"
role="switch"
[attr.aria-checked]="checked"
[class]="buttonClasses"
[disabled]="disabled"
(click)="toggle()"
>
<span [class]="thumbClasses"></span>
</button>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SwitchComponent),
multi: true,
},
],
})
export class SwitchComponent implements ControlValueAccessor {
@Input() checked = false;
@Input() disabled = false;
@Input() class = '';
@Output() checkedChange = new EventEmitter<boolean>();
private onChange: (value: boolean) => void = () => {};
private onTouched: () => void = () => {};
get buttonClasses(): string {
const stateClass = this.checked ? 'bg-primary' : 'bg-input';
return `peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 ${stateClass} ${this.class}`.trim();
}
get thumbClasses(): string {
const translateClass = this.checked ? 'translate-x-5' : 'translate-x-0';
return `pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${translateClass}`;
}
toggle(): void {
if (this.disabled) return;
this.checked = !this.checked;
this.checkedChange.emit(this.checked);
this.onChange(this.checked);
this.onTouched();
}
writeValue(value: boolean): void {
this.checked = value;
}
registerOnChange(fn: (value: boolean) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}

View File

@@ -0,0 +1,148 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-table',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [`
:host {
display: table;
width: 100%;
border-collapse: collapse;
}
`],
})
export class TableComponent {
@Input() class = '';
private baseClasses = 'w-full caption-bottom text-sm';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-table-header',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [':host { display: table-header-group; }'],
})
export class TableHeaderComponent {
@Input() class = '';
private baseClasses = '';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-table-body',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [':host { display: table-row-group; }'],
})
export class TableBodyComponent {
@Input() class = '';
private baseClasses = '';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-table-row',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [`
:host {
display: table-row;
}
:host:not(:last-child) {
border-bottom: 1px solid hsl(var(--border));
}
:host:hover {
background-color: hsl(var(--muted) / 0.5);
}
`],
})
export class TableRowComponent {
@Input() class = '';
private baseClasses = 'transition-colors';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-table-head',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [`
:host {
display: table-cell;
height: 3rem;
padding: 0 1rem;
text-align: left;
vertical-align: middle;
font-weight: 500;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}
`],
})
export class TableHeadComponent {
@Input() class = '';
private baseClasses = '';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}
@Component({
selector: 'ui-table-cell',
standalone: true,
template: `<ng-content />`,
host: {
'[class]': 'computedClasses',
},
styles: [`
:host {
display: table-cell;
padding: 1rem;
vertical-align: middle;
}
`],
})
export class TableCellComponent {
@Input() class = '';
private baseClasses = '';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
}
}

View File

@@ -0,0 +1,66 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { IToast, ToastType } from '../../core/types/api.types';
@Component({
selector: 'ui-toast',
standalone: true,
template: `
<div [class]="computedClasses">
<div class="flex items-start gap-3">
<div [class]="iconContainerClasses">
@switch (toast.type) {
@case ('success') {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
}
@case ('error') {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
}
@case ('warning') {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
}
@case ('info') {
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
}
</div>
<p class="flex-1 text-sm font-medium">{{ toast.message }}</p>
<button
type="button"
class="ml-auto rounded-md p-1 opacity-70 hover:opacity-100 focus:outline-none focus:ring-2"
(click)="dismiss.emit()"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
`,
})
export class ToastComponent {
@Input({ required: true }) toast!: IToast;
@Output() dismiss = new EventEmitter<void>();
private variantClasses: Record<ToastType, string> = {
success: 'border-success/50 bg-success/10 text-success',
error: 'border-destructive/50 bg-destructive/10 text-destructive',
warning: 'border-warning/50 bg-warning/10 text-warning',
info: 'border-primary/50 bg-primary/10 text-primary',
};
get computedClasses(): string {
return `pointer-events-auto w-full max-w-sm rounded-lg border p-4 shadow-lg animate-slide-in-right ${this.variantClasses[this.toast.type]}`;
}
get iconContainerClasses(): string {
return 'flex-shrink-0';
}
}

View File

@@ -0,0 +1,19 @@
import { Component, inject } from '@angular/core';
import { ToastService } from '../../core/services/toast.service';
import { ToastComponent } from './toast.component';
@Component({
selector: 'ui-toaster',
standalone: true,
imports: [ToastComponent],
template: `
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
@for (toast of toastService.toasts(); track toast.id) {
<ui-toast [toast]="toast" (dismiss)="toastService.dismiss(toast.id)" />
}
</div>
`,
})
export class ToasterComponent {
toastService = inject(ToastService);
}

View File

@@ -1 +0,0 @@
<!-- Empty favicon placeholder -->

View File

@@ -2,12 +2,20 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Onebox - Container Platform</title>
<title>Onebox</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
</style>
</head>
<body class="bg-gray-50">
<body class="min-h-screen antialiased">
<app-root></app-root>
</body>
</html>

View File

@@ -1,13 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
}).catch((err) => console.error(err));
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@@ -2,56 +2,91 @@
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--warning: 38 92% 50%;
--warning-foreground: 48 96% 89%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700;
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 70.6% 45.3%;
--success-foreground: 144.9 80.4% 10%;
--warning: 48 96% 53%;
--warning-foreground: 36 45% 15%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
.btn-secondary {
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
@layer base {
* {
@apply border-border;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700;
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
.btn-success {
@apply bg-green-600 text-white hover:bg-green-700;
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
.card {
@apply bg-white rounded-lg shadow-md p-6;
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500;
.scrollbar-thin::-webkit-scrollbar-track {
@apply bg-muted;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded-full;
}
.badge {
@apply px-2 py-1 text-xs font-semibold rounded-full;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-info {
@apply bg-blue-100 text-blue-800;
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
}

View File

@@ -1,25 +1,97 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{html,ts}",
],
darkMode: ['class'],
content: ['./src/**/*.{html,ts}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'fade-in': {
from: { opacity: '0' },
to: { opacity: '1' },
},
'fade-out': {
from: { opacity: '1' },
to: { opacity: '0' },
},
'slide-in-right': {
from: { transform: 'translateX(100%)' },
to: { transform: 'translateX(0)' },
},
'slide-out-right': {
from: { transform: 'translateX(0)' },
to: { transform: 'translateX(100%)' },
},
'slide-in-bottom': {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'fade-in': 'fade-in 150ms ease-out',
'fade-out': 'fade-out 150ms ease-in',
'slide-in-right': 'slide-in-right 200ms ease-out',
'slide-out-right': 'slide-out-right 200ms ease-in',
'slide-in-bottom': 'slide-in-bottom 200ms ease-out',
'accordion-down': 'accordion-down 200ms ease-out',
'accordion-up': 'accordion-up 200ms ease-out',
},
},
},
plugins: [],
}
};

View File

@@ -1,3 +1,5 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {

View File

@@ -1,26 +1,22 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
]
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,

15
ui/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}