update
This commit is contained in:
10
ui/src/app/app.component.ts
Normal file
10
ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
})
|
||||
export class AppComponent {}
|
||||
71
ui/src/app/app.routes.ts
Normal file
71
ui/src/app/app.routes.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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: '',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./shared/components/layout.component').then((m) => m.LayoutComponent),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'dashboard',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
loadComponent: () =>
|
||||
import('./features/services/services-list.component').then(
|
||||
(m) => m.ServicesListComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'services/new',
|
||||
loadComponent: () =>
|
||||
import('./features/services/service-create.component').then(
|
||||
(m) => m.ServiceCreateComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'services/:name',
|
||||
loadComponent: () =>
|
||||
import('./features/services/service-detail.component').then(
|
||||
(m) => m.ServiceDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'registries',
|
||||
loadComponent: () =>
|
||||
import('./features/registries/registries.component').then(
|
||||
(m) => m.RegistriesComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'dns',
|
||||
loadComponent: () =>
|
||||
import('./features/dns/dns.component').then((m) => m.DnsComponent),
|
||||
},
|
||||
{
|
||||
path: 'ssl',
|
||||
loadComponent: () =>
|
||||
import('./features/ssl/ssl.component').then((m) => m.SslComponent),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
import('./features/settings/settings.component').then((m) => m.SettingsComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
15
ui/src/app/core/guards/auth.guard.ts
Normal file
15
ui/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
};
|
||||
18
ui/src/app/core/interceptors/auth.interceptor.ts
Normal file
18
ui/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
const token = authService.getToken();
|
||||
|
||||
if (token && !req.url.includes('/api/auth/login')) {
|
||||
req = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
144
ui/src/app/core/services/api.service.ts
Normal file
144
ui/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
registry?: string;
|
||||
envVars: Record<string, string>;
|
||||
port: number;
|
||||
domain?: string;
|
||||
containerID?: string;
|
||||
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface Registry {
|
||||
id: number;
|
||||
url: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
docker: {
|
||||
running: boolean;
|
||||
version: any;
|
||||
};
|
||||
nginx: {
|
||||
status: string;
|
||||
installed: boolean;
|
||||
};
|
||||
dns: {
|
||||
configured: boolean;
|
||||
};
|
||||
ssl: {
|
||||
configured: boolean;
|
||||
certbotInstalled: boolean;
|
||||
};
|
||||
services: {
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = '/api';
|
||||
|
||||
// System
|
||||
getStatus(): Observable<ApiResponse<SystemStatus>> {
|
||||
return this.http.get<ApiResponse<SystemStatus>>(`${this.baseUrl}/status`);
|
||||
}
|
||||
|
||||
// Services
|
||||
getServices(): Observable<ApiResponse<Service[]>> {
|
||||
return this.http.get<ApiResponse<Service[]>>(`${this.baseUrl}/services`);
|
||||
}
|
||||
|
||||
getService(name: string): Observable<ApiResponse<Service>> {
|
||||
return this.http.get<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`);
|
||||
}
|
||||
|
||||
createService(data: any): Observable<ApiResponse<Service>> {
|
||||
return this.http.post<ApiResponse<Service>>(`${this.baseUrl}/services`, data);
|
||||
}
|
||||
|
||||
deleteService(name: string): Observable<ApiResponse> {
|
||||
return this.http.delete<ApiResponse>(`${this.baseUrl}/services/${name}`);
|
||||
}
|
||||
|
||||
startService(name: string): Observable<ApiResponse> {
|
||||
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/start`, {});
|
||||
}
|
||||
|
||||
stopService(name: string): Observable<ApiResponse> {
|
||||
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/stop`, {});
|
||||
}
|
||||
|
||||
restartService(name: string): Observable<ApiResponse> {
|
||||
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/restart`, {});
|
||||
}
|
||||
|
||||
getServiceLogs(name: string): Observable<ApiResponse<string>> {
|
||||
return this.http.get<ApiResponse<string>>(`${this.baseUrl}/services/${name}/logs`);
|
||||
}
|
||||
|
||||
// Registries
|
||||
getRegistries(): Observable<ApiResponse<Registry[]>> {
|
||||
return this.http.get<ApiResponse<Registry[]>>(`${this.baseUrl}/registries`);
|
||||
}
|
||||
|
||||
createRegistry(data: any): Observable<ApiResponse<Registry>> {
|
||||
return this.http.post<ApiResponse<Registry>>(`${this.baseUrl}/registries`, data);
|
||||
}
|
||||
|
||||
deleteRegistry(url: string): Observable<ApiResponse> {
|
||||
return this.http.delete<ApiResponse>(`${this.baseUrl}/registries/${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
// DNS
|
||||
getDnsRecords(): Observable<ApiResponse<any[]>> {
|
||||
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/dns`);
|
||||
}
|
||||
|
||||
createDnsRecord(data: any): Observable<ApiResponse> {
|
||||
return this.http.post<ApiResponse>(`${this.baseUrl}/dns`, data);
|
||||
}
|
||||
|
||||
deleteDnsRecord(domain: string): Observable<ApiResponse> {
|
||||
return this.http.delete<ApiResponse>(`${this.baseUrl}/dns/${domain}`);
|
||||
}
|
||||
|
||||
// SSL
|
||||
getSslCertificates(): Observable<ApiResponse<any[]>> {
|
||||
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/ssl`);
|
||||
}
|
||||
|
||||
renewSslCertificate(domain: string): Observable<ApiResponse> {
|
||||
return this.http.post<ApiResponse>(`${this.baseUrl}/ssl/${domain}/renew`, {});
|
||||
}
|
||||
|
||||
// Settings
|
||||
getSettings(): Observable<ApiResponse<Record<string, string>>> {
|
||||
return this.http.get<ApiResponse<Record<string, string>>>(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
updateSetting(key: string, value: string): Observable<ApiResponse> {
|
||||
return this.http.post<ApiResponse>(`${this.baseUrl}/settings`, { key, value });
|
||||
}
|
||||
}
|
||||
69
ui/src/app/core/services/auth.service.ts
Normal file
69
ui/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
token: string;
|
||||
user: {
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
|
||||
isAuthenticated = signal(false);
|
||||
currentUser = signal<{ username: string; role: string } | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Check if already authenticated
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
this.isAuthenticated.set(true);
|
||||
// TODO: Decode JWT to get user info
|
||||
this.currentUser.set({ username: 'admin', role: 'admin' });
|
||||
}
|
||||
}
|
||||
|
||||
login(credentials: LoginRequest): Observable<LoginResponse> {
|
||||
return this.http.post<LoginResponse>('/api/auth/login', credentials).pipe(
|
||||
tap((response) => {
|
||||
if (response.success && response.data) {
|
||||
this.setToken(response.data.token);
|
||||
this.currentUser.set(response.data.user);
|
||||
this.isAuthenticated.set(true);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem('onebox_token');
|
||||
this.isAuthenticated.set(false);
|
||||
this.currentUser.set(null);
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('onebox_token');
|
||||
}
|
||||
|
||||
private setToken(token: string): void {
|
||||
localStorage.setItem('onebox_token', token);
|
||||
}
|
||||
}
|
||||
192
ui/src/app/features/dashboard/dashboard.component.ts
Normal file
192
ui/src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService, SystemStatus } from '../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h1>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
} @else if (status()) {
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<!-- Total Services -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-primary-500 rounded-md p-3">
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Services</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.total }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Running Services -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-green-500 rounded-md p-3">
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Running</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.running }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stopped Services -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-gray-500 rounded-md p-3">
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Stopped</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.stopped }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker Status -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 rounded-md p-3" [ngClass]="status()!.docker.running ? 'bg-green-500' : 'bg-red-500'">
|
||||
<svg class="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338 0-.676.03-1.01.09-.458-1.314-1.605-2.16-2.898-2.16h-.048c-.328 0-.654.06-.969.18-.618-2.066-2.215-3.073-4.752-3.073-2.538 0-4.135 1.007-4.753 3.073-.315-.12-.64-.18-.969-.18h-.048c-1.293 0-2.44.846-2.898 2.16a8.39 8.39 0 00-1.01-.09c-1.282 0-1.889.459-1.954.51L0 10.2l.08.31s.935 3.605 4.059 4.794v.003c.563.215 1.156.322 1.756.322.71 0 1.423-.129 2.112-.385a8.804 8.804 0 002.208.275c.877 0 1.692-.165 2.411-.49a4.71 4.71 0 001.617.28c.606 0 1.201-.11 1.773-.328.572.219 1.167.327 1.772.327.71 0 1.423-.129 2.112-.385.79.251 1.57.376 2.315.376.606 0 1.2-.107 1.766-.322v-.003c3.124-1.189 4.059-4.794 4.059-4.794l.08-.31-.237-.31z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Docker</dt>
|
||||
<dd class="text-lg font-semibold text-gray-900">
|
||||
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Status -->
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Docker -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Docker</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Status</span>
|
||||
<span [ngClass]="status()!.docker.running ? 'badge-success' : 'badge-danger'" class="badge">
|
||||
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (status()!.docker.version) {
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Version</span>
|
||||
<span class="text-sm text-gray-900">{{ status()!.docker.version.Version }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nginx -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Nginx</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Status</span>
|
||||
<span [ngClass]="status()!.nginx.status === 'running' ? 'badge-success' : 'badge-danger'" class="badge">
|
||||
{{ status()!.nginx.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Installed</span>
|
||||
<span [ngClass]="status()!.nginx.installed ? 'badge-success' : 'badge-danger'" class="badge">
|
||||
{{ status()!.nginx.installed ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DNS & SSL -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">DNS & SSL</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">DNS Configured</span>
|
||||
<span [ngClass]="status()!.dns.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
||||
{{ status()!.dns.configured ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">SSL Configured</span>
|
||||
<span [ngClass]="status()!.ssl.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
||||
{{ status()!.ssl.configured ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
|
||||
<div class="flex space-x-4">
|
||||
<a routerLink="/services/new" class="btn btn-primary">
|
||||
Deploy New Service
|
||||
</a>
|
||||
<a routerLink="/services" class="btn btn-secondary">
|
||||
View All Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
status = signal<SystemStatus | null>(null);
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStatus();
|
||||
}
|
||||
|
||||
loadStatus(): void {
|
||||
this.loading.set(true);
|
||||
this.apiService.getStatus().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.status.set(response.data);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
73
ui/src/app/features/dns/dns.component.ts
Normal file
73
ui/src/app/features/dns/dns.component.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dns',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">DNS Records</h1>
|
||||
|
||||
@if (records().length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (record of records(); track record.domain) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.domain }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.type }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ record.value }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button (click)="deleteRecord(record)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-500">No DNS records configured</p>
|
||||
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DnsComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
records = signal<any[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRecords();
|
||||
}
|
||||
|
||||
loadRecords(): void {
|
||||
this.apiService.getDnsRecords().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.records.set(response.data);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteRecord(record: any): void {
|
||||
if (confirm(`Delete DNS record for ${record.domain}?`)) {
|
||||
this.apiService.deleteDnsRecord(record.domain).subscribe({
|
||||
next: () => this.loadRecords(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
103
ui/src/app/features/login/login.component.ts
Normal file
103
ui/src/app/features/login/login.component.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Onebox
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" (ngSubmit)="onSubmit()">
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="username" class="sr-only">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
[(ngModel)]="username"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
[(ngModel)]="password"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm text-red-800">{{ error }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="loading"
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 text-center">
|
||||
<p>Default credentials: admin / admin</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Please change after first login</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
username = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
error = '';
|
||||
|
||||
onSubmit(): void {
|
||||
this.error = '';
|
||||
this.loading = true;
|
||||
|
||||
this.authService.login({ username: this.username, password: this.password }).subscribe({
|
||||
next: (response) => {
|
||||
this.loading = false;
|
||||
if (response.success) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.error = response.error || 'Login failed';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.error = err.error?.error || 'An error occurred during login';
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
103
ui/src/app/features/registries/registries.component.ts
Normal file
103
ui/src/app/features/registries/registries.component.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService, Registry } from '../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-registries',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Docker Registries</h1>
|
||||
|
||||
<!-- Add Registry Form -->
|
||||
<div class="card mb-8 max-w-2xl">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Add Registry</h2>
|
||||
<form (ngSubmit)="addRegistry()" class="space-y-4">
|
||||
<div>
|
||||
<label for="url" class="label">Registry URL</label>
|
||||
<input type="text" id="url" [(ngModel)]="newRegistry.url" name="url" required placeholder="registry.example.com" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="label">Username</label>
|
||||
<input type="text" id="username" [(ngModel)]="newRegistry.username" name="username" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="label">Password</label>
|
||||
<input type="password" id="password" [(ngModel)]="newRegistry.password" name="password" required class="input" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Registry</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Registries List -->
|
||||
@if (registries().length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (registry of registries(); track registry.id) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.url }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.username }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(registry.createdAt) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button (click)="deleteRegistry(registry)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RegistriesComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
registries = signal<Registry[]>([]);
|
||||
newRegistry = { url: '', username: '', password: '' };
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRegistries();
|
||||
}
|
||||
|
||||
loadRegistries(): void {
|
||||
this.apiService.getRegistries().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.registries.set(response.data);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
addRegistry(): void {
|
||||
this.apiService.createRegistry(this.newRegistry).subscribe({
|
||||
next: () => {
|
||||
this.newRegistry = { url: '', username: '', password: '' };
|
||||
this.loadRegistries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteRegistry(registry: Registry): void {
|
||||
if (confirm(`Delete registry ${registry.url}?`)) {
|
||||
this.apiService.deleteRegistry(registry.url).subscribe({
|
||||
next: () => this.loadRegistries(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
221
ui/src/app/features/services/service-create.component.ts
Normal file
221
ui/src/app/features/services/service-create.component.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-service-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
|
||||
|
||||
<div class="card max-w-3xl">
|
||||
<form (ngSubmit)="onSubmit()">
|
||||
<!-- Name -->
|
||||
<div class="mb-6">
|
||||
<label for="name" class="label">Service Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="myapp"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="mb-6">
|
||||
<label for="image" class="label">Docker Image *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="image"
|
||||
[(ngModel)]="image"
|
||||
name="image"
|
||||
required
|
||||
placeholder="nginx:latest"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
|
||||
</div>
|
||||
|
||||
<!-- Port -->
|
||||
<div class="mb-6">
|
||||
<label for="port" class="label">Container Port *</label>
|
||||
<input
|
||||
type="number"
|
||||
id="port"
|
||||
[(ngModel)]="port"
|
||||
name="port"
|
||||
required
|
||||
placeholder="80"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Port that your application listens on</p>
|
||||
</div>
|
||||
|
||||
<!-- Domain -->
|
||||
<div class="mb-6">
|
||||
<label for="domain" class="label">Domain (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="domain"
|
||||
[(ngModel)]="domain"
|
||||
name="domain"
|
||||
placeholder="app.example.com"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Leave empty to skip automatic DNS & SSL</p>
|
||||
</div>
|
||||
|
||||
<!-- Environment Variables -->
|
||||
<div class="mb-6">
|
||||
<label class="label">Environment Variables</label>
|
||||
@for (env of envVars(); track $index) {
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="env.key"
|
||||
[name]="'envKey' + $index"
|
||||
placeholder="KEY"
|
||||
class="input flex-1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="env.value"
|
||||
[name]="'envValue' + $index"
|
||||
placeholder="value"
|
||||
class="input flex-1"
|
||||
/>
|
||||
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
|
||||
Add Environment Variable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoDNS"
|
||||
[(ngModel)]="autoDNS"
|
||||
name="autoDNS"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="autoDNS" class="ml-2 block text-sm text-gray-900">
|
||||
Configure DNS automatically
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoSSL"
|
||||
[(ngModel)]="autoSSL"
|
||||
name="autoSSL"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="autoSSL" class="ml-2 block text-sm text-gray-900">
|
||||
Obtain SSL certificate automatically
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="rounded-md bg-red-50 p-4 mb-6">
|
||||
<p class="text-sm text-red-800">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button type="button" (click)="cancel()" class="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" [disabled]="loading()" class="btn btn-primary">
|
||||
{{ loading() ? 'Deploying...' : 'Deploy Service' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ServiceCreateComponent {
|
||||
private apiService = inject(ApiService);
|
||||
private router = inject(Router);
|
||||
|
||||
name = '';
|
||||
image = '';
|
||||
port = 80;
|
||||
domain = '';
|
||||
autoDNS = true;
|
||||
autoSSL = true;
|
||||
envVars = signal<EnvVar[]>([]);
|
||||
loading = signal(false);
|
||||
error = signal('');
|
||||
|
||||
addEnvVar(): void {
|
||||
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
|
||||
}
|
||||
|
||||
removeEnvVar(index: number): void {
|
||||
this.envVars.update((vars) => vars.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
this.error.set('');
|
||||
this.loading.set(true);
|
||||
|
||||
// Convert env vars to object
|
||||
const envVarsObj: Record<string, string> = {};
|
||||
for (const env of this.envVars()) {
|
||||
if (env.key && env.value) {
|
||||
envVarsObj[env.key] = env.value;
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: this.name,
|
||||
image: this.image,
|
||||
port: this.port,
|
||||
domain: this.domain || undefined,
|
||||
envVars: envVarsObj,
|
||||
autoDNS: this.autoDNS,
|
||||
autoSSL: this.autoSSL,
|
||||
};
|
||||
|
||||
this.apiService.createService(data).subscribe({
|
||||
next: (response) => {
|
||||
this.loading.set(false);
|
||||
if (response.success) {
|
||||
this.router.navigate(['/services']);
|
||||
} else {
|
||||
this.error.set(response.error || 'Failed to deploy service');
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(err.error?.error || 'An error occurred');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/services']);
|
||||
}
|
||||
}
|
||||
209
ui/src/app/features/services/service-detail.component.ts
Normal file
209
ui/src/app/features/services/service-detail.component.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ApiService, Service } from '../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-service-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
@if (loading()) {
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
} @else if (service()) {
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ service()!.name }}</h1>
|
||||
<span [ngClass]="{
|
||||
'badge-success': service()!.status === 'running',
|
||||
'badge-danger': service()!.status === 'stopped' || service()!.status === 'failed',
|
||||
'badge-warning': service()!.status === 'starting' || service()!.status === 'stopping'
|
||||
}" class="badge text-lg">
|
||||
{{ service()!.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Card -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Service Details</h2>
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Image</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ service()!.image }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Port</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ service()!.port }}</dd>
|
||||
</div>
|
||||
@if (service()!.domain) {
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<a [href]="'https://' + service()!.domain" target="_blank" class="text-primary-600 hover:text-primary-900">
|
||||
{{ service()!.domain }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (service()!.containerID) {
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Container ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono">{{ service()!.containerID?.substring(0, 12) }}</dd>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.createdAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.updatedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Environment Variables -->
|
||||
@if (Object.keys(service()!.envVars).length > 0) {
|
||||
<div class="mt-6">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Environment Variables</h3>
|
||||
<div class="bg-gray-50 rounded-md p-4">
|
||||
@for (entry of Object.entries(service()!.envVars); track entry[0]) {
|
||||
<div class="flex justify-between py-1">
|
||||
<span class="text-sm font-mono text-gray-700">{{ entry[0] }}</span>
|
||||
<span class="text-sm font-mono text-gray-900">{{ entry[1] }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Actions</h2>
|
||||
<div class="flex space-x-4">
|
||||
@if (service()!.status === 'stopped') {
|
||||
<button (click)="startService()" class="btn btn-success">Start</button>
|
||||
}
|
||||
@if (service()!.status === 'running') {
|
||||
<button (click)="stopService()" class="btn btn-secondary">Stop</button>
|
||||
<button (click)="restartService()" class="btn btn-primary">Restart</button>
|
||||
}
|
||||
<button (click)="deleteService()" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-900">Logs</h2>
|
||||
<button (click)="refreshLogs()" class="btn btn-secondary text-sm">Refresh</button>
|
||||
</div>
|
||||
@if (loadingLogs()) {
|
||||
<div class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto">
|
||||
<pre class="text-xs text-gray-100 font-mono">{{ logs() || 'No logs available' }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ServiceDetailComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
service = signal<Service | null>(null);
|
||||
logs = signal('');
|
||||
loading = signal(true);
|
||||
loadingLogs = signal(false);
|
||||
|
||||
Object = Object;
|
||||
|
||||
ngOnInit(): void {
|
||||
const name = this.route.snapshot.paramMap.get('name')!;
|
||||
this.loadService(name);
|
||||
this.loadLogs(name);
|
||||
}
|
||||
|
||||
loadService(name: string): void {
|
||||
this.loading.set(true);
|
||||
this.apiService.getService(name).subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.service.set(response.data);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.router.navigate(['/services']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadLogs(name: string): void {
|
||||
this.loadingLogs.set(true);
|
||||
this.apiService.getServiceLogs(name).subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.logs.set(response.data);
|
||||
}
|
||||
this.loadingLogs.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingLogs.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
refreshLogs(): void {
|
||||
this.loadLogs(this.service()!.name);
|
||||
}
|
||||
|
||||
startService(): void {
|
||||
this.apiService.startService(this.service()!.name).subscribe({
|
||||
next: () => {
|
||||
this.loadService(this.service()!.name);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
stopService(): void {
|
||||
this.apiService.stopService(this.service()!.name).subscribe({
|
||||
next: () => {
|
||||
this.loadService(this.service()!.name);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
restartService(): void {
|
||||
this.apiService.restartService(this.service()!.name).subscribe({
|
||||
next: () => {
|
||||
this.loadService(this.service()!.name);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteService(): void {
|
||||
if (confirm(`Are you sure you want to delete ${this.service()!.name}?`)) {
|
||||
this.apiService.deleteService(this.service()!.name).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/services']);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
}
|
||||
150
ui/src/app/features/services/services-list.component.ts
Normal file
150
ui/src/app/features/services/services-list.component.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService, Service } from '../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<div class="sm:flex sm:items-center sm:justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Services</h1>
|
||||
<div class="mt-4 sm:mt-0">
|
||||
<a routerLink="/services/new" class="btn btn-primary">
|
||||
Deploy New Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
} @else if (services().length === 0) {
|
||||
<div class="card text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No services</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by deploying a new service.</p>
|
||||
<div class="mt-6">
|
||||
<a routerLink="/services/new" class="btn btn-primary">
|
||||
Deploy Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (service of services(); track service.id) {
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a [routerLink]="['/services', service.name]" class="text-sm font-medium text-primary-600 hover:text-primary-900">
|
||||
{{ service.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ service.image }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ service.domain || '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span [ngClass]="{
|
||||
'badge-success': service.status === 'running',
|
||||
'badge-danger': service.status === 'stopped' || service.status === 'failed',
|
||||
'badge-warning': service.status === 'starting' || service.status === 'stopping'
|
||||
}" class="badge">
|
||||
{{ service.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
@if (service.status === 'stopped') {
|
||||
<button (click)="startService(service)" class="text-green-600 hover:text-green-900">Start</button>
|
||||
}
|
||||
@if (service.status === 'running') {
|
||||
<button (click)="stopService(service)" class="text-yellow-600 hover:text-yellow-900">Stop</button>
|
||||
<button (click)="restartService(service)" class="text-blue-600 hover:text-blue-900">Restart</button>
|
||||
}
|
||||
<button (click)="deleteService(service)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ServicesListComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
services = signal<Service[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadServices();
|
||||
}
|
||||
|
||||
loadServices(): void {
|
||||
this.loading.set(true);
|
||||
this.apiService.getServices().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startService(service: Service): void {
|
||||
this.apiService.startService(service.name).subscribe({
|
||||
next: () => {
|
||||
this.loadServices();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
stopService(service: Service): void {
|
||||
this.apiService.stopService(service.name).subscribe({
|
||||
next: () => {
|
||||
this.loadServices();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
restartService(service: Service): void {
|
||||
this.apiService.restartService(service.name).subscribe({
|
||||
next: () => {
|
||||
this.loadServices();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteService(service: Service): void {
|
||||
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
|
||||
this.apiService.deleteService(service.name).subscribe({
|
||||
next: () => {
|
||||
this.loadServices();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
96
ui/src/app/features/settings/settings.component.ts
Normal file
96
ui/src/app/features/settings/settings.component.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Cloudflare Settings -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Cloudflare DNS</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">API Key</label>
|
||||
<input type="password" [(ngModel)]="settings.cloudflareAPIKey" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input type="email" [(ngModel)]="settings.cloudflareEmail" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Zone ID</label>
|
||||
<input type="text" [(ngModel)]="settings.cloudflareZoneID" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Settings -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Server</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Server IP</label>
|
||||
<input type="text" [(ngModel)]="settings.serverIP" class="input" placeholder="1.2.3.4" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">HTTP Port</label>
|
||||
<input type="number" [(ngModel)]="settings.httpPort" class="input" placeholder="3000" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Settings -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">SSL / ACME</h2>
|
||||
<div>
|
||||
<label class="label">ACME Email</label>
|
||||
<input type="email" [(ngModel)]="settings.acmeEmail" class="input" placeholder="admin@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<button (click)="saveSettings()" class="btn btn-primary">
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
settings: any = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
loadSettings(): void {
|
||||
this.apiService.getSettings().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.settings = response.data;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
// Save each setting individually
|
||||
const promises = Object.entries(this.settings).map(([key, value]) =>
|
||||
this.apiService.updateSetting(key, value as string).toPromise()
|
||||
);
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
alert('Settings saved successfully');
|
||||
});
|
||||
}
|
||||
}
|
||||
86
ui/src/app/features/ssl/ssl.component.ts
Normal file
86
ui/src/app/features/ssl/ssl.component.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ssl',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">SSL Certificates</h1>
|
||||
|
||||
@if (certificates().length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expiry</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (cert of certificates(); track cert.domain) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.domain }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.issuer }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span [ngClass]="isExpiringSoon(cert.expiryDate) ? 'text-red-600' : 'text-gray-500'">
|
||||
{{ formatDate(cert.expiryDate) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button (click)="renewCertificate(cert)" class="text-primary-600 hover:text-primary-900">Renew</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-500">No SSL certificates</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Certificates are obtained automatically when deploying services with domains</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class SslComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
certificates = signal<any[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCertificates();
|
||||
}
|
||||
|
||||
loadCertificates(): void {
|
||||
this.apiService.getSslCertificates().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.certificates.set(response.data);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
renewCertificate(cert: any): void {
|
||||
this.apiService.renewSslCertificate(cert.domain).subscribe({
|
||||
next: () => {
|
||||
alert('Certificate renewal initiated');
|
||||
this.loadCertificates();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
isExpiringSoon(timestamp: number): boolean {
|
||||
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
||||
return timestamp - Date.now() < thirtyDays;
|
||||
}
|
||||
}
|
||||
89
ui/src/app/shared/components/layout.component.ts
Normal file
89
ui/src/app/shared/components/layout.component.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<span class="text-2xl font-bold text-primary-600">Onebox</span>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<a
|
||||
routerLink="/dashboard"
|
||||
routerLinkActive="border-primary-500 text-gray-900"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
<a
|
||||
routerLink="/services"
|
||||
routerLinkActive="border-primary-500 text-gray-900"
|
||||
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Services
|
||||
</a>
|
||||
<a
|
||||
routerLink="/registries"
|
||||
routerLinkActive="border-primary-500 text-gray-900"
|
||||
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Registries
|
||||
</a>
|
||||
<a
|
||||
routerLink="/dns"
|
||||
routerLinkActive="border-primary-500 text-gray-900"
|
||||
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
DNS
|
||||
</a>
|
||||
<a
|
||||
routerLink="/ssl"
|
||||
routerLinkActive="border-primary-500 text-gray-900"
|
||||
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
SSL
|
||||
</a>
|
||||
<a
|
||||
routerLink="/settings"
|
||||
routerLinkActive="border-primary-500 text-gray-900"
|
||||
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-700 mr-4">{{ authService.currentUser()?.username }}</span>
|
||||
<button (click)="logout()" class="btn btn-secondary text-sm">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LayoutComponent {
|
||||
authService = inject(AuthService);
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
}
|
||||
}
|
||||
1
ui/src/favicon.ico
Normal file
1
ui/src/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Empty favicon placeholder -->
|
||||
13
ui/src/index.html
Normal file
13
ui/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Onebox - Container Platform</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
13
ui/src/main.ts
Normal file
13
ui/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { routes } from './app/app.routes';
|
||||
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
],
|
||||
}).catch((err) => console.error(err));
|
||||
57
ui/src/styles.css
Normal file
57
ui/src/styles.css
Normal file
@@ -0,0 +1,57 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 text-white hover:bg-green-700;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-md p-6;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply px-2 py-1 text-xs font-semibold rounded-full;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply bg-blue-100 text-blue-800;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user