feat: Implement platform service providers for MinIO and MongoDB
- 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.
This commit is contained in:
@@ -84,6 +84,13 @@ export const routes: Routes = [
|
||||
(m) => m.RegistriesComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
loadComponent: () =>
|
||||
import('./features/tokens/tokens.component').then(
|
||||
(m) => m.TokensComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -12,8 +12,14 @@ import {
|
||||
IDnsRecord,
|
||||
IRegistry,
|
||||
IRegistryCreate,
|
||||
IRegistryToken,
|
||||
ICreateTokenRequest,
|
||||
ITokenCreatedResponse,
|
||||
ISetting,
|
||||
ISettings,
|
||||
IPlatformService,
|
||||
IPlatformResource,
|
||||
TPlatformServiceType,
|
||||
} from '../types/api.types';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -75,6 +81,19 @@ export class ApiService {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registries/${id}`));
|
||||
}
|
||||
|
||||
// Registry Tokens
|
||||
async getRegistryTokens(): Promise<IApiResponse<IRegistryToken[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IRegistryToken[]>>('/api/registry/tokens'));
|
||||
}
|
||||
|
||||
async createRegistryToken(data: ICreateTokenRequest): Promise<IApiResponse<ITokenCreatedResponse>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<ITokenCreatedResponse>>('/api/registry/tokens', data));
|
||||
}
|
||||
|
||||
async deleteRegistryToken(id: number): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registry/tokens/${id}`));
|
||||
}
|
||||
|
||||
// DNS Records
|
||||
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IDnsRecord[]>>('/api/dns'));
|
||||
@@ -138,4 +157,25 @@ export class ApiService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Platform Services
|
||||
async getPlatformServices(): Promise<IApiResponse<IPlatformService[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IPlatformService[]>>('/api/platform-services'));
|
||||
}
|
||||
|
||||
async getPlatformService(type: TPlatformServiceType): Promise<IApiResponse<IPlatformService>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IPlatformService>>(`/api/platform-services/${type}`));
|
||||
}
|
||||
|
||||
async startPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/start`, {}));
|
||||
}
|
||||
|
||||
async stopPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
|
||||
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/stop`, {}));
|
||||
}
|
||||
|
||||
async getServicePlatformResources(serviceName: string): Promise<IApiResponse<IPlatformResource[]>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<IPlatformResource[]>>(`/api/services/${serviceName}/platform-resources`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ export interface ILoginResponse {
|
||||
user: IUser;
|
||||
}
|
||||
|
||||
// Platform Service Types (defined early for use in ISystemStatus)
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq';
|
||||
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||
|
||||
export interface IPlatformRequirements {
|
||||
mongodb?: boolean;
|
||||
s3?: boolean;
|
||||
}
|
||||
|
||||
export interface IService {
|
||||
id?: number;
|
||||
name: string;
|
||||
@@ -29,10 +39,10 @@ export interface IService {
|
||||
updatedAt: number;
|
||||
useOneboxRegistry?: boolean;
|
||||
registryRepository?: string;
|
||||
registryToken?: string;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
imageDigest?: string;
|
||||
platformRequirements?: IPlatformRequirements;
|
||||
}
|
||||
|
||||
export interface IServiceCreate {
|
||||
@@ -44,6 +54,8 @@ export interface IServiceCreate {
|
||||
useOneboxRegistry?: boolean;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
}
|
||||
|
||||
export interface IServiceUpdate {
|
||||
@@ -67,6 +79,7 @@ export interface ISystemStatus {
|
||||
dns: { configured: boolean };
|
||||
ssl: { configured: boolean; certbotInstalled: boolean };
|
||||
services: { total: number; running: number; stopped: number };
|
||||
platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>;
|
||||
}
|
||||
|
||||
export interface IDomain {
|
||||
@@ -138,6 +151,32 @@ export interface IRegistryCreate {
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Registry Token Types
|
||||
export interface IRegistryToken {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
scopeDisplay: string;
|
||||
expiresAt: number | null;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
createdBy: string;
|
||||
isExpired: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateTokenRequest {
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
expiresIn: '30d' | '90d' | '365d' | 'never';
|
||||
}
|
||||
|
||||
export interface ITokenCreatedResponse {
|
||||
token: IRegistryToken;
|
||||
plainToken: string;
|
||||
}
|
||||
|
||||
export interface ISetting {
|
||||
key: string;
|
||||
value: string;
|
||||
@@ -173,3 +212,27 @@ export interface IToast {
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// Platform Service Interfaces
|
||||
export interface IPlatformService {
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
resourceTypes: TPlatformResourceType[];
|
||||
status: TPlatformServiceStatus;
|
||||
containerId?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface IPlatformResource {
|
||||
id: number;
|
||||
resourceType: TPlatformResourceType;
|
||||
resourceName: string;
|
||||
platformService: {
|
||||
type: TPlatformServiceType;
|
||||
name: string;
|
||||
status: TPlatformServiceStatus;
|
||||
};
|
||||
envVars: Record<string, string>;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
@@ -43,6 +46,7 @@ import {
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
@@ -59,42 +63,75 @@ import {
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Docker Registries</h1>
|
||||
<p class="text-muted-foreground">Manage Docker registry credentials</p>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Registries</h1>
|
||||
<p class="text-muted-foreground">Manage container image registries</p>
|
||||
</div>
|
||||
|
||||
<!-- Add Registry Form -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Add Registry</ui-card-title>
|
||||
<ui-card-description>Add credentials for a private Docker registry</ui-card-description>
|
||||
<!-- Onebox Registry Card -->
|
||||
<ui-card class="border-primary/50">
|
||||
<ui-card-header class="flex flex-row items-start justify-between space-y-0">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
</svg>
|
||||
<ui-card-title>Onebox Registry (Built-in)</ui-card-title>
|
||||
<ui-badge>Default</ui-badge>
|
||||
</div>
|
||||
<ui-card-description>Built-in container registry for your services</ui-card-description>
|
||||
</div>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<form (ngSubmit)="addRegistry()" class="grid gap-4 md:grid-cols-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Registry URL</label>
|
||||
<input uiInput [(ngModel)]="form.url" name="url" placeholder="registry.example.com" required />
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="h-2 w-2 rounded-full bg-success animate-pulse"></span>
|
||||
<span class="font-medium text-success">Running</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Username</label>
|
||||
<input uiInput [(ngModel)]="form.username" name="username" required />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground">Registry URL</div>
|
||||
<div class="font-mono text-sm mt-1">localhost:3000/v2</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="form.password" name="password" required />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground">Authentication</div>
|
||||
<div class="mt-1">
|
||||
<a routerLink="/tokens" class="text-primary hover:underline text-sm">
|
||||
Manage Tokens
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button uiButton type="submit" [disabled]="loading()">Add Registry</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-muted rounded-lg">
|
||||
<h4 class="font-medium mb-2">Quick Start</h4>
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
To push images to the Onebox registry, use a CI or Global token:
|
||||
</p>
|
||||
<div class="font-mono text-xs bg-background p-3 rounded border overflow-x-auto">
|
||||
<div class="text-muted-foreground"># Login to the registry</div>
|
||||
<div>docker login localhost:3000 -u onebox -p YOUR_TOKEN</div>
|
||||
<div class="mt-2 text-muted-foreground"># Tag and push your image</div>
|
||||
<div>docker tag myapp localhost:3000/myservice:latest</div>
|
||||
<div>docker push localhost:3000/myservice:latest</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Registries List -->
|
||||
<!-- External Registries Section -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">External Registries</h2>
|
||||
<p class="text-sm text-muted-foreground">Add credentials for private Docker registries</p>
|
||||
</div>
|
||||
<button uiButton variant="outline" (click)="addDialogOpen.set(true)">
|
||||
Add Registry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Registered Registries</ui-card-title>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && registries().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@@ -104,15 +141,24 @@ import {
|
||||
</div>
|
||||
} @else if (registries().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-muted-foreground">No registries configured</p>
|
||||
<svg class="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium">No external registries</h3>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Add credentials for Docker Hub, GitHub Container Registry, or other private registries.
|
||||
</p>
|
||||
<button uiButton variant="outline" class="mt-4" (click)="addDialogOpen.set(true)">
|
||||
Add External Registry
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>URL</ui-table-head>
|
||||
<ui-table-head>Registry URL</ui-table-head>
|
||||
<ui-table-head>Username</ui-table-head>
|
||||
<ui-table-head>Created</ui-table-head>
|
||||
<ui-table-head>Added</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
@@ -136,6 +182,35 @@ import {
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Add Registry Dialog -->
|
||||
<ui-dialog [open]="addDialogOpen()" (openChange)="addDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Add External Registry</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Add credentials for a private Docker registry
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Registry URL</label>
|
||||
<input uiInput [(ngModel)]="form.url" placeholder="registry.example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Username</label>
|
||||
<input uiInput [(ngModel)]="form.username" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="form.password" />
|
||||
</div>
|
||||
</div>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="addDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton (click)="addRegistry()" [disabled]="loading()">Add Registry</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete Registry</ui-dialog-title>
|
||||
@@ -156,6 +231,7 @@ export class RegistriesComponent implements OnInit {
|
||||
|
||||
registries = signal<IRegistry[]>([]);
|
||||
loading = signal(false);
|
||||
addDialogOpen = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
registryToDelete = signal<IRegistry | null>(null);
|
||||
|
||||
@@ -191,6 +267,7 @@ export class RegistriesComponent implements OnInit {
|
||||
if (response.success) {
|
||||
this.toast.success('Registry added');
|
||||
this.form = { url: '', username: '', password: '' };
|
||||
this.addDialogOpen.set(false);
|
||||
this.loadRegistries();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to add registry');
|
||||
|
||||
@@ -186,6 +186,48 @@ interface EnvVar {
|
||||
|
||||
<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">
|
||||
@@ -257,6 +299,8 @@ export class ServiceCreateComponent implements OnInit {
|
||||
useOneboxRegistry: false,
|
||||
registryImageTag: 'latest',
|
||||
autoUpdateOnPush: false,
|
||||
enableMongoDB: false,
|
||||
enableS3: false,
|
||||
};
|
||||
|
||||
envVars = signal<EnvVar[]>([]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { LogStreamService } from '../../core/services/log-stream.service';
|
||||
import { IService, IServiceUpdate } from '../../core/types/api.types';
|
||||
import { IService, IServiceUpdate, IPlatformResource } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
@@ -209,6 +209,61 @@ import {
|
||||
</ui-card>
|
||||
}
|
||||
|
||||
<!-- Platform Resources -->
|
||||
@if (service()!.platformRequirements || platformResources().length > 0) {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Platform Resources</ui-card-title>
|
||||
<ui-card-description>Managed infrastructure provisioned for this service</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
@if (platformResources().length > 0) {
|
||||
@for (resource of platformResources(); track resource.id) {
|
||||
<div class="border rounded-lg p-4 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
@if (resource.resourceType === 'database') {
|
||||
<svg class="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
} @else if (resource.resourceType === 'bucket') {
|
||||
<svg class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
}
|
||||
<span class="font-medium">{{ resource.resourceName }}</span>
|
||||
</div>
|
||||
<ui-badge [variant]="resource.platformService.status === 'running' ? 'success' : 'secondary'">
|
||||
{{ resource.platformService.status }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ resource.platformService.type === 'mongodb' ? 'MongoDB Database' : 'S3 Bucket (MinIO)' }}
|
||||
</div>
|
||||
<div class="mt-2 pt-2 border-t">
|
||||
<p class="text-xs font-medium text-muted-foreground mb-1">Injected Environment Variables</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@for (key of getEnvKeys(resource.envVars); track key) {
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono bg-muted">{{ key }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else if (service()!.platformRequirements) {
|
||||
<div class="text-sm text-muted-foreground">
|
||||
@if (service()!.platformRequirements!.mongodb) {
|
||||
<p>MongoDB database pending provisioning...</p>
|
||||
}
|
||||
@if (service()!.platformRequirements!.s3) {
|
||||
<p>S3 bucket pending provisioning...</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
|
||||
<!-- Onebox Registry Info -->
|
||||
@if (service()!.useOneboxRegistry) {
|
||||
<ui-card>
|
||||
@@ -225,21 +280,11 @@ import {
|
||||
<dt class="text-sm font-medium text-muted-foreground">Tag</dt>
|
||||
<dd class="text-sm">{{ service()!.registryImageTag || 'latest' }}</dd>
|
||||
</div>
|
||||
@if (service()!.registryToken) {
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-muted-foreground">Push Token</dt>
|
||||
<dd class="flex items-center gap-2">
|
||||
<input
|
||||
uiInput
|
||||
type="password"
|
||||
[value]="service()!.registryToken"
|
||||
readonly
|
||||
class="font-mono text-xs"
|
||||
/>
|
||||
<button uiButton variant="outline" size="sm" (click)="copyToken()">Copy</button>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
<div class="pt-2 border-t">
|
||||
<a routerLink="/tokens" class="text-sm text-primary hover:underline">
|
||||
Manage registry tokens for CI/CD pipelines →
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-muted-foreground">Auto-update on push</dt>
|
||||
<dd class="text-sm">{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}</dd>
|
||||
@@ -346,6 +391,7 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
service = signal<IService | null>(null);
|
||||
platformResources = signal<IPlatformResource[]>([]);
|
||||
loading = signal(false);
|
||||
actionLoading = signal(false);
|
||||
editMode = signal(false);
|
||||
@@ -389,6 +435,11 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||
port: response.data.port,
|
||||
domain: response.data.domain,
|
||||
};
|
||||
|
||||
// Load platform resources if service has platform requirements
|
||||
if (response.data.platformRequirements) {
|
||||
this.loadPlatformResources(name);
|
||||
}
|
||||
} else {
|
||||
this.toast.error(response.error || 'Service not found');
|
||||
this.router.navigate(['/services']);
|
||||
@@ -400,6 +451,17 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async loadPlatformResources(name: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getServicePlatformResources(name);
|
||||
if (response.success && response.data) {
|
||||
this.platformResources.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - platform resources are optional
|
||||
}
|
||||
}
|
||||
|
||||
startLogStream(): void {
|
||||
const name = this.service()?.name;
|
||||
if (name) {
|
||||
@@ -546,12 +608,4 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||
this.deleteDialogOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
copyToken(): void {
|
||||
const token = this.service()?.registryToken;
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token);
|
||||
this.toast.success('Token copied to clipboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
509
ui/src/app/features/tokens/tokens.component.ts
Normal file
509
ui/src/app/features/tokens/tokens.component.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { Component, inject, signal, OnInit, computed } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IRegistryToken, ICreateTokenRequest, IService } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} 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 { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
} from '../../ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tokens',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Registry Tokens</h1>
|
||||
<p class="text-muted-foreground">Manage authentication tokens for the Onebox registry</p>
|
||||
</div>
|
||||
<button uiButton (click)="openCreateDialog()">Create Token</button>
|
||||
</div>
|
||||
|
||||
<!-- Global Tokens Section -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Global Tokens</ui-card-title>
|
||||
<ui-card-description>Tokens that can push images to multiple services</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && globalTokens().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (globalTokens().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-muted-foreground">No global tokens created</p>
|
||||
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('global')">
|
||||
Create Global Token
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Scope</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head>Last Used</ui-table-head>
|
||||
<ui-table-head>Created By</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (token of globalTokens(); track token.id) {
|
||||
<ui-table-row [class.opacity-50]="token.isExpired">
|
||||
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge variant="secondary">{{ token.scopeDisplay }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.isExpired) {
|
||||
<ui-badge variant="destructive">Expired</ui-badge>
|
||||
} @else if (token.expiresAt) {
|
||||
{{ formatExpiry(token.expiresAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.lastUsedAt) {
|
||||
{{ formatRelativeTime(token.lastUsedAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- CI Tokens Section -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>CI Tokens (Service-specific)</ui-card-title>
|
||||
<ui-card-description>Tokens tied to individual services for CI/CD pipelines</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && ciTokens().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (ciTokens().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-muted-foreground">No CI tokens created</p>
|
||||
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('ci')">
|
||||
Create CI Token
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Service</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head>Last Used</ui-table-head>
|
||||
<ui-table-head>Created By</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (token of ciTokens(); track token.id) {
|
||||
<ui-table-row [class.opacity-50]="token.isExpired">
|
||||
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||||
<ui-table-cell>{{ token.scopeDisplay }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.isExpired) {
|
||||
<ui-badge variant="destructive">Expired</ui-badge>
|
||||
} @else if (token.expiresAt) {
|
||||
{{ formatExpiry(token.expiresAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (token.lastUsedAt) {
|
||||
{{ formatRelativeTime(token.lastUsedAt) }}
|
||||
} @else {
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Create Token Dialog -->
|
||||
<ui-dialog [open]="createDialogOpen()" (openChange)="createDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Create Registry Token</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Create a new token for pushing images to the Onebox registry
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Token Name</label>
|
||||
<input uiInput [(ngModel)]="createForm.name" placeholder="e.g., deploy-token" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Token Type</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="type" value="global" [(ngModel)]="createForm.type" class="accent-primary" />
|
||||
<span>Global (multiple services)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="type" value="ci" [(ngModel)]="createForm.type" class="accent-primary" />
|
||||
<span>CI (single service)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (createForm.type === 'global') {
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Scope</label>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input type="checkbox" id="all-services" [(ngModel)]="scopeAll" class="accent-primary" />
|
||||
<label for="all-services" class="cursor-pointer">All services</label>
|
||||
</div>
|
||||
@if (!scopeAll) {
|
||||
<div class="border rounded-md p-3 max-h-48 overflow-y-auto space-y-2">
|
||||
@for (service of services(); track service.name) {
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selectedServices().includes(service.name)"
|
||||
(change)="toggleService(service.name)"
|
||||
class="accent-primary"
|
||||
/>
|
||||
<span>{{ service.name }}</span>
|
||||
</label>
|
||||
}
|
||||
@if (services().length === 0) {
|
||||
<p class="text-sm text-muted-foreground">No services available</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Service</label>
|
||||
<select [(ngModel)]="selectedSingleService" class="w-full p-2 border rounded-md bg-background">
|
||||
<option value="">Select a service</option>
|
||||
@for (service of services(); track service.name) {
|
||||
<option [value]="service.name">{{ service.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Expiration</label>
|
||||
<select [(ngModel)]="createForm.expiresIn" class="w-full p-2 border rounded-md bg-background">
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="365d">365 days</option>
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="createDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton (click)="createToken()" [disabled]="creating()">
|
||||
@if (creating()) {
|
||||
Creating...
|
||||
} @else {
|
||||
Create Token
|
||||
}
|
||||
</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
|
||||
<!-- Token Created Dialog -->
|
||||
<ui-dialog [open]="tokenCreatedDialogOpen()" (openChange)="tokenCreatedDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Token Created</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Copy this token now. You won't be able to see it again!
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<div class="py-4">
|
||||
<div class="p-4 bg-muted rounded-md font-mono text-sm break-all">
|
||||
{{ createdPlainToken() }}
|
||||
</div>
|
||||
</div>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton (click)="copyToken()">Copy Token</button>
|
||||
<button uiButton variant="outline" (click)="tokenCreatedDialogOpen.set(false)">Done</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete Token</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Are you sure you want to delete "{{ tokenToDelete()?.name }}"? This action cannot be undone.
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton variant="destructive" (click)="deleteToken()">Delete</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class TokensComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
tokens = signal<IRegistryToken[]>([]);
|
||||
services = signal<IService[]>([]);
|
||||
loading = signal(false);
|
||||
creating = signal(false);
|
||||
|
||||
createDialogOpen = signal(false);
|
||||
tokenCreatedDialogOpen = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
tokenToDelete = signal<IRegistryToken | null>(null);
|
||||
createdPlainToken = signal('');
|
||||
|
||||
// Form state
|
||||
createForm: ICreateTokenRequest = {
|
||||
name: '',
|
||||
type: 'global',
|
||||
scope: 'all',
|
||||
expiresIn: '90d',
|
||||
};
|
||||
scopeAll = true;
|
||||
selectedServices = signal<string[]>([]);
|
||||
selectedSingleService = '';
|
||||
|
||||
// Computed signals for filtered tokens
|
||||
globalTokens = computed(() => this.tokens().filter(t => t.type === 'global'));
|
||||
ciTokens = computed(() => this.tokens().filter(t => t.type === 'ci'));
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTokens();
|
||||
this.loadServices();
|
||||
}
|
||||
|
||||
async loadTokens(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getRegistryTokens();
|
||||
if (response.success && response.data) {
|
||||
this.tokens.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load tokens');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadServices(): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getServices();
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - services list is optional
|
||||
}
|
||||
}
|
||||
|
||||
openCreateDialog(type?: 'global' | 'ci'): void {
|
||||
this.createForm = {
|
||||
name: '',
|
||||
type: type || 'global',
|
||||
scope: 'all',
|
||||
expiresIn: '90d',
|
||||
};
|
||||
this.scopeAll = true;
|
||||
this.selectedServices.set([]);
|
||||
this.selectedSingleService = '';
|
||||
this.createDialogOpen.set(true);
|
||||
}
|
||||
|
||||
toggleService(serviceName: string): void {
|
||||
const current = this.selectedServices();
|
||||
if (current.includes(serviceName)) {
|
||||
this.selectedServices.set(current.filter(s => s !== serviceName));
|
||||
} else {
|
||||
this.selectedServices.set([...current, serviceName]);
|
||||
}
|
||||
}
|
||||
|
||||
async createToken(): Promise<void> {
|
||||
if (!this.createForm.name) {
|
||||
this.toast.error('Please enter a token name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build scope based on type
|
||||
let scope: 'all' | string[];
|
||||
if (this.createForm.type === 'global') {
|
||||
if (this.scopeAll) {
|
||||
scope = 'all';
|
||||
} else {
|
||||
if (this.selectedServices().length === 0) {
|
||||
this.toast.error('Please select at least one service');
|
||||
return;
|
||||
}
|
||||
scope = this.selectedServices();
|
||||
}
|
||||
} else {
|
||||
if (!this.selectedSingleService) {
|
||||
this.toast.error('Please select a service');
|
||||
return;
|
||||
}
|
||||
scope = [this.selectedSingleService];
|
||||
}
|
||||
|
||||
this.creating.set(true);
|
||||
try {
|
||||
const response = await this.api.createRegistryToken({
|
||||
...this.createForm,
|
||||
scope,
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
this.createdPlainToken.set(response.data.plainToken);
|
||||
this.createDialogOpen.set(false);
|
||||
this.tokenCreatedDialogOpen.set(true);
|
||||
this.loadTokens();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to create token');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to create token');
|
||||
} finally {
|
||||
this.creating.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
copyToken(): void {
|
||||
navigator.clipboard.writeText(this.createdPlainToken());
|
||||
this.toast.success('Token copied to clipboard');
|
||||
}
|
||||
|
||||
confirmDelete(token: IRegistryToken): void {
|
||||
this.tokenToDelete.set(token);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteToken(): Promise<void> {
|
||||
const token = this.tokenToDelete();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await this.api.deleteRegistryToken(token.id);
|
||||
if (response.success) {
|
||||
this.toast.success('Token deleted');
|
||||
this.loadTokens();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete token');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete token');
|
||||
} finally {
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.tokenToDelete.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const days = Math.ceil((timestamp - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Tomorrow';
|
||||
if (days < 30) return `${days} days`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatRelativeTime(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export class LayoutComponent {
|
||||
{ label: 'Dashboard', path: '/dashboard', icon: 'home' },
|
||||
{ label: 'Services', path: '/services', icon: 'server' },
|
||||
{ label: 'Registries', path: '/registries', icon: 'database' },
|
||||
{ label: 'Tokens', path: '/tokens', icon: 'key' },
|
||||
{ label: 'DNS', path: '/dns', icon: 'globe' },
|
||||
{ label: 'Domains', path: '/domains', icon: 'link' },
|
||||
{ label: 'Settings', path: '/settings', icon: 'settings' },
|
||||
|
||||
Reference in New Issue
Block a user