Files
onebox/ui/src/app/features/services/service-create.component.ts

392 lines
13 KiB
TypeScript
Raw Normal View History

import { Component, OnInit, inject, signal } from '@angular/core';
2025-11-18 00:03:24 +00:00
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
2025-11-18 00:03:24 +00:00
import { ApiService } from '../../core/services/api.service';
interface EnvVar {
key: string;
value: string;
}
interface Domain {
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
isObsolete: boolean;
}
2025-11-18 00:03:24 +00:00
@Component({
selector: 'app-service-create',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
2025-11-18 00:03:24 +00:00
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]="!useOneboxRegistry"
[disabled]="useOneboxRegistry"
2025-11-18 00:03:24 +00:00
placeholder="nginx:latest"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
</div>
<!-- Onebox Registry Option -->
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<input
type="checkbox"
id="useOneboxRegistry"
[(ngModel)]="useOneboxRegistry"
name="useOneboxRegistry"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="useOneboxRegistry" class="ml-2 block text-sm font-medium text-gray-900">
Use Onebox Registry
</label>
</div>
<p class="text-sm text-gray-600 mb-3">
Store your container image in the local Onebox registry instead of using an external image.
</p>
@if (useOneboxRegistry) {
<div class="space-y-3">
<div>
<label for="registryImageTag" class="label text-sm">Image Tag</label>
<input
type="text"
id="registryImageTag"
[(ngModel)]="registryImageTag"
name="registryImageTag"
placeholder="latest"
class="input text-sm"
/>
<p class="mt-1 text-xs text-gray-500">Tag to use (e.g., latest, v1.0, develop)</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="autoUpdateOnPush"
[(ngModel)]="autoUpdateOnPush"
name="autoUpdateOnPush"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoUpdateOnPush" class="ml-2 block text-sm text-gray-700">
Auto-restart on new image push
</label>
</div>
<p class="text-xs text-gray-500 ml-6">
Automatically pull and restart the service when a new image is pushed to the registry
</p>
</div>
}
</div>
2025-11-18 00:03:24 +00:00
<!-- 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"
(ngModelChange)="onDomainChange()"
2025-11-18 00:03:24 +00:00
name="domain"
placeholder="app.example.com"
list="domainList"
2025-11-18 00:03:24 +00:00
class="input"
[class.border-red-300]="domainWarning()"
2025-11-18 00:03:24 +00:00
/>
<datalist id="domainList">
@for (domain of availableDomains(); track domain.domain) {
<option [value]="domain.domain">{{ domain.domain }}</option>
}
</datalist>
@if (domainWarning()) {
<div class="mt-2 rounded-md bg-yellow-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
<strong>{{ domainWarningTitle() }}</strong>
</p>
<p class="mt-1 text-sm text-yellow-700">{{ domainWarningMessage() }}</p>
<div class="mt-2">
<a routerLink="/domains" class="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline">
View domains &rarr;
</a>
</div>
</div>
</div>
</div>
} @else {
<p class="mt-1 text-sm text-gray-500">
Leave empty to skip automatic DNS & SSL.
@if (availableDomains().length > 0) {
<span>Or select from {{ availableDomains().length }} available domain(s).</span>
}
</p>
}
2025-11-18 00:03:24 +00:00
</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 implements OnInit {
2025-11-18 00:03:24 +00:00
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('');
// Onebox Registry
useOneboxRegistry = false;
registryImageTag = 'latest';
autoUpdateOnPush = false;
// Domain validation
availableDomains = signal<Domain[]>([]);
domainWarning = signal(false);
domainWarningTitle = signal('');
domainWarningMessage = signal('');
ngOnInit(): void {
this.loadDomains();
}
loadDomains(): void {
this.apiService.getDomains().subscribe({
next: (response) => {
if (response.success && response.data) {
const domains: Domain[] = response.data.map((d: any) => ({
domain: d.domain.domain,
dnsProvider: d.domain.dnsProvider,
isObsolete: d.domain.isObsolete,
}));
this.availableDomains.set(domains);
}
},
error: () => {
// Silently fail - domains list not critical
},
});
}
onDomainChange(): void {
if (!this.domain) {
this.domainWarning.set(false);
return;
}
// Extract base domain from entered domain
const parts = this.domain.split('.');
if (parts.length < 2) {
// Not a valid domain format
this.domainWarning.set(false);
return;
}
const baseDomain = parts.slice(-2).join('.');
// Check if base domain exists in available domains
const matchingDomain = this.availableDomains().find(
(d) => d.domain === baseDomain
);
if (!matchingDomain) {
this.domainWarning.set(true);
this.domainWarningTitle.set('Domain not found');
this.domainWarningMessage.set(
`The base domain "${baseDomain}" is not in the Domain table. The service will deploy, but certificate management may not work. Sync your Cloudflare domains or manually add the domain first.`
);
} else if (matchingDomain.isObsolete) {
this.domainWarning.set(true);
this.domainWarningTitle.set('Domain is obsolete');
this.domainWarningMessage.set(
`The domain "${baseDomain}" is marked as obsolete (likely removed from Cloudflare). Certificate management may not work properly.`
);
} else {
this.domainWarning.set(false);
}
}
2025-11-18 00:03:24 +00:00
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,
useOneboxRegistry: this.useOneboxRegistry,
registryImageTag: this.useOneboxRegistry ? this.registryImageTag : undefined,
autoUpdateOnPush: this.useOneboxRegistry ? this.autoUpdateOnPush : undefined,
2025-11-18 00:03:24 +00:00
};
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']);
}
}