2026-04-05 00:37:37 +00:00
|
|
|
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`
|
2026-04-05 10:13:09 +00:00
|
|
|
<dees-heading level="2">Target Profiles</dees-heading>
|
2026-04-05 00:37:37 +00:00
|
|
|
<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 async showCreateProfileDialog() {
|
|
|
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
|
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-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} ></dees-input-text>
|
|
|
|
|
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'}></dees-input-text>
|
|
|
|
|
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'}></dees-input-text>
|
|
|
|
|
</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 = data.domains
|
|
|
|
|
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
|
|
|
: undefined;
|
|
|
|
|
const targets = data.targets
|
|
|
|
|
? String(data.targets).split(',').map((s: string) => {
|
|
|
|
|
const trimmed = s.trim();
|
|
|
|
|
const lastColon = trimmed.lastIndexOf(':');
|
|
|
|
|
if (lastColon === -1) return null;
|
|
|
|
|
return {
|
|
|
|
|
host: trimmed.substring(0, lastColon),
|
|
|
|
|
port: parseInt(trimmed.substring(lastColon + 1), 10),
|
|
|
|
|
};
|
|
|
|
|
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
|
|
|
|
|
: undefined;
|
|
|
|
|
const routeRefs = data.routeRefs
|
|
|
|
|
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
|
|
|
|
name: String(data.name),
|
|
|
|
|
description: data.description ? String(data.description) : undefined,
|
|
|
|
|
domains,
|
|
|
|
|
targets,
|
|
|
|
|
routeRefs,
|
|
|
|
|
});
|
|
|
|
|
modalArg.destroy();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
|
|
|
|
const currentDomains = profile.domains?.join(', ') ?? '';
|
|
|
|
|
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? '';
|
|
|
|
|
const currentRouteRefs = profile.routeRefs?.join(', ') ?? '';
|
|
|
|
|
|
|
|
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
|
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-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} .value=${currentDomains}></dees-input-text>
|
|
|
|
|
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'} .value=${currentTargets}></dees-input-text>
|
|
|
|
|
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'} .value=${currentRouteRefs}></dees-input-text>
|
|
|
|
|
</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 = data.domains
|
|
|
|
|
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
const targets = data.targets
|
|
|
|
|
? String(data.targets).split(',').map((s: string) => {
|
|
|
|
|
const trimmed = s.trim();
|
|
|
|
|
if (!trimmed) return null;
|
|
|
|
|
const lastColon = trimmed.lastIndexOf(':');
|
|
|
|
|
if (lastColon === -1) return null;
|
|
|
|
|
return {
|
|
|
|
|
host: trimmed.substring(0, lastColon),
|
|
|
|
|
port: parseInt(trimmed.substring(lastColon + 1), 10),
|
|
|
|
|
};
|
|
|
|
|
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
|
|
|
|
|
: [];
|
|
|
|
|
const routeRefs = data.routeRefs
|
|
|
|
|
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
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() },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|