- Added base interface and abstract class for platform service providers. - Created MinIOProvider class for S3-compatible storage with deployment, provisioning, and deprovisioning functionalities. - Implemented MongoDBProvider class for MongoDB service with similar capabilities. - Introduced error handling utilities for better error management. - Developed TokensComponent for managing registry tokens in the UI, including creation, deletion, and display of tokens.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
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>
|
|
|
|
@if (form.enableMongoDB || form.enableS3) {
|
|
<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,
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|