ui rebuild
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
### NEVER GUESS - ALWAYS READ THE ACTUAL CODE
|
### NEVER GUESS - ALWAYS READ THE ACTUAL CODE
|
||||||
**FUCKING ALWAYS look at the dependency actual code. Don't start fucking guessing stuff.**
|
**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:
|
When working with any dependency:
|
||||||
1. **READ the actual source code** in `node_modules/` or check the package documentation
|
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
|
2. **CHECK the exact API** - don't assume based on similar libraries
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"test": "deno test --allow-all test/",
|
"test": "deno test --allow-all test/",
|
||||||
"test:watch": "deno test --allow-all --watch test/",
|
"test:watch": "deno test --allow-all --watch test/",
|
||||||
"compile": "bash scripts/compile-all.sh",
|
"compile": "bash scripts/compile-all.sh",
|
||||||
"dev": "deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor"
|
"dev": "pnpm run watch"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@std/path": "jsr:@std/path@^1.1.2",
|
"@std/path": "jsr:@std/path@^1.1.2",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||||
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
||||||
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.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",
|
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
||||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^1.8.0",
|
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^1.8.0",
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"onebox": "./bin/onebox-wrapper.js"
|
"onebox": "./bin/onebox-wrapper.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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": [
|
"keywords": [
|
||||||
"docker",
|
"docker",
|
||||||
@@ -50,5 +51,8 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
"dependencies": {}
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
206
pnpm-lock.yaml
generated
Normal file
206
pnpm-lock.yaml
generated
Normal 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
491
readme.md
@@ -1,59 +1,88 @@
|
|||||||
# @serve.zone/onebox
|
# @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
|
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.
|
||||||
- 🌐 **Automatic Nginx Reverse Proxy** - Traffic routing with zero configuration
|
|
||||||
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration via SmartACME
|
## What Makes Onebox Different? 🎯
|
||||||
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record management
|
|
||||||
- 📊 **Metrics & Monitoring** - Historical CPU, memory, and network stats
|
- **Native Deno Reverse Proxy** - Built from scratch in Deno (no Nginx required!), featuring HTTP/HTTPS servers with SNI support and bidirectional WebSocket proxying
|
||||||
- 📝 **Log Aggregation** - Centralized container logs
|
- **Docker Swarm First** - All workloads run as Swarm services, not standalone containers, for built-in orchestration
|
||||||
- 🎨 **Angular Web UI** - Modern, responsive interface
|
- **Real-time Everything** - WebSocket-powered live updates for service status, logs, and metrics across all connected clients
|
||||||
- 👥 **Multi-user Support** - Role-based access control
|
- **Single Executable** - Compiles to a standalone binary - just run it, no dependencies
|
||||||
- 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries
|
- **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
|
- 💾 **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
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install via shell script
|
# Download the latest release for your platform
|
||||||
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
|
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
|
pnpm install -g @serve.zone/onebox
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy Your First Service
|
### First Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add a registry (optional, for private images)
|
# Start the server in development mode
|
||||||
onebox registry add --url registry.example.com --username myuser --password mypass
|
onebox server --ephemeral
|
||||||
|
|
||||||
# Deploy a service
|
# In another terminal, deploy your first service
|
||||||
onebox service add myapp \
|
onebox service add myapp \
|
||||||
--image nginx:latest \
|
--image nginx:latest \
|
||||||
--domain app.example.com \
|
--domain app.example.com \
|
||||||
--env PORT=80
|
--port 80
|
||||||
|
|
||||||
# Check status
|
|
||||||
onebox service list
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
onebox service logs myapp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Install systemd service
|
# Install as systemd service
|
||||||
sudo onebox daemon install
|
sudo onebox daemon install
|
||||||
|
|
||||||
# Start the daemon
|
# Start the daemon
|
||||||
@@ -63,147 +92,395 @@ sudo onebox daemon start
|
|||||||
sudo onebox daemon logs
|
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
|
### Service Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
onebox service add <name> --image <image> --domain <domain> [--env KEY=VALUE]
|
# Deploy a service
|
||||||
onebox service remove <name>
|
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 start <name>
|
||||||
onebox service stop <name>
|
onebox service stop <name>
|
||||||
onebox service restart <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
|
### Registry Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
onebox registry add --url <url> --username <user> --password <pass>
|
# Add external registry credentials
|
||||||
onebox registry remove <url>
|
onebox registry add --url registry.example.com --username user --password pass
|
||||||
|
|
||||||
|
# List registries
|
||||||
onebox registry list
|
onebox registry list
|
||||||
|
|
||||||
|
# Remove registry
|
||||||
|
onebox registry remove <url>
|
||||||
```
|
```
|
||||||
|
|
||||||
### DNS Management
|
### DNS Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
onebox dns add <domain> --ip <ip>
|
# Add DNS record (requires Cloudflare config)
|
||||||
onebox dns remove <domain>
|
onebox dns add <domain>
|
||||||
|
|
||||||
|
# List DNS records
|
||||||
onebox dns list
|
onebox dns list
|
||||||
|
|
||||||
|
# Sync from Cloudflare
|
||||||
onebox dns sync
|
onebox dns sync
|
||||||
|
|
||||||
|
# Remove DNS record
|
||||||
|
onebox dns remove <domain>
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSL Management
|
### SSL Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
onebox ssl renew [domain]
|
# Renew expiring certificates
|
||||||
onebox ssl list
|
onebox ssl renew
|
||||||
|
|
||||||
|
# Force renew specific domain
|
||||||
onebox ssl force-renew <domain>
|
onebox ssl force-renew <domain>
|
||||||
```
|
|
||||||
|
|
||||||
### Nginx Management
|
# List certificates
|
||||||
|
onebox ssl list
|
||||||
```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>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Show all settings
|
||||||
onebox config show
|
onebox config show
|
||||||
|
|
||||||
|
# Set configuration value
|
||||||
onebox config set <key> <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
|
```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
|
- **Linux** (x64 or ARM64)
|
||||||
- **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)
|
|
||||||
- **Docker** installed and running
|
- **Docker** installed and running
|
||||||
- **Nginx** installed
|
- **Docker Swarm** initialized (`docker swarm init`)
|
||||||
- **Root/sudo access** (for nginx, Docker, ports 80/443)
|
- **Root/sudo access** for ports 80/443
|
||||||
- **(Optional) Cloudflare account** for DNS management
|
- **(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
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone https://code.foss.global/serve.zone/onebox
|
git clone https://code.foss.global/serve.zone/onebox
|
||||||
cd 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
|
deno task dev
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
deno task test
|
deno task test
|
||||||
|
|
||||||
# Compile for all platforms
|
# Watch mode for tests
|
||||||
|
deno task test:watch
|
||||||
|
|
||||||
|
# Compile binaries for all platforms
|
||||||
deno task compile
|
deno task compile
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
### Project Structure
|
||||||
|
|
||||||
Onebox stores configuration in:
|
```
|
||||||
- **Database**: `/var/lib/onebox/onebox.db`
|
onebox/
|
||||||
- **Nginx configs**: `/etc/nginx/sites-available/onebox-*`
|
├── ts/
|
||||||
- **SSL certificates**: `/etc/letsencrypt/live/`
|
│ ├── 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)
|
Real-time updates are broadcast via WebSocket:
|
||||||
- [Issue Tracker](https://code.foss.global/serve.zone/onebox/issues)
|
|
||||||
- [Changelog](./changelog.md)
|
```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.
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export class CloudflareDomainSync {
|
|||||||
try {
|
try {
|
||||||
logger.info('Starting Cloudflare zone synchronization...');
|
logger.info('Starting Cloudflare zone synchronization...');
|
||||||
|
|
||||||
// Fetch all zones from Cloudflare
|
// Fetch all zones from Cloudflare (v6+ API uses convenience.listZones())
|
||||||
const zones = await this.cloudflareAccount!.getZones();
|
const zones = await this.cloudflareAccount!.convenience.listZones();
|
||||||
logger.info(`Found ${zones.length} Cloudflare zone(s)`);
|
logger.info(`Found ${zones.length} Cloudflare zone(s)`);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -651,7 +651,13 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.query('SELECT * FROM services WHERE name = ?', [name]);
|
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 {
|
getServiceByID(id: number): IService | null {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export class OneboxDockerManager {
|
|||||||
*/
|
*/
|
||||||
private async ensureNetwork(): Promise<void> {
|
private async ensureNetwork(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const networks = await this.dockerClient!.getNetworks();
|
const networks = await this.dockerClient!.listNetworks();
|
||||||
const existingNetwork = networks.find((n: any) => n.name === this.networkName);
|
const existingNetwork = networks.find((n: any) => n.Name === this.networkName);
|
||||||
|
|
||||||
if (!existingNetwork) {
|
if (!existingNetwork) {
|
||||||
logger.info(`Creating Docker network: ${this.networkName}`);
|
logger.info(`Creating Docker network: ${this.networkName}`);
|
||||||
@@ -228,14 +228,12 @@ export class OneboxDockerManager {
|
|||||||
* Get network ID by name
|
* Get network ID by name
|
||||||
*/
|
*/
|
||||||
private async getNetworkID(networkName: string): Promise<string> {
|
private async getNetworkID(networkName: string): Promise<string> {
|
||||||
const networks = await this.dockerClient!.getNetworks();
|
const networks = await this.dockerClient!.listNetworks();
|
||||||
const network = networks.find((n: any) =>
|
const network = networks.find((n: any) => n.Name === networkName);
|
||||||
(n.name || n.Name) === networkName
|
|
||||||
);
|
|
||||||
if (!network) {
|
if (!network) {
|
||||||
throw new Error(`Network not found: ${networkName}`);
|
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> {
|
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
|
||||||
try {
|
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 });
|
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
|
// Calculate CPU percentage
|
||||||
const cpuDelta =
|
const cpuDelta =
|
||||||
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_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;
|
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage || 0);
|
||||||
const cpuPercent =
|
const cpuPercent =
|
||||||
systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0;
|
systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0;
|
||||||
|
|
||||||
// Memory stats
|
// Memory stats
|
||||||
const memoryUsed = stats.memory_stats.usage || 0;
|
const memoryUsed = stats.memory_stats?.usage || 0;
|
||||||
const memoryLimit = stats.memory_stats.limit || 0;
|
const memoryLimit = stats.memory_stats?.limit || 0;
|
||||||
const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0;
|
const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0;
|
||||||
|
|
||||||
// Network stats
|
// Network stats
|
||||||
@@ -612,49 +623,88 @@ export class OneboxDockerManager {
|
|||||||
networkTx,
|
networkTx,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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}`);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get container logs
|
* Get container logs
|
||||||
|
* Handles both regular containers and Swarm services
|
||||||
*/
|
*/
|
||||||
async getContainerLogs(
|
async getContainerLogs(
|
||||||
containerID: string,
|
containerID: string,
|
||||||
tail = 100
|
tail = 100
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
try {
|
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({
|
const logs = await container.logs({
|
||||||
stdout: true,
|
stdout: true,
|
||||||
stderr: true,
|
stderr: true,
|
||||||
tail,
|
tail: tail,
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse logs (Docker returns them in a special format)
|
// v5 should return a string, but let's handle edge cases
|
||||||
const stdout: string[] = [];
|
if (typeof logs !== 'string') {
|
||||||
const stderr: string[] = [];
|
logger.error(`Unexpected logs type: ${typeof logs}, constructor: ${logs?.constructor?.name}`);
|
||||||
|
logger.error(`Logs content: ${JSON.stringify(logs).slice(0, 500)}`);
|
||||||
const lines = logs.toString().split('\n');
|
// If it's not a string, something went wrong
|
||||||
for (const line of lines) {
|
throw new Error(`Unexpected log format: expected string, got ${typeof logs}`);
|
||||||
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 returns already-parsed logs as a string
|
||||||
return {
|
return {
|
||||||
stdout: stdout.join('\n'),
|
stdout: logs,
|
||||||
stderr: stderr.join('\n'),
|
stderr: '', // v5 combines stdout/stderr into single string
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get container logs ${containerID}: ${error.message}`);
|
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
|
* List all onebox-managed containers
|
||||||
*/
|
*/
|
||||||
async listContainers(): Promise<any[]> {
|
async listContainers(): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const containers = await this.dockerClient!.getContainers();
|
const containers = await this.dockerClient!.listContainers();
|
||||||
// Filter for onebox-managed containers
|
// Filter for onebox-managed containers
|
||||||
return containers.filter((c: any) =>
|
return containers.filter((c: any) =>
|
||||||
c.labels && c.labels['managed-by'] === 'onebox'
|
c.Labels && c.Labels['managed-by'] === 'onebox'
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to list containers: ${error.message}`);
|
logger.error(`Failed to list containers: ${error.message}`);
|
||||||
@@ -724,14 +742,16 @@ export class OneboxDockerManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Docker version info
|
* Get Docker version info
|
||||||
|
* Note: v5 API doesn't expose version() method, so we return a placeholder
|
||||||
*/
|
*/
|
||||||
async getDockerVersion(): Promise<any> {
|
async getDockerVersion(): Promise<any> {
|
||||||
try {
|
// v5 API doesn't have a version() method
|
||||||
return await this.dockerClient!.version();
|
// Return a basic structure for compatibility
|
||||||
} catch (error) {
|
return {
|
||||||
logger.error(`Failed to get Docker version: ${error.message}`);
|
Version: 'N/A',
|
||||||
return null;
|
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> {
|
async getContainerIP(containerID: string): Promise<string | null> {
|
||||||
try {
|
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 info = await container.inspect();
|
||||||
|
|
||||||
const networks = info.NetworkSettings.Networks;
|
const networks = info.NetworkSettings.Networks;
|
||||||
@@ -776,7 +801,11 @@ export class OneboxDockerManager {
|
|||||||
cmd: string[]
|
cmd: string[]
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
try {
|
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({
|
const exec = await container.exec({
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export class OneboxHttpServer {
|
|||||||
return this.handleWebSocketUpgrade(req);
|
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)
|
// Docker Registry v2 API (no auth required - registry handles it)
|
||||||
if (path.startsWith('/v2/')) {
|
if (path.startsWith('/v2/')) {
|
||||||
return await this.oneboxRef.registry.handleRequest(req);
|
return await this.oneboxRef.registry.handleRequest(req);
|
||||||
@@ -107,25 +113,31 @@ export class OneboxHttpServer {
|
|||||||
filePath = '/index.html';
|
filePath = '/index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = `./ui/dist${filePath}`;
|
const fullPath = `./ui/dist/ui/browser${filePath}`;
|
||||||
|
|
||||||
// Read file
|
// Read file
|
||||||
const file = await Deno.readFile(fullPath);
|
const file = await Deno.readFile(fullPath);
|
||||||
|
|
||||||
// Determine content type
|
// Determine content type
|
||||||
const contentType = this.getContentType(filePath);
|
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, {
|
return new Response(file, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Cache-Control': filePath === '/index.html' ? 'no-cache' : 'public, max-age=3600',
|
'Cache-Control': cacheControl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File not found - serve index.html for Angular routing
|
// File not found - serve index.html for Angular routing
|
||||||
if (error instanceof Deno.errors.NotFound) {
|
if (error instanceof Deno.errors.NotFound) {
|
||||||
try {
|
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, {
|
return new Response(indexFile, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html',
|
'Content-Type': 'text/html',
|
||||||
@@ -450,6 +462,8 @@ export class OneboxHttpServer {
|
|||||||
private async handleGetLogsRequest(name: string): Promise<Response> {
|
private async handleGetLogsRequest(name: string): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const logs = await this.oneboxRef.services.getServiceLogs(name);
|
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 });
|
return this.jsonResponse({ success: true, data: logs });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
|
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
|
||||||
@@ -824,6 +838,135 @@ export class OneboxHttpServer {
|
|||||||
return response;
|
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
|
* Broadcast message to all connected WebSocket clients
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -157,13 +157,22 @@ export class RegistryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const manifest = await this.registry.getManifest(repository, tag);
|
||||||
if (manifest && manifest.digest) {
|
if (manifest && manifest.digest) {
|
||||||
return manifest.digest;
|
return manifest.digest;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} 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}`);
|
logger.warn(`Failed to get digest for ${repository}:${tag}: ${error.message}`);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,7 +333,13 @@ export class OneboxServicesManager {
|
|||||||
|
|
||||||
const logs = await this.docker.getContainerLogs(service.containerID, tail);
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
|
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
17
ui/.editorconfig
Normal file
17
ui/.editorconfig
Normal 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
46
ui/.gitignore
vendored
@@ -1,14 +1,42 @@
|
|||||||
# Dependencies
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build outputs
|
# Compiled output
|
||||||
dist/
|
/dist
|
||||||
.angular/
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
# IDE
|
# Node
|
||||||
.vscode/
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
.idea/
|
.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
|
.DS_Store
|
||||||
*.log
|
Thumbs.db
|
||||||
|
|||||||
59
ui/README.md
Normal file
59
ui/README.md
Normal 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.
|
||||||
@@ -3,29 +3,26 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
"onebox-ui": {
|
"ui": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {},
|
||||||
"@schematics/angular:component": {
|
|
||||||
"style": "css",
|
|
||||||
"standalone": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "",
|
"root": "",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:browser",
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist",
|
"outputPath": "dist/ui",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"main": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"polyfills": ["zone.js"],
|
"polyfills": [],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon.ico",
|
{
|
||||||
"src/assets"
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.css"
|
"src/styles.css"
|
||||||
@@ -37,39 +34,59 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kb",
|
"maximumWarning": "500kB",
|
||||||
"maximumError": "1mb"
|
"maximumError": "1MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "2kb",
|
"maximumWarning": "4kB",
|
||||||
"maximumError": "4kb"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildOptimizer": false,
|
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"vendorChunk": true,
|
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true,
|
"sourceMap": true
|
||||||
"namedChunks": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "onebox-ui:build:production"
|
"buildTarget": "ui:build:production"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "onebox-ui:build:development"
|
"buildTarget": "ui:build:development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "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": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "onebox-ui",
|
"name": "ui",
|
||||||
"version": "1.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve",
|
||||||
"build": "ng build --configuration production",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^18.0.0",
|
"@angular/common": "^19.2.0",
|
||||||
"@angular/common": "^18.0.0",
|
"@angular/compiler": "^19.2.0",
|
||||||
"@angular/compiler": "^18.0.0",
|
"@angular/core": "^19.2.0",
|
||||||
"@angular/core": "^18.0.0",
|
"@angular/forms": "^19.2.0",
|
||||||
"@angular/forms": "^18.0.0",
|
"@angular/platform-browser": "^19.2.0",
|
||||||
"@angular/platform-browser": "^18.0.0",
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
"@angular/platform-browser-dynamic": "^18.0.0",
|
"@angular/router": "^19.2.0",
|
||||||
"@angular/router": "^18.0.0",
|
"autoprefixer": "^10.4.22",
|
||||||
"chart.js": "^4.4.0",
|
"postcss": "^8.5.6",
|
||||||
"ng2-charts": "^6.0.0",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.14.3"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^18.0.0",
|
"@angular-devkit/build-angular": "^19.2.19",
|
||||||
"@angular/cli": "^18.0.0",
|
"@angular/cli": "^19.2.19",
|
||||||
"@angular/compiler-cli": "^18.0.0",
|
"@angular/compiler-cli": "^19.2.0",
|
||||||
"@types/node": "^20.11.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"@types/node": "^24.10.1",
|
||||||
"postcss": "^8.4.38",
|
"jasmine-core": "~5.6.0",
|
||||||
"tailwindcss": "^3.4.3",
|
"karma": "~6.4.0",
|
||||||
"typescript": "~5.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
4105
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
6
ui/postcss.config.js
Normal file
6
ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"/api": {
|
"/api": {
|
||||||
"target": "http://localhost:3000",
|
"target": "http://localhost:3000",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
|
"ws": true,
|
||||||
"changeOrigin": true
|
"changeOrigin": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
ui/public/favicon.ico
Normal file
BIN
ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
0
ui/src/app/app.component.css
Normal file
0
ui/src/app/app.component.css
Normal file
336
ui/src/app/app.component.html
Normal file
336
ui/src/app/app.component.html
Normal 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 />
|
||||||
29
ui/src/app/app.component.spec.ts
Normal file
29
ui/src/app/app.component.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,14 @@
|
|||||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { WebSocketService } from './core/services/websocket.service';
|
import { ToasterComponent } from './ui/toast/toaster.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, ToasterComponent],
|
||||||
template: `<router-outlet></router-outlet>`,
|
template: `
|
||||||
|
<router-outlet />
|
||||||
|
<ui-toaster />
|
||||||
|
`,
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit, OnDestroy {
|
export class AppComponent {}
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
13
ui/src/app/app.config.ts
Normal file
13
ui/src/app/app.config.ts
Normal 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])),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -9,9 +9,11 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
canActivate: [authGuard],
|
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./shared/components/layout.component').then((m) => m.LayoutComponent),
|
import('./shared/components/layout/layout.component').then(
|
||||||
|
(m) => m.LayoutComponent
|
||||||
|
),
|
||||||
|
canActivate: [authGuard],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -21,29 +23,60 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
|
import('./features/dashboard/dashboard.component').then(
|
||||||
|
(m) => m.DashboardComponent
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'services',
|
path: 'services',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/services/services-list.component').then(
|
import('./features/services/services-list.component').then(
|
||||||
(m) => m.ServicesListComponent
|
(m) => m.ServicesListComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'services/new',
|
path: 'create',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/services/service-create.component').then(
|
import('./features/services/service-create.component').then(
|
||||||
(m) => m.ServiceCreateComponent
|
(m) => m.ServiceCreateComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'services/:name',
|
path: ':name',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/services/service-detail.component').then(
|
import('./features/services/service-detail.component').then(
|
||||||
(m) => m.ServiceDetailComponent
|
(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',
|
path: 'registries',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
@@ -51,28 +84,17 @@ export const routes: Routes = [
|
|||||||
(m) => m.RegistriesComponent
|
(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',
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/settings/settings.component').then((m) => m.SettingsComponent),
|
import('./features/settings/settings.component').then(
|
||||||
|
(m) => m.SettingsComponent
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: 'dashboard',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Router, CanActivateFn } from '@angular/router';
|
|||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = () => {
|
export const authGuard: CanActivateFn = () => {
|
||||||
const authService = inject(AuthService);
|
const auth = inject(AuthService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
|
||||||
if (authService.isAuthenticated()) {
|
if (auth.isAuthenticated()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { HttpInterceptorFn } from '@angular/common/http';
|
|
||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
|
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
export const authInterceptor: HttpInterceptorFn = (
|
||||||
const authService = inject(AuthService);
|
req: HttpRequest<unknown>,
|
||||||
const token = authService.getToken();
|
next: HttpHandlerFn
|
||||||
|
) => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const token = auth.getToken();
|
||||||
|
|
||||||
if (token && !req.url.includes('/api/auth/login')) {
|
// Skip auth header for login request
|
||||||
req = req.clone({
|
if (req.url.includes('/api/auth/login')) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const authReq = req.clone({
|
||||||
setHeaders: {
|
setHeaders: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return next(authReq);
|
||||||
}
|
}
|
||||||
|
|
||||||
return next(req);
|
return next(req);
|
||||||
|
|||||||
@@ -1,186 +1,141 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
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> {
|
@Injectable({ providedIn: 'root' })
|
||||||
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',
|
|
||||||
})
|
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = '/api';
|
|
||||||
|
|
||||||
// System
|
// System Status
|
||||||
getStatus(): Observable<ApiResponse<SystemStatus>> {
|
async getStatus(): Promise<IApiResponse<ISystemStatus>> {
|
||||||
return this.http.get<ApiResponse<SystemStatus>>(`${this.baseUrl}/status`);
|
return firstValueFrom(this.http.get<IApiResponse<ISystemStatus>>('/api/status'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
getServices(): Observable<ApiResponse<Service[]>> {
|
async getServices(): Promise<IApiResponse<IService[]>> {
|
||||||
return this.http.get<ApiResponse<Service[]>>(`${this.baseUrl}/services`);
|
return firstValueFrom(this.http.get<IApiResponse<IService[]>>('/api/services'));
|
||||||
}
|
}
|
||||||
|
|
||||||
getService(name: string): Observable<ApiResponse<Service>> {
|
async getService(name: string): Promise<IApiResponse<IService>> {
|
||||||
return this.http.get<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`);
|
return firstValueFrom(this.http.get<IApiResponse<IService>>(`/api/services/${name}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
createService(data: any): Observable<ApiResponse<Service>> {
|
async createService(data: IServiceCreate): Promise<IApiResponse<IService>> {
|
||||||
return this.http.post<ApiResponse<Service>>(`${this.baseUrl}/services`, data);
|
return firstValueFrom(this.http.post<IApiResponse<IService>>('/api/services', data));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteService(name: string): Observable<ApiResponse> {
|
async updateService(name: string, data: IServiceUpdate): Promise<IApiResponse<IService>> {
|
||||||
return this.http.delete<ApiResponse>(`${this.baseUrl}/services/${name}`);
|
return firstValueFrom(this.http.put<IApiResponse<IService>>(`/api/services/${name}`, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
startService(name: string): Observable<ApiResponse> {
|
async deleteService(name: string): Promise<IApiResponse<void>> {
|
||||||
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/start`, {});
|
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/services/${name}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
stopService(name: string): Observable<ApiResponse> {
|
async startService(name: string): Promise<IApiResponse<void>> {
|
||||||
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/stop`, {});
|
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/start`, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
restartService(name: string): Observable<ApiResponse> {
|
async stopService(name: string): Promise<IApiResponse<void>> {
|
||||||
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/restart`, {});
|
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/stop`, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
getServiceLogs(name: string): Observable<ApiResponse<string>> {
|
async restartService(name: string): Promise<IApiResponse<void>> {
|
||||||
return this.http.get<ApiResponse<string>>(`${this.baseUrl}/services/${name}/logs`);
|
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/restart`, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateService(name: string, updates: {
|
async getServiceLogs(name: string): Promise<IApiResponse<string>> {
|
||||||
image?: string;
|
return firstValueFrom(this.http.get<IApiResponse<string>>(`/api/services/${name}/logs`));
|
||||||
registry?: string;
|
|
||||||
port?: number;
|
|
||||||
domain?: string;
|
|
||||||
envVars?: Record<string, string>;
|
|
||||||
}): Observable<ApiResponse<Service>> {
|
|
||||||
return this.http.put<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`, updates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registries
|
// Registries
|
||||||
getRegistries(): Observable<ApiResponse<Registry[]>> {
|
async getRegistries(): Promise<IApiResponse<IRegistry[]>> {
|
||||||
return this.http.get<ApiResponse<Registry[]>>(`${this.baseUrl}/registries`);
|
return firstValueFrom(this.http.get<IApiResponse<IRegistry[]>>('/api/registries'));
|
||||||
}
|
}
|
||||||
|
|
||||||
createRegistry(data: any): Observable<ApiResponse<Registry>> {
|
async createRegistry(data: IRegistryCreate): Promise<IApiResponse<IRegistry>> {
|
||||||
return this.http.post<ApiResponse<Registry>>(`${this.baseUrl}/registries`, data);
|
return firstValueFrom(this.http.post<IApiResponse<IRegistry>>('/api/registries', data));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRegistry(url: string): Observable<ApiResponse> {
|
async deleteRegistry(id: number): Promise<IApiResponse<void>> {
|
||||||
return this.http.delete<ApiResponse>(`${this.baseUrl}/registries/${encodeURIComponent(url)}`);
|
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registries/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS
|
// DNS Records
|
||||||
getDnsRecords(): Observable<ApiResponse<any[]>> {
|
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
|
||||||
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/dns`);
|
return firstValueFrom(this.http.get<IApiResponse<IDnsRecord[]>>('/api/dns'));
|
||||||
}
|
}
|
||||||
|
|
||||||
createDnsRecord(data: any): Observable<ApiResponse> {
|
async createDnsRecord(domain: string, ip?: string): Promise<IApiResponse<IDnsRecord>> {
|
||||||
return this.http.post<ApiResponse>(`${this.baseUrl}/dns`, data);
|
return firstValueFrom(this.http.post<IApiResponse<IDnsRecord>>('/api/dns', { domain, ip }));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteDnsRecord(domain: string): Observable<ApiResponse> {
|
async deleteDnsRecord(domain: string): Promise<IApiResponse<void>> {
|
||||||
return this.http.delete<ApiResponse>(`${this.baseUrl}/dns/${domain}`);
|
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/dns/${domain}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
syncDnsRecords(): Observable<ApiResponse> {
|
async syncDnsRecords(): Promise<IApiResponse<void>> {
|
||||||
return this.http.post<ApiResponse>(`${this.baseUrl}/dns/sync`, {});
|
return firstValueFrom(this.http.post<IApiResponse<void>>('/api/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`, {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domains
|
// Domains
|
||||||
getDomains(): Observable<ApiResponse<any[]>> {
|
async getDomains(): Promise<IApiResponse<IDomainDetail[]>> {
|
||||||
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/domains`);
|
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail[]>>('/api/domains'));
|
||||||
}
|
}
|
||||||
|
|
||||||
getDomainDetail(domain: string): Observable<ApiResponse<any>> {
|
async getDomainDetail(domain: string): Promise<IApiResponse<IDomainDetail>> {
|
||||||
return this.http.get<ApiResponse<any>>(`${this.baseUrl}/domains/${domain}`);
|
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail>>(`/api/domains/${domain}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
syncCloudflareDomains(): Observable<ApiResponse> {
|
async syncCloudflareDomains(): Promise<IApiResponse<void>> {
|
||||||
return this.http.post<ApiResponse>(`${this.baseUrl}/domains/sync`, {});
|
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
|
// Settings
|
||||||
getSettings(): Observable<ApiResponse<Record<string, string>>> {
|
async getSettings(): Promise<IApiResponse<ISetting[]>> {
|
||||||
return this.http.get<ApiResponse<Record<string, string>>>(`${this.baseUrl}/settings`);
|
return firstValueFrom(this.http.get<IApiResponse<ISetting[]>>('/api/settings'));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSetting(key: string, value: string): Observable<ApiResponse> {
|
async updateSettings(settings: Record<string, string> | ISettings): Promise<IApiResponse<void>> {
|
||||||
return this.http.post<ApiResponse>(`${this.baseUrl}/settings`, { key, value });
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { HttpClient } from '@angular/common/http';
|
||||||
import { Router } from '@angular/router';
|
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 {
|
@Injectable({ providedIn: 'root' })
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
success: boolean;
|
|
||||||
data?: {
|
|
||||||
token: string;
|
|
||||||
user: {
|
|
||||||
username: string;
|
|
||||||
role: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
isAuthenticated = signal(false);
|
private token = signal<string | null>(this.loadToken());
|
||||||
currentUser = signal<{ username: string; role: string } | null>(null);
|
currentUser = signal<IUser | null>(null);
|
||||||
|
isAuthenticated = computed(() => !!this.token());
|
||||||
|
|
||||||
constructor() {
|
private loadToken(): string | null {
|
||||||
// Check if already authenticated
|
if (typeof localStorage === 'undefined') return null;
|
||||||
const token = this.getToken();
|
return localStorage.getItem('onebox_token');
|
||||||
if (token) {
|
|
||||||
this.isAuthenticated.set(true);
|
|
||||||
// TODO: Decode JWT to get user info
|
|
||||||
this.currentUser.set({ username: 'admin', role: 'admin' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
login(credentials: LoginRequest): Observable<LoginResponse> {
|
async login(username: string, password: string): Promise<{ success: boolean; error?: string }> {
|
||||||
return this.http.post<LoginResponse>('/api/auth/login', credentials).pipe(
|
try {
|
||||||
tap((response) => {
|
const response = await firstValueFrom(
|
||||||
if (response.success && response.data) {
|
this.http.post<IApiResponse<ILoginResponse>>('/api/auth/login', { username, password })
|
||||||
this.setToken(response.data.token);
|
|
||||||
this.currentUser.set(response.data.user);
|
|
||||||
this.isAuthenticated.set(true);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
logout(): void {
|
||||||
localStorage.removeItem('onebox_token');
|
this.token.set(null);
|
||||||
this.isAuthenticated.set(false);
|
|
||||||
this.currentUser.set(null);
|
this.currentUser.set(null);
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem('onebox_token');
|
||||||
|
}
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
return localStorage.getItem('onebox_token');
|
return this.token();
|
||||||
}
|
|
||||||
|
|
||||||
private setToken(token: string): void {
|
|
||||||
localStorage.setItem('onebox_token', token);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
ui/src/app/core/services/log-stream.service.ts
Normal file
140
ui/src/app/core/services/log-stream.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
ui/src/app/core/services/theme.service.ts
Normal file
64
ui/src/app/core/services/theme.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +1,48 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
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 {
|
private generateId(): string {
|
||||||
id: string;
|
return Math.random().toString(36).substring(2, 9);
|
||||||
type: ToastType;
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
show(type: ToastType, message: string, duration = 5000): string {
|
||||||
providedIn: 'root'
|
const id = this.generateId();
|
||||||
})
|
const toast: IToast = { id, type, message, duration };
|
||||||
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 };
|
|
||||||
|
|
||||||
this.toasts.update(toasts => [...toasts, toast]);
|
this.toasts.update(toasts => [...toasts, toast]);
|
||||||
|
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
setTimeout(() => this.remove(id), duration);
|
setTimeout(() => this.dismiss(id), duration);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
success(message: string, duration?: number) {
|
return id;
|
||||||
this.show('success', message, duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string, duration?: number) {
|
success(message: string, duration?: number): string {
|
||||||
this.show('error', message, duration);
|
return this.show('success', message, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, duration?: number) {
|
error(message: string, duration?: number): string {
|
||||||
this.show('info', message, duration);
|
return this.show('error', message, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
warning(message: string, duration?: number) {
|
info(message: string, duration?: number): string {
|
||||||
this.show('warning', message, duration);
|
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));
|
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
dismissAll(): void {
|
||||||
this.toasts.set([]);
|
this.toasts.set([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,109 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, signal, computed, effect, inject } from '@angular/core';
|
||||||
import { Subject, Observable } from 'rxjs';
|
import { IWebSocketMessage } from '../types/api.types';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
export interface WebSocketMessage {
|
@Injectable({ providedIn: 'root' })
|
||||||
type: string;
|
|
||||||
action?: string;
|
|
||||||
serviceName?: string;
|
|
||||||
data?: any;
|
|
||||||
status?: string;
|
|
||||||
timestamp: number;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class WebSocketService {
|
export class WebSocketService {
|
||||||
|
private auth = inject(AuthService);
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private messageSubject = new Subject<WebSocketMessage>();
|
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 5;
|
private maxReconnectAttempts = 5;
|
||||||
private reconnectDelay = 3000;
|
private reconnectDelay = 1000;
|
||||||
private reconnectTimer: any = null;
|
|
||||||
|
|
||||||
constructor() {}
|
isConnected = signal(false);
|
||||||
|
lastMessage = signal<IWebSocketMessage | null>(null);
|
||||||
|
|
||||||
connect(): void {
|
// Computed signals for specific message types
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
serviceUpdates = computed(() => {
|
||||||
console.log('WebSocket already connected');
|
const msg = this.lastMessage();
|
||||||
return;
|
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 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);
|
try {
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
this.ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log('✓ WebSocket connected');
|
this.isConnected.set(true);
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message: WebSocketMessage = JSON.parse(event.data);
|
const message: IWebSocketMessage = JSON.parse(event.data);
|
||||||
console.log('📨 WebSocket message:', message);
|
this.lastMessage.set(message);
|
||||||
this.messageSubject.next(message);
|
} catch {
|
||||||
} catch (error) {
|
console.error('Failed to parse WebSocket message');
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onerror = (error) => {
|
|
||||||
console.error('✖ WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
console.log('⚠ WebSocket closed');
|
this.isConnected.set(false);
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.attemptReconnect();
|
this.attemptReconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.isConnected.set(false);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
this.isConnected.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private attemptReconnect(): void {
|
private attemptReconnect(): void {
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
if (!this.auth.isAuthenticated()) return;
|
||||||
console.error('Max WebSocket reconnect attempts reached');
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
const delay = this.reconnectDelay * this.reconnectAttempts;
|
setTimeout(() => {
|
||||||
|
|
||||||
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
||||||
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
|
||||||
this.connect();
|
this.connect();
|
||||||
}, delay);
|
}, this.reconnectDelay);
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
if (this.reconnectTimer) {
|
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close();
|
this.ws.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
}
|
}
|
||||||
|
this.isConnected.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMessages(): Observable<WebSocketMessage> {
|
send(message: any): void {
|
||||||
return this.messageSubject.asObservable();
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
}
|
this.ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
isConnected(): boolean {
|
|
||||||
return this.ws?.readyState === WebSocket.OPEN;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
ui/src/app/core/types/api.types.ts
Normal file
175
ui/src/app/core/types/api.types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,242 +1,262 @@
|
|||||||
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
|
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { RouterLink } from '@angular/router';
|
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 { 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({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="px-4 sm:px-0">
|
<div class="space-y-6">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<!-- Header -->
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div>
|
||||||
@if (lastUpdated()) {
|
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||||
<span class="text-sm text-gray-500">
|
<p class="text-muted-foreground">System overview and quick actions</p>
|
||||||
Last updated: {{ lastUpdated()!.toLocaleTimeString() }}
|
</div>
|
||||||
</span>
|
<button uiButton variant="outline" (click)="loadStatus()" [disabled]="loading()">
|
||||||
}
|
@if (loading()) {
|
||||||
<button (click)="refresh()" class="btn btn-secondary text-sm" [disabled]="loading()">
|
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
<svg class="w-4 h-4 mr-1" [class.animate-spin]="loading()" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
}
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading() && !status()) {
|
||||||
<div class="text-center py-12">
|
<!-- Loading skeleton -->
|
||||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
<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>
|
</div>
|
||||||
} @else if (status()) {
|
} @else if (status()) {
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<!-- Total Services -->
|
<ui-card>
|
||||||
<div class="card">
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div class="flex items-center">
|
<ui-card-title class="text-sm font-medium">Total Services</ui-card-title>
|
||||||
<div class="flex-shrink-0 bg-primary-500 rounded-md p-3">
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
||||||
<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" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</ui-card-header>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<ui-card-content>
|
||||||
<dl>
|
<div class="text-2xl font-bold">{{ status()!.services.total }}</div>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Services</dt>
|
</ui-card-content>
|
||||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.total }}</dd>
|
</ui-card>
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Running Services -->
|
<ui-card>
|
||||||
<div class="card">
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div class="flex items-center">
|
<ui-card-title class="text-sm font-medium">Running</ui-card-title>
|
||||||
<div class="flex-shrink-0 bg-green-500 rounded-md p-3">
|
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</ui-card-header>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<ui-card-content>
|
||||||
<dl>
|
<div class="text-2xl font-bold text-success">{{ status()!.services.running }}</div>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Running</dt>
|
</ui-card-content>
|
||||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.running }}</dd>
|
</ui-card>
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stopped Services -->
|
<ui-card>
|
||||||
<div class="card">
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div class="flex items-center">
|
<ui-card-title class="text-sm font-medium">Stopped</ui-card-title>
|
||||||
<div class="flex-shrink-0 bg-gray-500 rounded-md p-3">
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<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>
|
</svg>
|
||||||
</div>
|
</ui-card-header>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<ui-card-content>
|
||||||
<dl>
|
<div class="text-2xl font-bold">{{ status()!.services.stopped }}</div>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Stopped</dt>
|
</ui-card-content>
|
||||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.stopped }}</dd>
|
</ui-card>
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Docker Status -->
|
<ui-card>
|
||||||
<div class="card">
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div class="flex items-center">
|
<ui-card-title class="text-sm font-medium">Docker</ui-card-title>
|
||||||
<div class="flex-shrink-0 rounded-md p-3" [ngClass]="status()!.docker.running ? 'bg-green-500' : 'bg-red-500'">
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<svg class="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<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" />
|
||||||
<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"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</ui-card-header>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<ui-card-content>
|
||||||
<dl>
|
<ui-badge [variant]="status()!.docker.running ? 'success' : 'destructive'">
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Docker</dt>
|
|
||||||
<dd class="text-lg font-semibold text-gray-900">
|
|
||||||
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||||
</dd>
|
</ui-badge>
|
||||||
</dl>
|
</ui-card-content>
|
||||||
</div>
|
</ui-card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Status -->
|
<!-- System Status -->
|
||||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 md: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>
|
|
||||||
|
|
||||||
<!-- Reverse Proxy -->
|
<!-- Reverse Proxy -->
|
||||||
<div class="card">
|
<ui-card>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Reverse Proxy</h3>
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<div class="space-y-2">
|
<ui-card-title>Reverse Proxy</ui-card-title>
|
||||||
<div class="flex justify-between">
|
<ui-card-description>HTTP/HTTPS proxy status</ui-card-description>
|
||||||
<span class="text-sm text-gray-600">HTTP (Port {{ status()!.reverseProxy.http.port }})</span>
|
</ui-card-header>
|
||||||
<span [ngClass]="status()!.reverseProxy.http.running ? 'badge-success' : 'badge-danger'" class="badge">
|
<ui-card-content class="space-y-2">
|
||||||
{{ status()!.reverseProxy.http.running ? 'Running' : 'Stopped' }}
|
<div class="flex items-center justify-between">
|
||||||
</span>
|
<span class="text-sm">HTTP ({{ status()!.reverseProxy.http.port }})</span>
|
||||||
</div>
|
<ui-badge [variant]="status()!.reverseProxy.http.running ? 'success' : 'secondary'">
|
||||||
<div class="flex justify-between">
|
{{ status()!.reverseProxy.http.running ? 'Active' : 'Inactive' }}
|
||||||
<span class="text-sm text-gray-600">HTTPS (Port {{ status()!.reverseProxy.https.port }})</span>
|
</ui-badge>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
<!-- DNS & SSL -->
|
<!-- DNS -->
|
||||||
<div class="card">
|
<ui-card>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">DNS & SSL</h3>
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<div class="space-y-2">
|
<ui-card-title>DNS</ui-card-title>
|
||||||
<div class="flex justify-between">
|
<ui-card-description>DNS configuration status</ui-card-description>
|
||||||
<span class="text-sm text-gray-600">DNS Configured</span>
|
</ui-card-header>
|
||||||
<span [ngClass]="status()!.dns.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
<ui-card-content>
|
||||||
{{ status()!.dns.configured ? 'Yes' : 'No' }}
|
<div class="flex items-center justify-between">
|
||||||
</span>
|
<span class="text-sm">Cloudflare</span>
|
||||||
</div>
|
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
|
||||||
<div class="flex justify-between">
|
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
|
||||||
<span class="text-sm text-gray-600">SSL Configured</span>
|
</ui-badge>
|
||||||
<span [ngClass]="status()!.ssl.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
|
||||||
{{ status()!.ssl.configured ? 'Yes' : 'No' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="mt-8">
|
<ui-card>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<div class="flex space-x-4">
|
<ui-card-title>Quick Actions</ui-card-title>
|
||||||
<a routerLink="/services/new" class="btn btn-primary">
|
<ui-card-description>Common tasks and shortcuts</ui-card-description>
|
||||||
Deploy New Service
|
</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>
|
||||||
<a routerLink="/services" class="btn btn-secondary">
|
<a routerLink="/services">
|
||||||
View All Services
|
<button uiButton variant="outline">View All Services</button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<a routerLink="/domains">
|
||||||
</div>
|
<button uiButton variant="outline">Manage Domains</button>
|
||||||
|
</a>
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class DashboardComponent implements OnInit, OnDestroy {
|
export class DashboardComponent implements OnInit, OnDestroy {
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private wsService = inject(WebSocketService);
|
private ws = inject(WebSocketService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
status = signal<SystemStatus | null>(null);
|
status = signal<ISystemStatus | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(false);
|
||||||
lastUpdated = signal<Date | null>(null);
|
|
||||||
private wsSubscription?: Subscription;
|
|
||||||
private refreshInterval?: number;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
private refreshInterval: any;
|
||||||
this.loadStatus();
|
|
||||||
|
|
||||||
// Subscribe to WebSocket updates
|
constructor() {
|
||||||
this.wsSubscription = this.wsService.getMessages().subscribe((message: any) => {
|
// React to WebSocket updates
|
||||||
// Reload status on any service or system update
|
effect(() => {
|
||||||
if (message.type === 'service_update' || message.type === 'service_status' || message.type === 'system_status') {
|
const update = this.ws.serviceUpdates();
|
||||||
|
const systemStatus = this.ws.systemStatus();
|
||||||
|
if (update || systemStatus) {
|
||||||
this.loadStatus();
|
this.loadStatus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh every 30 seconds
|
ngOnInit(): void {
|
||||||
this.refreshInterval = window.setInterval(() => {
|
|
||||||
this.loadStatus();
|
this.loadStatus();
|
||||||
}, 30000);
|
// Auto-refresh every 30 seconds
|
||||||
|
this.refreshInterval = setInterval(() => this.loadStatus(), 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.wsSubscription?.unsubscribe();
|
|
||||||
if (this.refreshInterval) {
|
if (this.refreshInterval) {
|
||||||
clearInterval(this.refreshInterval);
|
clearInterval(this.refreshInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadStatus(): void {
|
async loadStatus(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.apiService.getStatus().subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.getStatus();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
this.status.set(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);
|
this.loading.set(false);
|
||||||
},
|
}
|
||||||
error: () => {
|
|
||||||
this.loading.set(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh(): void {
|
|
||||||
this.loadStatus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +1,207 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.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({
|
@Component({
|
||||||
selector: 'app-dns',
|
selector: 'app-dns',
|
||||||
standalone: true,
|
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: `
|
template: `
|
||||||
<div class="px-4 sm:px-0">
|
<div class="space-y-6">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">DNS Records</h1>
|
<div>
|
||||||
<button
|
<h1 class="text-3xl font-bold tracking-tight">DNS Records</h1>
|
||||||
(click)="syncRecords()"
|
<p class="text-muted-foreground">Manage DNS records synced with Cloudflare</p>
|
||||||
[disabled]="syncing()"
|
</div>
|
||||||
class="btn btn-primary"
|
<button uiButton (click)="syncRecords()" [disabled]="syncing()">
|
||||||
>
|
@if (syncing()) {
|
||||||
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (records().length > 0) {
|
<ui-card>
|
||||||
<div class="card overflow-hidden p-0">
|
<ui-card-content class="p-0">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
@if (loading() && records().length === 0) {
|
||||||
<thead class="bg-gray-50">
|
<div class="p-6 space-y-4">
|
||||||
<tr>
|
@for (_ of [1,2,3]; track $index) {
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
<ui-skeleton class="h-12 w-full" />
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
} @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>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="card text-center py-12">
|
<ui-table>
|
||||||
<p class="text-gray-500">No DNS records configured</p>
|
<ui-table-header>
|
||||||
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
|
<ui-table-row>
|
||||||
<p class="text-sm text-gray-400 mt-2">Or click "Sync Cloudflare" to import existing DNS records from Cloudflare</p>
|
<ui-table-head>Domain</ui-table-head>
|
||||||
</div>
|
<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>
|
</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 {
|
export class DnsComponent implements OnInit {
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private toastService = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
records = signal<any[]>([]);
|
|
||||||
|
records = signal<IDnsRecord[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
syncing = signal(false);
|
syncing = signal(false);
|
||||||
|
deleteDialogOpen = signal(false);
|
||||||
|
recordToDelete = signal<IDnsRecord | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadRecords();
|
this.loadRecords();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRecords(): void {
|
async loadRecords(): Promise<void> {
|
||||||
this.apiService.getDnsRecords().subscribe({
|
this.loading.set(true);
|
||||||
next: (response) => {
|
try {
|
||||||
|
const response = await this.api.getDnsRecords();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
this.records.set(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.syncing.set(true);
|
||||||
this.apiService.syncDnsRecords().subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.syncDnsRecords();
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.toastService.success('Cloudflare DNS records synced successfully');
|
this.toast.success('DNS records synced');
|
||||||
this.loadRecords();
|
this.loadRecords();
|
||||||
} else {
|
} 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);
|
this.syncing.set(false);
|
||||||
},
|
}
|
||||||
error: () => {
|
|
||||||
this.toastService.error('Failed to sync DNS records');
|
|
||||||
this.syncing.set(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRecord(record: any): void {
|
confirmDelete(record: IDnsRecord): void {
|
||||||
if (confirm(`Delete DNS record for ${record.domain}?`)) {
|
this.recordToDelete.set(record);
|
||||||
this.apiService.deleteDnsRecord(record.domain).subscribe({
|
this.deleteDialogOpen.set(true);
|
||||||
next: () => this.loadRecords(),
|
}
|
||||||
});
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,356 +1,289 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
interface DomainDetail {
|
import { IDomainDetail, IService } from '../../core/types/api.types';
|
||||||
domain: {
|
import {
|
||||||
id: number;
|
CardComponent,
|
||||||
domain: string;
|
CardHeaderComponent,
|
||||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
CardTitleComponent,
|
||||||
cloudflareZoneId?: string;
|
CardDescriptionComponent,
|
||||||
isObsolete: boolean;
|
CardContentComponent,
|
||||||
defaultWildcard: boolean;
|
} from '../../ui/card/card.component';
|
||||||
createdAt: number;
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
updatedAt: number;
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||||
};
|
import {
|
||||||
certificates: Array<{
|
TableComponent,
|
||||||
id: number;
|
TableHeaderComponent,
|
||||||
certDomain: string;
|
TableBodyComponent,
|
||||||
isWildcard: boolean;
|
TableRowComponent,
|
||||||
expiryDate: number;
|
TableHeadComponent,
|
||||||
issuer: string;
|
TableCellComponent,
|
||||||
isValid: boolean;
|
} from '../../ui/table/table.component';
|
||||||
createdAt: number;
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||||
}>;
|
|
||||||
requirements: Array<{
|
|
||||||
id: number;
|
|
||||||
serviceId: number;
|
|
||||||
subdomain: string;
|
|
||||||
status: 'pending' | 'active' | 'renewing';
|
|
||||||
certificateId?: number;
|
|
||||||
}>;
|
|
||||||
services: Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
domain: string;
|
|
||||||
status: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-domain-detail',
|
selector: 'app-domain-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="px-4 sm:px-0">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<div>
|
||||||
<div class="mb-8">
|
<a routerLink="/domains" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||||
<div class="flex items-center mb-4">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<a
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
routerLink="/domains"
|
</svg>
|
||||||
class="text-primary-600 hover:text-primary-900 mr-4"
|
Back to Domains
|
||||||
>
|
|
||||||
← Back to Domains
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading() && !domain()) {
|
||||||
<div class="card text-center py-12">
|
<ui-skeleton class="h-9 w-64" />
|
||||||
<p class="text-gray-500">Loading domain details...</p>
|
} @else if (domain()) {
|
||||||
</div>
|
<div class="flex items-center gap-4">
|
||||||
} @else if (domainDetail()) {
|
<h1 class="text-3xl font-bold tracking-tight">{{ domain()!.domain.domain }}</h1>
|
||||||
<div>
|
<ui-badge [variant]="domain()!.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
|
||||||
<div class="flex justify-between items-start">
|
{{ domain()!.domain.dnsProvider || 'Manual' }}
|
||||||
<div>
|
</ui-badge>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">
|
@if (domain()!.domain.defaultWildcard) {
|
||||||
{{ domainDetail()!.domain.domain }}
|
<ui-badge variant="outline">Wildcard</ui-badge>
|
||||||
</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 (domainDetail()!.domain.defaultWildcard) {
|
@if (domain()!.domain.isObsolete) {
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
<ui-badge variant="destructive">Obsolete</ui-badge>
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
@if (domain()) {
|
||||||
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
|
<!-- Stats -->
|
||||||
<div class="card">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Certificates</dt>
|
<ui-card>
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
{{ domainDetail()!.certificates.length }}
|
<ui-card-title class="text-sm font-medium">Certificates</ui-card-title>
|
||||||
</dd>
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
</div>
|
<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" />
|
||||||
<div class="card">
|
</svg>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Requirements</dt>
|
</ui-card-header>
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">
|
<ui-card-content>
|
||||||
{{ domainDetail()!.requirements.length }}
|
<div class="text-2xl font-bold">{{ domain()!.certificates.length }}</div>
|
||||||
</dd>
|
</ui-card-content>
|
||||||
</div>
|
</ui-card>
|
||||||
<div class="card">
|
<ui-card>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Services</dt>
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">
|
<ui-card-title class="text-sm font-medium">Requirements</ui-card-title>
|
||||||
{{ domainDetail()!.services.length }}
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
</dd>
|
<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" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Certificates Section -->
|
<!-- Certificates -->
|
||||||
<div class="mt-8">
|
<ui-card>
|
||||||
<h2 class="text-xl font-bold text-gray-900 mb-4">SSL Certificates</h2>
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
@if (domainDetail()!.certificates.length > 0) {
|
<ui-card-title>SSL Certificates</ui-card-title>
|
||||||
<div class="card overflow-hidden p-0">
|
<ui-card-description>Active certificates for this domain</ui-card-description>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
</ui-card-header>
|
||||||
<thead class="bg-gray-50">
|
<ui-card-content class="p-0">
|
||||||
<tr>
|
@if (domain()!.certificates.length === 0) {
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
<div class="p-6 text-center text-muted-foreground">No certificates</div>
|
||||||
<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>
|
|
||||||
} @else {
|
} @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">
|
<ui-table>
|
||||||
Standard
|
<ui-table-header>
|
||||||
</span>
|
<ui-table-row>
|
||||||
}
|
<ui-table-head>Domain</ui-table-head>
|
||||||
</td>
|
<ui-table-head>Type</ui-table-head>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<ui-table-head>Status</ui-table-head>
|
||||||
@if (getCertStatus(cert) === 'valid') {
|
<ui-table-head>Expires</ui-table-head>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
<ui-table-head>Issuer</ui-table-head>
|
||||||
Valid
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
</span>
|
</ui-table-row>
|
||||||
} @else if (getCertStatus(cert) === 'expiring') {
|
</ui-table-header>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
<ui-table-body>
|
||||||
Expiring Soon
|
@for (cert of domain()!.certificates; track cert.id) {
|
||||||
</span>
|
<ui-table-row>
|
||||||
} @else {
|
<ui-table-cell class="font-medium">{{ cert.certDomain }}</ui-table-cell>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
<ui-table-cell>
|
||||||
Expired/Invalid
|
<ui-badge variant="outline">{{ cert.isWildcard ? 'Wildcard' : 'Standard' }}</ui-badge>
|
||||||
</span>
|
</ui-table-cell>
|
||||||
}
|
<ui-table-cell>
|
||||||
</td>
|
<ui-badge [variant]="getCertStatusVariant(cert)">
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
{{ getCertStatus(cert) }}
|
||||||
|
</ui-badge>
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
{{ formatDate(cert.expiryDate) }}
|
{{ formatDate(cert.expiryDate) }}
|
||||||
<span class="text-gray-500">({{ getDaysRemaining(cert.expiryDate) }} days)</span>
|
<span class="text-xs text-muted-foreground ml-1">
|
||||||
</td>
|
({{ getDaysRemaining(cert.expiryDate) }} days)
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
</span>
|
||||||
{{ cert.issuer }}
|
</ui-table-cell>
|
||||||
</td>
|
<ui-table-cell>{{ cert.issuer }}</ui-table-cell>
|
||||||
</tr>
|
<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>
|
</ui-table-body>
|
||||||
</table>
|
</ui-table>
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="card text-center py-8">
|
|
||||||
<p class="text-gray-500">No certificates for this domain</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
<!-- Certificate Requirements Section -->
|
<!-- Services using this domain -->
|
||||||
<div class="mt-8">
|
<ui-card>
|
||||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Certificate Requirements</h2>
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
@if (domainDetail()!.requirements.length > 0) {
|
<ui-card-title>Services</ui-card-title>
|
||||||
<div class="card overflow-hidden p-0">
|
<ui-card-description>Services using this domain</ui-card-description>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
</ui-card-header>
|
||||||
<thead class="bg-gray-50">
|
<ui-card-content class="p-0">
|
||||||
<tr>
|
@if (services().length === 0) {
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service</th>
|
<div class="p-6 text-center text-muted-foreground">No services using this domain</div>
|
||||||
<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>
|
|
||||||
} @else {
|
} @else {
|
||||||
<div class="card text-center py-8">
|
<ui-table>
|
||||||
<p class="text-gray-500">No certificate requirements</p>
|
<ui-table-header>
|
||||||
</div>
|
<ui-table-row>
|
||||||
}
|
<ui-table-head>Service</ui-table-head>
|
||||||
</div>
|
<ui-table-head>Domain</ui-table-head>
|
||||||
|
<ui-table-head>Status</ui-table-head>
|
||||||
<!-- Services Section -->
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
<div class="mt-8">
|
</ui-table-row>
|
||||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Services Using This Domain</h2>
|
</ui-table-header>
|
||||||
@if (domainDetail()!.services.length > 0) {
|
<ui-table-body>
|
||||||
<div class="card overflow-hidden p-0">
|
@for (svc of services(); track svc.name) {
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<ui-table-row>
|
||||||
<thead class="bg-gray-50">
|
<ui-table-cell class="font-medium">{{ svc.name }}</ui-table-cell>
|
||||||
<tr>
|
<ui-table-cell>{{ svc.domain }}</ui-table-cell>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
<ui-table-cell>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
<ui-badge [variant]="svc.status === 'running' ? 'success' : 'secondary'">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
{{ svc.status }}
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
</ui-badge>
|
||||||
</tr>
|
</ui-table-cell>
|
||||||
</thead>
|
<ui-table-cell class="text-right">
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<a [routerLink]="['/services', svc.name]">
|
||||||
@for (service of domainDetail()!.services; track service.id) {
|
<button uiButton variant="outline" size="sm">View</button>
|
||||||
<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
|
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</ui-table-cell>
|
||||||
</tr>
|
</ui-table-row>
|
||||||
}
|
}
|
||||||
</tbody>
|
</ui-table-body>
|
||||||
</table>
|
</ui-table>
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="card text-center py-8">
|
|
||||||
<p class="text-gray-500">No services using this domain</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</ui-card-content>
|
||||||
</div>
|
</ui-card>
|
||||||
} @else {
|
|
||||||
<div class="card text-center py-12">
|
|
||||||
<p class="text-gray-500">Domain not found</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class DomainDetailComponent implements OnInit {
|
export class DomainDetailComponent implements OnInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
domainDetail = signal<DomainDetail | null>(null);
|
domain = signal<IDomainDetail | null>(null);
|
||||||
loading = signal(true);
|
services = signal<IService[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const domain = this.route.snapshot.paramMap.get('domain');
|
const domainName = this.route.snapshot.paramMap.get('domain');
|
||||||
if (domain) {
|
if (domainName) {
|
||||||
this.loadDomainDetail(domain);
|
this.loadDomain(domainName);
|
||||||
|
this.loadServices(domainName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDomainDetail(domain: string): void {
|
async loadDomain(name: string): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.apiService.getDomainDetail(domain).subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.getDomainDetail(name);
|
||||||
if (response.success && response.data) {
|
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);
|
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 {
|
formatDate(timestamp: number): string {
|
||||||
return new Date(timestamp).toLocaleDateString('en-US', {
|
return new Date(timestamp).toLocaleDateString();
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(expiryDate: number): number {
|
getDaysRemaining(timestamp: number): number {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diff = expiryDate - now;
|
return Math.floor((timestamp - now) / (1000 * 60 * 60 * 24));
|
||||||
return Math.floor(diff / (24 * 60 * 60 * 1000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCertStatus(cert: any): 'valid' | 'expiring' | 'invalid' {
|
getCertStatus(cert: any): string {
|
||||||
if (!cert.isValid) return 'invalid';
|
if (!cert.isValid) return 'Invalid';
|
||||||
const daysRemaining = this.getDaysRemaining(cert.expiryDate);
|
const days = this.getDaysRemaining(cert.expiryDate);
|
||||||
if (daysRemaining < 0) return 'invalid';
|
if (days < 0) return 'Expired';
|
||||||
if (daysRemaining <= 30) return 'expiring';
|
if (days <= 30) return 'Expiring';
|
||||||
return 'valid';
|
return 'Valid';
|
||||||
}
|
}
|
||||||
|
|
||||||
getServiceName(serviceId: number): string {
|
getCertStatusVariant(cert: any): 'success' | 'warning' | 'destructive' {
|
||||||
const service = this.domainDetail()?.services.find((s) => s.id === serviceId);
|
const status = this.getCertStatus(cert);
|
||||||
return service?.name || `Service #${serviceId}`;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,216 +1,242 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { IDomainDetail } from '../../core/types/api.types';
|
||||||
interface DomainView {
|
import {
|
||||||
domain: {
|
CardComponent,
|
||||||
id: number;
|
CardHeaderComponent,
|
||||||
domain: string;
|
CardTitleComponent,
|
||||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
CardDescriptionComponent,
|
||||||
isObsolete: boolean;
|
CardContentComponent,
|
||||||
defaultWildcard: boolean;
|
} from '../../ui/card/card.component';
|
||||||
};
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
serviceCount: number;
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||||
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
|
import {
|
||||||
daysRemaining: number | null;
|
TableComponent,
|
||||||
certificates: any[];
|
TableHeaderComponent,
|
||||||
requirements: any[];
|
TableBodyComponent,
|
||||||
}
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
} from '../../ui/table/table.component';
|
||||||
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-domains',
|
selector: 'app-domains',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="px-4 sm:px-0">
|
<div class="space-y-6">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Domains</h1>
|
<div>
|
||||||
<button
|
<h1 class="text-3xl font-bold tracking-tight">Domains</h1>
|
||||||
(click)="syncDomains()"
|
<p class="text-muted-foreground">Manage domains and SSL certificates</p>
|
||||||
[disabled]="syncing()"
|
</div>
|
||||||
class="btn btn-primary"
|
<button uiButton (click)="syncDomains()" [disabled]="syncing()">
|
||||||
>
|
@if (syncing()) {
|
||||||
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
<!-- Stats -->
|
||||||
<div class="card text-center py-12">
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
<p class="text-gray-500">Loading domains...</p>
|
<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>
|
</div>
|
||||||
} @else if (domains().length > 0) {
|
|
||||||
<div class="card overflow-hidden p-0">
|
<!-- Domains Table -->
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<ui-card>
|
||||||
<thead class="bg-gray-50">
|
<ui-card-content class="p-0">
|
||||||
<tr>
|
@if (loading() && domains().length === 0) {
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
<div class="p-6 space-y-4">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Provider</th>
|
@for (_ of [1,2,3]; track $index) {
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Services</th>
|
<ui-skeleton class="h-12 w-full" />
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
</td>
|
</div>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
} @else if (domains().length === 0) {
|
||||||
@if (domainView.domain.dnsProvider === 'cloudflare') {
|
<div class="p-12 text-center">
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
<h3 class="text-lg font-semibold">No domains found</h3>
|
||||||
Cloudflare
|
<p class="mt-2 text-sm text-muted-foreground">Sync domains from Cloudflare to get started.</p>
|
||||||
</span>
|
<button uiButton class="mt-4" (click)="syncDomains()">Sync Cloudflare Domains</button>
|
||||||
} @else if (domainView.domain.dnsProvider === 'manual') {
|
</div>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
} @else {
|
||||||
Manual
|
<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>
|
</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="text-sm text-gray-400">None</span>
|
<span class="text-muted-foreground">-</span>
|
||||||
}
|
}
|
||||||
</td>
|
</ui-table-cell>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<ui-table-cell class="text-right">
|
||||||
{{ domainView.serviceCount }}
|
<a [routerLink]="['/domains', d.domain.domain]">
|
||||||
</td>
|
<button uiButton variant="outline" size="sm">View</button>
|
||||||
<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
|
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</ui-table-cell>
|
||||||
</tr>
|
</ui-table-row>
|
||||||
}
|
}
|
||||||
</tbody>
|
</ui-table-body>
|
||||||
</table>
|
</ui-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-card-content>
|
||||||
|
</ui-card>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class DomainsComponent implements OnInit {
|
export class DomainsComponent implements OnInit {
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private toastService = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
domains = signal<DomainView[]>([]);
|
domains = signal<IDomainDetail[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(false);
|
||||||
syncing = signal(false);
|
syncing = signal(false);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadDomains();
|
this.loadDomains();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDomains(): void {
|
async loadDomains(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.apiService.getDomains().subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.getDomains();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
this.domains.set(response.data);
|
this.domains.set(response.data);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to load domains');
|
||||||
|
} finally {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
}
|
||||||
error: () => {
|
|
||||||
this.loading.set(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncDomains(): void {
|
async syncDomains(): Promise<void> {
|
||||||
this.syncing.set(true);
|
this.syncing.set(true);
|
||||||
this.apiService.syncCloudflareDomains().subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.syncCloudflareDomains();
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.toastService.success('Cloudflare domains synced successfully');
|
this.toast.success('Domains synced');
|
||||||
this.loadDomains();
|
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);
|
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;
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,163 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
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({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
CardFooterComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
LabelComponent,
|
||||||
|
AlertComponent,
|
||||||
|
AlertDescriptionComponent,
|
||||||
|
],
|
||||||
template: `
|
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="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<div class="max-w-md w-full space-y-8">
|
<div class="absolute top-4 right-4">
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
uiButton
|
||||||
[disabled]="loading"
|
variant="ghost"
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 text-center">
|
<ui-card class="w-full max-w-md">
|
||||||
<p>Default credentials: admin / admin</p>
|
<ui-card-header class="text-center">
|
||||||
<p class="text-xs text-gray-500 mt-1">Please change after first login</p>
|
<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>
|
</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>
|
</form>
|
||||||
|
|
||||||
|
<div class="px-6 pb-6">
|
||||||
|
<p class="text-xs text-center text-muted-foreground">
|
||||||
|
Default credentials: admin / admin
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</ui-card>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
private authService = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
theme = inject(ThemeService);
|
||||||
|
|
||||||
username = '';
|
username = '';
|
||||||
password = '';
|
password = '';
|
||||||
loading = false;
|
loading = signal(false);
|
||||||
error = '';
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
onSubmit(): void {
|
async onSubmit(): Promise<void> {
|
||||||
this.error = '';
|
if (!this.username || !this.password) {
|
||||||
this.loading = true;
|
this.error.set('Please enter username and password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.authService.login({ username: this.username, password: this.password }).subscribe({
|
this.loading.set(true);
|
||||||
next: (response) => {
|
this.error.set(null);
|
||||||
this.loading = false;
|
|
||||||
if (response.success) {
|
const result = await this.auth.login(this.username, this.password);
|
||||||
|
|
||||||
|
this.loading.set(false);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
this.router.navigate(['/dashboard']);
|
this.router.navigate(['/dashboard']);
|
||||||
} else {
|
} else {
|
||||||
this.error = response.error || 'Login failed';
|
this.error.set(result.error || 'Invalid credentials');
|
||||||
}
|
}
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.loading = false;
|
|
||||||
this.error = err.error?.error || 'An error occurred during login';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,229 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
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({
|
@Component({
|
||||||
selector: 'app-registries',
|
selector: 'app-registries',
|
||||||
standalone: true,
|
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: `
|
template: `
|
||||||
<div class="px-4 sm:px-0">
|
<div class="space-y-6">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Docker Registries</h1>
|
<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 -->
|
<!-- Add Registry Form -->
|
||||||
<div class="card mb-8 max-w-2xl">
|
<ui-card>
|
||||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Add Registry</h2>
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<form (ngSubmit)="addRegistry()" class="space-y-4">
|
<ui-card-title>Add Registry</ui-card-title>
|
||||||
<div>
|
<ui-card-description>Add credentials for a private Docker registry</ui-card-description>
|
||||||
<label for="url" class="label">Registry URL</label>
|
</ui-card-header>
|
||||||
<input type="text" id="url" [(ngModel)]="newRegistry.url" name="url" required placeholder="registry.example.com" class="input" />
|
<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>
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<label for="username" class="label">Username</label>
|
<label uiLabel>Username</label>
|
||||||
<input type="text" id="username" [(ngModel)]="newRegistry.username" name="username" required class="input" />
|
<input uiInput [(ngModel)]="form.username" name="username" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<label for="password" class="label">Password</label>
|
<label uiLabel>Password</label>
|
||||||
<input type="password" id="password" [(ngModel)]="newRegistry.password" name="password" required class="input" />
|
<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>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add Registry</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
<!-- Registries List -->
|
<!-- Registries List -->
|
||||||
@if (registries().length > 0) {
|
<ui-card>
|
||||||
<div class="card overflow-hidden p-0">
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<ui-card-title>Registered Registries</ui-card-title>
|
||||||
<thead class="bg-gray-50">
|
</ui-card-header>
|
||||||
<tr>
|
<ui-card-content class="p-0">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
|
@if (loading() && registries().length === 0) {
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
|
<div class="p-6 space-y-4">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
@for (_ of [1,2]; track $index) {
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
<ui-skeleton class="h-12 w-full" />
|
||||||
</tr>
|
}
|
||||||
</thead>
|
</div>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
} @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) {
|
@for (registry of registries(); track registry.id) {
|
||||||
<tr>
|
<ui-table-row>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.url }}</td>
|
<ui-table-cell class="font-medium">{{ registry.url }}</ui-table-cell>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.username }}</td>
|
<ui-table-cell>{{ registry.username }}</ui-table-cell>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(registry.createdAt) }}</td>
|
<ui-table-cell>{{ formatDate(registry.createdAt) }}</ui-table-cell>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
<ui-table-cell class="text-right">
|
||||||
<button (click)="deleteRegistry(registry)" class="text-red-600 hover:text-red-900">Delete</button>
|
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(registry)">
|
||||||
</td>
|
Delete
|
||||||
</tr>
|
</button>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
}
|
}
|
||||||
</tbody>
|
</ui-table-body>
|
||||||
</table>
|
</ui-table>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
</div>
|
</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 {
|
export class RegistriesComponent implements OnInit {
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
registries = signal<Registry[]>([]);
|
private toast = inject(ToastService);
|
||||||
newRegistry = { url: '', username: '', password: '' };
|
|
||||||
|
registries = signal<IRegistry[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
deleteDialogOpen = signal(false);
|
||||||
|
registryToDelete = signal<IRegistry | null>(null);
|
||||||
|
|
||||||
|
form: IRegistryCreate = { url: '', username: '', password: '' };
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadRegistries();
|
this.loadRegistries();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRegistries(): void {
|
async loadRegistries(): Promise<void> {
|
||||||
this.apiService.getRegistries().subscribe({
|
this.loading.set(true);
|
||||||
next: (response) => {
|
try {
|
||||||
|
const response = await this.api.getRegistries();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
this.registries.set(response.data);
|
this.registries.set(response.data);
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
});
|
this.toast.error('Failed to load registries');
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addRegistry(): void {
|
async addRegistry(): Promise<void> {
|
||||||
this.apiService.createRegistry(this.newRegistry).subscribe({
|
if (!this.form.url || !this.form.username || !this.form.password) {
|
||||||
next: () => {
|
this.toast.error('Please fill in all fields');
|
||||||
this.newRegistry = { url: '', username: '', password: '' };
|
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();
|
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 {
|
confirmDelete(registry: IRegistry): void {
|
||||||
if (confirm(`Delete registry ${registry.url}?`)) {
|
this.registryToDelete.set(registry);
|
||||||
this.apiService.deleteRegistry(registry.url).subscribe({
|
this.deleteDialogOpen.set(true);
|
||||||
next: () => this.loadRegistries(),
|
}
|
||||||
});
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,354 +1,317 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
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 {
|
interface EnvVar {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Domain {
|
|
||||||
domain: string;
|
|
||||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
|
||||||
isObsolete: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-service-create',
|
selector: 'app-service-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, RouterLink],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
CardFooterComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
LabelComponent,
|
||||||
|
CheckboxComponent,
|
||||||
|
AlertComponent,
|
||||||
|
AlertDescriptionComponent,
|
||||||
|
SeparatorComponent,
|
||||||
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="px-4 sm:px-0">
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
|
<!-- Header -->
|
||||||
|
|
||||||
<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>
|
<div>
|
||||||
<label for="registryImageTag" class="label text-sm">Image Tag</label>
|
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||||
<input
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
type="text"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
id="registryImageTag"
|
</svg>
|
||||||
[(ngModel)]="registryImageTag"
|
Back to Services
|
||||||
name="registryImageTag"
|
</a>
|
||||||
placeholder="latest"
|
<h1 class="text-3xl font-bold tracking-tight">Deploy Service</h1>
|
||||||
class="input text-sm"
|
<p class="text-muted-foreground">Deploy a new Docker service</p>
|
||||||
/>
|
|
||||||
<p class="mt-1 text-xs text-gray-500">Tag to use (e.g., latest, v1.0, develop)</p>
|
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
uiInput
|
||||||
id="autoUpdateOnPush"
|
id="name"
|
||||||
[(ngModel)]="autoUpdateOnPush"
|
[(ngModel)]="form.name"
|
||||||
name="autoUpdateOnPush"
|
name="name"
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
placeholder="my-service"
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
required
|
required
|
||||||
placeholder="80"
|
pattern="[a-z0-9-]+"
|
||||||
class="input"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Domain -->
|
<div class="space-y-2">
|
||||||
<div class="mb-6">
|
<label uiLabel for="image">Docker Image</label>
|
||||||
<label for="domain" class="label">Domain (Optional)</label>
|
|
||||||
<input
|
<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"
|
id="domain"
|
||||||
[(ngModel)]="domain"
|
[(ngModel)]="form.domain"
|
||||||
(ngModelChange)="onDomainChange()"
|
|
||||||
name="domain"
|
name="domain"
|
||||||
placeholder="app.example.com"
|
placeholder="app.example.com"
|
||||||
list="domainList"
|
list="domains-list"
|
||||||
class="input"
|
|
||||||
[class.border-red-300]="domainWarning()"
|
|
||||||
/>
|
/>
|
||||||
<datalist id="domainList">
|
<datalist id="domains-list">
|
||||||
@for (domain of availableDomains(); track domain.domain) {
|
@for (d of domains(); track d.domain.domain) {
|
||||||
<option [value]="domain.domain">{{ domain.domain }}</option>
|
<option [value]="d.domain.domain">{{ d.domain.domain }}</option>
|
||||||
}
|
}
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
@if (domainWarning()) {
|
@if (domainWarning()) {
|
||||||
<div class="mt-2 rounded-md bg-yellow-50 p-3">
|
<ui-alert variant="warning" class="mt-2">
|
||||||
<div class="flex">
|
<ui-alert-description>{{ domainWarning() }}</ui-alert-description>
|
||||||
<div class="flex-shrink-0">
|
</ui-alert>
|
||||||
<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 →
|
|
||||||
</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>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-separator />
|
||||||
|
|
||||||
<!-- Environment Variables -->
|
<!-- Environment Variables -->
|
||||||
<div class="mb-6">
|
<div class="space-y-4">
|
||||||
<label class="label">Environment Variables</label>
|
<div class="flex items-center justify-between">
|
||||||
@for (env of envVars(); track $index) {
|
<div>
|
||||||
<div class="flex gap-2 mb-2">
|
<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
|
<input
|
||||||
type="text"
|
uiInput
|
||||||
[(ngModel)]="env.key"
|
[(ngModel)]="env.key"
|
||||||
[name]="'envKey' + $index"
|
[name]="'env-key-' + i"
|
||||||
placeholder="KEY"
|
placeholder="KEY"
|
||||||
class="input flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
uiInput
|
||||||
[(ngModel)]="env.value"
|
[(ngModel)]="env.value"
|
||||||
[name]="'envValue' + $index"
|
[name]="'env-value-' + i"
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
class="input flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
|
<button
|
||||||
Remove
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
</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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ServiceCreateComponent implements OnInit {
|
export class ServiceCreateComponent implements OnInit {
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private router = inject(Router);
|
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[]>([]);
|
envVars = signal<EnvVar[]>([]);
|
||||||
|
domains = signal<IDomainDetail[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
error = signal('');
|
domainWarning = signal<string | null>(null);
|
||||||
|
|
||||||
// Onebox Registry
|
|
||||||
useOneboxRegistry = false;
|
|
||||||
registryImageTag = 'latest';
|
|
||||||
autoUpdateOnPush = false;
|
|
||||||
|
|
||||||
// Domain validation
|
|
||||||
availableDomains = signal<Domain[]>([]);
|
|
||||||
domainWarning = signal(false);
|
|
||||||
domainWarningTitle = signal('');
|
|
||||||
domainWarningMessage = signal('');
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadDomains();
|
this.loadDomains();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDomains(): void {
|
async loadDomains(): Promise<void> {
|
||||||
this.apiService.getDomains().subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.getDomains();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const domains: Domain[] = response.data.map((d: any) => ({
|
this.domains.set(response.data);
|
||||||
domain: d.domain.domain,
|
|
||||||
dnsProvider: d.domain.dnsProvider,
|
|
||||||
isObsolete: d.domain.isObsolete,
|
|
||||||
}));
|
|
||||||
this.availableDomains.set(domains);
|
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
error: () => {
|
// Silent fail - domain autocomplete is optional
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEnvVar(): void {
|
addEnvVar(): void {
|
||||||
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
|
this.envVars.update(vars => [...vars, { key: '', value: '' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeEnvVar(index: number): void {
|
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);
|
this.loading.set(true);
|
||||||
|
|
||||||
// Convert env vars to object
|
// Build env vars object
|
||||||
const envVarsObj: Record<string, string> = {};
|
const envVarsObj: Record<string, string> = {};
|
||||||
for (const env of this.envVars()) {
|
for (const env of this.envVars()) {
|
||||||
if (env.key && env.value) {
|
if (env.key && env.value) {
|
||||||
@@ -356,36 +319,23 @@ export class ServiceCreateComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data: IServiceCreate = {
|
||||||
name: this.name,
|
...this.form,
|
||||||
image: this.image,
|
envVars: Object.keys(envVarsObj).length > 0 ? envVarsObj : undefined,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.apiService.createService(data).subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.createService(data);
|
||||||
this.loading.set(false);
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
this.toast.success(`Service "${this.form.name}" deployed successfully`);
|
||||||
this.router.navigate(['/services']);
|
this.router.navigate(['/services']);
|
||||||
} else {
|
} else {
|
||||||
this.error.set(response.error || 'Failed to deploy service');
|
this.toast.error(response.error || 'Failed to deploy service');
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
error: (err) => {
|
this.toast.error('Failed to deploy service');
|
||||||
|
} finally {
|
||||||
this.loading.set(false);
|
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
@@ -1,180 +1,332 @@
|
|||||||
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
|
import { Component, inject, signal, effect, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { RouterLink } from '@angular/router';
|
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 { 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({
|
@Component({
|
||||||
selector: 'app-services-list',
|
selector: 'app-services-list',
|
||||||
standalone: true,
|
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: `
|
template: `
|
||||||
<div class="px-4 sm:px-0">
|
<div class="space-y-6">
|
||||||
<div class="sm:flex sm:items-center sm:justify-between mb-8">
|
<!-- Header -->
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Services</h1>
|
<div class="flex items-center justify-between">
|
||||||
<div class="mt-4 sm:mt-0">
|
<div>
|
||||||
<a routerLink="/services/new" class="btn btn-primary">
|
<h1 class="text-3xl font-bold tracking-tight">Services</h1>
|
||||||
Deploy New Service
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loading()) {
|
<!-- Services Table -->
|
||||||
<div class="text-center py-12">
|
<ui-card>
|
||||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
<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>
|
</div>
|
||||||
} @else if (services().length === 0) {
|
} @else if (services().length === 0) {
|
||||||
<div class="card text-center py-12">
|
<div class="p-12 text-center">
|
||||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" 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" />
|
<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>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No services</h3>
|
<h3 class="mt-4 text-lg font-semibold">No services</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">Get started by deploying a new service.</p>
|
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
|
||||||
<div class="mt-6">
|
<a routerLink="/services/create" class="mt-4 inline-block">
|
||||||
<a routerLink="/services/new" class="btn btn-primary">
|
<button uiButton>Deploy Service</button>
|
||||||
Deploy Service
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
} @else {
|
} @else {
|
||||||
<div class="card overflow-hidden p-0">
|
<ui-table>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<ui-table-header>
|
||||||
<thead class="bg-gray-50">
|
<ui-table-row>
|
||||||
<tr>
|
<ui-table-head>Name</ui-table-head>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
<ui-table-head>Image</ui-table-head>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
|
<ui-table-head>Domain</ui-table-head>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
|
<ui-table-head>Status</ui-table-head>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
</ui-table-row>
|
||||||
</tr>
|
</ui-table-header>
|
||||||
</thead>
|
<ui-table-body>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
@for (service of services(); track service.name) {
|
||||||
@for (service of services(); track service.id) {
|
<ui-table-row>
|
||||||
<tr class="hover:bg-gray-50">
|
<ui-table-cell>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<a [routerLink]="['/services', service.name]" class="font-medium hover:underline">
|
||||||
<a [routerLink]="['/services', service.name]" class="text-sm font-medium text-primary-600 hover:text-primary-900">
|
|
||||||
{{ service.name }}
|
{{ service.name }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</ui-table-cell>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<ui-table-cell class="text-muted-foreground">{{ service.image }}</ui-table-cell>
|
||||||
{{ service.image }}
|
<ui-table-cell>
|
||||||
</td>
|
@if (service.domain) {
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<a [href]="'https://' + service.domain" target="_blank" class="text-primary hover:underline">
|
||||||
{{ service.domain || '-' }}
|
{{ service.domain }}
|
||||||
</td>
|
</a>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
} @else {
|
||||||
<span [ngClass]="{
|
<span class="text-muted-foreground">-</span>
|
||||||
'badge-success': service.status === 'running',
|
}
|
||||||
'badge-danger': service.status === 'stopped' || service.status === 'failed',
|
</ui-table-cell>
|
||||||
'badge-warning': service.status === 'starting' || service.status === 'stopping'
|
<ui-table-cell>
|
||||||
}" class="badge">
|
<ui-badge [variant]="getStatusVariant(service.status)">
|
||||||
{{ service.status }}
|
{{ service.status }}
|
||||||
</span>
|
</ui-badge>
|
||||||
</td>
|
</ui-table-cell>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
<ui-table-cell class="text-right">
|
||||||
@if (service.status === 'stopped') {
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button (click)="startService(service)" class="text-green-600 hover:text-green-900">Start</button>
|
@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') {
|
@if (service.status === 'running') {
|
||||||
<button (click)="stopService(service)" class="text-yellow-600 hover:text-yellow-900">Stop</button>
|
<button
|
||||||
<button (click)="restartService(service)" class="text-blue-600 hover:text-blue-900">Restart</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>
|
<button
|
||||||
</td>
|
uiButton
|
||||||
</tr>
|
variant="destructive"
|
||||||
}
|
size="sm"
|
||||||
</tbody>
|
(click)="confirmDelete(service)"
|
||||||
</table>
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
}
|
}
|
||||||
|
</ui-table-body>
|
||||||
|
</ui-table>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
</div>
|
</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 {
|
export class ServicesListComponent implements OnInit {
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private wsService = inject(WebSocketService);
|
private ws = inject(WebSocketService);
|
||||||
private wsSubscription?: Subscription;
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
services = signal<Service[]>([]);
|
services = signal<IService[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(false);
|
||||||
|
actionLoading = signal<string | null>(null);
|
||||||
|
deleteDialogOpen = signal(false);
|
||||||
|
serviceToDelete = signal<IService | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
constructor() {
|
||||||
// Initial load
|
// React to WebSocket updates
|
||||||
|
effect(() => {
|
||||||
|
const update = this.ws.serviceUpdates();
|
||||||
|
const status = this.ws.serviceStatus();
|
||||||
|
if (update || status) {
|
||||||
this.loadServices();
|
this.loadServices();
|
||||||
|
}
|
||||||
// Subscribe to WebSocket updates
|
|
||||||
this.wsSubscription = this.wsService.getMessages().subscribe((message) => {
|
|
||||||
this.handleWebSocketMessage(message);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnInit(): void {
|
||||||
this.wsSubscription?.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleWebSocketMessage(message: any): void {
|
|
||||||
if (message.type === 'service_update') {
|
|
||||||
// Reload the full service list on any service update
|
|
||||||
this.loadServices();
|
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.loading.set(true);
|
||||||
this.apiService.getServices().subscribe({
|
try {
|
||||||
next: (response) => {
|
const response = await this.api.getServices();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
this.services.set(response.data);
|
this.services.set(response.data);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to load services');
|
||||||
|
} finally {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
}
|
||||||
error: () => {
|
|
||||||
this.loading.set(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startService(service: Service): void {
|
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||||
this.apiService.startService(service.name).subscribe({
|
switch (status) {
|
||||||
next: () => {
|
case 'running':
|
||||||
// WebSocket will handle the update
|
return 'success';
|
||||||
},
|
case 'stopped':
|
||||||
});
|
return 'secondary';
|
||||||
|
case 'failed':
|
||||||
|
return 'destructive';
|
||||||
|
case 'starting':
|
||||||
|
case 'stopping':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopService(service: Service): void {
|
async startService(name: string): Promise<void> {
|
||||||
this.apiService.stopService(service.name).subscribe({
|
this.actionLoading.set(name);
|
||||||
next: () => {
|
try {
|
||||||
// WebSocket will handle the update
|
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 {
|
async stopService(name: string): Promise<void> {
|
||||||
this.apiService.restartService(service.name).subscribe({
|
this.actionLoading.set(name);
|
||||||
next: () => {
|
try {
|
||||||
// WebSocket will handle the update
|
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 {
|
async restartService(name: string): Promise<void> {
|
||||||
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
|
this.actionLoading.set(name);
|
||||||
this.apiService.deleteService(service.name).subscribe({
|
try {
|
||||||
next: () => {
|
const response = await this.api.restartService(name);
|
||||||
// WebSocket will handle the update
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,337 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.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({
|
@Component({
|
||||||
selector: 'app-settings',
|
selector: 'app-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
LabelComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
SeparatorComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
],
|
||||||
template: `
|
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">
|
<div class="space-y-6">
|
||||||
<!-- Cloudflare Settings -->
|
<div>
|
||||||
<div class="card">
|
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
|
||||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Cloudflare DNS</h2>
|
<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 class="space-y-4">
|
||||||
<div>
|
<h4 class="text-sm font-medium">Change Password</h4>
|
||||||
<label class="label">API Key</label>
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<input type="password" [(ngModel)]="settings.cloudflareAPIKey" class="input" />
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Current Password</label>
|
||||||
|
<input uiInput type="password" [(ngModel)]="passwordForm.current" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<label class="label">Email</label>
|
<label uiLabel>New Password</label>
|
||||||
<input type="email" [(ngModel)]="settings.cloudflareEmail" class="input" />
|
<input uiInput type="password" [(ngModel)]="passwordForm.new" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<label class="label">Zone ID</label>
|
<label uiLabel>Confirm Password</label>
|
||||||
<input type="text" [(ngModel)]="settings.cloudflareZoneID" class="input" />
|
<input uiInput type="password" [(ngModel)]="passwordForm.confirm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button uiButton variant="outline" (click)="changePassword()">
|
||||||
|
Update Password
|
||||||
<!-- 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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class SettingsComponent implements OnInit {
|
export class SettingsComponent implements OnInit {
|
||||||
private apiService = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private toastService = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
settings: any = {};
|
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 {
|
ngOnInit(): void {
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSettings(): void {
|
async loadSettings(): Promise<void> {
|
||||||
this.apiService.getSettings().subscribe({
|
this.loading.set(true);
|
||||||
next: (response) => {
|
try {
|
||||||
|
const response = await this.api.getSettings();
|
||||||
if (response.success && response.data) {
|
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 {
|
async saveSettings(): Promise<void> {
|
||||||
// Save each setting individually
|
this.saving.set(true);
|
||||||
const promises = Object.entries(this.settings).map(([key, value]) =>
|
try {
|
||||||
this.apiService.updateSetting(key, value as string).toPromise()
|
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
|
||||||
);
|
);
|
||||||
|
if (response.success) {
|
||||||
Promise.all(promises).then(() => {
|
this.toast.success('Password changed');
|
||||||
this.toastService.success('Settings saved successfully');
|
this.passwordForm = { current: '', new: '', confirm: '' };
|
||||||
}).catch((error) => {
|
} else {
|
||||||
this.toastService.error('Failed to save settings: ' + (error.message || 'Unknown error'));
|
this.toast.error(response.error || 'Failed to change password');
|
||||||
});
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to change password');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
126
ui/src/app/shared/components/layout/layout.component.ts
Normal file
126
ui/src/app/shared/components/layout/layout.component.ts
Normal 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' },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
62
ui/src/app/ui/alert/alert.component.ts
Normal file
62
ui/src/app/ui/alert/alert.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ui/src/app/ui/badge/badge.component.ts
Normal file
32
ui/src/app/ui/badge/badge.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
ui/src/app/ui/button/button.component.ts
Normal file
45
ui/src/app/ui/button/button.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
112
ui/src/app/ui/card/card.component.ts
Normal file
112
ui/src/app/ui/card/card.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
ui/src/app/ui/checkbox/checkbox.component.ts
Normal file
79
ui/src/app/ui/checkbox/checkbox.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
ui/src/app/ui/dialog/dialog.component.ts
Normal file
67
ui/src/app/ui/dialog/dialog.component.ts
Normal 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
62
ui/src/app/ui/index.ts
Normal 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';
|
||||||
26
ui/src/app/ui/input/input.component.ts
Normal file
26
ui/src/app/ui/input/input.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
ui/src/app/ui/label/label.component.ts
Normal file
20
ui/src/app/ui/label/label.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
121
ui/src/app/ui/select/select.component.ts
Normal file
121
ui/src/app/ui/select/select.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ui/src/app/ui/separator/separator.component.ts
Normal file
21
ui/src/app/ui/separator/separator.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ui/src/app/ui/skeleton/skeleton.component.ts
Normal file
19
ui/src/app/ui/skeleton/skeleton.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
ui/src/app/ui/switch/switch.component.ts
Normal file
69
ui/src/app/ui/switch/switch.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
ui/src/app/ui/table/table.component.ts
Normal file
148
ui/src/app/ui/table/table.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ui/src/app/ui/toast/toast.component.ts
Normal file
66
ui/src/app/ui/toast/toast.component.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ui/src/app/ui/toast/toaster.component.ts
Normal file
19
ui/src/app/ui/toast/toaster.component.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
<!-- Empty favicon placeholder -->
|
|
||||||
@@ -2,12 +2,20 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Onebox - Container Platform</title>
|
<title>Onebox</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<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>
|
</head>
|
||||||
<body class="bg-gray-50">
|
<body class="min-h-screen antialiased">
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { provideRouter } from '@angular/router';
|
import { appConfig } from './app/app.config';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
||||||
import { AppComponent } from './app/app.component';
|
import { AppComponent } from './app/app.component';
|
||||||
import { routes } from './app/app.routes';
|
|
||||||
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, {
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
providers: [
|
.catch((err) => console.error(err));
|
||||||
provideRouter(routes),
|
|
||||||
provideHttpClient(withInterceptors([authInterceptor])),
|
|
||||||
],
|
|
||||||
}).catch((err) => console.error(err));
|
|
||||||
|
|||||||
@@ -2,56 +2,91 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer components {
|
@layer base {
|
||||||
.btn {
|
:root {
|
||||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
--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 {
|
.dark {
|
||||||
@apply bg-primary-600 text-white hover:bg-primary-700;
|
--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 {
|
@layer base {
|
||||||
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
|
* {
|
||||||
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
body {
|
||||||
@apply bg-red-600 text-white hover:bg-red-700;
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
@layer utilities {
|
||||||
@apply bg-green-600 text-white hover:bg-green-700;
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
@apply bg-white rounded-lg shadow-md p-6;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500;
|
@apply bg-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
@apply bg-muted-foreground/30 rounded-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
@apply px-2 py-1 text-xs font-semibold rounded-full;
|
@apply bg-muted-foreground/50;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,97 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
darkMode: ['class'],
|
||||||
"./src/**/*.{html,ts}",
|
content: ['./src/**/*.{html,ts}'],
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
primary: {
|
primary: {
|
||||||
50: '#f0f9ff',
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
100: '#e0f2fe',
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
200: '#bae6fd',
|
|
||||||
300: '#7dd3fc',
|
|
||||||
400: '#38bdf8',
|
|
||||||
500: '#0ea5e9',
|
|
||||||
600: '#0284c7',
|
|
||||||
700: '#0369a1',
|
|
||||||
800: '#075985',
|
|
||||||
900: '#0c4a6e',
|
|
||||||
},
|
},
|
||||||
|
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: [],
|
plugins: [],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
@@ -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,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/out-tsc",
|
"outDir": "./dist/out-tsc",
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"sourceMap": true,
|
|
||||||
"declaration": false,
|
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "ES2022"
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"lib": [
|
|
||||||
"ES2022",
|
|
||||||
"dom"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
|||||||
15
ui/tsconfig.spec.json
Normal file
15
ui/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user