Files
dcrouter/ts_web/elements/ops-dashboard.ts

364 lines
13 KiB
TypeScript
Raw Normal View History

import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { appRouter } from '../router.js';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult
} from '@design.estate/dees-element';
import type { IView } from '@design.estate/dees-catalog';
// Top-level / flat views
import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
// Overview group
import { OpsViewOverview } from './overview/ops-view-overview.js';
import { OpsViewConfig } from './overview/ops-view-config.js';
// Network group
import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
import { OpsViewRoutes } from './network/ops-view-routes.js';
import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
import { OpsViewRemoteIngress } from './network/ops-view-remoteingress.js';
import { OpsViewVpn } from './network/ops-view-vpn.js';
// Email group
import { OpsViewEmails } from './email/ops-view-emails.js';
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
// Access group
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
// Security group
import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js';
import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js';
import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js';
/**
* Extended IView with explicit URL slug. Without an explicit `slug`, the URL
* slug is derived from `name.toLowerCase().replace(/\s+/g, '')`.
*/
interface ITabbedView extends IView {
slug?: string;
subViews?: ITabbedView[];
}
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@state() accessor loginState: appstate.ILoginState = {
identity: null,
isLoggedIn: false,
};
@state() accessor uiState: appstate.IUiState = {
2025-07-04 18:50:15 +00:00
activeView: 'overview',
activeSubview: null,
sidebarCollapsed: false,
autoRefresh: true,
2025-07-04 18:50:15 +00:00
refreshInterval: 1000,
theme: 'light',
};
@state() accessor configState: appstate.IConfigState = {
config: null,
isLoading: false,
error: null,
};
// Store viewTabs as a property to maintain object references (used for === selectedView identity)
private viewTabs: ITabbedView[] = [
2025-07-04 18:58:10 +00:00
{
name: 'Overview',
iconName: 'lucide:layoutDashboard',
subViews: [
{ slug: 'stats', name: 'Stats', iconName: 'lucide:activity', element: OpsViewOverview },
{ slug: 'configuration', name: 'Configuration', iconName: 'lucide:settings', element: OpsViewConfig },
],
},
2025-07-04 18:58:10 +00:00
{
name: 'Network',
iconName: 'lucide:network',
subViews: [
{ slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
{ slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
{ slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
{ slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
{ slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
{ slug: 'remoteingress', name: 'Remote Ingress', iconName: 'lucide:globe', element: OpsViewRemoteIngress },
{ slug: 'vpn', name: 'VPN', iconName: 'lucide:shield', element: OpsViewVpn },
],
2025-07-04 18:58:10 +00:00
},
{
name: 'Email',
iconName: 'lucide:mail',
subViews: [
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
],
2025-07-04 18:58:10 +00:00
},
{
name: 'Logs',
iconName: 'lucide:scrollText',
2025-07-04 18:58:10 +00:00
element: OpsViewLogs,
},
{
name: 'Access',
iconName: 'lucide:keyRound',
subViews: [
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
],
},
2025-07-04 18:58:10 +00:00
{
name: 'Security',
iconName: 'lucide:shield',
subViews: [
{ slug: 'overview', name: 'Overview', iconName: 'lucide:eye', element: OpsViewSecurityOverview },
{ slug: 'blocked', name: 'Blocked IPs', iconName: 'lucide:shieldBan', element: OpsViewSecurityBlocked },
{ slug: 'authentication', name: 'Authentication', iconName: 'lucide:lock', element: OpsViewSecurityAuthentication },
],
2025-07-04 18:58:10 +00:00
},
{
name: 'Certificates',
iconName: 'lucide:badgeCheck',
element: OpsViewCertificates,
},
2025-07-04 18:58:10 +00:00
];
/** URL slug for a view (explicit `slug` field, or lowercased name with spaces stripped). */
private slugFor(view: ITabbedView): string {
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '');
}
/** Find the parent group of a subview, or undefined for top-level views. */
private findParent(view: ITabbedView): ITabbedView | undefined {
return this.viewTabs.find((v) => v.subViews?.includes(view));
}
/** Look up a view (or subview) by its URL slug pair. */
private findViewBySlug(viewSlug: string, subSlug: string | null): ITabbedView | undefined {
const top = this.viewTabs.find((v) => this.slugFor(v) === viewSlug);
if (!top) return undefined;
if (subSlug && top.subViews) {
return top.subViews.find((sv) => this.slugFor(sv) === subSlug) ?? top;
}
return top;
}
private get globalMessages() {
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
const config = this.configState.config;
if (config && !config.cache.enabled) {
messages.push({
id: 'db-disabled',
type: 'warning',
message: 'Database is disabled. Creating and editing routes, profiles, targets, and API tokens is not available.',
dismissible: false,
});
}
return messages;
}
/**
* Get the current view tab based on the UI state's activeView/activeSubview.
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
*/
private get currentViewTab(): ITabbedView {
return (
this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0]
);
}
constructor() {
super();
document.title = 'DCRouter OpsServer';
// Subscribe to login state
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg)
.subscribe((loginState) => {
this.loginState = loginState;
// Trigger data fetch when logged in
if (loginState.isLoggedIn) {
appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
}
});
this.rxSubscriptions.push(loginSubscription);
// Subscribe to config state (for global warnings)
const configSubscription = appstate.configStatePart
.select((stateArg) => stateArg)
.subscribe((configState) => {
this.configState = configState;
});
this.rxSubscriptions.push(configSubscription);
// Subscribe to UI state
const uiSubscription = appstate.uiStatePart
.select((stateArg) => stateArg)
.subscribe((uiState) => {
this.uiState = uiState;
// Sync appdash view when state changes (e.g., from URL navigation)
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
});
this.rxSubscriptions.push(uiSubscription);
}
/**
* Sync the dees-simple-appdash view selection with the current state.
* This is needed when the URL changes externally (back/forward, deep link).
*/
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash) return;
const targetView = this.findViewBySlug(viewSlug, subviewSlug);
if (!targetView) return;
if (appDash.selectedView === targetView) return;
// Use loadView to update both selectedView and the mounted element.
// It will dispatch view-select; our handler skips when state already matches.
appDash.loadView(targetView);
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100vh;
overflow: hidden;
}
.maincontainer {
position: relative;
width: 100%;
height: 100%;
}
`,
];
public render(): TemplateResult {
return html`
<div class="maincontainer">
<dees-simple-login
name="DCRouter OpsServer"
>
<dees-simple-appdash
name="DCRouter OpsServer"
2025-07-04 18:58:10 +00:00
.viewTabs=${this.viewTabs}
.selectedView=${this.currentViewTab}
.globalMessages=${this.globalMessages}
>
</dees-simple-appdash>
</dees-simple-login>
</div>
`;
}
2025-06-08 12:51:48 +00:00
public async firstUpdated() {
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
simpleLogin.addEventListener('login', (e: Event) => {
// Handle login event
const detail = (e as CustomEvent).detail;
this.login(detail.data.username, detail.data.password);
2025-06-08 12:51:48 +00:00
});
2025-07-02 11:33:50 +00:00
// Handle view changes
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
2025-07-02 11:33:50 +00:00
if (appDash) {
appDash.addEventListener('view-select', (e: Event) => {
const view = (e as CustomEvent).detail.view as ITabbedView;
const parent = this.findParent(view);
const currentState = appstate.uiStatePart.getState();
if (parent) {
const parentSlug = this.slugFor(parent);
const subSlug = this.slugFor(view);
// Skip if already on this exact subview — preserves URL on initial mount
if (currentState?.activeView === parentSlug && currentState?.activeSubview === subSlug) {
return;
}
appRouter.navigateToView(parentSlug, subSlug);
} else {
const slug = this.slugFor(view);
if (currentState?.activeView === slug && !currentState?.activeSubview) {
return;
}
appRouter.navigateToView(slug);
}
2025-07-02 11:33:50 +00:00
});
2025-07-04 18:50:15 +00:00
// Handle logout event
appDash.addEventListener('logout', async () => {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
});
2025-07-02 11:33:50 +00:00
}
// Handle initial state - check if we have a stored session that's still valid
const loginState = appstate.loginStatePart.getState()!;
if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) {
// Client-side expiry looks valid — verify with server (keypair may have changed)
try {
const verifyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_VerifyIdentity
>('/typedrequest', 'verifyIdentity');
const response = await verifyRequest.fire({ identity: loginState.identity });
if (response.valid) {
// JWT confirmed valid by server
this.loginState = loginState;
await (simpleLogin as any).switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else {
// Server rejected the JWT — clear state, show login
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
} catch {
// Server unreachable or error — clear state, show login
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
} else {
// JWT expired, clear the stored state
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
2025-06-08 12:51:48 +00:00
}
}
private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise;
console.log(`Attempting to login...`);
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
2025-06-08 12:51:48 +00:00
form.setStatus('pending', 'Logging in...');
2025-06-08 12:51:48 +00:00
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
username,
password,
});
2025-06-08 12:51:48 +00:00
if (state.identity) {
console.log('Login successful');
this.loginState = state;
form!.setStatus('success', 'Logged in!');
await simpleLogin!.switchToSlottedContent();
2025-06-08 12:51:48 +00:00
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else {
form!.setStatus('error', 'Login failed!');
2025-06-08 12:51:48 +00:00
await domtools.convenience.smartdelay.delayFor(2000);
form!.reset();
2025-06-08 12:51:48 +00:00
}
}
}