4 Commits

12 changed files with 222 additions and 69 deletions

View File

@@ -1,5 +1,24 @@
# Changelog # Changelog
## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning
- MinIO provider now detects existing data directory and will reuse stored admin credentials when available instead of regenerating them.
- If data exists but no credentials are stored, MinIO deployment will wipe the data directory to avoid credential mismatch and fail early with a clear error if wiping fails.
- Provisioning and deprovisioning now connect to MinIO via the container's host-mapped port (127.0.0.1:<hostPort>) instead of relying on overlay network addresses; an error is thrown when the host port mapping cannot be determined.
- Bucket provisioning creates policies and returns environment variables using container network hostnames for in-network access; a warning notes that per-service MinIO accounts are TODO and root credentials are used for now.
- Added logging improvements around MinIO deploy/provision/deprovision steps for easier debugging.
- Added VSCode workspace files (extensions, launch, tasks) for the ui project to improve developer experience.
## 2025-11-26 - 1.2.0 - feat(ui)
Sync UI tab state with URL and update routes/links
- Add VSCode workspace recommendations, launch and tasks configs for the UI (ui/.vscode/*)
- Update Angular routes to support tab URL segments and default redirects for services, network and registries
- Change service detail route to use explicit 'detail/:name' path and update links accordingly
- Make ServicesList, Registries and Network components read tab from route params and navigate on tab changes; add ngOnDestroy to unsubscribe
- Update Domain detail template link to point to the new services detail route
## 2025-11-26 - 1.1.0 - feat(platform-services) ## 2025-11-26 - 1.1.0 - feat(platform-services)
Add platform service log streaming, improve health checks and provisioning robustness Add platform service log streaming, improve health checks and provisioning robustness

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.1.0", "version": "1.2.1",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.1.0", "version": "1.2.1",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.1.0', version: '1.2.1',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }

View File

@@ -57,22 +57,55 @@ export class MinioProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/minio';
// Generate admin credentials
const adminUser = 'admin';
const adminPassword = credentialEncryption.generatePassword(32);
const adminCredentials = {
username: adminUser,
password: adminPassword,
};
logger.info(`Deploying MinIO platform service as ${containerName}...`); logger.info(`Deploying MinIO platform service as ${containerName}...`);
// Check if we have existing data and stored credentials
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
let adminCredentials: { username: string; password: string };
let dataExists = false;
// Check if data directory has existing MinIO data
// MinIO creates .minio.sys directory on first startup
try {
const stat = await Deno.stat(`${dataDir}/.minio.sys`);
dataExists = stat.isDirectory;
logger.info(`MinIO data directory exists with .minio.sys folder`);
} catch {
// .minio.sys doesn't exist, this is a fresh install
dataExists = false;
}
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing MinIO credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new MinIO admin credentials');
adminCredentials = {
username: 'admin',
password: credentialEncryption.generatePassword(32),
};
// If data exists but we don't have credentials, we need to wipe the data
if (dataExists) {
logger.warn('MinIO data exists but no credentials in database - wiping data directory');
try {
await Deno.remove(dataDir, { recursive: true });
} catch (e) {
logger.error(`Failed to wipe MinIO data directory: ${getErrorMessage(e)}`);
throw new Error('Cannot deploy MinIO: data directory exists without credentials');
}
}
}
// Ensure data directory exists // Ensure data directory exists
try { try {
await Deno.mkdir('/var/lib/onebox/minio', { recursive: true }); await Deno.mkdir(dataDir, { recursive: true });
} catch (e) { } catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) { if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create MinIO data directory: ${getErrorMessage(e)}`); logger.warn(`Could not create MinIO data directory: ${getErrorMessage(e)}`);
} }
@@ -95,9 +128,8 @@ export class MinioProvider extends BasePlatformServiceProvider {
exposePorts: [9000, 9001], // API and Console ports exposePorts: [9000, 9001], // API and Console ports
}); });
// Store encrypted admin credentials // Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) { if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, { this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId, containerId,
@@ -118,41 +150,58 @@ export class MinioProvider extends BasePlatformServiceProvider {
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
logger.info('MinIO health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.containerId) { if (!platformService) {
logger.info('MinIO health check: platform service not found in database');
return false;
}
if (!platformService.adminCredentialsEncrypted) {
logger.info('MinIO health check: no admin credentials stored');
return false;
}
if (!platformService.containerId) {
logger.info('MinIO health check: no container ID in database record');
return false; return false;
} }
// Get container IP for health check (hostname won't resolve from host) logger.info(`MinIO health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
const containerIP = await this.oneboxRef.docker.getContainerIP(platformService.containerId);
if (!containerIP) { // Use docker exec to run health check inside the container
logger.debug('MinIO health check: could not get container IP'); // This avoids network issues with overlay networks
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['curl', '-sf', 'http://localhost:9000/minio/health/live']
);
if (result.exitCode === 0) {
logger.info('MinIO health check: success');
return true;
} else {
logger.info(`MinIO health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
return false; return false;
} }
const endpoint = `http://${containerIP}:9000/minio/health/live`;
const response = await fetch(endpoint, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch (error) { } catch (error) {
logger.debug(`MinIO health check failed: ${getErrorMessage(error)}`); logger.info(`MinIO health check exception: ${getErrorMessage(error)}`);
return false; return false;
} }
} }
async provisionResource(userService: IService): Promise<IProvisionedResource> { async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) { if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MinIO platform service not found or not configured'); throw new Error('MinIO platform service not found or not configured');
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); const containerName = this.getContainerName();
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
// Generate bucket name and credentials // Generate bucket name and credentials
const bucketName = this.generateBucketName(userService.name); const bucketName = this.generateBucketName(userService.name);
const accessKey = credentialEncryption.generateAccessKey(20); const accessKey = credentialEncryption.generateAccessKey(20);
@@ -160,14 +209,15 @@ export class MinioProvider extends BasePlatformServiceProvider {
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`); logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
const endpoint = `http://${containerName}:9000`; // Connect to MinIO via localhost and the mapped host port (for provisioning from host)
const provisioningEndpoint = `http://127.0.0.1:${hostPort}`;
// Import AWS S3 client // Import AWS S3 client
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3'); const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
// Create S3 client with admin credentials // Create S3 client with admin credentials - connect via host port
const s3Client = new S3Client({ const s3Client = new S3Client({
endpoint, endpoint: provisioningEndpoint,
region: 'us-east-1', region: 'us-east-1',
credentials: { credentials: {
accessKeyId: adminCreds.username, accessKeyId: adminCreds.username,
@@ -225,8 +275,11 @@ export class MinioProvider extends BasePlatformServiceProvider {
// TODO: Implement MinIO service account creation // TODO: Implement MinIO service account creation
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.'); logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
// Use container name for the endpoint in credentials (user services run in same network)
const serviceEndpoint = `http://${containerName}:9000`;
const credentials: Record<string, string> = { const credentials: Record<string, string> = {
endpoint, endpoint: serviceEndpoint,
bucket: bucketName, bucket: bucketName,
accessKey: adminCreds.username, // Using root for now accessKey: adminCreds.username, // Using root for now
secretKey: adminCreds.password, secretKey: adminCreds.password,
@@ -253,20 +306,24 @@ export class MinioProvider extends BasePlatformServiceProvider {
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> { async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) { if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MinIO platform service not found or not configured'); throw new Error('MinIO platform service not found or not configured');
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
const endpoint = `http://${containerName}:9000`; // Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`); logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3'); const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
const s3Client = new S3Client({ const s3Client = new S3Client({
endpoint, endpoint: `http://127.0.0.1:${hostPort}`,
region: 'us-east-1', region: 'us-east-1',
credentials: { credentials: {
accessKeyId: adminCreds.username, accessKeyId: adminCreds.username,

View File

@@ -32,10 +32,8 @@ export const routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
loadComponent: () => redirectTo: 'user',
import('./features/services/services-list.component').then( pathMatch: 'full',
(m) => m.ServicesListComponent
),
}, },
{ {
path: 'create', path: 'create',
@@ -52,12 +50,19 @@ export const routes: Routes = [
), ),
}, },
{ {
path: ':name', path: 'detail/:name',
loadComponent: () => loadComponent: () =>
import('./features/services/service-detail.component').then( import('./features/services/service-detail.component').then(
(m) => m.ServiceDetailComponent (m) => m.ServiceDetailComponent
), ),
}, },
{
path: ':tab',
loadComponent: () =>
import('./features/services/services-list.component').then(
(m) => m.ServicesListComponent
),
},
], ],
}, },
{ {
@@ -65,10 +70,8 @@ export const routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
loadComponent: () => redirectTo: 'proxy',
import('./features/network/network.component').then( pathMatch: 'full',
(m) => m.NetworkComponent
),
}, },
{ {
path: 'domains/:domain', path: 'domains/:domain',
@@ -77,14 +80,31 @@ export const routes: Routes = [
(m) => m.DomainDetailComponent (m) => m.DomainDetailComponent
), ),
}, },
{
path: ':tab',
loadComponent: () =>
import('./features/network/network.component').then(
(m) => m.NetworkComponent
),
},
], ],
}, },
{ {
path: 'registries', path: 'registries',
loadComponent: () => children: [
import('./features/registries/registries.component').then( {
(m) => m.RegistriesComponent path: '',
), redirectTo: 'onebox',
pathMatch: 'full',
},
{
path: ':tab',
loadComponent: () =>
import('./features/registries/registries.component').then(
(m) => m.RegistriesComponent
),
},
],
}, },
{ {
path: 'tokens', path: 'tokens',

View File

@@ -191,7 +191,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
</ui-badge> </ui-badge>
</ui-table-cell> </ui-table-cell>
<ui-table-cell class="text-right"> <ui-table-cell class="text-right">
<a [routerLink]="['/services', svc.name]"> <a [routerLink]="['/services/detail', svc.name]">
<button uiButton variant="outline" size="sm">View</button> <button uiButton variant="outline" size="sm">View</button>
</a> </a>
</ui-table-cell> </ui-table-cell>

View File

@@ -1,4 +1,6 @@
import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core'; import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
import { NetworkLogStreamService } from '../../core/services/network-log-stream.service'; import { NetworkLogStreamService } from '../../core/services/network-log-stream.service';
@@ -294,6 +296,9 @@ type TNetworkTab = 'proxy' | 'dns' | 'domains';
export class NetworkComponent implements OnInit, OnDestroy { export class NetworkComponent implements OnInit, OnDestroy {
private api = inject(ApiService); private api = inject(ApiService);
private toast = inject(ToastService); private toast = inject(ToastService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private routeSub?: Subscription;
networkLogStream = inject(NetworkLogStreamService); networkLogStream = inject(NetworkLogStreamService);
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>; @ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
@@ -321,15 +326,24 @@ export class NetworkComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
// Subscribe to route params to sync tab state with URL
this.routeSub = this.route.paramMap.subscribe((params) => {
const tab = params.get('tab') as TNetworkTab;
if (tab && ['proxy', 'dns', 'domains'].includes(tab)) {
this.activeTab.set(tab);
}
});
this.loadData(); this.loadData();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.routeSub?.unsubscribe();
this.networkLogStream.disconnect(); this.networkLogStream.disconnect();
} }
setTab(tab: TNetworkTab): void { setTab(tab: TNetworkTab): void {
this.activeTab.set(tab); this.router.navigate(['/network', tab]);
} }
async loadData(): Promise<void> { async loadData(): Promise<void> {

View File

@@ -1,6 +1,7 @@
import { Component, inject, signal, OnInit } from '@angular/core'; import { Component, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink, ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
import { IRegistry, IRegistryCreate } from '../../core/types/api.types'; import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
@@ -242,9 +243,12 @@ type TRegistriesTab = 'onebox' | 'external';
</ui-dialog> </ui-dialog>
`, `,
}) })
export class RegistriesComponent implements OnInit { export class RegistriesComponent implements OnInit, OnDestroy {
private api = inject(ApiService); private api = inject(ApiService);
private toast = inject(ToastService); private toast = inject(ToastService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private routeSub?: Subscription;
activeTab = signal<TRegistriesTab>('onebox'); activeTab = signal<TRegistriesTab>('onebox');
registries = signal<IRegistry[]>([]); registries = signal<IRegistry[]>([]);
@@ -256,13 +260,25 @@ export class RegistriesComponent implements OnInit {
form: IRegistryCreate = { url: '', username: '', password: '' }; form: IRegistryCreate = { url: '', username: '', password: '' };
setTab(tab: TRegistriesTab): void { setTab(tab: TRegistriesTab): void {
this.activeTab.set(tab); this.router.navigate(['/registries', tab]);
} }
ngOnInit(): void { ngOnInit(): void {
// Subscribe to route params to sync tab state with URL
this.routeSub = this.route.paramMap.subscribe((params) => {
const tab = params.get('tab') as TRegistriesTab;
if (tab && ['onebox', 'external'].includes(tab)) {
this.activeTab.set(tab);
}
});
this.loadRegistries(); this.loadRegistries();
} }
ngOnDestroy(): void {
this.routeSub?.unsubscribe();
}
async loadRegistries(): Promise<void> { async loadRegistries(): Promise<void> {
this.loading.set(true); this.loading.set(true);
try { try {

View File

@@ -1,5 +1,6 @@
import { Component, inject, signal, OnInit, OnDestroy, effect, ViewChild, ElementRef } from '@angular/core'; import { Component, inject, signal, OnInit, OnDestroy, effect, ViewChild, ElementRef } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Location } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
@@ -22,7 +23,6 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
selector: 'app-platform-service-detail', selector: 'app-platform-service-detail',
standalone: true, standalone: true,
imports: [ imports: [
RouterLink,
FormsModule, FormsModule,
CardComponent, CardComponent,
CardHeaderComponent, CardHeaderComponent,
@@ -38,7 +38,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <div>
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"> <a (click)="goBack()" class="cursor-pointer 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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
@@ -235,6 +235,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
`, `,
}) })
export class PlatformServiceDetailComponent implements OnInit, OnDestroy { export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
private location = inject(Location);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private api = inject(ApiService); private api = inject(ApiService);
@@ -281,6 +282,10 @@ export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
} }
} }
goBack(): void {
this.location.back();
}
async loadService(type: TPlatformServiceType): Promise<void> { async loadService(type: TPlatformServiceType): Promise<void> {
this.loading.set(true); this.loading.set(true);
try { try {

View File

@@ -1,4 +1,5 @@
import { Component, inject, signal, computed, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core'; import { Component, inject, signal, computed, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
import { Location } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
@@ -58,7 +59,7 @@ import {
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <div>
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"> <a (click)="goBack()" class="cursor-pointer 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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
@@ -422,6 +423,7 @@ import {
`, `,
}) })
export class ServiceDetailComponent implements OnInit, OnDestroy { export class ServiceDetailComponent implements OnInit, OnDestroy {
private location = inject(Location);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private api = inject(ApiService); private api = inject(ApiService);
@@ -479,6 +481,10 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
} }
} }
goBack(): void {
this.location.back();
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.logStream.disconnect(); this.logStream.disconnect();
} }

View File

@@ -1,5 +1,6 @@
import { Component, inject, signal, effect, OnInit } from '@angular/core'; import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink, ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service'; import { WebSocketService } from '../../core/services/websocket.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
@@ -69,7 +70,7 @@ type TServicesTab = 'user' | 'system';
<p class="text-muted-foreground">Manage your deployed and system services</p> <p class="text-muted-foreground">Manage your deployed and system services</p>
</div> </div>
@if (activeTab() === 'user') { @if (activeTab() === 'user') {
<a routerLink="/services/create"> <a [routerLink]="['/services/create']">
<button uiButton> <button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
@@ -105,7 +106,7 @@ type TServicesTab = 'user' | 'system';
</svg> </svg>
<h3 class="mt-4 text-lg font-semibold">No services</h3> <h3 class="mt-4 text-lg font-semibold">No services</h3>
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p> <p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
<a routerLink="/services/create" class="mt-4 inline-block"> <a [routerLink]="['/services/create']" class="mt-4 inline-block">
<button uiButton>Deploy Service</button> <button uiButton>Deploy Service</button>
</a> </a>
</div> </div>
@@ -124,7 +125,7 @@ type TServicesTab = 'user' | 'system';
@for (service of services(); track service.name) { @for (service of services(); track service.name) {
<ui-table-row> <ui-table-row>
<ui-table-cell> <ui-table-cell>
<a [routerLink]="['/services', service.name]" class="font-medium hover:underline"> <a [routerLink]="['/services/detail', service.name]" class="font-medium hover:underline">
{{ service.name }} {{ service.name }}
</a> </a>
</ui-table-cell> </ui-table-cell>
@@ -299,10 +300,13 @@ type TServicesTab = 'user' | 'system';
</ui-dialog> </ui-dialog>
`, `,
}) })
export class ServicesListComponent implements OnInit { export class ServicesListComponent implements OnInit, OnDestroy {
private api = inject(ApiService); private api = inject(ApiService);
private ws = inject(WebSocketService); private ws = inject(WebSocketService);
private toast = inject(ToastService); private toast = inject(ToastService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private routeSub?: Subscription;
// Tab state // Tab state
activeTab = signal<TServicesTab>('user'); activeTab = signal<TServicesTab>('user');
@@ -331,12 +335,24 @@ export class ServicesListComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
// Subscribe to route params to sync tab state with URL
this.routeSub = this.route.paramMap.subscribe((params) => {
const tab = params.get('tab') as TServicesTab;
if (tab && ['user', 'system'].includes(tab)) {
this.activeTab.set(tab);
}
});
this.loadServices(); this.loadServices();
this.loadPlatformServices(); this.loadPlatformServices();
} }
ngOnDestroy(): void {
this.routeSub?.unsubscribe();
}
setTab(tab: TServicesTab): void { setTab(tab: TServicesTab): void {
this.activeTab.set(tab); this.router.navigate(['/services', tab]);
} }
async loadServices(): Promise<void> { async loadServices(): Promise<void> {