import * as plugins from '../plugins.js'; import * as appstate from '../appstate.js'; import * as interfaces from '../../ts_interfaces/index.js'; import { appRouter } from '../router.js'; import { DeesElement, customElement, html, state, css, cssManager, type TemplateResult, } from '@design.estate/dees-element'; interface IUnresolvedView { slug?: string; name: string; iconName?: string; element?: Promise; subViews?: IUnresolvedView[]; } interface IResolvedView { slug?: string; name: string; iconName?: string; element?: any; subViews?: IResolvedView[]; } @customElement('ob-app-shell') export class ObAppShell extends DeesElement { @state() accessor loginState: appstate.ILoginState = { identity: null, isLoggedIn: false }; @state() accessor uiState: appstate.IUiState = { activeView: 'dashboard', activeSubview: null, autoRefresh: true, refreshInterval: 30000, }; @state() accessor systemState: appstate.ISystemState = { status: null, }; @state() accessor globalMessages: plugins.deesCatalog.IGlobalMessage[] = []; @state() accessor loginLoading: boolean = false; @state() accessor loginError: string = ''; 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: IResolvedView[] = []; private suppressedUpdateVersion = ''; private upgradeFlowRunning = false; constructor() { super(); document.title = 'Onebox'; const loginSubscription = appstate.loginStatePart .select((stateArg: appstate.ILoginState) => stateArg) .subscribe((loginState: appstate.ILoginState) => { this.loginState = loginState; this.updateGlobalMessages(); if (loginState.isLoggedIn) { appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null); } }); this.rxSubscriptions.push(loginSubscription); const systemSubscription = appstate.systemStatePart .select((stateArg: appstate.ISystemState) => stateArg) .subscribe((systemState: appstate.ISystemState) => { this.systemState = systemState; this.updateGlobalMessages(); }); this.rxSubscriptions.push(systemSubscription); const uiSubscription = appstate.uiStatePart .select((stateArg: appstate.IUiState) => stateArg) .subscribe((uiState: appstate.IUiState) => { this.uiState = uiState; this.syncAppdashView(uiState.activeView, uiState.activeSubview); }); this.rxSubscriptions.push(uiSubscription); } private async resolveViewTabs(tabs: IUnresolvedView[]): Promise { 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 { display: block; width: 100%; height: 100%; } .maincontainer { width: 100%; height: 100vh; } `, ]; public override render(): TemplateResult { return html`
`; } public override async firstUpdated() { this.resolvedViewTabs = await this.resolveViewTabs(this.viewTabs); this.requestUpdate(); await this.updateComplete; const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; if (simpleLogin) { simpleLogin.addEventListener('login', (e: CustomEvent) => { this.login(e.detail.data.username, e.detail.data.password); }); } const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any; if (appDash) { appDash.addEventListener('view-select', (e: CustomEvent) => { 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); }); } if (appDash && this.resolvedViewTabs.length > 0) { const currentUiState = appstate.uiStatePart.getState(); const initialView = this.findViewBySlug(currentUiState.activeView, currentUiState.activeSubview) || this.resolvedViewTabs[0]; await appDash.loadView(initialView); } const loginState = appstate.loginStatePart.getState(); if (loginState.identity?.jwt) { if (loginState.identity.expiresAt > Date.now()) { this.loginState = loginState; if (simpleLogin) { await simpleLogin.switchToSlottedContent(); } try { const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetSystemStatus >('/typedrequest', 'getSystemStatus'); const response = await typedRequest.fire({ identity: loginState.identity }); appstate.systemStatePart.setState({ status: response.status }); } catch (err) { console.warn('Stored session invalid, returning to login:', err); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); if (simpleLogin) { window.location.reload(); } } } else { await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); } } } private async login(username: string, password: string) { const domtools = await this.domtoolsPromise; const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; const form = simpleLogin?.shadowRoot?.querySelector('dees-form') as any; if (form) { form.setStatus('pending', 'Logging in...'); } const newState = await appstate.loginStatePart.dispatchAction(appstate.loginAction, { username, password, }); if (newState.identity) { if (form) { form.setStatus('success', 'Logged in!'); } if (simpleLogin) { await simpleLogin.switchToSlottedContent(); } await appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null); } else { if (form) { form.setStatus('error', 'Login failed!'); await domtools.convenience.smartdelay.delayFor(2000); form.reset(); } } } private updateGlobalMessages(): void { const updateStatus = this.systemState.status?.onebox.update; if ( !this.loginState.isLoggedIn || !updateStatus?.updateAvailable || !updateStatus.latestVersion || updateStatus.latestVersion === this.suppressedUpdateVersion ) { this.globalMessages = []; return; } this.globalMessages = [ { id: `onebox-update-${updateStatus.latestVersion}`, type: 'info', icon: 'lucide:download', message: `Onebox ${updateStatus.latestVersion} is available. Current version: ${updateStatus.currentVersion}.`, dismissible: false, actions: [ { name: 'Update Now', iconName: 'lucide:download', action: () => this.startOneboxUpgradeFlow(), }, { name: 'Release Notes', iconName: 'lucide:fileText', action: () => this.openUpdateUrl(updateStatus.changelogUrl || updateStatus.releaseUrl), }, { name: 'Later', iconName: 'lucide:clock', action: () => { this.suppressedUpdateVersion = updateStatus.latestVersion || ''; this.updateGlobalMessages(); }, }, ], }, ]; } private async startOneboxUpgradeFlow(): Promise { if (this.upgradeFlowRunning) { return; } const identity = appstate.loginStatePart.getState().identity; const updateStatus = this.systemState.status?.onebox.update; if (!identity || !updateStatus?.latestVersion) { return; } this.upgradeFlowRunning = true; const updater = await plugins.deesCatalog.DeesUpdater.createAndShow({ currentVersion: updateStatus.currentVersion, updatedVersion: updateStatus.latestVersion, moreInfoUrl: updateStatus.releaseUrl, changelogUrl: updateStatus.changelogUrl, successAction: 'reload', successDelayMs: 30000, successActionLabel: 'Reloading Onebox UI', }); try { updater.updateProgress({ percentage: 10, indeterminate: true, statusText: 'Requesting upgrade...', terminalLines: ['Requesting Onebox upgrade'], }); const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_StartOneboxUpgrade >('/typedrequest', 'startOneboxUpgrade'); const response = await typedRequest.fire({ identity }); if (!response.upgrade.accepted) { updater.markUpdateError(response.upgrade.message); await this.delay(5000); await updater.destroy(); return; } updater.appendProgressLine(response.upgrade.message); if (response.upgrade.pid) { updater.appendProgressLine(`Upgrade process PID: ${response.upgrade.pid}`); } if (response.upgrade.logPath) { updater.appendProgressLine(`Upgrade log: ${response.upgrade.logPath}`); } updater.updateProgress({ percentage: 45, indeterminate: true, statusText: 'Installer started...', }); await this.waitForOneboxUpgrade(updater, response.upgrade.targetVersion, identity); await updater.markUpdateReady(); } catch (error) { updater.markUpdateError(this.getErrorMessage(error)); await this.delay(5000); await updater.destroy(); } finally { this.upgradeFlowRunning = false; } } private async waitForOneboxUpgrade( updaterArg: plugins.deesCatalog.DeesUpdater, targetVersionArg: string, identityArg: interfaces.data.IIdentity, ): Promise { const normalizedTargetVersion = this.normalizeVersion(targetVersionArg); const timeoutAt = Date.now() + 90000; let attempt = 0; updaterArg.appendProgressLine('Waiting for Onebox to restart with the new version'); while (Date.now() < timeoutAt) { await this.delay(5000); attempt++; try { const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetSystemStatus >('/typedrequest', 'getSystemStatus'); const response = await typedRequest.fire({ identity: identityArg }); const onlineVersion = this.normalizeVersion(response.status.onebox.version); updaterArg.appendProgressLine(`Onebox API answered with ${onlineVersion}`); if (onlineVersion === normalizedTargetVersion) { updaterArg.updateProgress({ percentage: 100, indeterminate: false, statusText: `Onebox ${normalizedTargetVersion} is online.`, }); return; } } catch { updaterArg.appendProgressLine('Onebox API is restarting...'); } updaterArg.updateProgress({ percentage: Math.min(95, 45 + attempt * 5), indeterminate: true, statusText: `Waiting for Onebox ${normalizedTargetVersion}...`, }); } updaterArg.appendProgressLine('Timed out waiting for the version check; reloading the UI anyway'); } private openUpdateUrl(urlArg: string): void { window.open(urlArg, '_blank', 'noopener,noreferrer'); } private async delay(millisecondsArg: number): Promise { const domtools = await this.domtoolsPromise; await domtools.convenience.smartdelay.delayFor(millisecondsArg); } private getErrorMessage(errorArg: unknown): string { return errorArg instanceof Error ? errorArg.message : String(errorArg); } private normalizeVersion(versionArg: string): string { const trimmedVersion = versionArg.trim(); return trimmedVersion.startsWith('v') ? trimmedVersion : `v${trimmedVersion}`; } 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; const targetTab = this.findViewBySlug(viewName, subviewName); if (!targetTab || appDash.selectedView === targetTab) return; appDash.loadView(targetTab); } }