feat: integrate toast notifications in settings and layout components
- Added ToastService for managing toast notifications. - Replaced alert in settings component with toast notifications for success and error messages. - Included ToastComponent in layout for displaying notifications. - Created loading spinner component for better user experience. - Implemented domain detail component with detailed views for certificates, requirements, and services. - Added functionality to manage and display SSL certificates and their statuses. - Introduced a registry manager class for handling Docker registry operations.
This commit is contained in:
@@ -1,12 +1,25 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { ApiService, Service } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
domain: string;
|
||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||
isObsolete: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-service-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
@if (loading()) {
|
||||
@@ -29,109 +42,383 @@ import { ApiService, Service } from '../../core/services/api.service';
|
||||
|
||||
<!-- 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>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-900">Service Details</h2>
|
||||
@if (!isEditing()) {
|
||||
<button (click)="startEditing()" class="btn btn-secondary text-sm">
|
||||
Edit Service
|
||||
</button>
|
||||
}
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
@if (!isEditing()) {
|
||||
<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>
|
||||
|
||||
<!-- Registry Information -->
|
||||
@if (service()!.useOneboxRegistry) {
|
||||
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-blue-900 mb-3">Onebox Registry</h3>
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-blue-700">Repository</dt>
|
||||
<dd class="mt-1 text-sm text-blue-900 font-mono">{{ service()!.registryRepository }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-blue-700">Tag</dt>
|
||||
<dd class="mt-1 text-sm text-blue-900">{{ service()!.registryImageTag || 'latest' }}</dd>
|
||||
</div>
|
||||
@if (service()!.registryToken) {
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-blue-700">Push/Pull Token</dt>
|
||||
<dd class="mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
[value]="service()!.registryToken"
|
||||
readonly
|
||||
class="input text-xs font-mono flex-1"
|
||||
#tokenInput
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="copyToken(tokenInput.value)"
|
||||
class="btn btn-secondary text-xs"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-blue-600">
|
||||
Use this token to push images: <code class="bg-blue-100 px-1 py-0.5 rounded">docker login -u unused -p [token] {{ registryBaseUrl() }}</code>
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-blue-700">Auto-update</dt>
|
||||
<dd class="mt-1 text-sm text-blue-900">
|
||||
{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}
|
||||
</dd>
|
||||
</div>
|
||||
@if (service()!.imageDigest) {
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-blue-700">Current Digest</dt>
|
||||
<dd class="mt-1 text-xs text-blue-900 font-mono break-all">{{ service()!.imageDigest }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
} @else {
|
||||
<!-- Edit Form -->
|
||||
<form (ngSubmit)="saveService()">
|
||||
<!-- Image -->
|
||||
<div class="mb-6">
|
||||
<label for="edit-image" class="label">Docker Image *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-image"
|
||||
[(ngModel)]="editForm.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="edit-port" class="label">Container Port *</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-port"
|
||||
[(ngModel)]="editForm.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="edit-domain" class="label">Domain (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-domain"
|
||||
[(ngModel)]="editForm.domain"
|
||||
(ngModelChange)="onDomainChange()"
|
||||
name="domain"
|
||||
placeholder="app.example.com"
|
||||
list="domainList"
|
||||
class="input"
|
||||
[class.border-red-300]="domainWarning()"
|
||||
/>
|
||||
<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 →
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Variables -->
|
||||
<div class="mb-6">
|
||||
<label class="label">Environment Variables</label>
|
||||
@for (env of editEnvVars(); 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>
|
||||
|
||||
@if (error()) {
|
||||
<div class="rounded-md bg-red-50 p-4 mb-6">
|
||||
<p class="text-sm text-red-800">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Edit Actions -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button type="button" (click)="cancelEditing()" class="btn btn-secondary" [disabled]="saving()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="saving()">
|
||||
{{ saving() ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</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>
|
||||
@if (!isEditing()) {
|
||||
<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>
|
||||
</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>
|
||||
@if (!isEditing()) {
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-900">Logs</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="logSearch"
|
||||
(ngModelChange)="filterLogs()"
|
||||
placeholder="Search logs..."
|
||||
class="input text-sm w-48"
|
||||
/>
|
||||
|
||||
<!-- Log Level Filter -->
|
||||
<select [(ngModel)]="logLevelFilter" (ngModelChange)="filterLogs()" class="input text-sm">
|
||||
<option value="all">All Levels</option>
|
||||
<option value="error">Errors</option>
|
||||
<option value="warn">Warnings</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
|
||||
<!-- Auto-refresh toggle -->
|
||||
<label class="flex items-center text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="logsAutoRefresh"
|
||||
(ngModelChange)="toggleLogsAutoRefresh()"
|
||||
class="mr-2"
|
||||
/>
|
||||
Auto-refresh
|
||||
</label>
|
||||
|
||||
<button (click)="refreshLogs()" class="btn btn-secondary text-sm" [disabled]="loadingLogs()">
|
||||
<svg class="w-4 h-4" [class.animate-spin]="loadingLogs()" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</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 max-h-96 overflow-y-auto">
|
||||
@if (filteredLogs().length === 0) {
|
||||
<p class="text-sm text-gray-400">No logs available</p>
|
||||
} @else {
|
||||
@for (line of filteredLogs(); track $index) {
|
||||
<div class="text-xs font-mono mb-1" [ngClass]="{
|
||||
'text-red-400': isLogLevel(line, 'error'),
|
||||
'text-yellow-400': isLogLevel(line, 'warn'),
|
||||
'text-blue-300': isLogLevel(line, 'info'),
|
||||
'text-gray-400': isLogLevel(line, 'debug'),
|
||||
'text-gray-100': !hasLogLevel(line)
|
||||
}">{{ line }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (filteredLogs().length > 0 && filteredLogs().length !== logLines().length) {
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
Showing {{ filteredLogs().length }} of {{ logLines().length }} lines
|
||||
</div>
|
||||
}
|
||||
</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 {
|
||||
export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
service = signal<Service | null>(null);
|
||||
logs = signal('');
|
||||
logLines = signal<string[]>([]);
|
||||
filteredLogs = signal<string[]>([]);
|
||||
logSearch = '';
|
||||
logLevelFilter = 'all';
|
||||
logsAutoRefresh = false;
|
||||
private logsRefreshInterval?: number;
|
||||
loading = signal(true);
|
||||
loadingLogs = signal(false);
|
||||
|
||||
// Edit mode
|
||||
isEditing = signal(false);
|
||||
saving = signal(false);
|
||||
error = signal('');
|
||||
editForm = {
|
||||
image: '',
|
||||
port: 80,
|
||||
domain: '',
|
||||
};
|
||||
editEnvVars = signal<EnvVar[]>([]);
|
||||
|
||||
// Domain validation
|
||||
availableDomains = signal<Domain[]>([]);
|
||||
domainWarning = signal(false);
|
||||
domainWarningTitle = signal('');
|
||||
domainWarningMessage = signal('');
|
||||
|
||||
Object = Object;
|
||||
|
||||
ngOnInit(): void {
|
||||
const name = this.route.snapshot.paramMap.get('name')!;
|
||||
this.loadService(name);
|
||||
this.loadLogs(name);
|
||||
this.loadDomains();
|
||||
}
|
||||
|
||||
loadService(name: string): void {
|
||||
@@ -156,6 +443,9 @@ export class ServiceDetailComponent implements OnInit {
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.logs.set(response.data);
|
||||
const lines = response.data.split('\n').filter((line: string) => line.trim());
|
||||
this.logLines.set(lines);
|
||||
this.filterLogs();
|
||||
}
|
||||
this.loadingLogs.set(false);
|
||||
},
|
||||
@@ -165,6 +455,174 @@ export class ServiceDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
filterLogs(): void {
|
||||
let lines = this.logLines();
|
||||
|
||||
// Apply level filter
|
||||
if (this.logLevelFilter !== 'all') {
|
||||
lines = lines.filter(line => this.isLogLevel(line, this.logLevelFilter));
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (this.logSearch.trim()) {
|
||||
const searchLower = this.logSearch.toLowerCase();
|
||||
lines = lines.filter(line => line.toLowerCase().includes(searchLower));
|
||||
}
|
||||
|
||||
this.filteredLogs.set(lines);
|
||||
}
|
||||
|
||||
isLogLevel(line: string, level: string): boolean {
|
||||
const lineLower = line.toLowerCase();
|
||||
if (level === 'error') return lineLower.includes('error') || lineLower.includes('✖');
|
||||
if (level === 'warn') return lineLower.includes('warn') || lineLower.includes('warning');
|
||||
if (level === 'info') return lineLower.includes('info') || lineLower.includes('ℹ');
|
||||
if (level === 'debug') return lineLower.includes('debug');
|
||||
return false;
|
||||
}
|
||||
|
||||
hasLogLevel(line: string): boolean {
|
||||
return this.isLogLevel(line, 'error') ||
|
||||
this.isLogLevel(line, 'warn') ||
|
||||
this.isLogLevel(line, 'info') ||
|
||||
this.isLogLevel(line, 'debug');
|
||||
}
|
||||
|
||||
toggleLogsAutoRefresh(): void {
|
||||
if (this.logsAutoRefresh) {
|
||||
this.logsRefreshInterval = window.setInterval(() => {
|
||||
this.refreshLogs();
|
||||
}, 5000); // Refresh every 5 seconds
|
||||
} else {
|
||||
if (this.logsRefreshInterval) {
|
||||
clearInterval(this.logsRefreshInterval);
|
||||
this.logsRefreshInterval = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startEditing(): void {
|
||||
const svc = this.service()!;
|
||||
this.editForm.image = svc.image;
|
||||
this.editForm.port = svc.port;
|
||||
this.editForm.domain = svc.domain || '';
|
||||
|
||||
// Convert env vars to array
|
||||
const envVars: EnvVar[] = [];
|
||||
for (const [key, value] of Object.entries(svc.envVars || {})) {
|
||||
envVars.push({ key, value });
|
||||
}
|
||||
this.editEnvVars.set(envVars);
|
||||
|
||||
this.isEditing.set(true);
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
cancelEditing(): void {
|
||||
this.isEditing.set(false);
|
||||
this.error.set('');
|
||||
this.domainWarning.set(false);
|
||||
}
|
||||
|
||||
saveService(): void {
|
||||
this.error.set('');
|
||||
this.saving.set(true);
|
||||
|
||||
// Convert env vars to object
|
||||
const envVarsObj: Record<string, string> = {};
|
||||
for (const env of this.editEnvVars()) {
|
||||
if (env.key && env.value) {
|
||||
envVarsObj[env.key] = env.value;
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
image: this.editForm.image,
|
||||
port: this.editForm.port,
|
||||
domain: this.editForm.domain || undefined,
|
||||
envVars: envVarsObj,
|
||||
};
|
||||
|
||||
this.apiService.updateService(this.service()!.name, updates).subscribe({
|
||||
next: (response) => {
|
||||
this.saving.set(false);
|
||||
if (response.success) {
|
||||
this.service.set(response.data!);
|
||||
this.isEditing.set(false);
|
||||
} else {
|
||||
this.error.set(response.error || 'Failed to update service');
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.error.set(err.error?.error || 'An error occurred');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
addEnvVar(): void {
|
||||
this.editEnvVars.update((vars) => [...vars, { key: '', value: '' }]);
|
||||
}
|
||||
|
||||
removeEnvVar(index: number): void {
|
||||
this.editEnvVars.update((vars) => vars.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
onDomainChange(): void {
|
||||
if (!this.editForm.domain) {
|
||||
this.domainWarning.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract base domain from entered domain
|
||||
const parts = this.editForm.domain.split('.');
|
||||
if (parts.length < 2) {
|
||||
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 update, 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);
|
||||
}
|
||||
}
|
||||
|
||||
refreshLogs(): void {
|
||||
this.loadLogs(this.service()!.name);
|
||||
}
|
||||
@@ -206,4 +664,22 @@ export class ServiceDetailComponent implements OnInit {
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
copyToken(token: string): void {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
this.toastService.success('Token copied to clipboard!');
|
||||
}).catch(() => {
|
||||
this.toastService.error('Failed to copy token');
|
||||
});
|
||||
}
|
||||
|
||||
registryBaseUrl = signal('localhost:5000');
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.logsRefreshInterval) {
|
||||
clearInterval(this.logsRefreshInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user