Files
dcrouter/ts_web/elements/ops-view-targetprofiles.ts

392 lines
16 KiB
TypeScript

import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
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';
declare global {
interface HTMLElementTagNameMap {
'ops-view-targetprofiles': OpsViewTargetProfiles;
}
}
@customElement('ops-view-targetprofiles')
export class OpsViewTargetProfiles extends DeesElement {
@state()
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
constructor() {
super();
const sub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
this.targetProfilesState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.profilesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.tagBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
margin-right: 4px;
margin-bottom: 2px;
}
`,
];
public render(): TemplateResult {
const profiles = this.targetProfilesState.profiles;
const statsTiles: IStatsTile[] = [
{
id: 'totalProfiles',
title: 'Total Profiles',
type: 'number',
value: profiles.length,
icon: 'lucide:target',
description: 'Reusable target profiles',
color: '#8b5cf6',
},
];
return html`
<dees-heading level="2">Target Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Target Profiles'}
.heading2=${'Define what resources VPN clients can access'}
.data=${profiles}
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
Name: profile.name,
Description: profile.description || '-',
Domains: profile.domains?.length
? html`${profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)}`
: '-',
Targets: profile.targets?.length
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)}`
: '-',
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
: '-',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Create Profile',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateProfileDialog();
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
},
},
{
name: 'Detail',
iconName: 'lucide:info',
type: ['doubleClick'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showDetailDialog(profile);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showEditProfileDialog(profile);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.deleteProfile(profile);
},
},
]}
></dees-table>
</div>
`;
}
private getRouteCandidates() {
const routeState = appstate.routeManagementStatePart.getState();
const routes = routeState?.mergedRoutes || [];
return routes
.filter((mr) => mr.route.name)
.map((mr) => ({ viewKey: mr.route.name! }));
}
private async ensureRoutesLoaded() {
const routeState = appstate.routeManagementStatePart.getState();
if (!routeState?.mergedRoutes?.length) {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
}
private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
const routeCandidates = this.getRouteCandidates();
DeesModal.createAndShow({
heading: 'Create Target Profile',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
if (!data.name) return;
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
const targets = targetStrings
.map((s: string) => {
const lastColon = s.lastIndexOf(':');
if (lastColon === -1) return null;
return {
host: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains: domains.length > 0 ? domains : undefined,
targets: targets.length > 0 ? targets : undefined,
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
});
modalArg.destroy();
},
},
],
});
}
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`) || [];
const currentRouteRefs = profile.routeRefs || [];
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
const routeCandidates = this.getRouteCandidates();
DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
iconName: 'lucide:check',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
const targets = targetStrings
.map((s: string) => {
const lastColon = s.lastIndexOf(':');
if (lastColon === -1) return null;
return {
host: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains,
targets,
routeRefs,
});
modalArg.destroy();
},
},
],
});
}
private async showDetailDialog(profile: interfaces.data.ITargetProfile) {
const { DeesModal } = await import('@design.estate/dees-catalog');
// Fetch usage (which VPN clients reference this profile)
let usageHtml = html`<p style="color: #9ca3af;">Loading usage...</p>`;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetTargetProfileUsage
>('/typedrequest', 'getTargetProfileUsage');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
id: profile.id,
});
if (response.clients.length > 0) {
usageHtml = html`
<div style="margin-top: 8px;">
${response.clients.map(c => html`
<div style="padding: 4px 0; font-size: 13px;">
<strong>${c.clientId}</strong>${c.description ? html` - ${c.description}` : ''}
</div>
`)}
</div>
`;
} else {
usageHtml = html`<p style="color: #9ca3af; font-size: 13px;">No VPN clients reference this profile.</p>`;
}
} catch {
usageHtml = html`<p style="color: #9ca3af;">Usage data unavailable.</p>`;
}
DeesModal.createAndShow({
heading: `Target Profile: ${profile.name}`,
content: html`
<div style="display: flex; flex-direction: column; gap: 12px;">
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Description</div>
<div style="font-size: 14px; margin-top: 4px;">${profile.description || '-'}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Domains</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.domains?.length
? profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Targets</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.targets?.length
? profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.routeRefs?.length
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Updated</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.updatedAt).toLocaleString()}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">VPN Clients Using This Profile</div>
${usageHtml}
</div>
</div>
`,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
],
});
}
private async deleteProfile(profile: interfaces.data.ITargetProfile) {
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
id: profile.id,
force: false,
});
const currentState = appstate.targetProfilesStatePart.getState()!;
if (currentState.error?.includes('in use')) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Profile In Use',
content: html`<p>${currentState.error} Force delete?</p>`,
menuOptions: [
{
name: 'Force Delete',
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
id: profile.id,
force: true,
});
modalArg.destroy();
},
},
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}
}