feat(ui): Sync UI tab state with URL and update routes/links
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.0',
|
||||||
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user