feat(opsserver): introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
# 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
|
||||
42
ui/.gitignore
vendored
42
ui/.gitignore
vendored
@@ -1,42 +0,0 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# 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
|
||||
Thumbs.db
|
||||
59
ui/README.md
59
ui/README.md
@@ -1,59 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"ui": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/ui",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "ui:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "ui:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6",
|
||||
"rxjs": "~7.8.0",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.19",
|
||||
"@angular/cli": "^19.2.19",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
9197
ui/pnpm-lock.yaml
generated
9197
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false,
|
||||
"ws": true,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,336 +0,0 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * 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 />
|
||||
@@ -1,29 +0,0 @@
|
||||
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,14 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { ToasterComponent } from './ui/toast/toaster.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, ToasterComponent],
|
||||
template: `
|
||||
<router-outlet />
|
||||
<ui-toaster />
|
||||
`,
|
||||
})
|
||||
export class AppComponent {}
|
||||
@@ -1,13 +0,0 @@
|
||||
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])),
|
||||
],
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./shared/components/layout/layout.component').then(
|
||||
(m) => m.LayoutComponent
|
||||
),
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'dashboard',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/dashboard.component').then(
|
||||
(m) => m.DashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'user',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
loadComponent: () =>
|
||||
import('./features/services/service-create.component').then(
|
||||
(m) => m.ServiceCreateComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'platform/:type',
|
||||
loadComponent: () =>
|
||||
import('./features/services/platform-service-detail.component').then(
|
||||
(m) => m.PlatformServiceDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'detail/:name',
|
||||
loadComponent: () =>
|
||||
import('./features/services/service-detail.component').then(
|
||||
(m) => m.ServiceDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':tab',
|
||||
loadComponent: () =>
|
||||
import('./features/services/services-list.component').then(
|
||||
(m) => m.ServicesListComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'network',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'proxy',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'domains/:domain',
|
||||
loadComponent: () =>
|
||||
import('./features/domains/domain-detail.component').then(
|
||||
(m) => m.DomainDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':tab',
|
||||
loadComponent: () =>
|
||||
import('./features/network/network.component').then(
|
||||
(m) => m.NetworkComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'registries',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'onebox',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: ':tab',
|
||||
loadComponent: () =>
|
||||
import('./features/registries/registries.component').then(
|
||||
(m) => m.RegistriesComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
loadComponent: () =>
|
||||
import('./features/tokens/tokens.component').then(
|
||||
(m) => m.TokensComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
import('./features/settings/settings.component').then(
|
||||
(m) => m.SettingsComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'dashboard',
|
||||
},
|
||||
];
|
||||
@@ -1,15 +0,0 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (
|
||||
req: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn
|
||||
) => {
|
||||
const auth = inject(AuthService);
|
||||
const token = auth.getToken();
|
||||
|
||||
// Skip auth header for login request
|
||||
if (req.url.includes('/api/auth/login')) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const authReq = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
return next(authReq);
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
@@ -1,334 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
IApiResponse,
|
||||
IService,
|
||||
IServiceCreate,
|
||||
IServiceUpdate,
|
||||
ISystemStatus,
|
||||
IDomain,
|
||||
IDomainDetail,
|
||||
IDnsRecord,
|
||||
IRegistry,
|
||||
IRegistryCreate,
|
||||
IRegistryToken,
|
||||
ICreateTokenRequest,
|
||||
ITokenCreatedResponse,
|
||||
ISetting,
|
||||
ISettings,
|
||||
IPlatformService,
|
||||
IPlatformResource,
|
||||
TPlatformServiceType,
|
||||
INetworkTarget,
|
||||
INetworkStats,
|
||||
IContainerStats,
|
||||
IMetric,
|
||||
ITrafficStats,
|
||||
IBackup,
|
||||
IRestoreOptions,
|
||||
IRestoreResult,
|
||||
IBackupPasswordStatus,
|
||||
IBackupSchedule,
|
||||
IBackupScheduleCreate,
|
||||
IBackupScheduleUpdate,
|
||||
} from '../types/api.types';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// System Status
|
||||
async getStatus(): Promise<IApiResponse<ISystemStatus>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<ISystemStatus>>('/api/status'));
|
||||
}
|
||||
|
||||
// Services
|
||||
async getServices(): Promise<IApiResponse<IService[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IService[]>>('/api/services'));
|
||||
}
|
||||
|
||||
async getService(name: string): Promise<IApiResponse<IService>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IService>>(`/api/services/${name}`));
|
||||
}
|
||||
|
||||
async createService(data: IServiceCreate): Promise<IApiResponse<IService>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<IService>>('/api/services', data));
|
||||
}
|
||||
|
||||
async updateService(name: string, data: IServiceUpdate): Promise<IApiResponse<IService>> {
|
||||
return firstValueFrom(this.http.put<IApiResponse<IService>>(`/api/services/${name}`, data));
|
||||
}
|
||||
|
||||
async deleteService(name: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/services/${name}`));
|
||||
}
|
||||
|
||||
async startService(name: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/start`, {}));
|
||||
}
|
||||
|
||||
async stopService(name: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/stop`, {}));
|
||||
}
|
||||
|
||||
async restartService(name: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/restart`, {}));
|
||||
}
|
||||
|
||||
async getServiceLogs(name: string): Promise<IApiResponse<string>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<string>>(`/api/services/${name}/logs`));
|
||||
}
|
||||
|
||||
async getServiceStats(name: string): Promise<IApiResponse<IContainerStats>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IContainerStats>>(`/api/services/${name}/stats`));
|
||||
}
|
||||
|
||||
async getServiceMetrics(name: string, limit?: number): Promise<IApiResponse<IMetric[]>> {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return firstValueFrom(this.http.get<IApiResponse<IMetric[]>>(`/api/services/${name}/metrics${params}`));
|
||||
}
|
||||
|
||||
// Registries
|
||||
async getRegistries(): Promise<IApiResponse<IRegistry[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IRegistry[]>>('/api/registries'));
|
||||
}
|
||||
|
||||
async createRegistry(data: IRegistryCreate): Promise<IApiResponse<IRegistry>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<IRegistry>>('/api/registries', data));
|
||||
}
|
||||
|
||||
async deleteRegistry(id: number): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registries/${id}`));
|
||||
}
|
||||
|
||||
// Registry Tokens
|
||||
async getRegistryTokens(): Promise<IApiResponse<IRegistryToken[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IRegistryToken[]>>('/api/registry/tokens'));
|
||||
}
|
||||
|
||||
async createRegistryToken(data: ICreateTokenRequest): Promise<IApiResponse<ITokenCreatedResponse>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<ITokenCreatedResponse>>('/api/registry/tokens', data));
|
||||
}
|
||||
|
||||
async deleteRegistryToken(id: number): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registry/tokens/${id}`));
|
||||
}
|
||||
|
||||
// DNS Records
|
||||
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IDnsRecord[]>>('/api/dns'));
|
||||
}
|
||||
|
||||
async createDnsRecord(domain: string, ip?: string): Promise<IApiResponse<IDnsRecord>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<IDnsRecord>>('/api/dns', { domain, ip }));
|
||||
}
|
||||
|
||||
async deleteDnsRecord(domain: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/dns/${domain}`));
|
||||
}
|
||||
|
||||
async syncDnsRecords(): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>('/api/dns/sync', {}));
|
||||
}
|
||||
|
||||
// Domains
|
||||
async getDomains(): Promise<IApiResponse<IDomainDetail[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail[]>>('/api/domains'));
|
||||
}
|
||||
|
||||
async getDomainDetail(domain: string): Promise<IApiResponse<IDomainDetail>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail>>(`/api/domains/${domain}`));
|
||||
}
|
||||
|
||||
async syncCloudflareDomains(): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>('/api/domains/sync', {}));
|
||||
}
|
||||
|
||||
// SSL Certificates
|
||||
async obtainCertificate(domain: string, includeWildcard?: boolean): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(
|
||||
this.http.post<IApiResponse<void>>('/api/ssl/obtain', { domain, includeWildcard })
|
||||
);
|
||||
}
|
||||
|
||||
async renewCertificate(domain: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/ssl/${domain}/renew`, {}));
|
||||
}
|
||||
|
||||
// Settings
|
||||
async getSettings(): Promise<IApiResponse<ISetting[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<ISetting[]>>('/api/settings'));
|
||||
}
|
||||
|
||||
async updateSettings(settings: Record<string, string> | ISettings): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.put<IApiResponse<void>>('/api/settings', settings));
|
||||
}
|
||||
|
||||
async updateSetting(key: string, value: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.put<IApiResponse<void>>('/api/settings', { key, value }));
|
||||
}
|
||||
|
||||
// Auth
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(
|
||||
this.http.post<IApiResponse<void>>('/api/auth/change-password', {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Platform Services
|
||||
async getPlatformServices(): Promise<IApiResponse<IPlatformService[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IPlatformService[]>>('/api/platform-services'));
|
||||
}
|
||||
|
||||
async getPlatformService(type: TPlatformServiceType): Promise<IApiResponse<IPlatformService>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IPlatformService>>(`/api/platform-services/${type}`));
|
||||
}
|
||||
|
||||
async startPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/start`, {}));
|
||||
}
|
||||
|
||||
async stopPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/stop`, {}));
|
||||
}
|
||||
|
||||
async getPlatformServiceStats(type: TPlatformServiceType): Promise<IApiResponse<IContainerStats>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IContainerStats>>(`/api/platform-services/${type}/stats`));
|
||||
}
|
||||
|
||||
async getServicePlatformResources(serviceName: string): Promise<IApiResponse<IPlatformResource[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IPlatformResource[]>>(`/api/services/${serviceName}/platform-resources`));
|
||||
}
|
||||
|
||||
// Network
|
||||
async getNetworkTargets(): Promise<IApiResponse<INetworkTarget[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<INetworkTarget[]>>('/api/network/targets'));
|
||||
}
|
||||
|
||||
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats'));
|
||||
}
|
||||
|
||||
async getTrafficStats(minutes?: number): Promise<IApiResponse<ITrafficStats>> {
|
||||
const params = minutes ? `?minutes=${minutes}` : '';
|
||||
return firstValueFrom(this.http.get<IApiResponse<ITrafficStats>>(`/api/network/traffic-stats${params}`));
|
||||
}
|
||||
|
||||
// Backups
|
||||
async getBackups(): Promise<IApiResponse<IBackup[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>('/api/backups'));
|
||||
}
|
||||
|
||||
async getServiceBackups(serviceName: string): Promise<IApiResponse<IBackup[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>(`/api/services/${serviceName}/backups`));
|
||||
}
|
||||
|
||||
async createBackup(serviceName: string): Promise<IApiResponse<IBackup>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<IBackup>>(`/api/services/${serviceName}/backup`, {}));
|
||||
}
|
||||
|
||||
async getBackup(backupId: number): Promise<IApiResponse<IBackup>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IBackup>>(`/api/backups/${backupId}`));
|
||||
}
|
||||
|
||||
async deleteBackup(backupId: number): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backups/${backupId}`));
|
||||
}
|
||||
|
||||
getBackupDownloadUrl(backupId: number): string {
|
||||
return `/api/backups/${backupId}/download`;
|
||||
}
|
||||
|
||||
async downloadBackup(backupId: number, filename: string): Promise<void> {
|
||||
const token = localStorage.getItem('onebox_token');
|
||||
const response = await fetch(`/api/backups/${backupId}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async importBackupFromFile(file: File, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (newServiceName) {
|
||||
formData.append('newServiceName', newServiceName);
|
||||
}
|
||||
return firstValueFrom(
|
||||
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', formData)
|
||||
);
|
||||
}
|
||||
|
||||
async importBackupFromUrl(url: string, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
|
||||
return firstValueFrom(
|
||||
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', {
|
||||
url,
|
||||
newServiceName,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async restoreBackup(backupId: number, options: IRestoreOptions): Promise<IApiResponse<IRestoreResult>> {
|
||||
return firstValueFrom(
|
||||
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/restore', {
|
||||
backupId,
|
||||
...options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async setBackupPassword(password: string): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(
|
||||
this.http.post<IApiResponse<void>>('/api/settings/backup-password', { password })
|
||||
);
|
||||
}
|
||||
|
||||
async checkBackupPassword(): Promise<IApiResponse<IBackupPasswordStatus>> {
|
||||
return firstValueFrom(
|
||||
this.http.get<IApiResponse<IBackupPasswordStatus>>('/api/settings/backup-password')
|
||||
);
|
||||
}
|
||||
|
||||
// Backup Schedules
|
||||
async getBackupSchedules(): Promise<IApiResponse<IBackupSchedule[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>('/api/backup-schedules'));
|
||||
}
|
||||
|
||||
async getBackupSchedule(scheduleId: number): Promise<IApiResponse<IBackupSchedule>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`));
|
||||
}
|
||||
|
||||
async createBackupSchedule(data: IBackupScheduleCreate): Promise<IApiResponse<IBackupSchedule>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<IBackupSchedule>>('/api/backup-schedules', data));
|
||||
}
|
||||
|
||||
async updateBackupSchedule(scheduleId: number, data: IBackupScheduleUpdate): Promise<IApiResponse<IBackupSchedule>> {
|
||||
return firstValueFrom(this.http.put<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`, data));
|
||||
}
|
||||
|
||||
async deleteBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}`));
|
||||
}
|
||||
|
||||
async triggerBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}/trigger`, {}));
|
||||
}
|
||||
|
||||
async getServiceBackupSchedules(serviceName: string): Promise<IApiResponse<IBackupSchedule[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>(`/api/services/${serviceName}/backup-schedules`));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { IApiResponse, ILoginResponse, IUser } from '../types/api.types';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
|
||||
private token = signal<string | null>(this.loadToken());
|
||||
currentUser = signal<IUser | null>(null);
|
||||
isAuthenticated = computed(() => !!this.token());
|
||||
|
||||
private loadToken(): string | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return localStorage.getItem('onebox_token');
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<IApiResponse<ILoginResponse>>('/api/auth/login', { username, password })
|
||||
);
|
||||
|
||||
if (response?.success && response.data) {
|
||||
this.token.set(response.data.token);
|
||||
this.currentUser.set(response.data.user);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('onebox_token', response.data.token);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: response?.error || 'Login failed' };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.error?.error || err?.message || 'Login failed';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.token.set(null);
|
||||
this.currentUser.set(null);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('onebox_token');
|
||||
}
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token();
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
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.logs.set([]); // Clear logs when disconnecting to prevent stale logs showing on next service
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to log stream for a platform service (MongoDB, MinIO, etc.)
|
||||
*/
|
||||
connectPlatform(type: string): void {
|
||||
// Disconnect any existing stream
|
||||
this.disconnect();
|
||||
|
||||
this.currentService = `platform:${type}`;
|
||||
this.isStreaming.set(true);
|
||||
this.logs.set([]);
|
||||
this.state.set({
|
||||
connected: false,
|
||||
error: null,
|
||||
serviceName: type,
|
||||
});
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const url = `${protocol}//${host}/api/platform-services/${type}/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 || type,
|
||||
});
|
||||
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: type,
|
||||
});
|
||||
this.isStreaming.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import type { ICaddyAccessLog, INetworkLogMessage } from '../types/api.types';
|
||||
|
||||
export interface INetworkLogStreamState {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
clientId: string | null;
|
||||
}
|
||||
|
||||
export interface INetworkLogFilter {
|
||||
domain?: string;
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NetworkLogStreamService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Signals for reactive state
|
||||
state = signal<INetworkLogStreamState>({
|
||||
connected: false,
|
||||
error: null,
|
||||
clientId: null,
|
||||
});
|
||||
|
||||
logs = signal<ICaddyAccessLog[]>([]);
|
||||
isStreaming = signal(false);
|
||||
filter = signal<INetworkLogFilter | null>(null);
|
||||
|
||||
/**
|
||||
* Connect to network log stream
|
||||
*/
|
||||
connect(initialFilter?: INetworkLogFilter): void {
|
||||
// Disconnect any existing stream
|
||||
this.disconnect();
|
||||
|
||||
this.isStreaming.set(true);
|
||||
this.logs.set([]);
|
||||
this.filter.set(initialFilter || null);
|
||||
this.state.set({
|
||||
connected: false,
|
||||
error: null,
|
||||
clientId: null,
|
||||
});
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
let url = `${protocol}//${host}/api/network/logs/stream`;
|
||||
|
||||
// Add initial filter as query params
|
||||
if (initialFilter?.domain) {
|
||||
url += `?domain=${encodeURIComponent(initialFilter.domain)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as INetworkLogMessage;
|
||||
|
||||
if (message.type === 'connected') {
|
||||
this.state.set({
|
||||
connected: true,
|
||||
error: null,
|
||||
clientId: message.clientId || null,
|
||||
});
|
||||
if (message.filter) {
|
||||
this.filter.set(message.filter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'filter_updated') {
|
||||
this.filter.set(message.filter || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'access_log' && message.data) {
|
||||
this.logs.update((lines) => {
|
||||
const newLines = [...lines, message.data!];
|
||||
// Keep last 500 logs to prevent memory issues
|
||||
if (newLines.length > 500) {
|
||||
return newLines.slice(-500);
|
||||
}
|
||||
return newLines;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse network log message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.state.update((s) => ({ ...s, connected: false }));
|
||||
this.ws = null;
|
||||
|
||||
// Auto-reconnect with exponential backoff
|
||||
if (this.isStreaming() && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.connect(this.filter() || undefined);
|
||||
}, delay);
|
||||
} else {
|
||||
this.isStreaming.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.state.update((s) => ({
|
||||
...s,
|
||||
connected: false,
|
||||
error: 'WebSocket connection failed',
|
||||
}));
|
||||
};
|
||||
} catch (error) {
|
||||
this.state.set({
|
||||
connected: false,
|
||||
error: 'Failed to connect to network log stream',
|
||||
clientId: null,
|
||||
});
|
||||
this.isStreaming.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from log stream
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isStreaming.set(false);
|
||||
this.reconnectAttempts = 0;
|
||||
this.state.set({
|
||||
connected: false,
|
||||
error: null,
|
||||
clientId: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filter on existing connection
|
||||
*/
|
||||
setFilter(newFilter: INetworkLogFilter | null): void {
|
||||
this.filter.set(newFilter);
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'set_filter',
|
||||
domain: newFilter?.domain,
|
||||
sampleRate: newFilter?.sampleRate,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear logs buffer
|
||||
*/
|
||||
clearLogs(): void {
|
||||
this.logs.set([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.state().connected;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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,48 +0,0 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { IToast, ToastType } from '../types/api.types';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
toasts = signal<IToast[]>([]);
|
||||
|
||||
private generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
}
|
||||
|
||||
show(type: ToastType, message: string, duration = 5000): string {
|
||||
const id = this.generateId();
|
||||
const toast: IToast = { id, type, message, duration };
|
||||
|
||||
this.toasts.update(toasts => [...toasts, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => this.dismiss(id), duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
success(message: string, duration?: number): string {
|
||||
return this.show('success', message, duration);
|
||||
}
|
||||
|
||||
error(message: string, duration?: number): string {
|
||||
return this.show('error', message, duration);
|
||||
}
|
||||
|
||||
info(message: string, duration?: number): string {
|
||||
return this.show('info', message, duration);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
dismissAll(): void {
|
||||
this.toasts.set([]);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Injectable, signal, computed, effect, inject } from '@angular/core';
|
||||
import { IWebSocketMessage, IStatsUpdateMessage, IContainerStats } from '../types/api.types';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WebSocketService {
|
||||
private auth = inject(AuthService);
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 1000;
|
||||
|
||||
isConnected = signal(false);
|
||||
lastMessage = signal<IWebSocketMessage | null>(null);
|
||||
|
||||
// Computed signals for specific message types
|
||||
serviceUpdates = computed(() => {
|
||||
const msg = this.lastMessage();
|
||||
return msg?.type === 'service_update' ? msg : null;
|
||||
});
|
||||
|
||||
serviceStatus = computed(() => {
|
||||
const msg = this.lastMessage();
|
||||
return msg?.type === 'service_status' ? msg : null;
|
||||
});
|
||||
|
||||
systemStatus = computed(() => {
|
||||
const msg = this.lastMessage();
|
||||
return msg?.type === 'system_status' ? msg : null;
|
||||
});
|
||||
|
||||
statsUpdate = computed(() => {
|
||||
const msg = this.lastMessage();
|
||||
return msg?.type === 'stats_update' ? (msg as unknown as IStatsUpdateMessage) : 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 host = window.location.host;
|
||||
const url = `${protocol}//${host}/api/ws`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected.set(true);
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = 1000;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: IWebSocketMessage = JSON.parse(event.data);
|
||||
this.lastMessage.set(message);
|
||||
} catch {
|
||||
console.error('Failed to parse WebSocket message');
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.isConnected.set(false);
|
||||
this.ws = null;
|
||||
this.attemptReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.isConnected.set(false);
|
||||
};
|
||||
} catch {
|
||||
this.isConnected.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private attemptReconnect(): void {
|
||||
if (!this.auth.isAuthenticated()) return;
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
||||
|
||||
this.reconnectAttempts++;
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
|
||||
// Exponential backoff
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected.set(false);
|
||||
}
|
||||
|
||||
send(message: any): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Platform Service Types (defined early for use in ISystemStatus)
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
|
||||
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||
|
||||
export interface IPlatformRequirements {
|
||||
mongodb?: boolean;
|
||||
s3?: boolean;
|
||||
clickhouse?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
imageDigest?: string;
|
||||
platformRequirements?: IPlatformRequirements;
|
||||
}
|
||||
|
||||
export interface IServiceCreate {
|
||||
name: string;
|
||||
image: string;
|
||||
port: number;
|
||||
domain?: string;
|
||||
envVars?: Record<string, string>;
|
||||
useOneboxRegistry?: boolean;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
enableClickHouse?: boolean; // ClickHouse analytics database
|
||||
}
|
||||
|
||||
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; certificateCount: number };
|
||||
services: { total: number; running: number; stopped: number };
|
||||
platformServices: Array<{
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
status: TPlatformServiceStatus;
|
||||
resourceCount: number;
|
||||
}>;
|
||||
certificateHealth: {
|
||||
valid: number;
|
||||
expiringSoon: number;
|
||||
expired: number;
|
||||
expiringDomains: Array<{ domain: string; daysRemaining: 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;
|
||||
}
|
||||
|
||||
// Registry Token Types
|
||||
export interface IRegistryToken {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
scopeDisplay: string;
|
||||
expiresAt: number | null;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
createdBy: string;
|
||||
isExpired: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateTokenRequest {
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
expiresIn: '30d' | '90d' | '365d' | 'never';
|
||||
}
|
||||
|
||||
export interface ITokenCreatedResponse {
|
||||
token: IRegistryToken;
|
||||
plainToken: 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' | 'stats_update';
|
||||
action?: 'created' | 'updated' | 'deleted' | 'started' | 'stopped';
|
||||
serviceName?: string;
|
||||
status?: string;
|
||||
stats?: IContainerStats;
|
||||
data?: any;
|
||||
message?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface IToast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// Platform Service Interfaces
|
||||
export interface IPlatformService {
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
resourceTypes: TPlatformResourceType[];
|
||||
status: TPlatformServiceStatus;
|
||||
containerId?: string;
|
||||
isCore?: boolean; // true for core services like Caddy (cannot be stopped)
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface IPlatformResource {
|
||||
id: number;
|
||||
resourceType: TPlatformResourceType;
|
||||
resourceName: string;
|
||||
platformService: {
|
||||
type: TPlatformServiceType;
|
||||
name: string;
|
||||
status: TPlatformServiceStatus;
|
||||
};
|
||||
envVars: Record<string, string>;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Network Types
|
||||
export type TNetworkTargetType = 'service' | 'registry' | 'platform';
|
||||
|
||||
export interface INetworkTarget {
|
||||
type: TNetworkTargetType;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface INetworkStats {
|
||||
proxy: {
|
||||
running: boolean;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
routes: number;
|
||||
certificates: number;
|
||||
};
|
||||
logReceiver: {
|
||||
running: boolean;
|
||||
port: number;
|
||||
clients: number;
|
||||
connections: number;
|
||||
sampleRate: number;
|
||||
recentLogsCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICaddyAccessLog {
|
||||
ts: number;
|
||||
request: {
|
||||
remote_ip: string;
|
||||
method: string;
|
||||
host: string;
|
||||
uri: string;
|
||||
proto: string;
|
||||
};
|
||||
status: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface INetworkLogMessage {
|
||||
type: 'connected' | 'access_log' | 'filter_updated';
|
||||
clientId?: string;
|
||||
filter?: { domain?: string; sampleRate?: number };
|
||||
data?: ICaddyAccessLog;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Container stats (live)
|
||||
export interface IContainerStats {
|
||||
cpuPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryLimit: number;
|
||||
memoryPercent: number;
|
||||
networkRx: number;
|
||||
networkTx: number;
|
||||
}
|
||||
|
||||
// Historical metrics
|
||||
export interface IMetric {
|
||||
id?: number;
|
||||
serviceId: number;
|
||||
timestamp: number;
|
||||
cpuPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryLimit: number;
|
||||
networkRxBytes: number;
|
||||
networkTxBytes: number;
|
||||
}
|
||||
|
||||
// Stats update WebSocket message
|
||||
export interface IStatsUpdateMessage {
|
||||
type: 'stats_update';
|
||||
serviceName: string;
|
||||
stats: IContainerStats;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Traffic stats from Caddy access logs
|
||||
export interface ITrafficStats {
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
avgResponseTime: number; // milliseconds
|
||||
totalBytes: number;
|
||||
statusCounts: Record<string, number>; // '2xx', '3xx', '4xx', '5xx'
|
||||
requestsPerMinute: number;
|
||||
errorRate: number; // percentage
|
||||
}
|
||||
|
||||
// Backup Types
|
||||
export interface IBackup {
|
||||
id?: number;
|
||||
serviceId: number;
|
||||
serviceName: string;
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
createdAt: number;
|
||||
includesImage: boolean;
|
||||
platformResources: TPlatformServiceType[];
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
export type TRestoreMode = 'restore' | 'import' | 'clone';
|
||||
|
||||
export interface IRestoreOptions {
|
||||
mode: TRestoreMode;
|
||||
newServiceName?: string;
|
||||
overwriteExisting?: boolean;
|
||||
skipPlatformData?: boolean;
|
||||
}
|
||||
|
||||
export interface IRestoreResult {
|
||||
service: IService;
|
||||
platformResourcesRestored: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface IBackupPasswordStatus {
|
||||
isConfigured: boolean;
|
||||
}
|
||||
|
||||
// Backup Schedule Types
|
||||
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
|
||||
|
||||
// Retention policy for GFS (Grandfather-Father-Son) time-window based retention
|
||||
export interface IRetentionPolicy {
|
||||
hourly: number; // 0 = disabled, else keep up to N backups from last 24h
|
||||
daily: number; // Keep 1 backup per day for last N days
|
||||
weekly: number; // Keep 1 backup per week for last N weeks
|
||||
monthly: number; // Keep 1 backup per month for last N months
|
||||
}
|
||||
|
||||
// Default retention presets
|
||||
export const RETENTION_PRESETS = {
|
||||
standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 },
|
||||
frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 },
|
||||
minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 },
|
||||
longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 },
|
||||
} as const;
|
||||
|
||||
export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom';
|
||||
|
||||
export interface IBackupSchedule {
|
||||
id?: number;
|
||||
scopeType: TBackupScheduleScope;
|
||||
scopePattern?: string; // Glob pattern for 'pattern' scope type
|
||||
serviceId?: number; // Only for 'service' scope type
|
||||
serviceName?: string; // Only for 'service' scope type
|
||||
cronExpression: string;
|
||||
retention: IRetentionPolicy; // Per-tier retention counts
|
||||
enabled: boolean;
|
||||
lastRunAt: number | null;
|
||||
nextRunAt: number | null;
|
||||
lastStatus: 'success' | 'failed' | null;
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface IBackupScheduleCreate {
|
||||
scopeType: TBackupScheduleScope;
|
||||
scopePattern?: string; // Required for 'pattern' scope type
|
||||
serviceName?: string; // Required for 'service' scope type
|
||||
cronExpression: string;
|
||||
retention: IRetentionPolicy;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IBackupScheduleUpdate {
|
||||
cronExpression?: string;
|
||||
retention?: IRetentionPolicy;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Updated IBackup with schedule fields
|
||||
export interface IBackupWithSchedule extends IBackup {
|
||||
scheduleId?: number;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
|
||||
interface ICertificateHealth {
|
||||
valid: number;
|
||||
expiringSoon: number;
|
||||
expired: number;
|
||||
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-certificates-card',
|
||||
standalone: true,
|
||||
host: { class: 'block h-full' },
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card class="h-full">
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Certificates</ui-card-title>
|
||||
<ui-card-description>SSL/TLS certificate status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-3">
|
||||
<!-- Status summary -->
|
||||
<div class="space-y-2">
|
||||
@if (health.valid > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-sm">{{ health.valid }} valid</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (health.expiringSoon > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<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 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>
|
||||
<span class="text-sm text-warning">{{ health.expiringSoon }} expiring soon</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (health.expired > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="text-sm text-destructive">{{ health.expired }} expired</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (health.valid === 0 && health.expiringSoon === 0 && health.expired === 0) {
|
||||
<div class="text-sm text-muted-foreground">No certificates</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Expiring domains list -->
|
||||
@if (health.expiringDomains.length > 0) {
|
||||
<div class="border-t pt-2 space-y-1">
|
||||
@for (item of health.expiringDomains; track item.domain) {
|
||||
<a [routerLink]="['/network']"
|
||||
class="flex items-center justify-between text-sm py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
|
||||
<span class="truncate text-muted-foreground">{{ item.domain }}</span>
|
||||
<span
|
||||
class="ml-2 whitespace-nowrap"
|
||||
[class.text-warning]="item.daysRemaining > 7"
|
||||
[class.text-destructive]="item.daysRemaining <= 7">
|
||||
{{ item.daysRemaining }}d
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class CertificatesCardComponent {
|
||||
@Input() health: ICertificateHealth = {
|
||||
valid: 0,
|
||||
expiringSoon: 0,
|
||||
expired: 0,
|
||||
expiringDomains: [],
|
||||
};
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { WebSocketService } from '../../core/services/websocket.service';
|
||||
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';
|
||||
import { TrafficCardComponent } from './traffic-card.component';
|
||||
import { PlatformServicesCardComponent } from './platform-services-card.component';
|
||||
import { CertificatesCardComponent } from './certificates-card.component';
|
||||
import { ResourceUsageCardComponent } from './resource-usage-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
SkeletonComponent,
|
||||
TrafficCardComponent,
|
||||
PlatformServicesCardComponent,
|
||||
CertificatesCardComponent,
|
||||
ResourceUsageCardComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p class="text-muted-foreground">System overview and quick actions</p>
|
||||
</div>
|
||||
<button uiButton variant="outline" (click)="loadStatus()" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading() && !status()) {
|
||||
<!-- Loading skeleton -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@for (_ of [1,2,3,4]; track $index) {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-skeleton class="h-4 w-24" />
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-skeleton class="h-8 w-16" />
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
} @else if (status()) {
|
||||
<!-- Row 1: Key Stats Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Total Services</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ status()!.services.total }}</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">Running</ui-card-title>
|
||||
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-success">{{ status()!.services.running }}</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">Stopped</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ status()!.services.stopped }}</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">Docker</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-badge [variant]="status()!.docker.running ? 'success' : 'destructive'">
|
||||
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||
</ui-badge>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Resource Usage (full width) -->
|
||||
<app-resource-usage-card />
|
||||
|
||||
<!-- Row 3: Traffic & Platform Services (2-column) -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Traffic Overview -->
|
||||
<app-traffic-card />
|
||||
|
||||
<!-- Platform Services Status -->
|
||||
<app-platform-services-card [services]="status()!.platformServices" />
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Certificates & System Status (3-column) -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Certificates Health -->
|
||||
<app-certificates-card [health]="status()!.certificateHealth" />
|
||||
|
||||
<!-- Reverse Proxy -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Reverse Proxy</ui-card-title>
|
||||
<ui-card-description>HTTP/HTTPS proxy status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">HTTP ({{ status()!.reverseProxy.http.port }})</span>
|
||||
<ui-badge [variant]="status()!.reverseProxy.http.running ? 'success' : 'secondary'">
|
||||
{{ status()!.reverseProxy.http.running ? 'Active' : 'Inactive' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">HTTPS ({{ status()!.reverseProxy.https.port }})</span>
|
||||
<ui-badge [variant]="status()!.reverseProxy.https.running ? 'success' : 'secondary'">
|
||||
{{ status()!.reverseProxy.https.running ? 'Active' : 'Inactive' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Routes</span>
|
||||
<span class="text-sm font-medium">{{ status()!.reverseProxy.routes }}</span>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- DNS & SSL Combined -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>DNS & SSL</ui-card-title>
|
||||
<ui-card-description>Configuration status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Cloudflare DNS</span>
|
||||
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
|
||||
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">ACME (Let's Encrypt)</span>
|
||||
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
|
||||
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Row 5: Quick Actions -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Quick Actions</ui-card-title>
|
||||
<ui-card-description>Common tasks and shortcuts</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="flex flex-wrap gap-4">
|
||||
<a routerLink="/services/create">
|
||||
<button uiButton>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Deploy Service
|
||||
</button>
|
||||
</a>
|
||||
<a routerLink="/services">
|
||||
<button uiButton variant="outline">View All Services</button>
|
||||
</a>
|
||||
<a routerLink="/platform-services">
|
||||
<button uiButton variant="outline">Platform Services</button>
|
||||
</a>
|
||||
<a routerLink="/network">
|
||||
<button uiButton variant="outline">Manage Domains</button>
|
||||
</a>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
private ws = inject(WebSocketService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
status = signal<ISystemStatus | null>(null);
|
||||
loading = signal(false);
|
||||
|
||||
private refreshInterval: any;
|
||||
|
||||
constructor() {
|
||||
// React to WebSocket updates
|
||||
effect(() => {
|
||||
const update = this.ws.serviceUpdates();
|
||||
const systemStatus = this.ws.systemStatus();
|
||||
if (update || systemStatus) {
|
||||
this.loadStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStatus();
|
||||
// Auto-refresh every 30 seconds
|
||||
this.refreshInterval = setInterval(() => this.loadStatus(), 30000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async loadStatus(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getStatus();
|
||||
if (response.success && response.data) {
|
||||
this.status.set(response.data);
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to load status');
|
||||
}
|
||||
} catch (err) {
|
||||
this.toast.error('Failed to load status');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TPlatformServiceType, TPlatformServiceStatus } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
|
||||
interface IPlatformServiceSummary {
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
status: TPlatformServiceStatus;
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-services-card',
|
||||
standalone: true,
|
||||
host: { class: 'block h-full' },
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card class="h-full">
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Platform Services</ui-card-title>
|
||||
<ui-card-description>Infrastructure status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-2">
|
||||
@for (service of services; track service.type) {
|
||||
<a [routerLink]="['/platform-services', service.type]"
|
||||
class="flex items-center justify-between py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status indicator -->
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class.bg-success]="service.status === 'running'"
|
||||
[class.bg-muted-foreground]="service.status === 'not-deployed' || service.status === 'stopped'"
|
||||
[class.bg-warning]="service.status === 'starting' || service.status === 'stopping'"
|
||||
[class.bg-destructive]="service.status === 'failed'">
|
||||
</span>
|
||||
<span class="text-sm">{{ service.displayName }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@if (service.status === 'running') {
|
||||
@if (service.resourceCount > 0) {
|
||||
<span>{{ service.resourceCount }} {{ service.resourceCount === 1 ? getResourceLabel(service.type) : getResourceLabelPlural(service.type) }}</span>
|
||||
} @else {
|
||||
<span>Running</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="capitalize">{{ formatStatus(service.status) }}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
} @empty {
|
||||
<div class="text-sm text-muted-foreground">No platform services</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class PlatformServicesCardComponent {
|
||||
@Input() services: IPlatformServiceSummary[] = [];
|
||||
|
||||
formatStatus(status: string): string {
|
||||
return status.replace('-', ' ');
|
||||
}
|
||||
|
||||
getResourceLabel(type: TPlatformServiceType): string {
|
||||
switch (type) {
|
||||
case 'mongodb':
|
||||
case 'postgresql':
|
||||
case 'clickhouse':
|
||||
return 'DB';
|
||||
case 'minio':
|
||||
return 'bucket';
|
||||
case 'redis':
|
||||
return 'cache';
|
||||
case 'rabbitmq':
|
||||
return 'queue';
|
||||
default:
|
||||
return 'resource';
|
||||
}
|
||||
}
|
||||
|
||||
getResourceLabelPlural(type: TPlatformServiceType): string {
|
||||
switch (type) {
|
||||
case 'mongodb':
|
||||
case 'postgresql':
|
||||
case 'clickhouse':
|
||||
return 'DBs';
|
||||
case 'minio':
|
||||
return 'buckets';
|
||||
case 'redis':
|
||||
return 'caches';
|
||||
case 'rabbitmq':
|
||||
return 'queues';
|
||||
default:
|
||||
return 'resources';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { Component, inject, signal, effect, OnDestroy } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { WebSocketService } from '../../core/services/websocket.service';
|
||||
import { IContainerStats } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
|
||||
interface IServiceStats {
|
||||
name: string;
|
||||
stats: IContainerStats;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface IAggregatedStats {
|
||||
totalCpuPercent: number;
|
||||
totalMemoryUsed: number;
|
||||
totalMemoryLimit: number;
|
||||
memoryPercent: number;
|
||||
networkRxRate: number;
|
||||
networkTxRate: number;
|
||||
serviceCount: number;
|
||||
topCpuServices: { name: string; value: number }[];
|
||||
topMemoryServices: { name: string; value: number }[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-resource-usage-card',
|
||||
standalone: true,
|
||||
host: { class: 'block' },
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<ui-card-title>Resource Usage</ui-card-title>
|
||||
<ui-card-description>Aggregated across {{ aggregated().serviceCount }} services</ui-card-description>
|
||||
</div>
|
||||
<a routerLink="/services" class="text-xs text-muted-foreground hover:text-primary transition-colors">
|
||||
View All
|
||||
</a>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
@if (aggregated().serviceCount === 0) {
|
||||
<div class="text-sm text-muted-foreground">No running services</div>
|
||||
} @else {
|
||||
<!-- CPU Usage -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">CPU</span>
|
||||
<span class="font-medium" [class.text-warning]="aggregated().totalCpuPercent > 70" [class.text-destructive]="aggregated().totalCpuPercent > 90">
|
||||
{{ aggregated().totalCpuPercent.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
[class.bg-success]="aggregated().totalCpuPercent <= 70"
|
||||
[class.bg-warning]="aggregated().totalCpuPercent > 70 && aggregated().totalCpuPercent <= 90"
|
||||
[class.bg-destructive]="aggregated().totalCpuPercent > 90"
|
||||
[style.width.%]="Math.min(aggregated().totalCpuPercent, 100)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Usage -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Memory</span>
|
||||
<span class="font-medium" [class.text-warning]="aggregated().memoryPercent > 70" [class.text-destructive]="aggregated().memoryPercent > 90">
|
||||
{{ formatBytes(aggregated().totalMemoryUsed) }} / {{ formatBytes(aggregated().totalMemoryLimit) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
[class.bg-success]="aggregated().memoryPercent <= 70"
|
||||
[class.bg-warning]="aggregated().memoryPercent > 70 && aggregated().memoryPercent <= 90"
|
||||
[class.bg-destructive]="aggregated().memoryPercent > 90"
|
||||
[style.width.%]="Math.min(aggregated().memoryPercent, 100)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="flex items-center justify-between text-sm pt-1 border-t">
|
||||
<span class="text-muted-foreground">Network</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
{{ formatBytesRate(aggregated().networkRxRate) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
{{ formatBytesRate(aggregated().networkTxRate) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Consumers -->
|
||||
@if (aggregated().topCpuServices.length > 0 || aggregated().topMemoryServices.length > 0) {
|
||||
<div class="pt-2 border-t">
|
||||
<div class="text-xs text-muted-foreground mb-1">Top consumers</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
@for (svc of aggregated().topCpuServices.slice(0, 2); track svc.name) {
|
||||
<span>
|
||||
<span class="text-muted-foreground">{{ svc.name }}:</span>
|
||||
<span class="font-medium"> {{ svc.value.toFixed(1) }}% CPU</span>
|
||||
</span>
|
||||
}
|
||||
@for (svc of aggregated().topMemoryServices.slice(0, 2); track svc.name) {
|
||||
<span>
|
||||
<span class="text-muted-foreground">{{ svc.name }}:</span>
|
||||
<span class="font-medium"> {{ formatBytes(svc.value) }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class ResourceUsageCardComponent implements OnDestroy {
|
||||
private ws = inject(WebSocketService);
|
||||
|
||||
// Store stats per service
|
||||
private serviceStats = new Map<string, IServiceStats>();
|
||||
private cleanupInterval: any;
|
||||
|
||||
// Expose Math for template
|
||||
Math = Math;
|
||||
|
||||
aggregated = signal<IAggregatedStats>({
|
||||
totalCpuPercent: 0,
|
||||
totalMemoryUsed: 0,
|
||||
totalMemoryLimit: 0,
|
||||
memoryPercent: 0,
|
||||
networkRxRate: 0,
|
||||
networkTxRate: 0,
|
||||
serviceCount: 0,
|
||||
topCpuServices: [],
|
||||
topMemoryServices: [],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Listen for stats updates
|
||||
effect(() => {
|
||||
const update = this.ws.statsUpdate();
|
||||
if (update) {
|
||||
this.serviceStats.set(update.serviceName, {
|
||||
name: update.serviceName,
|
||||
stats: update.stats,
|
||||
timestamp: update.timestamp,
|
||||
});
|
||||
this.recalculateAggregated();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up stale entries every 30 seconds
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const staleThreshold = 60000; // 60 seconds
|
||||
let changed = false;
|
||||
|
||||
for (const [name, entry] of this.serviceStats.entries()) {
|
||||
if (now - entry.timestamp > staleThreshold) {
|
||||
this.serviceStats.delete(name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.recalculateAggregated();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private recalculateAggregated(): void {
|
||||
const entries = Array.from(this.serviceStats.values());
|
||||
|
||||
if (entries.length === 0) {
|
||||
this.aggregated.set({
|
||||
totalCpuPercent: 0,
|
||||
totalMemoryUsed: 0,
|
||||
totalMemoryLimit: 0,
|
||||
memoryPercent: 0,
|
||||
networkRxRate: 0,
|
||||
networkTxRate: 0,
|
||||
serviceCount: 0,
|
||||
topCpuServices: [],
|
||||
topMemoryServices: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let totalCpu = 0;
|
||||
let totalMemUsed = 0;
|
||||
let totalMemLimit = 0;
|
||||
let totalNetRx = 0;
|
||||
let totalNetTx = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
totalCpu += entry.stats.cpuPercent;
|
||||
totalMemUsed += entry.stats.memoryUsed;
|
||||
totalMemLimit += entry.stats.memoryLimit;
|
||||
totalNetRx += entry.stats.networkRx;
|
||||
totalNetTx += entry.stats.networkTx;
|
||||
}
|
||||
|
||||
// Sort by CPU usage for top consumers
|
||||
const sortedByCpu = [...entries]
|
||||
.filter(e => e.stats.cpuPercent > 0)
|
||||
.sort((a, b) => b.stats.cpuPercent - a.stats.cpuPercent)
|
||||
.slice(0, 3)
|
||||
.map(e => ({ name: e.name, value: e.stats.cpuPercent }));
|
||||
|
||||
// Sort by memory usage for top consumers
|
||||
const sortedByMem = [...entries]
|
||||
.filter(e => e.stats.memoryUsed > 0)
|
||||
.sort((a, b) => b.stats.memoryUsed - a.stats.memoryUsed)
|
||||
.slice(0, 3)
|
||||
.map(e => ({ name: e.name, value: e.stats.memoryUsed }));
|
||||
|
||||
this.aggregated.set({
|
||||
totalCpuPercent: totalCpu,
|
||||
totalMemoryUsed: totalMemUsed,
|
||||
totalMemoryLimit: totalMemLimit,
|
||||
memoryPercent: totalMemLimit > 0 ? (totalMemUsed / totalMemLimit) * 100 : 0,
|
||||
networkRxRate: totalNetRx,
|
||||
networkTxRate: totalNetTx,
|
||||
serviceCount: entries.length,
|
||||
topCpuServices: sortedByCpu,
|
||||
topMemoryServices: sortedByMem,
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatBytesRate(bytes: number): string {
|
||||
return this.formatBytes(bytes) + '/s';
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ITrafficStats } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-traffic-card',
|
||||
standalone: true,
|
||||
host: { class: 'block h-full' },
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card class="h-full">
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Traffic (Last Hour)</ui-card-title>
|
||||
<ui-card-description>Request metrics from access logs</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-3">
|
||||
@if (loading() && !stats()) {
|
||||
<ui-skeleton class="h-4 w-32" />
|
||||
<ui-skeleton class="h-4 w-24" />
|
||||
<ui-skeleton class="h-4 w-28" />
|
||||
} @else if (stats()) {
|
||||
<div class="space-y-2">
|
||||
<!-- Request count -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Requests</span>
|
||||
<span class="text-sm font-medium">{{ formatNumber(stats()!.requestCount) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error rate -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Errors</span>
|
||||
<span class="text-sm font-medium" [class.text-destructive]="stats()!.errorRate > 5">
|
||||
{{ stats()!.errorCount }} ({{ stats()!.errorRate }}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Avg response time -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Avg Response</span>
|
||||
<span class="text-sm font-medium" [class.text-warning]="stats()!.avgResponseTime > 500">
|
||||
{{ stats()!.avgResponseTime }}ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Requests per minute -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Req/min</span>
|
||||
<span class="text-sm font-medium">{{ stats()!.requestsPerMinute }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Status code distribution -->
|
||||
<div class="pt-2 border-t">
|
||||
<div class="flex gap-1 h-2 rounded overflow-hidden bg-muted">
|
||||
@if (getStatusPercent('2xx') > 0) {
|
||||
<div
|
||||
class="bg-success transition-all"
|
||||
[style.width.%]="getStatusPercent('2xx')"
|
||||
[title]="'2xx: ' + stats()!.statusCounts['2xx']">
|
||||
</div>
|
||||
}
|
||||
@if (getStatusPercent('3xx') > 0) {
|
||||
<div
|
||||
class="bg-blue-500 transition-all"
|
||||
[style.width.%]="getStatusPercent('3xx')"
|
||||
[title]="'3xx: ' + stats()!.statusCounts['3xx']">
|
||||
</div>
|
||||
}
|
||||
@if (getStatusPercent('4xx') > 0) {
|
||||
<div
|
||||
class="bg-warning transition-all"
|
||||
[style.width.%]="getStatusPercent('4xx')"
|
||||
[title]="'4xx: ' + stats()!.statusCounts['4xx']">
|
||||
</div>
|
||||
}
|
||||
@if (getStatusPercent('5xx') > 0) {
|
||||
<div
|
||||
class="bg-destructive transition-all"
|
||||
[style.width.%]="getStatusPercent('5xx')"
|
||||
[title]="'5xx: ' + stats()!.statusCounts['5xx']">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-between mt-1 text-xs text-muted-foreground">
|
||||
<span>2xx</span>
|
||||
<span>3xx</span>
|
||||
<span>4xx</span>
|
||||
<span>5xx</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="text-sm text-muted-foreground">No traffic data available</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class TrafficCardComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
|
||||
stats = signal<ITrafficStats | null>(null);
|
||||
loading = signal(false);
|
||||
|
||||
private refreshInterval: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStats();
|
||||
// Refresh every 30 seconds
|
||||
this.refreshInterval = setInterval(() => this.loadStats(), 30000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async loadStats(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getTrafficStats(60);
|
||||
if (response.success && response.data) {
|
||||
this.stats.set(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load traffic stats:', err);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
getStatusPercent(status: string): number {
|
||||
const s = this.stats();
|
||||
if (!s || s.requestCount === 0) return 0;
|
||||
const count = s.statusCounts[status] || 0;
|
||||
return (count / s.requestCount) * 100;
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IDnsRecord } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
} from '../../ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dns',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">DNS Records</h1>
|
||||
<p class="text-muted-foreground">Manage DNS records synced with Cloudflare</p>
|
||||
</div>
|
||||
<button uiButton (click)="syncRecords()" [disabled]="syncing()">
|
||||
@if (syncing()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Syncing...
|
||||
} @else {
|
||||
Sync Cloudflare
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && records().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (records().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-semibold">No DNS records</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">DNS records are created automatically when you deploy services with domains.</p>
|
||||
<button uiButton class="mt-4" (click)="syncRecords()">Sync from Cloudflare</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Value</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (record of records(); track record.id) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ record.domain }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge variant="secondary">{{ record.type }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="font-mono text-sm">{{ record.value }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(record)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete DNS Record</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Are you sure you want to delete the record for "{{ recordToDelete()?.domain }}"?
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton variant="destructive" (click)="deleteRecord()">Delete</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class DnsComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
records = signal<IDnsRecord[]>([]);
|
||||
loading = signal(false);
|
||||
syncing = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
recordToDelete = signal<IDnsRecord | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRecords();
|
||||
}
|
||||
|
||||
async loadRecords(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getDnsRecords();
|
||||
if (response.success && response.data) {
|
||||
this.records.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load DNS records');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async syncRecords(): Promise<void> {
|
||||
this.syncing.set(true);
|
||||
try {
|
||||
const response = await this.api.syncDnsRecords();
|
||||
if (response.success) {
|
||||
this.toast.success('DNS records synced');
|
||||
this.loadRecords();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to sync DNS records');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to sync DNS records');
|
||||
} finally {
|
||||
this.syncing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(record: IDnsRecord): void {
|
||||
this.recordToDelete.set(record);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteRecord(): Promise<void> {
|
||||
const record = this.recordToDelete();
|
||||
if (!record) return;
|
||||
|
||||
try {
|
||||
const response = await this.api.deleteDnsRecord(record.domain);
|
||||
if (response.success) {
|
||||
this.toast.success('DNS record deleted');
|
||||
this.loadRecords();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete record');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete record');
|
||||
} finally {
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.recordToDelete.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IDomainDetail, IService } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-domain-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a routerLink="/domains" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Domains
|
||||
</a>
|
||||
|
||||
@if (loading() && !domain()) {
|
||||
<ui-skeleton class="h-9 w-64" />
|
||||
} @else if (domain()) {
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ domain()!.domain.domain }}</h1>
|
||||
<ui-badge [variant]="domain()!.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
|
||||
{{ domain()!.domain.dnsProvider || 'Manual' }}
|
||||
</ui-badge>
|
||||
@if (domain()!.domain.defaultWildcard) {
|
||||
<ui-badge variant="outline">Wildcard</ui-badge>
|
||||
}
|
||||
@if (domain()!.domain.isObsolete) {
|
||||
<ui-badge variant="destructive">Obsolete</ui-badge>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (domain()) {
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Certificates</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domain()!.certificates.length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Requirements</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domain()!.requirements.length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Services</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domain()!.serviceCount }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Certificates -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>SSL Certificates</ui-card-title>
|
||||
<ui-card-description>Active certificates for this domain</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (domain()!.certificates.length === 0) {
|
||||
<div class="p-6 text-center text-muted-foreground">No certificates</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head>Issuer</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (cert of domain()!.certificates; track cert.id) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ cert.certDomain }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge variant="outline">{{ cert.isWildcard ? 'Wildcard' : 'Standard' }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getCertStatusVariant(cert)">
|
||||
{{ getCertStatus(cert) }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
{{ formatDate(cert.expiryDate) }}
|
||||
<span class="text-xs text-muted-foreground ml-1">
|
||||
({{ getDaysRemaining(cert.expiryDate) }} days)
|
||||
</span>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ cert.issuer }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="outline" size="sm" (click)="renewCertificate(cert.certDomain)">
|
||||
Renew
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Services using this domain -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Services</ui-card-title>
|
||||
<ui-card-description>Services using this domain</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (services().length === 0) {
|
||||
<div class="p-6 text-center text-muted-foreground">No services using this domain</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Service</ui-table-head>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (svc of services(); track svc.name) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ svc.name }}</ui-table-cell>
|
||||
<ui-table-cell>{{ svc.domain }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="svc.status === 'running' ? 'success' : 'secondary'">
|
||||
{{ svc.status }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<a [routerLink]="['/services/detail', svc.name]">
|
||||
<button uiButton variant="outline" size="sm">View</button>
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DomainDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
domain = signal<IDomainDetail | null>(null);
|
||||
services = signal<IService[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
const domainName = this.route.snapshot.paramMap.get('domain');
|
||||
if (domainName) {
|
||||
this.loadDomain(domainName);
|
||||
this.loadServices(domainName);
|
||||
}
|
||||
}
|
||||
|
||||
async loadDomain(name: string): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getDomainDetail(name);
|
||||
if (response.success && response.data) {
|
||||
this.domain.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load domain');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadServices(domainName: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getServices();
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data.filter(s => s.domain?.includes(domainName)));
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
getDaysRemaining(timestamp: number): number {
|
||||
const now = Date.now();
|
||||
return Math.floor((timestamp - now) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
getCertStatus(cert: any): string {
|
||||
if (!cert.isValid) return 'Invalid';
|
||||
const days = this.getDaysRemaining(cert.expiryDate);
|
||||
if (days < 0) return 'Expired';
|
||||
if (days <= 30) return 'Expiring';
|
||||
return 'Valid';
|
||||
}
|
||||
|
||||
getCertStatusVariant(cert: any): 'success' | 'warning' | 'destructive' {
|
||||
const status = this.getCertStatus(cert);
|
||||
switch (status) {
|
||||
case 'Valid': return 'success';
|
||||
case 'Expiring': return 'warning';
|
||||
default: return 'destructive';
|
||||
}
|
||||
}
|
||||
|
||||
async renewCertificate(domain: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.renewCertificate(domain);
|
||||
if (response.success) {
|
||||
this.toast.success('Certificate renewal initiated');
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to renew certificate');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to renew certificate');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IDomainDetail } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-domains',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Domains</h1>
|
||||
<p class="text-muted-foreground">Manage domains and SSL certificates</p>
|
||||
</div>
|
||||
<button uiButton (click)="syncDomains()" [disabled]="syncing()">
|
||||
@if (syncing()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Syncing...
|
||||
} @else {
|
||||
Sync Cloudflare
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Total Domains</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domains().length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Valid Certificates</ui-card-title>
|
||||
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-success">{{ countByStatus('valid') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Expiring Soon</ui-card-title>
|
||||
<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-warning">{{ countByStatus('expiring-soon') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Expired/Pending</ui-card-title>
|
||||
<svg class="h-4 w-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-destructive">{{ countByStatus('expired') + countByStatus('pending') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Domains Table -->
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && domains().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (domains().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<h3 class="text-lg font-semibold">No domains found</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Sync domains from Cloudflare to get started.</p>
|
||||
<button uiButton class="mt-4" (click)="syncDomains()">Sync Cloudflare Domains</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Provider</ui-table-head>
|
||||
<ui-table-head>Services</ui-table-head>
|
||||
<ui-table-head>Certificate</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (d of domains(); track d.domain.id) {
|
||||
<ui-table-row [class.opacity-50]="d.domain.isObsolete">
|
||||
<ui-table-cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ d.domain.domain }}</span>
|
||||
@if (d.domain.isObsolete) {
|
||||
<ui-badge variant="destructive">Obsolete</ui-badge>
|
||||
}
|
||||
</div>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="d.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
|
||||
{{ d.domain.dnsProvider || 'None' }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ d.serviceCount }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getCertStatusVariant(d.certificateStatus)">
|
||||
{{ d.certificateStatus }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (d.daysRemaining !== null) {
|
||||
<span [class.text-destructive]="d.daysRemaining <= 30">
|
||||
{{ d.daysRemaining }} days
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">-</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<a [routerLink]="['/domains', d.domain.domain]">
|
||||
<button uiButton variant="outline" size="sm">View</button>
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DomainsComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
domains = signal<IDomainDetail[]>([]);
|
||||
loading = signal(false);
|
||||
syncing = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDomains();
|
||||
}
|
||||
|
||||
async loadDomains(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getDomains();
|
||||
if (response.success && response.data) {
|
||||
this.domains.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load domains');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async syncDomains(): Promise<void> {
|
||||
this.syncing.set(true);
|
||||
try {
|
||||
const response = await this.api.syncCloudflareDomains();
|
||||
if (response.success) {
|
||||
this.toast.success('Domains synced');
|
||||
this.loadDomains();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to sync domains');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to sync domains');
|
||||
} finally {
|
||||
this.syncing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
countByStatus(status: string): number {
|
||||
return this.domains().filter(d => d.certificateStatus === status).length;
|
||||
}
|
||||
|
||||
getCertStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'valid': return 'success';
|
||||
case 'expiring-soon': return 'warning';
|
||||
case 'expired': return 'destructive';
|
||||
case 'pending': return 'secondary';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
AlertComponent,
|
||||
AlertDescriptionComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div class="absolute top-4 right-4">
|
||||
<button
|
||||
uiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
(click)="theme.toggle()"
|
||||
>
|
||||
@if (theme.resolvedTheme() === 'dark') {
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ui-card class="w-full max-w-md">
|
||||
<ui-card-header class="text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<svg class="h-12 w-12 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<ui-card-title>Welcome to Onebox</ui-card-title>
|
||||
<ui-card-description>Enter your credentials to sign in</ui-card-description>
|
||||
</ui-card-header>
|
||||
|
||||
<form (ngSubmit)="onSubmit()">
|
||||
<ui-card-content class="space-y-4">
|
||||
@if (error()) {
|
||||
<ui-alert variant="destructive">
|
||||
<ui-alert-description>{{ error() }}</ui-alert-description>
|
||||
</ui-alert>
|
||||
}
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="username">Username</label>
|
||||
<input
|
||||
uiInput
|
||||
id="username"
|
||||
type="text"
|
||||
[(ngModel)]="username"
|
||||
name="username"
|
||||
placeholder="Enter username"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="password">Password</label>
|
||||
<input
|
||||
uiInput
|
||||
id="password"
|
||||
type="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
|
||||
<ui-card-footer>
|
||||
<button
|
||||
uiButton
|
||||
type="submit"
|
||||
class="w-full"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</ui-card-footer>
|
||||
</form>
|
||||
|
||||
<div class="px-6 pb-6">
|
||||
<p class="text-xs text-center text-muted-foreground">
|
||||
Default credentials: admin / admin
|
||||
</p>
|
||||
</div>
|
||||
</ui-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
theme = inject(ThemeService);
|
||||
|
||||
username = '';
|
||||
password = '';
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
if (!this.username || !this.password) {
|
||||
this.error.set('Please enter username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const result = await this.auth.login(this.username, this.password);
|
||||
|
||||
this.loading.set(false);
|
||||
|
||||
if (result.success) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.error.set(result.error || 'Invalid credentials');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IDnsRecord } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
} from '../../ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dns-content',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-muted-foreground">Manage DNS records synced with Cloudflare</p>
|
||||
<button uiButton (click)="syncRecords()" [disabled]="syncing()">
|
||||
@if (syncing()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Syncing...
|
||||
} @else {
|
||||
Sync Cloudflare
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && records().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (records().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-semibold">No DNS records</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">DNS records are created automatically when you deploy services with domains.</p>
|
||||
<button uiButton class="mt-4" (click)="syncRecords()">Sync from Cloudflare</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Value</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (record of records(); track record.id) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ record.domain }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge variant="secondary">{{ record.type }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="font-mono text-sm">{{ record.value }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(record)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete DNS Record</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Are you sure you want to delete the record for "{{ recordToDelete()?.domain }}"?
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton variant="destructive" (click)="deleteRecord()">Delete</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class DnsContentComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
records = signal<IDnsRecord[]>([]);
|
||||
loading = signal(false);
|
||||
syncing = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
recordToDelete = signal<IDnsRecord | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRecords();
|
||||
}
|
||||
|
||||
async loadRecords(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getDnsRecords();
|
||||
if (response.success && response.data) {
|
||||
this.records.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load DNS records');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async syncRecords(): Promise<void> {
|
||||
this.syncing.set(true);
|
||||
try {
|
||||
const response = await this.api.syncDnsRecords();
|
||||
if (response.success) {
|
||||
this.toast.success('DNS records synced');
|
||||
this.loadRecords();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to sync DNS records');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to sync DNS records');
|
||||
} finally {
|
||||
this.syncing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(record: IDnsRecord): void {
|
||||
this.recordToDelete.set(record);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteRecord(): Promise<void> {
|
||||
const record = this.recordToDelete();
|
||||
if (!record) return;
|
||||
|
||||
try {
|
||||
const response = await this.api.deleteDnsRecord(record.domain);
|
||||
if (response.success) {
|
||||
this.toast.success('DNS record deleted');
|
||||
this.loadRecords();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete record');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete record');
|
||||
} finally {
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.recordToDelete.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IDomainDetail } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-domains-content',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-muted-foreground">Manage domains and SSL certificates</p>
|
||||
<button uiButton (click)="syncDomains()" [disabled]="syncing()">
|
||||
@if (syncing()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Syncing...
|
||||
} @else {
|
||||
Sync Cloudflare
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Total Domains</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domains().length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Valid Certificates</ui-card-title>
|
||||
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-success">{{ countByStatus('valid') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Expiring Soon</ui-card-title>
|
||||
<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-warning">{{ countByStatus('expiring-soon') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Expired/Pending</ui-card-title>
|
||||
<svg class="h-4 w-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-destructive">{{ countByStatus('expired') + countByStatus('pending') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Domains Table -->
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && domains().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (domains().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<h3 class="text-lg font-semibold">No domains found</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Sync domains from Cloudflare to get started.</p>
|
||||
<button uiButton class="mt-4" (click)="syncDomains()">Sync Cloudflare Domains</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Provider</ui-table-head>
|
||||
<ui-table-head>Services</ui-table-head>
|
||||
<ui-table-head>Certificate</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (d of domains(); track d.domain.id) {
|
||||
<ui-table-row [class.opacity-50]="d.domain.isObsolete">
|
||||
<ui-table-cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ d.domain.domain }}</span>
|
||||
@if (d.domain.isObsolete) {
|
||||
<ui-badge variant="destructive">Obsolete</ui-badge>
|
||||
}
|
||||
</div>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="d.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
|
||||
{{ d.domain.dnsProvider || 'None' }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ d.serviceCount }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getCertStatusVariant(d.certificateStatus)">
|
||||
{{ d.certificateStatus }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (d.daysRemaining !== null) {
|
||||
<span [class.text-destructive]="d.daysRemaining <= 30">
|
||||
{{ d.daysRemaining }} days
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">-</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<a [routerLink]="['/network/domains', d.domain.domain]">
|
||||
<button uiButton variant="outline" size="sm">View</button>
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DomainsContentComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
domains = signal<IDomainDetail[]>([]);
|
||||
loading = signal(false);
|
||||
syncing = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDomains();
|
||||
}
|
||||
|
||||
async loadDomains(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getDomains();
|
||||
if (response.success && response.data) {
|
||||
this.domains.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load domains');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async syncDomains(): Promise<void> {
|
||||
this.syncing.set(true);
|
||||
try {
|
||||
const response = await this.api.syncCloudflareDomains();
|
||||
if (response.success) {
|
||||
this.toast.success('Domains synced');
|
||||
this.loadDomains();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to sync domains');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to sync domains');
|
||||
} finally {
|
||||
this.syncing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
countByStatus(status: string): number {
|
||||
return this.domains().filter(d => d.certificateStatus === status).length;
|
||||
}
|
||||
|
||||
getCertStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'valid': return 'success';
|
||||
case 'expiring-soon': return 'warning';
|
||||
case 'expired': return 'destructive';
|
||||
case 'pending': return 'secondary';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { NetworkLogStreamService } from '../../core/services/network-log-stream.service';
|
||||
import type { INetworkTarget, INetworkStats, ICaddyAccessLog } 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';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component';
|
||||
import { DnsContentComponent } from './dns-content.component';
|
||||
import { DomainsContentComponent } from './domains-content.component';
|
||||
|
||||
type TNetworkTab = 'proxy' | 'dns' | 'domains';
|
||||
|
||||
@Component({
|
||||
selector: 'app-network',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
SkeletonComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
TabsComponent,
|
||||
TabComponent,
|
||||
DnsContentComponent,
|
||||
DomainsContentComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Network</h1>
|
||||
<p class="text-muted-foreground">Manage proxy, DNS, and domains</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ui-tabs class="block">
|
||||
<ui-tab [active]="activeTab() === 'proxy'" (tabClick)="setTab('proxy')">Proxy</ui-tab>
|
||||
<ui-tab [active]="activeTab() === 'dns'" (tabClick)="setTab('dns')">DNS</ui-tab>
|
||||
<ui-tab [active]="activeTab() === 'domains'" (tabClick)="setTab('domains')">Domains</ui-tab>
|
||||
</ui-tabs>
|
||||
|
||||
<!-- Tab Content -->
|
||||
@switch (activeTab()) {
|
||||
@case ('proxy') {
|
||||
<!-- Proxy Tab Content -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-end">
|
||||
<button uiButton variant="outline" (click)="loadData()" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading() && !stats()) {
|
||||
<!-- Loading skeleton -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
@for (_ of [1,2,3,4]; track $index) {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-skeleton class="h-4 w-24" />
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-skeleton class="h-8 w-16" />
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
} @else if (stats()) {
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Proxy Status</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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-badge [variant]="stats()!.proxy.running ? 'success' : 'secondary'">
|
||||
{{ stats()!.proxy.running ? 'Running' : 'Stopped' }}
|
||||
</ui-badge>
|
||||
</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">Routes</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ stats()!.proxy.routes }}</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">Certificates</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ stats()!.proxy.certificates }}</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">Targets</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">{{ targets().length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Traffic Targets Table -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Traffic Targets</ui-card-title>
|
||||
<ui-card-description>Services, registry, and platform services with their routing info. Click to filter logs.</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
@if (targets().length === 0 && !loading()) {
|
||||
<p class="text-muted-foreground text-center py-8">No traffic targets configured</p>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Target</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (target of targets(); track target.name) {
|
||||
<ui-table-row [class]="'cursor-pointer ' + (activeFilter() === target.domain ? 'bg-muted' : '')" (click)="onTargetClick(target)">
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getTypeVariant(target.type)">{{ target.type }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="font-medium">{{ target.name }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (target.domain) {
|
||||
<span class="font-mono text-sm">{{ target.domain }}</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">-</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="font-mono text-sm">{{ target.targetHost }}:{{ target.targetPort }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getStatusVariant(target.status)">{{ target.status }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Access Logs -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Access Logs</ui-card-title>
|
||||
<ui-card-description>
|
||||
@if (networkLogStream.isStreaming()) {
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live streaming
|
||||
@if (activeFilter()) {
|
||||
<span class="text-muted-foreground">- filtered by {{ activeFilter() }}</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
Real-time Caddy access logs
|
||||
}
|
||||
</ui-card-description>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (activeFilter()) {
|
||||
<button uiButton variant="ghost" size="sm" (click)="clearFilter()">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear Filter
|
||||
</button>
|
||||
}
|
||||
@if (networkLogStream.isStreaming()) {
|
||||
<button uiButton variant="outline" size="sm" (click)="stopLogStream()">
|
||||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
} @else {
|
||||
<button uiButton variant="outline" size="sm" (click)="startLogStream()">
|
||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Stream
|
||||
</button>
|
||||
}
|
||||
<button uiButton variant="ghost" size="sm" (click)="clearLogs()" title="Clear logs">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div
|
||||
#logContainer
|
||||
class="bg-zinc-950 text-zinc-100 rounded-md p-4 h-96 overflow-auto font-mono text-xs"
|
||||
>
|
||||
@if (networkLogStream.state().error) {
|
||||
<p class="text-red-400">Error: {{ networkLogStream.state().error }}</p>
|
||||
} @else if (networkLogStream.logs().length > 0) {
|
||||
@for (log of networkLogStream.logs(); track $index) {
|
||||
<div class="whitespace-pre hover:bg-zinc-800/50 py-0.5" [class]="getLogClass(log.status)">
|
||||
{{ formatLog(log) }}
|
||||
</div>
|
||||
}
|
||||
} @else if (networkLogStream.isStreaming()) {
|
||||
<p class="text-zinc-500">Waiting for access logs...</p>
|
||||
} @else {
|
||||
<p class="text-zinc-500">Click "Stream" to start live access log streaming</p>
|
||||
}
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
}
|
||||
@case ('dns') {
|
||||
<div class="space-y-6">
|
||||
<app-dns-content />
|
||||
</div>
|
||||
}
|
||||
@case ('domains') {
|
||||
<div class="space-y-6">
|
||||
<app-domains-content />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class NetworkComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private routeSub?: Subscription;
|
||||
networkLogStream = inject(NetworkLogStreamService);
|
||||
|
||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
// Tab state
|
||||
activeTab = signal<TNetworkTab>('proxy');
|
||||
|
||||
// Proxy tab data
|
||||
targets = signal<INetworkTarget[]>([]);
|
||||
stats = signal<INetworkStats | null>(null);
|
||||
loading = signal(false);
|
||||
activeFilter = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Auto-scroll when new logs arrive
|
||||
effect(() => {
|
||||
const logs = this.networkLogStream.logs();
|
||||
if (logs.length > 0 && this.logContainer?.nativeElement) {
|
||||
setTimeout(() => {
|
||||
const container = this.logContainer.nativeElement;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Subscribe to route params to sync tab state with URL
|
||||
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||
const tab = params.get('tab') as TNetworkTab;
|
||||
if (tab && ['proxy', 'dns', 'domains'].includes(tab)) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
});
|
||||
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSub?.unsubscribe();
|
||||
this.networkLogStream.disconnect();
|
||||
}
|
||||
|
||||
setTab(tab: TNetworkTab): void {
|
||||
this.router.navigate(['/network', tab]);
|
||||
}
|
||||
|
||||
async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [targetsResponse, statsResponse] = await Promise.all([
|
||||
this.api.getNetworkTargets(),
|
||||
this.api.getNetworkStats(),
|
||||
]);
|
||||
|
||||
if (targetsResponse.success && targetsResponse.data) {
|
||||
this.targets.set(targetsResponse.data);
|
||||
}
|
||||
|
||||
if (statsResponse.success && statsResponse.data) {
|
||||
this.stats.set(statsResponse.data);
|
||||
}
|
||||
} catch (err) {
|
||||
this.toast.error('Failed to load network data');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onTargetClick(target: INetworkTarget): void {
|
||||
if (target.domain) {
|
||||
this.activeFilter.set(target.domain);
|
||||
this.networkLogStream.setFilter({ domain: target.domain });
|
||||
|
||||
// Start streaming if not already
|
||||
if (!this.networkLogStream.isStreaming()) {
|
||||
this.startLogStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearFilter(): void {
|
||||
this.activeFilter.set(null);
|
||||
this.networkLogStream.setFilter(null);
|
||||
}
|
||||
|
||||
startLogStream(): void {
|
||||
const filter = this.activeFilter() ? { domain: this.activeFilter()! } : undefined;
|
||||
this.networkLogStream.connect(filter);
|
||||
}
|
||||
|
||||
stopLogStream(): void {
|
||||
this.networkLogStream.disconnect();
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.networkLogStream.clearLogs();
|
||||
}
|
||||
|
||||
getTypeVariant(type: string): 'default' | 'secondary' | 'outline' {
|
||||
switch (type) {
|
||||
case 'service': return 'default';
|
||||
case 'registry': return 'secondary';
|
||||
case 'platform': return 'outline';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'running': return 'success';
|
||||
case 'stopped': return 'secondary';
|
||||
case 'failed': return 'destructive';
|
||||
case 'starting':
|
||||
case 'stopping': return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
getLogClass(status: number): string {
|
||||
if (status >= 500) return 'text-red-400';
|
||||
if (status >= 400) return 'text-yellow-400';
|
||||
if (status >= 300) return 'text-blue-400';
|
||||
return 'text-green-400';
|
||||
}
|
||||
|
||||
formatLog(log: ICaddyAccessLog): string {
|
||||
const time = new Date(log.ts * 1000).toLocaleTimeString();
|
||||
const duration = log.duration < 1 ? `${(log.duration * 1000).toFixed(1)}ms` : `${log.duration.toFixed(2)}s`;
|
||||
const size = this.formatBytes(log.size);
|
||||
const method = log.request.method.padEnd(7);
|
||||
const status = String(log.status).padStart(3);
|
||||
const host = log.request.host.substring(0, 30).padEnd(30);
|
||||
const uri = log.request.uri.substring(0, 40);
|
||||
|
||||
return `${time} ${status} ${method} ${host} ${uri.padEnd(40)} ${duration.padStart(8)} ${size.padStart(8)} ${log.request.remote_ip}`;
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
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 { 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';
|
||||
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component';
|
||||
|
||||
type TRegistriesTab = 'onebox' | 'external';
|
||||
|
||||
@Component({
|
||||
selector: 'app-registries',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
TabsComponent,
|
||||
TabComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Registries</h1>
|
||||
<p class="text-muted-foreground">Manage container image registries</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ui-tabs class="block">
|
||||
<ui-tab [active]="activeTab() === 'onebox'" (tabClick)="setTab('onebox')">Onebox Registry</ui-tab>
|
||||
<ui-tab [active]="activeTab() === 'external'" (tabClick)="setTab('external')">External Registries</ui-tab>
|
||||
</ui-tabs>
|
||||
|
||||
<!-- Tab Content -->
|
||||
@switch (activeTab()) {
|
||||
@case ('onebox') {
|
||||
<!-- Onebox Registry Card -->
|
||||
<ui-card class="border-primary/50">
|
||||
<ui-card-header class="flex flex-row items-start justify-between space-y-0">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 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>
|
||||
<ui-card-title>Onebox Registry (Built-in)</ui-card-title>
|
||||
<ui-badge>Default</ui-badge>
|
||||
</div>
|
||||
<ui-card-description>Built-in container registry for your services</ui-card-description>
|
||||
</div>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="h-2 w-2 rounded-full bg-success animate-pulse"></span>
|
||||
<span class="font-medium text-success">Running</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground">Registry URL</div>
|
||||
<div class="font-mono text-sm mt-1">localhost:3000/v2</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground">Authentication</div>
|
||||
<div class="mt-1">
|
||||
<a routerLink="/tokens" class="text-primary hover:underline text-sm">
|
||||
Manage Tokens
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-muted rounded-lg">
|
||||
<h4 class="font-medium mb-2">Quick Start</h4>
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
To push images to the Onebox registry, use a CI or Global token:
|
||||
</p>
|
||||
<div class="font-mono text-xs bg-background p-3 rounded border overflow-x-auto">
|
||||
<div class="text-muted-foreground"># Login to the registry</div>
|
||||
<div>docker login localhost:3000 -u onebox -p YOUR_TOKEN</div>
|
||||
<div class="mt-2 text-muted-foreground"># Tag and push your image</div>
|
||||
<div>docker tag myapp localhost:3000/myservice:latest</div>
|
||||
<div>docker push localhost:3000/myservice:latest</div>
|
||||
</div>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
@case ('external') {
|
||||
<!-- External Registries Section -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">External Registries</h2>
|
||||
<p class="text-sm text-muted-foreground">Add credentials for private Docker registries</p>
|
||||
</div>
|
||||
<button uiButton variant="outline" (click)="addDialogOpen.set(true)">
|
||||
Add Registry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && registries().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (registries().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<svg class="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium">No external registries</h3>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Add credentials for Docker Hub, GitHub Container Registry, or other private registries.
|
||||
</p>
|
||||
<button uiButton variant="outline" class="mt-4" (click)="addDialogOpen.set(true)">
|
||||
Add External Registry
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Registry URL</ui-table-head>
|
||||
<ui-table-head>Username</ui-table-head>
|
||||
<ui-table-head>Added</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) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ registry.url }}</ui-table-cell>
|
||||
<ui-table-cell>{{ registry.username }}</ui-table-cell>
|
||||
<ui-table-cell>{{ formatDate(registry.createdAt) }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(registry)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add Registry Dialog -->
|
||||
<ui-dialog [open]="addDialogOpen()" (openChange)="addDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Add External Registry</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Add credentials for a private Docker registry
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Registry URL</label>
|
||||
<input uiInput [(ngModel)]="form.url" placeholder="registry.example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Username</label>
|
||||
<input uiInput [(ngModel)]="form.username" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="form.password" />
|
||||
</div>
|
||||
</div>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="addDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton (click)="addRegistry()" [disabled]="loading()">Add Registry</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<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, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private routeSub?: Subscription;
|
||||
|
||||
activeTab = signal<TRegistriesTab>('onebox');
|
||||
registries = signal<IRegistry[]>([]);
|
||||
loading = signal(false);
|
||||
addDialogOpen = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
registryToDelete = signal<IRegistry | null>(null);
|
||||
|
||||
form: IRegistryCreate = { url: '', username: '', password: '' };
|
||||
|
||||
setTab(tab: TRegistriesTab): void {
|
||||
this.router.navigate(['/registries', tab]);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Subscribe to route params to sync tab state with URL
|
||||
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||
const tab = params.get('tab') as TRegistriesTab;
|
||||
if (tab && ['onebox', 'external'].includes(tab)) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
});
|
||||
|
||||
this.loadRegistries();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSub?.unsubscribe();
|
||||
}
|
||||
|
||||
async loadRegistries(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getRegistries();
|
||||
if (response.success && response.data) {
|
||||
this.registries.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load registries');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async addRegistry(): Promise<void> {
|
||||
if (!this.form.url || !this.form.username || !this.form.password) {
|
||||
this.toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.createRegistry(this.form);
|
||||
if (response.success) {
|
||||
this.toast.success('Registry added');
|
||||
this.form = { url: '', username: '', password: '' };
|
||||
this.addDialogOpen.set(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(registry: IRegistry): void {
|
||||
this.registryToDelete.set(registry);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteRegistry(): Promise<void> {
|
||||
const registry = this.registryToDelete();
|
||||
if (!registry?.id) return;
|
||||
|
||||
try {
|
||||
const response = await this.api.deleteRegistry(registry.id);
|
||||
if (response.success) {
|
||||
this.toast.success('Registry deleted');
|
||||
this.loadRegistries();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete registry');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete registry');
|
||||
} finally {
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.registryToDelete.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,432 +0,0 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy, effect, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { WebSocketService } from '../../core/services/websocket.service';
|
||||
import { LogStreamService } from '../../core/services/log-stream.service';
|
||||
import { IPlatformService, IContainerStats, TPlatformServiceType } from '../../core/types/api.types';
|
||||
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-service-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
SkeletonComponent,
|
||||
ContainerStatsComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<a (click)="goBack()" class="cursor-pointer text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Services
|
||||
</a>
|
||||
|
||||
@if (loading() && !service()) {
|
||||
<ui-skeleton class="h-9 w-48" />
|
||||
} @else if (service()) {
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ service()!.displayName }}</h1>
|
||||
<ui-badge [variant]="getStatusVariant(service()!.status)">{{ service()!.status }}</ui-badge>
|
||||
@if (service()!.isCore) {
|
||||
<ui-badge variant="outline">Core Service</ui-badge>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading() && !service()) {
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-skeleton class="h-6 w-32" />
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-4 w-full" />
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
} @else if (service()) {
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Service Details -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Service Details</ui-card-title>
|
||||
<ui-card-description>Platform service information</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-muted-foreground">Type</dt>
|
||||
<dd class="text-sm">{{ service()!.type }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-muted-foreground">Resource Types</dt>
|
||||
<dd class="text-sm">
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
@for (type of service()!.resourceTypes; track type) {
|
||||
<ui-badge variant="outline">{{ type }}</ui-badge>
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
@if (service()!.containerId) {
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-muted-foreground">Container ID</dt>
|
||||
<dd class="text-sm font-mono">{{ service()!.containerId?.slice(0, 12) }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (service()!.createdAt) {
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-muted-foreground">Created</dt>
|
||||
<dd class="text-sm">{{ formatDate(service()!.createdAt!) }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Actions -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Actions</ui-card-title>
|
||||
<ui-card-description>Manage platform service state</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
@if (service()!.isCore) {
|
||||
<p class="text-sm text-muted-foreground">
|
||||
This is a core service managed by Onebox. It cannot be stopped manually.
|
||||
</p>
|
||||
} @else {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if (service()!.status === 'stopped' || service()!.status === 'not-deployed' || service()!.status === 'failed') {
|
||||
<button uiButton (click)="startService()" [disabled]="actionLoading()">
|
||||
@if (actionLoading()) {
|
||||
<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>
|
||||
}
|
||||
Start Service
|
||||
</button>
|
||||
}
|
||||
@if (service()!.status === 'running') {
|
||||
<button uiButton variant="outline" (click)="stopService()" [disabled]="actionLoading()">
|
||||
Stop Service
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Resource Stats (only shown when service is running) -->
|
||||
@if (service()!.status === 'running') {
|
||||
<app-container-stats [stats]="stats()" [showLiveIndicator]="true" />
|
||||
}
|
||||
|
||||
<!-- Service Description -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>About {{ service()!.displayName }}</ui-card-title>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<p class="text-sm text-muted-foreground">{{ getServiceDescription(service()!.type) }}</p>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Logs Section -->
|
||||
@if (service()!.status === 'running') {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Logs</ui-card-title>
|
||||
<ui-card-description>
|
||||
@if (logStream.isStreaming()) {
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live streaming
|
||||
</span>
|
||||
} @else {
|
||||
Container logs
|
||||
}
|
||||
</ui-card-description>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (logStream.isStreaming()) {
|
||||
<button uiButton variant="outline" size="sm" (click)="stopLogStream()">
|
||||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
} @else {
|
||||
<button uiButton variant="outline" size="sm" (click)="startLogStream()">
|
||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Stream
|
||||
</button>
|
||||
}
|
||||
<button uiButton variant="ghost" size="sm" (click)="clearLogs()" title="Clear logs">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<label class="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" [(ngModel)]="autoScroll" class="rounded border-input" />
|
||||
Auto-scroll
|
||||
</label>
|
||||
</div>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div
|
||||
#logContainer
|
||||
class="bg-zinc-950 text-zinc-100 rounded-md p-4 h-96 overflow-auto font-mono text-xs"
|
||||
>
|
||||
@if (logStream.state().error) {
|
||||
<p class="text-red-400">Error: {{ logStream.state().error }}</p>
|
||||
} @else if (logStream.logs().length > 0) {
|
||||
@for (line of logStream.logs(); track $index) {
|
||||
<div class="whitespace-pre-wrap hover:bg-zinc-800/50">{{ line }}</div>
|
||||
}
|
||||
} @else if (logStream.isStreaming()) {
|
||||
<p class="text-zinc-500">Waiting for logs...</p>
|
||||
} @else {
|
||||
<p class="text-zinc-500">Click "Stream" to start live log streaming</p>
|
||||
}
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
|
||||
private location = inject(Location);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
private ws = inject(WebSocketService);
|
||||
logStream = inject(LogStreamService);
|
||||
|
||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
service = signal<IPlatformService | null>(null);
|
||||
stats = signal<IContainerStats | null>(null);
|
||||
loading = signal(false);
|
||||
actionLoading = signal(false);
|
||||
autoScroll = true;
|
||||
|
||||
private statsInterval: any;
|
||||
|
||||
constructor() {
|
||||
// Listen for WebSocket stats updates for platform services
|
||||
effect(() => {
|
||||
const update = this.ws.statsUpdate();
|
||||
const currentService = this.service();
|
||||
// Platform services use "onebox-{type}" as service name in WebSocket
|
||||
if (update && currentService && update.serviceName === `onebox-${currentService.type}`) {
|
||||
this.stats.set(update.stats);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-scroll when new logs arrive
|
||||
effect(() => {
|
||||
const logs = this.logStream.logs();
|
||||
if (logs.length > 0 && this.autoScroll && this.logContainer?.nativeElement) {
|
||||
setTimeout(() => {
|
||||
this.logContainer.nativeElement.scrollTop = this.logContainer.nativeElement.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const type = this.route.snapshot.paramMap.get('type') as TPlatformServiceType;
|
||||
if (type) {
|
||||
this.loadService(type);
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
async loadService(type: TPlatformServiceType): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getPlatformService(type);
|
||||
if (response.success && response.data) {
|
||||
this.service.set(response.data);
|
||||
// Load stats if service is running
|
||||
if (response.data.status === 'running') {
|
||||
this.loadStats(type);
|
||||
// Start polling stats every 5 seconds
|
||||
this.startStatsPolling(type);
|
||||
}
|
||||
} else {
|
||||
this.toast.error(response.error || 'Platform service not found');
|
||||
this.router.navigate(['/services']);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load platform service');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadStats(type: TPlatformServiceType): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getPlatformServiceStats(type);
|
||||
if (response.success && response.data) {
|
||||
this.stats.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - stats are optional
|
||||
}
|
||||
}
|
||||
|
||||
startStatsPolling(type: TPlatformServiceType): void {
|
||||
// Clear existing interval if any
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
}
|
||||
// Poll every 5 seconds
|
||||
this.statsInterval = setInterval(() => {
|
||||
if (this.service()?.status === 'running') {
|
||||
this.loadStats(type);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'running': return 'success';
|
||||
case 'stopped':
|
||||
case 'not-deployed': return 'secondary';
|
||||
case 'failed': return 'destructive';
|
||||
case 'starting':
|
||||
case 'stopping': return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
getServiceDescription(type: TPlatformServiceType): string {
|
||||
const descriptions: Record<TPlatformServiceType, string> = {
|
||||
mongodb: 'MongoDB is a document-oriented NoSQL database used for high volume data storage. It stores data in flexible, JSON-like documents.',
|
||||
minio: 'MinIO is a high-performance, S3-compatible object storage service. Use it to store unstructured data like photos, videos, log files, and backups.',
|
||||
redis: 'Redis is an in-memory data structure store, used as a distributed cache, message broker, and key-value database.',
|
||||
postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.',
|
||||
rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.',
|
||||
caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.',
|
||||
clickhouse: 'ClickHouse is a fast, open-source columnar database management system optimized for real-time analytics and data warehousing.',
|
||||
};
|
||||
return descriptions[type] || 'A platform service managed by Onebox.';
|
||||
}
|
||||
|
||||
async startService(): Promise<void> {
|
||||
const type = this.service()?.type;
|
||||
if (!type) return;
|
||||
|
||||
this.actionLoading.set(true);
|
||||
try {
|
||||
const response = await this.api.startPlatformService(type);
|
||||
if (response.success) {
|
||||
this.toast.success('Platform service started');
|
||||
this.loadService(type);
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to start platform service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to start platform service');
|
||||
} finally {
|
||||
this.actionLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async stopService(): Promise<void> {
|
||||
const type = this.service()?.type;
|
||||
if (!type) return;
|
||||
|
||||
this.actionLoading.set(true);
|
||||
try {
|
||||
const response = await this.api.stopPlatformService(type);
|
||||
if (response.success) {
|
||||
this.toast.success('Platform service stopped');
|
||||
// Clear stats and stop polling
|
||||
this.stats.set(null);
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
this.statsInterval = null;
|
||||
}
|
||||
this.loadService(type);
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to stop platform service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to stop platform service');
|
||||
} finally {
|
||||
this.actionLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.logStream.disconnect();
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
}
|
||||
}
|
||||
|
||||
startLogStream(): void {
|
||||
const type = this.service()?.type;
|
||||
if (type) {
|
||||
this.logStream.connectPlatform(type);
|
||||
}
|
||||
}
|
||||
|
||||
stopLogStream(): void {
|
||||
this.logStream.disconnect();
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.logStream.clearLogs();
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IServiceCreate, IDomainDetail } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { CheckboxComponent } from '../../ui/checkbox/checkbox.component';
|
||||
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
|
||||
import { SeparatorComponent } from '../../ui/separator/separator.component';
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-service-create',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
CheckboxComponent,
|
||||
AlertComponent,
|
||||
AlertDescriptionComponent,
|
||||
SeparatorComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Services
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Deploy Service</h1>
|
||||
<p class="text-muted-foreground">Deploy a new Docker service</p>
|
||||
</div>
|
||||
|
||||
<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
|
||||
uiInput
|
||||
id="name"
|
||||
[(ngModel)]="form.name"
|
||||
name="name"
|
||||
placeholder="my-service"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="image">Docker Image</label>
|
||||
<input
|
||||
uiInput
|
||||
id="image"
|
||||
[(ngModel)]="form.image"
|
||||
name="image"
|
||||
placeholder="nginx:latest"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">e.g., nginx:latest, registry.example.com/image:tag</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="port">Container Port</label>
|
||||
<input
|
||||
uiInput
|
||||
id="port"
|
||||
type="number"
|
||||
[(ngModel)]="form.port"
|
||||
name="port"
|
||||
placeholder="80"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="domain">Domain (optional)</label>
|
||||
<input
|
||||
uiInput
|
||||
id="domain"
|
||||
[(ngModel)]="form.domain"
|
||||
name="domain"
|
||||
placeholder="app.example.com"
|
||||
list="domains-list"
|
||||
/>
|
||||
<datalist id="domains-list">
|
||||
@for (d of domains(); track d.domain.domain) {
|
||||
<option [value]="d.domain.domain">{{ d.domain.domain }}</option>
|
||||
}
|
||||
</datalist>
|
||||
@if (domainWarning()) {
|
||||
<ui-alert variant="warning" class="mt-2">
|
||||
<ui-alert-description>{{ domainWarning() }}</ui-alert-description>
|
||||
</ui-alert>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-separator />
|
||||
|
||||
<!-- Environment Variables -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">Environment Variables</h3>
|
||||
<p class="text-xs text-muted-foreground">Configure environment variables for your service</p>
|
||||
</div>
|
||||
<button uiButton variant="outline" size="sm" type="button" (click)="addEnvVar()">
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (envVars().length > 0) {
|
||||
<div class="space-y-2">
|
||||
@for (env of envVars(); track $index; let i = $index) {
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
uiInput
|
||||
[(ngModel)]="env.key"
|
||||
[name]="'env-key-' + i"
|
||||
placeholder="KEY"
|
||||
class="flex-1"
|
||||
/>
|
||||
<input
|
||||
uiInput
|
||||
[(ngModel)]="env.value"
|
||||
[name]="'env-value-' + i"
|
||||
placeholder="value"
|
||||
class="flex-1"
|
||||
/>
|
||||
<button
|
||||
uiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
(click)="removeEnvVar(i)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ui-separator />
|
||||
|
||||
<!-- Platform Services -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">Platform Services</h3>
|
||||
<p class="text-xs text-muted-foreground">Enable managed infrastructure for your service</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<ui-checkbox
|
||||
[checked]="form.enableMongoDB ?? false"
|
||||
(checkedChange)="form.enableMongoDB = $event"
|
||||
/>
|
||||
<div>
|
||||
<label uiLabel class="cursor-pointer">MongoDB Database</label>
|
||||
<p class="text-xs text-muted-foreground">A dedicated database will be created and credentials injected as MONGODB_URI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<ui-checkbox
|
||||
[checked]="form.enableS3 ?? false"
|
||||
(checkedChange)="form.enableS3 = $event"
|
||||
/>
|
||||
<div>
|
||||
<label uiLabel class="cursor-pointer">S3 Storage (MinIO)</label>
|
||||
<p class="text-xs text-muted-foreground">A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<ui-checkbox
|
||||
[checked]="form.enableClickHouse ?? false"
|
||||
(checkedChange)="form.enableClickHouse = $event"
|
||||
/>
|
||||
<div>
|
||||
<label uiLabel class="cursor-pointer">ClickHouse Database</label>
|
||||
<p class="text-xs text-muted-foreground">A dedicated database will be created and credentials injected as CLICKHOUSE_* env vars</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form.enableMongoDB || form.enableS3 || form.enableClickHouse) {
|
||||
<ui-alert variant="default">
|
||||
<ui-alert-description>
|
||||
Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables.
|
||||
</ui-alert-description>
|
||||
</ui-alert>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ui-separator />
|
||||
|
||||
<!-- Onebox Registry -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<ui-checkbox
|
||||
[checked]="form.useOneboxRegistry ?? false"
|
||||
(checkedChange)="form.useOneboxRegistry = $event"
|
||||
/>
|
||||
<div>
|
||||
<label uiLabel class="cursor-pointer">Use Onebox Registry</label>
|
||||
<p class="text-xs text-muted-foreground">Push images directly to this Onebox instance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form.useOneboxRegistry) {
|
||||
<div class="pl-7 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="registryImageTag">Image Tag</label>
|
||||
<input
|
||||
uiInput
|
||||
id="registryImageTag"
|
||||
[(ngModel)]="form.registryImageTag"
|
||||
name="registryImageTag"
|
||||
placeholder="latest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<ui-checkbox
|
||||
[checked]="form.autoUpdateOnPush ?? false"
|
||||
(checkedChange)="form.autoUpdateOnPush = $event"
|
||||
/>
|
||||
<label uiLabel class="cursor-pointer">Auto-restart on push</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ui-card-content>
|
||||
<ui-card-footer class="flex justify-between">
|
||||
<a routerLink="/services">
|
||||
<button uiButton variant="outline" type="button">Cancel</button>
|
||||
</a>
|
||||
<button uiButton type="submit" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Deploying...
|
||||
} @else {
|
||||
Deploy Service
|
||||
}
|
||||
</button>
|
||||
</ui-card-footer>
|
||||
</ui-card>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ServiceCreateComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private router = inject(Router);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
form: IServiceCreate = {
|
||||
name: '',
|
||||
image: '',
|
||||
port: 80,
|
||||
domain: '',
|
||||
useOneboxRegistry: false,
|
||||
registryImageTag: 'latest',
|
||||
autoUpdateOnPush: false,
|
||||
enableMongoDB: false,
|
||||
enableS3: false,
|
||||
enableClickHouse: false,
|
||||
};
|
||||
|
||||
envVars = signal<EnvVar[]>([]);
|
||||
domains = signal<IDomainDetail[]>([]);
|
||||
loading = signal(false);
|
||||
domainWarning = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDomains();
|
||||
}
|
||||
|
||||
async loadDomains(): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getDomains();
|
||||
if (response.success && response.data) {
|
||||
this.domains.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - domain autocomplete is optional
|
||||
}
|
||||
}
|
||||
|
||||
addEnvVar(): void {
|
||||
this.envVars.update(vars => [...vars, { key: '', value: '' }]);
|
||||
}
|
||||
|
||||
removeEnvVar(index: number): void {
|
||||
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;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
|
||||
// Build env vars object
|
||||
const envVarsObj: Record<string, string> = {};
|
||||
for (const env of this.envVars()) {
|
||||
if (env.key && env.value) {
|
||||
envVarsObj[env.key] = env.value;
|
||||
}
|
||||
}
|
||||
|
||||
const data: IServiceCreate = {
|
||||
...this.form,
|
||||
envVars: Object.keys(envVarsObj).length > 0 ? envVarsObj : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.api.createService(data);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${this.form.name}" deployed successfully`);
|
||||
this.router.navigate(['/services']);
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to deploy service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to deploy service');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,771 +0,0 @@
|
||||
import { Component, inject, signal, effect, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { WebSocketService } from '../../core/services/websocket.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IService, IPlatformService, TPlatformServiceType } from '../../core/types/api.types';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
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';
|
||||
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component';
|
||||
import { BackupsTabComponent } from './backups-tab.component';
|
||||
|
||||
type TServicesTab = 'user' | 'system' | 'backups';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
TabsComponent,
|
||||
TabComponent,
|
||||
BackupsTabComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Services</h1>
|
||||
<p class="text-muted-foreground">Manage your deployed and system services</p>
|
||||
</div>
|
||||
@if (activeTab() === 'user') {
|
||||
<div class="flex items-center gap-2">
|
||||
<button uiButton variant="outline" (click)="openImportDialog()">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Import Backup
|
||||
</button>
|
||||
<a [routerLink]="['/services/create']">
|
||||
<button uiButton>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Deploy Service
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ui-tabs class="block">
|
||||
<ui-tab [active]="activeTab() === 'user'" (tabClick)="setTab('user')">User Services</ui-tab>
|
||||
<ui-tab [active]="activeTab() === 'system'" (tabClick)="setTab('system')">System Services</ui-tab>
|
||||
<ui-tab [active]="activeTab() === 'backups'" (tabClick)="setTab('backups')">Backups</ui-tab>
|
||||
</ui-tabs>
|
||||
|
||||
<!-- Tab Content -->
|
||||
@switch (activeTab()) {
|
||||
@case ('user') {
|
||||
<!-- User Services Table -->
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && services().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (services().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-semibold">No services</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
|
||||
<a [routerLink]="['/services/create']" class="mt-4 inline-block">
|
||||
<button uiButton>Deploy Service</button>
|
||||
</a>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Image</ui-table-head>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (service of services(); track service.name) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell>
|
||||
<a [routerLink]="['/services/detail', service.name]" class="font-medium hover:underline">
|
||||
{{ service.name }}
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-muted-foreground">{{ service.image }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (service.domain) {
|
||||
<a [href]="'https://' + service.domain" target="_blank" class="text-primary hover:underline">
|
||||
{{ service.domain }}
|
||||
</a>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">-</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getStatusVariant(service.status)">
|
||||
{{ service.status }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
@if (service.status === 'stopped' || service.status === 'failed') {
|
||||
<button
|
||||
uiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
(click)="startService(service.name)"
|
||||
[disabled]="actionLoading() === service.name"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
}
|
||||
@if (service.status === 'running') {
|
||||
<button
|
||||
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
|
||||
uiButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
(click)="confirmDelete(service)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
@case ('system') {
|
||||
<!-- System Services Table -->
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (platformLoading() && platformServices().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (platformServices().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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-semibold">No system services</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">System services will appear here once configured.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Service</ui-table-head>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (service of platformServices(); track service.type) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<a [routerLink]="['/services/platform', service.type]" class="font-medium hover:underline">
|
||||
{{ service.displayName }}
|
||||
</a>
|
||||
@if (service.isCore) {
|
||||
<ui-badge variant="outline">Core</ui-badge>
|
||||
}
|
||||
</div>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-muted-foreground">{{ service.type }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getPlatformStatusVariant(service.status)">
|
||||
{{ service.status }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
@if (service.isCore) {
|
||||
<span class="text-xs text-muted-foreground">Managed by Onebox</span>
|
||||
} @else {
|
||||
@if (service.status === 'stopped' || service.status === 'not-deployed' || service.status === 'failed') {
|
||||
<button
|
||||
uiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
(click)="startPlatformService(service.type)"
|
||||
[disabled]="platformActionLoading() === service.type"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
}
|
||||
@if (service.status === 'running') {
|
||||
<button
|
||||
uiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
(click)="stopPlatformService(service.type)"
|
||||
[disabled]="platformActionLoading() === service.type"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
@case ('backups') {
|
||||
<app-backups-tab />
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- Import Backup Dialog -->
|
||||
<ui-dialog [open]="importDialogOpen()" (openChange)="importDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Import Backup</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Import a backup file to create a new service. The backup will be decrypted and the service restored.
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<div class="space-y-4 py-4">
|
||||
<!-- Import Mode Tabs -->
|
||||
<div class="flex border-b">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
[class.border-primary]="importMode() === 'file'"
|
||||
[class.text-primary]="importMode() === 'file'"
|
||||
[class.border-transparent]="importMode() !== 'file'"
|
||||
[class.text-muted-foreground]="importMode() !== 'file'"
|
||||
(click)="importMode.set('file')"
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
[class.border-primary]="importMode() === 'url'"
|
||||
[class.text-primary]="importMode() === 'url'"
|
||||
[class.border-transparent]="importMode() !== 'url'"
|
||||
[class.text-muted-foreground]="importMode() !== 'url'"
|
||||
(click)="importMode.set('url')"
|
||||
>
|
||||
From URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (importMode() === 'file') {
|
||||
<!-- File Upload -->
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Backup File</label>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
[class.border-primary]="importFile()"
|
||||
(click)="fileInput.click()"
|
||||
(dragover)="onDragOver($event)"
|
||||
(drop)="onFileDrop($event)"
|
||||
>
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
accept=".tar.enc"
|
||||
class="hidden"
|
||||
(change)="onFileSelect($event)"
|
||||
/>
|
||||
@if (importFile()) {
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="font-medium">{{ importFile()?.name }}</span>
|
||||
<span class="text-muted-foreground text-sm">({{ formatFileSize(importFile()?.size || 0) }})</span>
|
||||
</div>
|
||||
} @else {
|
||||
<svg class="mx-auto h-10 w-10 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Click to select or drag and drop</p>
|
||||
<p class="text-xs text-muted-foreground">Accepts .tar.enc files</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- URL Input -->
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Backup URL</label>
|
||||
<input
|
||||
uiInput
|
||||
type="url"
|
||||
placeholder="https://example.com/backup.tar.enc"
|
||||
[value]="importUrl()"
|
||||
(input)="importUrl.set($any($event.target).value)"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">URL to a .tar.enc backup file</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Service Name -->
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Service Name (optional)</label>
|
||||
<input
|
||||
uiInput
|
||||
type="text"
|
||||
placeholder="my-service"
|
||||
[value]="importServiceName()"
|
||||
(input)="importServiceName.set($any($event.target).value)"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Leave empty to use the name from the backup manifest</p>
|
||||
</div>
|
||||
</div>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="closeImportDialog()">Cancel</button>
|
||||
<button
|
||||
uiButton
|
||||
(click)="importBackup()"
|
||||
[disabled]="importLoading() || !isImportValid()"
|
||||
>
|
||||
@if (importLoading()) {
|
||||
<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>
|
||||
Importing...
|
||||
} @else {
|
||||
Import
|
||||
}
|
||||
</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class ServicesListComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
private ws = inject(WebSocketService);
|
||||
private toast = inject(ToastService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private routeSub?: Subscription;
|
||||
|
||||
// Tab state
|
||||
activeTab = signal<TServicesTab>('user');
|
||||
|
||||
// User services
|
||||
services = signal<IService[]>([]);
|
||||
loading = signal(false);
|
||||
actionLoading = signal<string | null>(null);
|
||||
deleteDialogOpen = signal(false);
|
||||
serviceToDelete = signal<IService | null>(null);
|
||||
|
||||
// Platform services
|
||||
platformServices = signal<IPlatformService[]>([]);
|
||||
platformLoading = signal(false);
|
||||
platformActionLoading = signal<TPlatformServiceType | null>(null);
|
||||
|
||||
// Import dialog
|
||||
importDialogOpen = signal(false);
|
||||
importMode = signal<'file' | 'url'>('file');
|
||||
importFile = signal<File | null>(null);
|
||||
importUrl = signal('');
|
||||
importServiceName = signal('');
|
||||
importLoading = signal(false);
|
||||
|
||||
constructor() {
|
||||
// React to WebSocket updates
|
||||
effect(() => {
|
||||
const update = this.ws.serviceUpdates();
|
||||
const status = this.ws.serviceStatus();
|
||||
if (update || status) {
|
||||
this.loadServices();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Subscribe to route params to sync tab state with URL
|
||||
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||
const tab = params.get('tab') as TServicesTab;
|
||||
if (tab && ['user', 'system', 'backups'].includes(tab)) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
});
|
||||
|
||||
this.loadServices();
|
||||
this.loadPlatformServices();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSub?.unsubscribe();
|
||||
}
|
||||
|
||||
setTab(tab: TServicesTab): void {
|
||||
this.router.navigate(['/services', tab]);
|
||||
}
|
||||
|
||||
async loadServices(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getServices();
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load services');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'success';
|
||||
case 'stopped':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
async startService(name: string): Promise<void> {
|
||||
this.actionLoading.set(name);
|
||||
try {
|
||||
const response = await this.api.startService(name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${name}" started`);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to start service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to start service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async stopService(name: string): Promise<void> {
|
||||
this.actionLoading.set(name);
|
||||
try {
|
||||
const response = await this.api.stopService(name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${name}" stopped`);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to stop service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to stop service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async restartService(name: string): Promise<void> {
|
||||
this.actionLoading.set(name);
|
||||
try {
|
||||
const response = await this.api.restartService(name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${name}" restarted`);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to restart service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to restart service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(service: IService): void {
|
||||
this.serviceToDelete.set(service);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteService(): Promise<void> {
|
||||
const service = this.serviceToDelete();
|
||||
if (!service) return;
|
||||
|
||||
this.actionLoading.set(service.name);
|
||||
try {
|
||||
const response = await this.api.deleteService(service.name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${service.name}" deleted`);
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
this.serviceToDelete.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Platform Service Methods
|
||||
async loadPlatformServices(): Promise<void> {
|
||||
this.platformLoading.set(true);
|
||||
try {
|
||||
const response = await this.api.getPlatformServices();
|
||||
if (response.success && response.data) {
|
||||
this.platformServices.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load system services');
|
||||
} finally {
|
||||
this.platformLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
getPlatformStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'success';
|
||||
case 'stopped':
|
||||
case 'not-deployed':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
async startPlatformService(type: TPlatformServiceType): Promise<void> {
|
||||
this.platformActionLoading.set(type);
|
||||
try {
|
||||
const response = await this.api.startPlatformService(type);
|
||||
if (response.success) {
|
||||
this.toast.success(`Platform service "${type}" started`);
|
||||
this.loadPlatformServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to start platform service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to start platform service');
|
||||
} finally {
|
||||
this.platformActionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async stopPlatformService(type: TPlatformServiceType): Promise<void> {
|
||||
this.platformActionLoading.set(type);
|
||||
try {
|
||||
const response = await this.api.stopPlatformService(type);
|
||||
if (response.success) {
|
||||
this.toast.success(`Platform service "${type}" stopped`);
|
||||
this.loadPlatformServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to stop platform service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to stop platform service');
|
||||
} finally {
|
||||
this.platformActionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Import Dialog Methods
|
||||
openImportDialog(): void {
|
||||
this.importDialogOpen.set(true);
|
||||
this.importMode.set('file');
|
||||
this.importFile.set(null);
|
||||
this.importUrl.set('');
|
||||
this.importServiceName.set('');
|
||||
}
|
||||
|
||||
closeImportDialog(): void {
|
||||
this.importDialogOpen.set(false);
|
||||
this.importFile.set(null);
|
||||
this.importUrl.set('');
|
||||
this.importServiceName.set('');
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onFileDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.tar.enc')) {
|
||||
this.importFile.set(file);
|
||||
} else {
|
||||
this.toast.error('Please select a .tar.enc backup file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFileSelect(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const file = input.files[0];
|
||||
if (file.name.endsWith('.tar.enc')) {
|
||||
this.importFile.set(file);
|
||||
} else {
|
||||
this.toast.error('Please select a .tar.enc backup file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isImportValid(): boolean {
|
||||
if (this.importMode() === 'file') {
|
||||
return this.importFile() !== null;
|
||||
} else {
|
||||
const url = this.importUrl().trim();
|
||||
return url.length > 0 && (url.startsWith('http://') || url.startsWith('https://'));
|
||||
}
|
||||
}
|
||||
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
async importBackup(): Promise<void> {
|
||||
if (!this.isImportValid()) return;
|
||||
|
||||
this.importLoading.set(true);
|
||||
try {
|
||||
let response;
|
||||
const serviceName = this.importServiceName().trim() || undefined;
|
||||
|
||||
if (this.importMode() === 'file') {
|
||||
const file = this.importFile();
|
||||
if (!file) return;
|
||||
response = await this.api.importBackupFromFile(file, serviceName);
|
||||
} else {
|
||||
const url = this.importUrl().trim();
|
||||
response = await this.api.importBackupFromUrl(url, serviceName);
|
||||
}
|
||||
|
||||
if (response.success && response.data) {
|
||||
this.toast.success(`Service "${response.data.service.name}" imported successfully`);
|
||||
this.closeImportDialog();
|
||||
this.loadServices();
|
||||
// Navigate to the new service
|
||||
this.router.navigate(['/services/detail', response.data.service.name]);
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to import backup');
|
||||
}
|
||||
} catch (error) {
|
||||
this.toast.error('Failed to import backup');
|
||||
} finally {
|
||||
this.importLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ISettings } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { SwitchComponent } from '../../ui/switch/switch.component';
|
||||
import { SeparatorComponent } from '../../ui/separator/separator.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
SwitchComponent,
|
||||
SeparatorComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p class="text-muted-foreground">Manage system configuration</p>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="space-y-6">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-skeleton class="h-6 w-48" />
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-skeleton class="h-20 w-full" />
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Appearance -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Appearance</ui-card-title>
|
||||
<ui-card-description>Customize the look and feel</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<label uiLabel>Dark Mode</label>
|
||||
<p class="text-sm text-muted-foreground">Toggle dark mode on or off</p>
|
||||
</div>
|
||||
<ui-switch [ngModel]="theme.isDark()" (ngModelChange)="theme.toggle()" />
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Cloudflare Integration -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Cloudflare Integration</ui-card-title>
|
||||
<ui-card-description>Configure Cloudflare API for DNS management</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>API Token</label>
|
||||
<input
|
||||
uiInput
|
||||
type="password"
|
||||
[(ngModel)]="settings.cloudflareToken"
|
||||
placeholder="Enter Cloudflare API token"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Zone ID (Optional)</label>
|
||||
<input
|
||||
uiInput
|
||||
[(ngModel)]="settings.cloudflareZoneId"
|
||||
placeholder="Default zone ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Get your API token from the Cloudflare dashboard with DNS edit permissions.
|
||||
</p>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- SSL/TLS Settings -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>SSL/TLS Settings</ui-card-title>
|
||||
<ui-card-description>Configure certificate management</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<label uiLabel>Auto-Renew Certificates</label>
|
||||
<p class="text-sm text-muted-foreground">Automatically renew certificates before expiry</p>
|
||||
</div>
|
||||
<ui-switch [(ngModel)]="settings.autoRenewCerts" />
|
||||
</div>
|
||||
<ui-separator />
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Renewal Threshold (days)</label>
|
||||
<input
|
||||
uiInput
|
||||
type="number"
|
||||
[(ngModel)]="settings.renewalThreshold"
|
||||
min="1"
|
||||
max="90"
|
||||
class="w-32"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Renew certificates when they have fewer than this many days remaining.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>ACME Email</label>
|
||||
<input
|
||||
uiInput
|
||||
type="email"
|
||||
[(ngModel)]="settings.acmeEmail"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Email address for Let's Encrypt notifications.
|
||||
</p>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Network Settings -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Network Settings</ui-card-title>
|
||||
<ui-card-description>Configure network and proxy settings</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>HTTP Port</label>
|
||||
<input
|
||||
uiInput
|
||||
type="number"
|
||||
[(ngModel)]="settings.httpPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>HTTPS Port</label>
|
||||
<input
|
||||
uiInput
|
||||
type="number"
|
||||
[(ngModel)]="settings.httpsPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<label uiLabel>Force HTTPS</label>
|
||||
<p class="text-sm text-muted-foreground">Redirect all HTTP traffic to HTTPS</p>
|
||||
</div>
|
||||
<ui-switch [(ngModel)]="settings.forceHttps" />
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Account Settings -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Account</ui-card-title>
|
||||
<ui-card-description>Manage your account settings</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Current User</label>
|
||||
<p class="text-sm font-medium">{{ auth.currentUser()?.username || 'Unknown' }}</p>
|
||||
</div>
|
||||
<ui-separator />
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium">Change Password</h4>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Current Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="passwordForm.current" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>New Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="passwordForm.new" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Confirm Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="passwordForm.confirm" />
|
||||
</div>
|
||||
</div>
|
||||
<button uiButton variant="outline" (click)="changePassword()">
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end gap-4">
|
||||
<button uiButton variant="outline" (click)="loadSettings()">Reset</button>
|
||||
<button uiButton (click)="saveSettings()" [disabled]="saving()">
|
||||
@if (saving()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
} @else {
|
||||
Save Settings
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
protected theme = inject(ThemeService);
|
||||
protected auth = inject(AuthService);
|
||||
|
||||
loading = signal(false);
|
||||
saving = signal(false);
|
||||
|
||||
settings: ISettings = {
|
||||
cloudflareToken: '',
|
||||
cloudflareZoneId: '',
|
||||
autoRenewCerts: true,
|
||||
renewalThreshold: 30,
|
||||
acmeEmail: '',
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
forceHttps: true,
|
||||
};
|
||||
|
||||
passwordForm = {
|
||||
current: '',
|
||||
new: '',
|
||||
confirm: '',
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getSettings();
|
||||
if (response.success && response.data) {
|
||||
this.settings = { ...this.settings, ...response.data };
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load settings');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
this.saving.set(true);
|
||||
try {
|
||||
const response = await this.api.updateSettings(this.settings);
|
||||
if (response.success) {
|
||||
this.toast.success('Settings saved');
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to save settings');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to save settings');
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(): Promise<void> {
|
||||
if (!this.passwordForm.current || !this.passwordForm.new) {
|
||||
this.toast.error('Please fill in all password fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.passwordForm.new !== this.passwordForm.confirm) {
|
||||
this.toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.passwordForm.new.length < 6) {
|
||||
this.toast.error('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.changePassword(
|
||||
this.passwordForm.current,
|
||||
this.passwordForm.new
|
||||
);
|
||||
if (response.success) {
|
||||
this.toast.success('Password changed');
|
||||
this.passwordForm = { current: '', new: '', confirm: '' };
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to change password');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to change password');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
import { Component, inject, signal, OnInit, computed } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IRegistryToken, ICreateTokenRequest, 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 { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
} from '../../ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tokens',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Registry Tokens</h1>
|
||||
<p class="text-muted-foreground">Manage authentication tokens for the Onebox registry</p>
|
||||
</div>
|
||||
<button uiButton (click)="openCreateDialog()">Create Token</button>
|
||||
</div>
|
||||
|
||||
<!-- Global Tokens Section -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Global Tokens</ui-card-title>
|
||||
<ui-card-description>Tokens that can push images to multiple services</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && globalTokens().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (globalTokens().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-muted-foreground">No global tokens created</p>
|
||||
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('global')">
|
||||
Create Global Token
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Scope</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head>Last Used</ui-table-head>
|
||||
<ui-table-head>Created By</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (token of globalTokens(); track token.id) {
|
||||
<ui-table-row [class.opacity-50]="token.isExpired">
|
||||
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge variant="secondary">{{ token.scopeDisplay }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.isExpired) {
|
||||
<ui-badge variant="destructive">Expired</ui-badge>
|
||||
} @else if (token.expiresAt) {
|
||||
{{ formatExpiry(token.expiresAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.lastUsedAt) {
|
||||
{{ formatRelativeTime(token.lastUsedAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- CI Tokens Section -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>CI Tokens (Service-specific)</ui-card-title>
|
||||
<ui-card-description>Tokens tied to individual services for CI/CD pipelines</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && ciTokens().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (ciTokens().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-muted-foreground">No CI tokens created</p>
|
||||
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('ci')">
|
||||
Create CI Token
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Service</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head>Last Used</ui-table-head>
|
||||
<ui-table-head>Created By</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (token of ciTokens(); track token.id) {
|
||||
<ui-table-row [class.opacity-50]="token.isExpired">
|
||||
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||||
<ui-table-cell>{{ token.scopeDisplay }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.isExpired) {
|
||||
<ui-badge variant="destructive">Expired</ui-badge>
|
||||
} @else if (token.expiresAt) {
|
||||
{{ formatExpiry(token.expiresAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.lastUsedAt) {
|
||||
{{ formatRelativeTime(token.lastUsedAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Create Token Dialog -->
|
||||
<ui-dialog [open]="createDialogOpen()" (openChange)="createDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Create Registry Token</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Create a new token for pushing images to the Onebox registry
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Token Name</label>
|
||||
<input uiInput [(ngModel)]="createForm.name" placeholder="e.g., deploy-token" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Token Type</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="type" value="global" [(ngModel)]="createForm.type" class="accent-primary" />
|
||||
<span>Global (multiple services)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="type" value="ci" [(ngModel)]="createForm.type" class="accent-primary" />
|
||||
<span>CI (single service)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (createForm.type === 'global') {
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Scope</label>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input type="checkbox" id="all-services" [(ngModel)]="scopeAll" class="accent-primary" />
|
||||
<label for="all-services" class="cursor-pointer">All services</label>
|
||||
</div>
|
||||
@if (!scopeAll) {
|
||||
<div class="border rounded-md p-3 max-h-48 overflow-y-auto space-y-2">
|
||||
@for (service of services(); track service.name) {
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selectedServices().includes(service.name)"
|
||||
(change)="toggleService(service.name)"
|
||||
class="accent-primary"
|
||||
/>
|
||||
<span>{{ service.name }}</span>
|
||||
</label>
|
||||
}
|
||||
@if (services().length === 0) {
|
||||
<p class="text-sm text-muted-foreground">No services available</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Service</label>
|
||||
<select [(ngModel)]="selectedSingleService" class="w-full p-2 border rounded-md bg-background">
|
||||
<option value="">Select a service</option>
|
||||
@for (service of services(); track service.name) {
|
||||
<option [value]="service.name">{{ service.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Expiration</label>
|
||||
<select [(ngModel)]="createForm.expiresIn" class="w-full p-2 border rounded-md bg-background">
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="365d">365 days</option>
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="createDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton (click)="createToken()" [disabled]="creating()">
|
||||
@if (creating()) {
|
||||
Creating...
|
||||
} @else {
|
||||
Create Token
|
||||
}
|
||||
</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
|
||||
<!-- Token Created Dialog -->
|
||||
<ui-dialog [open]="tokenCreatedDialogOpen()" (openChange)="tokenCreatedDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Token Created</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Copy this token now. You won't be able to see it again!
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<div class="py-4">
|
||||
<div class="p-4 bg-muted rounded-md font-mono text-sm break-all">
|
||||
{{ createdPlainToken() }}
|
||||
</div>
|
||||
</div>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton (click)="copyToken()">Copy Token</button>
|
||||
<button uiButton variant="outline" (click)="tokenCreatedDialogOpen.set(false)">Done</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete Token</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Are you sure you want to delete "{{ tokenToDelete()?.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)="deleteToken()">Delete</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class TokensComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
tokens = signal<IRegistryToken[]>([]);
|
||||
services = signal<IService[]>([]);
|
||||
loading = signal(false);
|
||||
creating = signal(false);
|
||||
|
||||
createDialogOpen = signal(false);
|
||||
tokenCreatedDialogOpen = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
tokenToDelete = signal<IRegistryToken | null>(null);
|
||||
createdPlainToken = signal('');
|
||||
|
||||
// Form state
|
||||
createForm: ICreateTokenRequest = {
|
||||
name: '',
|
||||
type: 'global',
|
||||
scope: 'all',
|
||||
expiresIn: '90d',
|
||||
};
|
||||
scopeAll = true;
|
||||
selectedServices = signal<string[]>([]);
|
||||
selectedSingleService = '';
|
||||
|
||||
// Computed signals for filtered tokens
|
||||
globalTokens = computed(() => this.tokens().filter(t => t.type === 'global'));
|
||||
ciTokens = computed(() => this.tokens().filter(t => t.type === 'ci'));
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTokens();
|
||||
this.loadServices();
|
||||
}
|
||||
|
||||
async loadTokens(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getRegistryTokens();
|
||||
if (response.success && response.data) {
|
||||
this.tokens.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load tokens');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadServices(): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getServices();
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - services list is optional
|
||||
}
|
||||
}
|
||||
|
||||
openCreateDialog(type?: 'global' | 'ci'): void {
|
||||
this.createForm = {
|
||||
name: '',
|
||||
type: type || 'global',
|
||||
scope: 'all',
|
||||
expiresIn: '90d',
|
||||
};
|
||||
this.scopeAll = true;
|
||||
this.selectedServices.set([]);
|
||||
this.selectedSingleService = '';
|
||||
this.createDialogOpen.set(true);
|
||||
}
|
||||
|
||||
toggleService(serviceName: string): void {
|
||||
const current = this.selectedServices();
|
||||
if (current.includes(serviceName)) {
|
||||
this.selectedServices.set(current.filter(s => s !== serviceName));
|
||||
} else {
|
||||
this.selectedServices.set([...current, serviceName]);
|
||||
}
|
||||
}
|
||||
|
||||
async createToken(): Promise<void> {
|
||||
if (!this.createForm.name) {
|
||||
this.toast.error('Please enter a token name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build scope based on type
|
||||
let scope: 'all' | string[];
|
||||
if (this.createForm.type === 'global') {
|
||||
if (this.scopeAll) {
|
||||
scope = 'all';
|
||||
} else {
|
||||
if (this.selectedServices().length === 0) {
|
||||
this.toast.error('Please select at least one service');
|
||||
return;
|
||||
}
|
||||
scope = this.selectedServices();
|
||||
}
|
||||
} else {
|
||||
if (!this.selectedSingleService) {
|
||||
this.toast.error('Please select a service');
|
||||
return;
|
||||
}
|
||||
scope = [this.selectedSingleService];
|
||||
}
|
||||
|
||||
this.creating.set(true);
|
||||
try {
|
||||
const response = await this.api.createRegistryToken({
|
||||
...this.createForm,
|
||||
scope,
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
this.createdPlainToken.set(response.data.plainToken);
|
||||
this.createDialogOpen.set(false);
|
||||
this.tokenCreatedDialogOpen.set(true);
|
||||
this.loadTokens();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to create token');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to create token');
|
||||
} finally {
|
||||
this.creating.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
copyToken(): void {
|
||||
navigator.clipboard.writeText(this.createdPlainToken());
|
||||
this.toast.success('Token copied to clipboard');
|
||||
}
|
||||
|
||||
confirmDelete(token: IRegistryToken): void {
|
||||
this.tokenToDelete.set(token);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteToken(): Promise<void> {
|
||||
const token = this.tokenToDelete();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await this.api.deleteRegistryToken(token.id);
|
||||
if (response.success) {
|
||||
this.toast.success('Token deleted');
|
||||
this.loadTokens();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete token');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete token');
|
||||
} finally {
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.tokenToDelete.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const days = Math.ceil((timestamp - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Tomorrow';
|
||||
if (days < 30) return `${days} days`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatRelativeTime(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { Component, input, computed } from '@angular/core';
|
||||
import { IContainerStats } from '../../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardContentComponent,
|
||||
} from '../../../ui/card/card.component';
|
||||
import { SkeletonComponent } from '../../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-container-stats',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardContentComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<!-- Live indicator -->
|
||||
@if (showLiveIndicator() && stats()) {
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">Live stats</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<!-- CPU -->
|
||||
<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">CPU</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
@if (stats()) {
|
||||
<div class="text-2xl font-bold">{{ formatPercent(stats()!.cpuPercent) }}</div>
|
||||
} @else {
|
||||
<ui-skeleton class="h-8 w-16" />
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Memory -->
|
||||
<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">Memory</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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
@if (stats()) {
|
||||
<div class="text-2xl font-bold">{{ formatBytes(stats()!.memoryUsed) }}</div>
|
||||
<p class="text-xs text-muted-foreground">of {{ formatBytes(stats()!.memoryLimit) }}</p>
|
||||
} @else {
|
||||
<ui-skeleton class="h-8 w-20" />
|
||||
<ui-skeleton class="h-3 w-16 mt-1" />
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Network In -->
|
||||
<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">Network In</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
@if (stats()) {
|
||||
<div class="text-2xl font-bold">{{ formatBytes(stats()!.networkRx) }}</div>
|
||||
} @else {
|
||||
<ui-skeleton class="h-8 w-16" />
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Network Out -->
|
||||
<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">Network Out</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 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
@if (stats()) {
|
||||
<div class="text-2xl font-bold">{{ formatBytes(stats()!.networkTx) }}</div>
|
||||
} @else {
|
||||
<ui-skeleton class="h-8 w-16" />
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ContainerStatsComponent {
|
||||
stats = input<IContainerStats | null>(null);
|
||||
showLiveIndicator = input<boolean>(true);
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatPercent(value: number): string {
|
||||
return value.toFixed(1) + '%';
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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: 'Network', path: '/network', icon: 'activity' },
|
||||
{ label: 'Registries', path: '/registries', icon: 'database' },
|
||||
{ label: 'Tokens', path: '/tokens', icon: 'key' },
|
||||
{ label: 'Settings', path: '/settings', icon: 'settings' },
|
||||
];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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 = 'block p-6';
|
||||
|
||||
get computedClasses(): string {
|
||||
return `${this.baseClasses} ${this.class}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ui-card-title',
|
||||
standalone: true,
|
||||
template: `<ng-content />`,
|
||||
host: {
|
||||
'[class]': 'computedClasses',
|
||||
},
|
||||
styles: [':host { display: block; }'],
|
||||
})
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,65 +0,0 @@
|
||||
// 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';
|
||||
|
||||
// Tabs
|
||||
export { TabsComponent, TabComponent } from './tabs/tabs.component';
|
||||
@@ -1,26 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-tabs',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="border-b border-border">
|
||||
<nav class="flex space-x-2" aria-label="Tabs">
|
||||
<ng-content />
|
||||
</nav>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TabsComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'ui-tab',
|
||||
standalone: true,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[class]="getClass()"
|
||||
(click)="tabClick.emit()"
|
||||
>
|
||||
<ng-content />
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class TabComponent {
|
||||
@Input() active = false;
|
||||
@Output() tabClick = new EventEmitter<void>();
|
||||
|
||||
getClass(): string {
|
||||
const base = 'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors focus:outline-none';
|
||||
if (this.active) {
|
||||
return `${base} border-primary text-foreground`;
|
||||
}
|
||||
return `${base} border-transparent text-muted-foreground hover:text-foreground hover:border-border`;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Onebox</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen antialiased">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +0,0 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -1,92 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--success: 142.1 76.2% 36.3%;
|
||||
--success-foreground: 355.7 100% 97.3%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 48 96% 89%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 4%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 4%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 0 0% 10%;
|
||||
--secondary: 0 0% 12%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 12%;
|
||||
--muted-foreground: 0 0% 65%;
|
||||
--accent: 0 0% 12%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 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: 0 0% 15%;
|
||||
--input: 0 0% 15%;
|
||||
--ring: 0 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
@apply bg-muted;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
@apply bg-muted-foreground/30 rounded-full;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-muted-foreground/50;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: ['./src/**/*.{html,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
foreground: 'hsl(var(--success-foreground))',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'hsl(var(--warning))',
|
||||
foreground: 'hsl(var(--warning-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
from: { opacity: '0' },
|
||||
to: { opacity: '1' },
|
||||
},
|
||||
'fade-out': {
|
||||
from: { opacity: '1' },
|
||||
to: { opacity: '0' },
|
||||
},
|
||||
'slide-in-right': {
|
||||
from: { transform: 'translateX(100%)' },
|
||||
to: { transform: 'translateX(0)' },
|
||||
},
|
||||
'slide-out-right': {
|
||||
from: { transform: 'translateX(0)' },
|
||||
to: { transform: 'translateX(100%)' },
|
||||
},
|
||||
'slide-in-bottom': {
|
||||
from: { transform: 'translateY(100%)' },
|
||||
to: { transform: 'translateY(0)' },
|
||||
},
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fade-in 150ms ease-out',
|
||||
'fade-out': 'fade-out 150ms ease-in',
|
||||
'slide-in-right': 'slide-in-right 200ms ease-out',
|
||||
'slide-out-right': 'slide-out-right 200ms ease-in',
|
||||
'slide-in-bottom': 'slide-in-bottom 200ms ease-out',
|
||||
'accordion-down': 'accordion-down 200ms ease-out',
|
||||
'accordion-up': 'accordion-up 200ms ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
/* 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/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/* 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