390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import * as appstate from '../appstate.js';
|
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
|
import { viewHostCss } from './shared/css.js';
|
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
|
|
|
import {
|
|
DeesElement,
|
|
css,
|
|
cssManager,
|
|
customElement,
|
|
html,
|
|
state,
|
|
type TemplateResult,
|
|
} from '@design.estate/dees-element';
|
|
|
|
@customElement('ops-view-routes')
|
|
export class OpsViewRoutes extends DeesElement {
|
|
@state() accessor routeState: appstate.IRouteManagementState = {
|
|
mergedRoutes: [],
|
|
warnings: [],
|
|
apiTokens: [],
|
|
isLoading: false,
|
|
error: null,
|
|
lastUpdated: 0,
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
const sub = appstate.routeManagementStatePart
|
|
.select((s) => s)
|
|
.subscribe((routeState) => {
|
|
this.routeState = routeState;
|
|
});
|
|
this.rxSubscriptions.push(sub);
|
|
|
|
// Re-fetch routes when user logs in (fixes race condition where
|
|
// the view is created before authentication completes)
|
|
const loginSub = appstate.loginStatePart
|
|
.select((s) => s.isLoggedIn)
|
|
.subscribe((isLoggedIn) => {
|
|
if (isLoggedIn) {
|
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
|
}
|
|
});
|
|
this.rxSubscriptions.push(loginSub);
|
|
}
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
viewHostCss,
|
|
css`
|
|
.routesContainer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
}
|
|
|
|
.warnings-bar {
|
|
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
|
|
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
.warning-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 4px 0;
|
|
font-size: 13px;
|
|
color: ${cssManager.bdTheme('#b45309', '#fa0')};
|
|
}
|
|
|
|
.warning-icon {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: ${cssManager.bdTheme('#6b7280', '#666')};
|
|
}
|
|
|
|
.empty-state p {
|
|
margin: 8px 0;
|
|
}
|
|
`,
|
|
];
|
|
|
|
public render(): TemplateResult {
|
|
const { mergedRoutes, warnings } = this.routeState;
|
|
|
|
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
|
|
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
|
|
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
|
|
|
const statsTiles: IStatsTile[] = [
|
|
{
|
|
id: 'totalRoutes',
|
|
title: 'Total Routes',
|
|
type: 'number',
|
|
value: mergedRoutes.length,
|
|
icon: 'lucide:route',
|
|
description: 'All configured routes',
|
|
color: '#3b82f6',
|
|
},
|
|
{
|
|
id: 'hardcoded',
|
|
title: 'Hardcoded',
|
|
type: 'number',
|
|
value: hardcodedCount,
|
|
icon: 'lucide:lock',
|
|
description: 'Routes from constructor config',
|
|
color: '#8b5cf6',
|
|
},
|
|
{
|
|
id: 'programmatic',
|
|
title: 'Programmatic',
|
|
type: 'number',
|
|
value: programmaticCount,
|
|
icon: 'lucide:code',
|
|
description: 'Routes added via API',
|
|
color: '#0ea5e9',
|
|
},
|
|
{
|
|
id: 'disabled',
|
|
title: 'Disabled',
|
|
type: 'number',
|
|
value: disabledCount,
|
|
icon: 'lucide:pauseCircle',
|
|
description: 'Currently disabled routes',
|
|
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
|
|
},
|
|
];
|
|
|
|
// Map merged routes to sz-route-list-view format
|
|
const szRoutes = mergedRoutes.map((mr) => {
|
|
const tags = [...(mr.route.tags || [])];
|
|
tags.push(mr.source);
|
|
if (!mr.enabled) tags.push('disabled');
|
|
if (mr.overridden) tags.push('overridden');
|
|
|
|
return {
|
|
...mr.route,
|
|
enabled: mr.enabled,
|
|
tags,
|
|
id: mr.storedRouteId || mr.route.name || undefined,
|
|
};
|
|
});
|
|
|
|
return html`
|
|
<ops-sectionheading>Route Management</ops-sectionheading>
|
|
|
|
<div class="routesContainer">
|
|
<dees-statsgrid
|
|
.tiles=${statsTiles}
|
|
.gridActions=${[
|
|
{
|
|
name: 'Add Route',
|
|
iconName: 'lucide:plus',
|
|
action: () => this.showCreateRouteDialog(),
|
|
},
|
|
{
|
|
name: 'Refresh',
|
|
iconName: 'lucide:refreshCw',
|
|
action: () => this.refreshData(),
|
|
},
|
|
]}
|
|
></dees-statsgrid>
|
|
|
|
${warnings.length > 0
|
|
? html`
|
|
<div class="warnings-bar">
|
|
${warnings.map(
|
|
(w) => html`
|
|
<div class="warning-item">
|
|
<span class="warning-icon">⚠</span>
|
|
<span>${w.message}</span>
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: ''}
|
|
|
|
${szRoutes.length > 0
|
|
? html`
|
|
<sz-route-list-view
|
|
.routes=${szRoutes}
|
|
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
|
></sz-route-list-view>
|
|
`
|
|
: html`
|
|
<div class="empty-state">
|
|
<p>No routes configured</p>
|
|
<p>Add a programmatic route or check your constructor configuration.</p>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private async handleRouteClick(e: CustomEvent) {
|
|
const clickedRoute = e.detail;
|
|
if (!clickedRoute) return;
|
|
|
|
// Find the corresponding merged route
|
|
const merged = this.routeState.mergedRoutes.find(
|
|
(mr) => mr.route.name === clickedRoute.name,
|
|
);
|
|
if (!merged) return;
|
|
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
if (merged.source === 'hardcoded') {
|
|
const menuOptions = merged.enabled
|
|
? [
|
|
{
|
|
name: 'Disable Route',
|
|
iconName: 'lucide:pause',
|
|
action: async (modalArg: any) => {
|
|
await appstate.routeManagementStatePart.dispatchAction(
|
|
appstate.setRouteOverrideAction,
|
|
{ routeName: merged.route.name!, enabled: false },
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Close',
|
|
iconName: 'lucide:x',
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
name: 'Enable Route',
|
|
iconName: 'lucide:play',
|
|
action: async (modalArg: any) => {
|
|
await appstate.routeManagementStatePart.dispatchAction(
|
|
appstate.setRouteOverrideAction,
|
|
{ routeName: merged.route.name!, enabled: true },
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Remove Override',
|
|
iconName: 'lucide:undo',
|
|
action: async (modalArg: any) => {
|
|
await appstate.routeManagementStatePart.dispatchAction(
|
|
appstate.removeRouteOverrideAction,
|
|
merged.route.name!,
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Close',
|
|
iconName: 'lucide:x',
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
},
|
|
];
|
|
|
|
await DeesModal.createAndShow({
|
|
heading: `Route: ${merged.route.name}`,
|
|
content: html`
|
|
<div style="color: #ccc; padding: 8px 0;">
|
|
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
|
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
|
|
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
|
|
</div>
|
|
`,
|
|
menuOptions,
|
|
});
|
|
} else {
|
|
// Programmatic route
|
|
await DeesModal.createAndShow({
|
|
heading: `Route: ${merged.route.name}`,
|
|
content: html`
|
|
<div style="color: #ccc; padding: 8px 0;">
|
|
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
|
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
|
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: merged.enabled ? 'Disable' : 'Enable',
|
|
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
|
|
action: async (modalArg: any) => {
|
|
await appstate.routeManagementStatePart.dispatchAction(
|
|
appstate.toggleRouteAction,
|
|
{ id: merged.storedRouteId!, enabled: !merged.enabled },
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash-2',
|
|
action: async (modalArg: any) => {
|
|
await appstate.routeManagementStatePart.dispatchAction(
|
|
appstate.deleteRouteAction,
|
|
merged.storedRouteId!,
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
{
|
|
name: 'Close',
|
|
iconName: 'lucide:x',
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
private async showCreateRouteDialog() {
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
await DeesModal.createAndShow({
|
|
heading: 'Add Programmatic Route',
|
|
content: html`
|
|
<dees-form>
|
|
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
|
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
|
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, optional)'}></dees-input-text>
|
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .value=${'localhost'} .required=${true}></dees-input-text>
|
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .required=${true}></dees-input-text>
|
|
</dees-form>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
},
|
|
{
|
|
name: 'Create',
|
|
iconName: 'lucide:plus',
|
|
action: async (modalArg: any) => {
|
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
|
if (!form) return;
|
|
const formData = await form.collectFormData();
|
|
if (!formData.name || !formData.ports) return;
|
|
|
|
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
|
const domains = formData.domains
|
|
? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean)
|
|
: undefined;
|
|
|
|
const route: any = {
|
|
name: formData.name,
|
|
match: {
|
|
ports,
|
|
...(domains && domains.length > 0 ? { domains } : {}),
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
targets: [
|
|
{
|
|
host: formData.targetHost || 'localhost',
|
|
port: parseInt(formData.targetPort, 10),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
await appstate.routeManagementStatePart.dispatchAction(
|
|
appstate.createRouteAction,
|
|
{ route },
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private refreshData() {
|
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
|
}
|
|
|
|
async firstUpdated() {
|
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
|
}
|
|
}
|