Compare commits

...

5 Commits

Author SHA1 Message Date
jkunz e6ebac76b4 v1.27.0
Release / build-and-release (push) Successful in 2m34s
2026-05-21 20:35:07 +00:00
jkunz 27888a9fd1 chore(web): update bundled onebox app 2026-05-21 20:34:36 +00:00
jkunz 3f6b058ce5 feat(web): group onebox sidebar navigation 2026-05-21 20:32:40 +00:00
jkunz ba370cbce8 v1.26.3
Release / build-and-release (push) Successful in 2m29s
2026-05-21 17:06:38 +00:00
jkunz 43c8f261cc fix(web): use dees-table for gateway domains and DNS records views 2026-05-21 17:06:15 +00:00
11 changed files with 403 additions and 154 deletions
+17
View File
@@ -3,6 +3,23 @@
## Pending
## 2026-05-21 - 1.27.0
### Features
- group Onebox sidebar navigation into Apps, Network, and Registry sections (web)
- add parent/subview routes for grouped app, network, and registry pages
## 2026-05-21 - 1.26.3
### Fixes
- use `dees-table` for gateway domains and DNS records views (web)
- replace custom row grids with catalog tables, filtering, refresh, and row actions
- use dees-table for gateway domains and DNS records views (web)
- replace custom row layouts with dees-table in gateway domains and DNS records views
- add table filtering, refresh actions, and row/context actions for dcrouter management
## 2026-05-20 - 1.26.2
### Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.26.2",
"version": "1.27.0",
"exports": "./mod.ts",
"tasks": {
"test": "deno test --allow-all test/",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.26.2",
"version": "1.27.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts",
"type": "module",
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.26.2',
version: '1.27.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.26.2',
version: '1.27.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}
+11 -2
View File
@@ -64,6 +64,7 @@ export interface IAppStoreState {
export interface IUiState {
activeView: string;
activeSubview: string | null;
autoRefresh: boolean;
refreshInterval: number;
pendingAppTemplate?: any;
@@ -161,6 +162,7 @@ export const uiStatePart = await appState.getStatePart<IUiState>(
'ui',
{
activeView: 'dashboard',
activeSubview: null,
autoRefresh: true,
refreshInterval: 30000,
},
@@ -1016,10 +1018,17 @@ export const setBackupPasswordAction = settingsStatePart.createAction<{ password
// UI Actions
// ============================================================================
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
export const setActiveViewAction = uiStatePart.createAction<{ view: string; subview?: string | null }>(
async (statePartArg, dataArg) => {
const normalizedView = dataArg.view.toLowerCase().replace(/\s+/g, '-');
return { ...statePartArg.getState(), activeView: normalizedView };
const normalizedSubview = dataArg.subview
? dataArg.subview.toLowerCase().replace(/\s+/g, '-')
: null;
return {
...statePartArg.getState(),
activeView: normalizedView,
activeSubview: normalizedSubview,
};
},
);
+170 -57
View File
@@ -12,14 +12,21 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
import type { ObViewDashboard } from './ob-view-dashboard.js';
import type { ObViewServices } from './ob-view-services.js';
import type { ObViewDomains } from './ob-view-domains.js';
import type { ObViewDnsRecords } from './ob-view-dns-records.js';
import type { ObViewNetwork } from './ob-view-network.js';
import type { ObViewRegistries } from './ob-view-registries.js';
import type { ObViewTokens } from './ob-view-tokens.js';
import type { ObViewSettings } from './ob-view-settings.js';
interface IUnresolvedView {
slug?: string;
name: string;
iconName?: string;
element?: Promise<any>;
subViews?: IUnresolvedView[];
}
interface IResolvedView {
slug?: string;
name: string;
iconName?: string;
element?: any;
subViews?: IResolvedView[];
}
@customElement('ob-app-shell')
export class ObAppShell extends DeesElement {
@@ -29,6 +36,7 @@ export class ObAppShell extends DeesElement {
@state()
accessor uiState: appstate.IUiState = {
activeView: 'dashboard',
activeSubview: null,
autoRefresh: true,
refreshInterval: 30000,
};
@@ -39,27 +47,93 @@ export class ObAppShell extends DeesElement {
@state()
accessor loginError: string = '';
private viewTabs = [
{ name: 'Dashboard', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() },
{ name: 'App Store', iconName: 'lucide:store', element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)() },
{ name: 'Services', iconName: 'lucide:boxes', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() },
{ name: 'Domains', iconName: 'lucide:globe', element: (async () => (await import('./ob-view-domains.js')).ObViewDomains)() },
{ name: 'DNS Records', iconName: 'lucide:listTree', element: (async () => (await import('./ob-view-dns-records.js')).ObViewDnsRecords)() },
{ name: 'Network', iconName: 'lucide:network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() },
{ name: 'Registries', iconName: 'lucide:package', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() },
{ name: 'Tokens', iconName: 'lucide:key', element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)() },
{ name: 'Settings', iconName: 'lucide:settings', element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)() },
private viewTabs: IUnresolvedView[] = [
{
slug: 'dashboard',
name: 'Dashboard',
iconName: 'lucide:layoutDashboard',
element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)(),
},
{
slug: 'apps',
name: 'Apps',
iconName: 'lucide:store',
subViews: [
{
slug: 'app-store',
name: 'App Store',
iconName: 'lucide:store',
element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)(),
},
{
slug: 'services',
name: 'Services',
iconName: 'lucide:boxes',
element: (async () => (await import('./ob-view-services.js')).ObViewServices)(),
},
],
},
{
slug: 'network',
name: 'Network',
iconName: 'lucide:network',
subViews: [
{
slug: 'proxy',
name: 'Proxy',
iconName: 'lucide:route',
element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)(),
},
{
slug: 'domains',
name: 'Domains',
iconName: 'lucide:globe',
element: (async () => (await import('./ob-view-domains.js')).ObViewDomains)(),
},
{
slug: 'dns-records',
name: 'DNS Records',
iconName: 'lucide:listTree',
element: (async () => (await import('./ob-view-dns-records.js')).ObViewDnsRecords)(),
},
],
},
{
slug: 'registry',
name: 'Registry',
iconName: 'lucide:package',
subViews: [
{
slug: 'registries',
name: 'Registries',
iconName: 'lucide:package',
element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)(),
},
{
slug: 'tokens',
name: 'Tokens',
iconName: 'lucide:key',
element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)(),
},
],
},
{
slug: 'settings',
name: 'Settings',
iconName: 'lucide:settings',
element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)(),
},
];
private resolvedViewTabs: Array<{ name: string; iconName?: string; element: any }> = [];
private resolvedViewTabs: IResolvedView[] = [];
constructor() {
super();
document.title = 'Onebox';
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg)
.subscribe((loginState) => {
.select((stateArg: appstate.ILoginState) => stateArg)
.subscribe((loginState: appstate.ILoginState) => {
this.loginState = loginState;
if (loginState.isLoggedIn) {
appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null);
@@ -68,15 +142,56 @@ export class ObAppShell extends DeesElement {
this.rxSubscriptions.push(loginSubscription);
const uiSubscription = appstate.uiStatePart
.select((stateArg) => stateArg)
.subscribe((uiState) => {
.select((stateArg: appstate.IUiState) => stateArg)
.subscribe((uiState: appstate.IUiState) => {
this.uiState = uiState;
this.syncAppdashView(uiState.activeView);
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
});
this.rxSubscriptions.push(uiSubscription);
}
public static styles = [
private async resolveViewTabs(tabs: IUnresolvedView[]): Promise<IResolvedView[]> {
return Promise.all(
tabs.map(async (tab) => {
const resolvedTab: IResolvedView = {
slug: tab.slug,
name: tab.name,
iconName: tab.iconName,
};
if (tab.element) {
resolvedTab.element = await tab.element;
}
if (tab.subViews) {
resolvedTab.subViews = await this.resolveViewTabs(tab.subViews);
}
return resolvedTab;
}),
);
}
private slugFor(view: IResolvedView): string {
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '-');
}
private findParent(view: IResolvedView): IResolvedView | undefined {
return this.resolvedViewTabs.find((viewTab) => viewTab.subViews?.includes(view));
}
private findViewBySlug(viewSlug: string, subviewSlug: string | null): IResolvedView | undefined {
const topLevelView = this.resolvedViewTabs.find((view) => this.slugFor(view) === viewSlug);
if (!topLevelView) return undefined;
if (subviewSlug && topLevelView.subViews) {
return topLevelView.subViews.find((subview) => this.slugFor(subview) === subviewSlug) ?? topLevelView;
}
return topLevelView;
}
private get currentViewTab(): IResolvedView | undefined {
if (this.resolvedViewTabs.length === 0) return undefined;
return this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.resolvedViewTabs[0];
}
public static override styles = [
cssManager.defaultStyles,
css`
:host {
@@ -91,16 +206,14 @@ export class ObAppShell extends DeesElement {
`,
];
public render(): TemplateResult {
public override render(): TemplateResult {
return html`
<div class="maincontainer">
<dees-simple-login name="Onebox">
<dees-simple-appdash
name="Onebox"
.viewTabs=${this.resolvedViewTabs}
.selectedView=${this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView
) || this.resolvedViewTabs[0]}
.selectedView=${this.currentViewTab}
>
</dees-simple-appdash>
</dees-simple-login>
@@ -108,15 +221,8 @@ export class ObAppShell extends DeesElement {
`;
}
public async firstUpdated() {
// Resolve async view tab imports
this.resolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({
name: tab.name,
iconName: tab.iconName,
element: await tab.element,
})),
);
public override async firstUpdated() {
this.resolvedViewTabs = await this.resolveViewTabs(this.viewTabs);
this.requestUpdate();
await this.updateComplete;
@@ -130,34 +236,44 @@ export class ObAppShell extends DeesElement {
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => {
const viewName = e.detail.view.name.toLowerCase().replace(/\s+/g, '-');
appRouter.navigateToView(viewName);
const view = e.detail.view as IResolvedView;
const parent = this.findParent(view);
const currentState = appstate.uiStatePart.getState();
if (parent) {
const parentSlug = this.slugFor(parent);
const subviewSlug = this.slugFor(view);
if (currentState.activeView === parentSlug && currentState.activeSubview === subviewSlug) {
return;
}
appRouter.navigateToView(parentSlug, subviewSlug);
} else {
const slug = this.slugFor(view);
if (currentState.activeView === slug && !currentState.activeSubview) {
return;
}
appRouter.navigateToView(slug);
}
});
appDash.addEventListener('logout', async () => {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
});
}
// Load the initial view on the appdash now that tabs are resolved
// Read activeView directly from state (not this.uiState which may be stale)
if (appDash && this.resolvedViewTabs.length > 0) {
const currentActiveView = appstate.uiStatePart.getState().activeView;
const initialView = this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === currentActiveView,
) || this.resolvedViewTabs[0];
const currentUiState = appstate.uiStatePart.getState();
const initialView =
this.findViewBySlug(currentUiState.activeView, currentUiState.activeSubview) ||
this.resolvedViewTabs[0];
await appDash.loadView(initialView);
}
// Check for stored session (persistent login state)
const loginState = appstate.loginStatePart.getState();
if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) {
// Switch to dashboard immediately (no flash of login form)
this.loginState = loginState;
if (simpleLogin) {
await simpleLogin.switchToSlottedContent();
}
// Validate token with server in the background
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSystemStatus
@@ -165,11 +281,9 @@ export class ObAppShell extends DeesElement {
const response = await typedRequest.fire({ identity: loginState.identity });
appstate.systemStatePart.setState({ status: response.status });
} catch (err) {
// Token rejected by server - switch back to login
console.warn('Stored session invalid, returning to login:', err);
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
if (simpleLogin) {
// Force page reload to show login properly
window.location.reload();
}
}
@@ -210,14 +324,13 @@ export class ObAppShell extends DeesElement {
}
}
private syncAppdashView(viewName: string): void {
private syncAppdashView(viewName: string, subviewName: string | null): void {
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash || this.resolvedViewTabs.length === 0) return;
// Match kebab-case view name (e.g., 'app-store') to tab name (e.g., 'App Store')
const targetTab = this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === viewName
);
if (!targetTab) return;
const targetTab = this.findViewBySlug(viewName, subviewName);
if (!targetTab || appDash.selectedView === targetTab) return;
appDash.loadView(targetTab);
}
}
+58 -29
View File
@@ -1,4 +1,5 @@
import * as shared from './shared/index.js';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import {
@@ -11,6 +12,8 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
type TGatewayDnsRecord = appstate.INetworkState['gatewayDnsRecords'][number];
@customElement('ob-view-dns-records')
export class ObViewDnsRecords extends DeesElement {
@state()
@@ -37,16 +40,11 @@ export class ObViewDnsRecords extends DeesElement {
cssManager.defaultStyles,
shared.viewHostCss,
css`
.table { border: 1px solid var(--ci-shade-2, #e4e4e7); border-radius: 10px; overflow: hidden; }
.row { display: grid; grid-template-columns: 2fr 90px 2fr 90px 140px 220px; gap: 16px; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--ci-shade-2, #e4e4e7); }
.row:last-child { border-bottom: none; }
.header { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--ci-shade-5, #71717a); background: var(--ci-shade-1, #f4f4f5); }
.name { font-weight: 600; }
.value { font-family: monospace; color: var(--ci-shade-5, #71717a); overflow-wrap: anywhere; }
.muted { color: var(--ci-shade-5, #71717a); font-size: 13px; }
.badge { border-radius: 999px; padding: 3px 8px; background: var(--ci-shade-1, #f4f4f5); font-size: 12px; }
.missing { color: #dc2626; }
a, button.link { color: var(--ci-primary, #2563eb); background: none; border: none; padding: 0; cursor: pointer; font: inherit; text-decoration: none; }
.actions { display: flex; gap: 12px; }
.empty { padding: 32px; text-align: center; color: var(--ci-shade-5, #71717a); }
`,
];
@@ -60,29 +58,60 @@ export class ObViewDnsRecords extends DeesElement {
const records = this.networkState.gatewayDnsRecords;
return html`
<ob-sectionheading>DNS Records</ob-sectionheading>
<div class="table">
<div class="row header">
<span>Name</span>
<span>Type</span>
<span>Value</span>
<span>Status</span>
<span>Service</span>
<span>Actions</span>
</div>
${records.length ? records.map((record) => html`
<div class="row ${record.status === 'missing' ? 'missing' : ''}">
<span class="name">${record.name}</span>
<span><span class="badge">${record.type}</span></span>
<span class="value">${record.value || '-'}</span>
<span>${record.status}</span>
<span>${record.serviceName || record.appId}</span>
<span class="actions">
<button class="link" @click=${() => appRouter.navigateToView('services')}>View service</button>
${record.manageUrl ? html`<a href=${record.manageUrl} target="_blank" rel="noopener">Manage in dcrouter</a>` : ''}
</span>
</div>
`) : html`<div class="empty">No gateway DNS records found. Configure a dcrouter gateway in Settings.</div>`}
</div>
${records.length
? html`
<dees-table
.heading1=${'Gateway DNS Records'}
.heading2=${'DNS records published through dcrouter for Onebox services'}
.data=${records}
.showColumnFilters=${true}
.displayFunction=${(record: TGatewayDnsRecord) => ({
Name: html`
<div>
<div class="name">${record.name}</div>
${record.domainName ? html`<div class="muted">${record.domainName}</div>` : ''}
</div>
`,
Type: html`<span class="badge">${record.type}</span>`,
Value: html`<span class="value">${record.value || '-'}</span>`,
Status: html`<span class=${record.status === 'missing' ? 'missing' : ''}>${record.status}</span>`,
Service: record.serviceName || record.appId || '-',
})}
.dataActions=${[
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header'],
actionFunc: async () => {
await appstate.networkStatePart.dispatchAction(
appstate.fetchGatewayDnsRecordsAction,
null,
);
},
},
{
name: 'View service',
iconName: 'lucide:boxes',
type: ['inRow', 'contextmenu'],
actionFunc: async () => {
appRouter.navigateToView('services');
},
},
{
name: 'Manage in dcrouter',
iconName: 'lucide:externalLink',
type: ['inRow', 'contextmenu'],
actionRelevancyCheckFunc: (record: TGatewayDnsRecord) => !!record.manageUrl,
actionFunc: async (actionData: plugins.deesCatalog.ITableActionDataArg<TGatewayDnsRecord>) => {
if (actionData.item.manageUrl) {
globalThis.open(actionData.item.manageUrl, '_blank', 'noopener');
}
},
},
] as plugins.deesCatalog.ITableAction<TGatewayDnsRecord>[]}
></dees-table>
`
: html`<div class="empty">No gateway DNS records found. Configure a dcrouter gateway in Settings.</div>`}
`;
}
}
+48 -37
View File
@@ -1,4 +1,5 @@
import * as shared from './shared/index.js';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import {
DeesElement,
@@ -10,6 +11,8 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
type TGatewayDomain = appstate.INetworkState['gatewayDomains'][number];
@customElement('ob-view-domains')
export class ObViewDomains extends DeesElement {
@state()
@@ -36,25 +39,9 @@ export class ObViewDomains extends DeesElement {
cssManager.defaultStyles,
shared.viewHostCss,
css`
.table {
border: 1px solid var(--ci-shade-2, #e4e4e7);
border-radius: 10px;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 2fr 1fr 120px 120px 140px;
gap: 16px;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--ci-shade-2, #e4e4e7);
}
.row:last-child { border-bottom: none; }
.header { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--ci-shade-5, #71717a); background: var(--ci-shade-1, #f4f4f5); }
.domain { font-weight: 600; }
.muted { color: var(--ci-shade-5, #71717a); font-size: 13px; }
.badge { border-radius: 999px; padding: 3px 8px; background: var(--ci-shade-1, #f4f4f5); font-size: 12px; }
a { color: var(--ci-primary, #2563eb); text-decoration: none; }
.empty { padding: 32px; text-align: center; color: var(--ci-shade-5, #71717a); }
`,
];
@@ -71,27 +58,51 @@ export class ObViewDomains extends DeesElement {
<div class="muted" style="margin-bottom: 16px;">
Domains are managed in dcrouter. Onebox shows gateway visibility for deployed services.
</div>
<div class="table">
<div class="row header">
<span>Domain</span>
<span>Source</span>
<span>Authoritative</span>
<span>Services</span>
<span>Actions</span>
</div>
${domains.length ? domains.map((domain) => html`
<div class="row">
<span>
<span class="domain">${domain.name}</span>
${domain.providerId ? html`<div class="muted">Provider: ${domain.providerId}</div>` : ''}
</span>
<span><span class="badge">${domain.source || 'dcrouter'}</span></span>
<span>${domain.authoritative ? 'Yes' : 'No'}</span>
<span>${domain.serviceCount || 0}</span>
<span>${domain.manageUrl ? html`<a href=${domain.manageUrl} target="_blank" rel="noopener">Manage in dcrouter</a>` : '-'}</span>
</div>
`) : html`<div class="empty">No gateway domains found. Configure a dcrouter gateway in Settings.</div>`}
</div>
${domains.length
? html`
<dees-table
.heading1=${'Gateway Domains'}
.heading2=${'Domains imported from dcrouter gateway visibility'}
.data=${domains}
.showColumnFilters=${true}
.displayFunction=${(domain: TGatewayDomain) => ({
Domain: html`
<div>
<div class="domain">${domain.name}</div>
${domain.providerId ? html`<div class="muted">Provider: ${domain.providerId}</div>` : ''}
</div>
`,
Source: html`<span class="badge">${domain.source || 'dcrouter'}</span>`,
Authoritative: domain.authoritative ? 'Yes' : 'No',
Services: domain.serviceCount || 0,
})}
.dataActions=${[
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header'],
actionFunc: async () => {
await appstate.networkStatePart.dispatchAction(
appstate.fetchGatewayDomainsAction,
null,
);
},
},
{
name: 'Manage in dcrouter',
iconName: 'lucide:externalLink',
type: ['inRow', 'contextmenu'],
actionRelevancyCheckFunc: (domain: TGatewayDomain) => !!domain.manageUrl,
actionFunc: async (actionData: plugins.deesCatalog.ITableActionDataArg<TGatewayDomain>) => {
if (actionData.item.manageUrl) {
globalThis.open(actionData.item.manageUrl, '_blank', 'noopener');
}
},
},
] as plugins.deesCatalog.ITableAction<TGatewayDomain>[]}
></dees-table>
`
: html`<div class="empty">No gateway domains found. Configure a dcrouter gateway in Settings.</div>`}
`;
}
}
+94 -24
View File
@@ -3,12 +3,40 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = [
'dashboard', 'app-store', 'services', 'domains', 'dns-records', 'network',
'registries', 'tokens', 'settings',
] as const;
const flatViews = ['dashboard', 'settings'] as const;
export type TValidView = typeof validViews[number];
const subviewMap: Record<string, readonly string[]> = {
apps: ['app-store', 'services'] as const,
network: ['proxy', 'domains', 'dns-records'] as const,
registry: ['registries', 'tokens'] as const,
};
const defaultSubview: Record<string, string> = {
apps: 'app-store',
network: 'proxy',
registry: 'registries',
};
const legacySubviewTargetMap: Record<string, { view: string; subview: string }> = {
'app-store': { view: 'apps', subview: 'app-store' },
services: { view: 'apps', subview: 'services' },
proxy: { view: 'network', subview: 'proxy' },
domains: { view: 'network', subview: 'domains' },
'dns-records': { view: 'network', subview: 'dns-records' },
registries: { view: 'registry', subview: 'registries' },
tokens: { view: 'registry', subview: 'tokens' },
};
export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;
export type TValidView = typeof validTopLevelViews[number];
export function isValidView(view: string): boolean {
return (validTopLevelViews as readonly string[]).includes(view);
}
export function isValidSubview(view: string, subview: string): boolean {
return subviewMap[view]?.includes(subview) ?? false;
}
class AppRouter {
private router: InstanceType<typeof SmartRouter>;
@@ -28,24 +56,37 @@ class AppRouter {
}
private setupRoutes(): void {
for (const view of validViews) {
for (const view of flatViews) {
this.router.on(`/${view}`, async () => {
this.updateViewState(view);
this.updateViewState(view, null);
});
}
// Root redirect
for (const view of Object.keys(subviewMap)) {
this.router.on(`/${view}`, async () => {
this.navigateTo(`/${view}/${defaultSubview[view]}`);
});
for (const subview of subviewMap[view]) {
this.router.on(`/${view}/${subview}`, async () => {
this.updateViewState(view, subview);
});
}
}
this.router.on('/', async () => {
this.navigateTo('/dashboard');
});
}
private setupStateSync(): void {
appstate.uiStatePart.select((s) => s.activeView).subscribe((activeView) => {
appstate.uiStatePart.select().subscribe((uiState: appstate.IUiState) => {
if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname;
const expectedPath = `/${activeView}`;
const expectedPath = uiState.activeSubview
? `/${uiState.activeView}/${uiState.activeSubview}`
: `/${uiState.activeView}`;
if (currentPath !== expectedPath) {
this.suppressStateUpdate = true;
@@ -60,25 +101,37 @@ class AppRouter {
if (!path || path === '/') {
this.router.pushUrl('/dashboard');
} else {
const segments = path.split('/').filter(Boolean);
const view = segments[0];
return;
}
if (validViews.includes(view as TValidView)) {
this.updateViewState(view as TValidView);
const segments = path.split('/').filter(Boolean);
const view = segments[0];
const subview = segments[1];
if (!isValidView(view)) {
this.router.pushUrl('/dashboard');
return;
}
if (subviewMap[view]) {
if (subview && isValidSubview(view, subview)) {
this.updateViewState(view, subview);
} else {
this.router.pushUrl('/dashboard');
this.router.pushUrl(`/${view}/${defaultSubview[view]}`);
}
} else {
this.updateViewState(view, null);
}
}
private updateViewState(view: string): void {
private updateViewState(view: string, subview: string | null): void {
this.suppressStateUpdate = true;
const currentState = appstate.uiStatePart.getState();
if (currentState.activeView !== view) {
if (currentState.activeView !== view || currentState.activeSubview !== subview) {
appstate.uiStatePart.setState({
...currentState,
activeView: view,
activeSubview: subview,
});
}
this.suppressStateUpdate = false;
@@ -88,17 +141,34 @@ class AppRouter {
this.router.pushUrl(path);
}
public navigateToView(view: string): void {
const normalized = view.toLowerCase().replace(/\s+/g, '-');
if (validViews.includes(normalized as TValidView)) {
this.navigateTo(`/${normalized}`);
} else {
public navigateToView(view: string, subview?: string): void {
const normalizedView = view.toLowerCase().replace(/\s+/g, '-');
const normalizedSubview = subview?.toLowerCase().replace(/\s+/g, '-');
if (!isValidView(normalizedView)) {
const legacyTarget = legacySubviewTargetMap[normalizedView];
if (legacyTarget) {
this.navigateToView(legacyTarget.view, legacyTarget.subview);
return;
}
this.navigateTo('/dashboard');
return;
}
if (normalizedSubview && isValidSubview(normalizedView, normalizedSubview)) {
this.navigateTo(`/${normalizedView}/${normalizedSubview}`);
} else if (subviewMap[normalizedView]) {
this.navigateTo(`/${normalizedView}/${defaultSubview[normalizedView]}`);
} else {
this.navigateTo(`/${normalizedView}`);
}
}
public getCurrentView(): string {
return appstate.uiStatePart.getState().activeView;
const uiState = appstate.uiStatePart.getState();
return uiState.activeSubview
? `${uiState.activeView}/${uiState.activeSubview}`
: uiState.activeView;
}
public destroy(): void {