update
This commit is contained in:
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user