feat(opsserver): introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces

This commit is contained in:
2026-02-24 18:15:44 +00:00
parent 84c47cd7f5
commit ba05cc84fe
143 changed files with 46631 additions and 20632 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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.

View File

@@ -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": []
}
}
}
}
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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

View File

@@ -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 />

View File

@@ -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');
});
});

View File

@@ -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 {}

View File

@@ -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])),
],
};

View File

@@ -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',
},
];

View File

@@ -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;
};

View File

@@ -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);
};

View File

@@ -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`));
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -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([]);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}

View File

@@ -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: [],
};
}

View File

@@ -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);
}
}
}

View File

@@ -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';
}
}
}

View File

@@ -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';
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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');
}
}
}

View File

@@ -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';
}
}
}

View File

@@ -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');
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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';
}
}
}

View File

@@ -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]}`;
}
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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');
}
}
}

View File

@@ -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();
}
}

View File

@@ -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) + '%';
}
}

View File

@@ -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' },
];
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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';

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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`;
}
}

View File

@@ -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';
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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));

View File

@@ -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;
}
}

View File

@@ -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: [],
};

View File

@@ -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"
]
}

View File

@@ -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
}
}

View File

@@ -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"
]
}