327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import {
|
|
DeesElement,
|
|
html,
|
|
customElement,
|
|
type TemplateResult,
|
|
css,
|
|
state,
|
|
cssManager,
|
|
} from '@design.estate/dees-element';
|
|
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-vpn': OpsViewVpn;
|
|
}
|
|
}
|
|
|
|
@customElement('ops-view-vpn')
|
|
export class OpsViewVpn extends DeesElement {
|
|
@state()
|
|
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
|
|
|
|
constructor() {
|
|
super();
|
|
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
|
|
this.vpnState = newState;
|
|
});
|
|
this.rxSubscriptions.push(sub);
|
|
}
|
|
|
|
async connectedCallback() {
|
|
await super.connectedCallback();
|
|
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
|
|
}
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
viewHostCss,
|
|
css`
|
|
.vpnContainer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
}
|
|
|
|
.statusBadge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.statusBadge.enabled {
|
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
|
}
|
|
|
|
.statusBadge.disabled {
|
|
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
|
}
|
|
|
|
.configDialog {
|
|
padding: 16px;
|
|
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
|
|
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
|
|
border-radius: 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.configDialog pre {
|
|
display: block;
|
|
padding: 12px;
|
|
background: ${cssManager.bdTheme('#1f2937', '#111827')};
|
|
color: #10b981;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
margin: 8px 0;
|
|
user-select: all;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.configDialog .warning {
|
|
font-size: 12px;
|
|
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.serverInfo {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: 12px;
|
|
padding: 16px;
|
|
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
|
border-radius: 8px;
|
|
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')};
|
|
}
|
|
|
|
.serverInfo .infoItem {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.serverInfo .infoLabel {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
|
}
|
|
|
|
.serverInfo .infoValue {
|
|
font-size: 14px;
|
|
font-family: monospace;
|
|
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
|
}
|
|
`,
|
|
];
|
|
|
|
render(): TemplateResult {
|
|
const status = this.vpnState.status;
|
|
const clients = this.vpnState.clients;
|
|
const connectedCount = status?.connectedClients ?? 0;
|
|
const totalClients = clients.length;
|
|
const enabledClients = clients.filter(c => c.enabled).length;
|
|
|
|
const statsTiles: IStatsTile[] = [
|
|
{
|
|
id: 'totalClients',
|
|
title: 'Total Clients',
|
|
type: 'number',
|
|
value: totalClients,
|
|
icon: 'lucide:users',
|
|
description: 'Registered VPN clients',
|
|
color: '#3b82f6',
|
|
},
|
|
{
|
|
id: 'connectedClients',
|
|
title: 'Connected',
|
|
type: 'number',
|
|
value: connectedCount,
|
|
icon: 'lucide:link',
|
|
description: 'Currently connected',
|
|
color: '#10b981',
|
|
},
|
|
{
|
|
id: 'enabledClients',
|
|
title: 'Enabled',
|
|
type: 'number',
|
|
value: enabledClients,
|
|
icon: 'lucide:shieldCheck',
|
|
description: 'Active client registrations',
|
|
color: '#8b5cf6',
|
|
},
|
|
{
|
|
id: 'serverStatus',
|
|
title: 'Server',
|
|
type: 'text',
|
|
value: status?.running ? 'Running' : 'Stopped',
|
|
icon: 'lucide:server',
|
|
description: status?.running ? 'Active' : 'VPN server not running',
|
|
color: status?.running ? '#10b981' : '#ef4444',
|
|
},
|
|
];
|
|
|
|
return html`
|
|
<ops-sectionheading>VPN</ops-sectionheading>
|
|
|
|
${this.vpnState.newClientConfig ? html`
|
|
<div class="configDialog">
|
|
<strong>Client created successfully!</strong>
|
|
<div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
|
|
<pre>${this.vpnState.newClientConfig}</pre>
|
|
<dees-button
|
|
@click=${async () => {
|
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
await navigator.clipboard.writeText(this.vpnState.newClientConfig!);
|
|
}
|
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
|
DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
|
|
}}
|
|
>Copy to Clipboard</dees-button>
|
|
<dees-button
|
|
@click=${() => {
|
|
const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'wireguard.conf';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}}
|
|
>Download .conf</dees-button>
|
|
<dees-button
|
|
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
|
|
>Dismiss</dees-button>
|
|
</div>
|
|
` : ''}
|
|
|
|
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
|
|
|
|
${status ? html`
|
|
<div class="serverInfo">
|
|
<div class="infoItem">
|
|
<span class="infoLabel">Subnet</span>
|
|
<span class="infoValue">${status.subnet}</span>
|
|
</div>
|
|
<div class="infoItem">
|
|
<span class="infoLabel">WireGuard Port</span>
|
|
<span class="infoValue">${status.wgListenPort}</span>
|
|
</div>
|
|
${status.serverPublicKeys ? html`
|
|
<div class="infoItem">
|
|
<span class="infoLabel">WG Public Key</span>
|
|
<span class="infoValue" style="font-size: 11px; word-break: break-all;">${status.serverPublicKeys.wgPublicKey}</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
` : ''}
|
|
|
|
<dees-table
|
|
.heading1=${'VPN Clients'}
|
|
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
|
.data=${clients}
|
|
.displayFunction=${(client: interfaces.data.IVpnClient) => ({
|
|
'Client ID': client.clientId,
|
|
'Status': client.enabled
|
|
? html`<span class="statusBadge enabled">enabled</span>`
|
|
: html`<span class="statusBadge disabled">disabled</span>`,
|
|
'VPN IP': client.assignedIp || '-',
|
|
'Tags': client.serverDefinedClientTags?.length
|
|
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
|
: '-',
|
|
'Description': client.description || '-',
|
|
'Created': new Date(client.createdAt).toLocaleDateString(),
|
|
})}
|
|
.dataActions=${[
|
|
{
|
|
name: 'Toggle',
|
|
iconName: 'lucide:power',
|
|
action: async (client: interfaces.data.IVpnClient) => {
|
|
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
|
clientId: client.clientId,
|
|
enabled: !client.enabled,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash2',
|
|
action: async (client: interfaces.data.IVpnClient) => {
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
DeesModal.createAndShow({
|
|
heading: 'Delete VPN Client',
|
|
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
|
menuOptions: [
|
|
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
|
{
|
|
name: 'Delete',
|
|
action: async (modal: any) => {
|
|
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
|
modal.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
},
|
|
},
|
|
]}
|
|
.createNewItem=${async () => {
|
|
const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog');
|
|
DeesModal.createAndShow({
|
|
heading: 'Create VPN Client',
|
|
content: html`
|
|
<dees-form>
|
|
<dees-input-text id="clientId" .label=${'Client ID'} .key=${'clientId'} required></dees-input-text>
|
|
<dees-input-text id="description" .label=${'Description'} .key=${'description'}></dees-input-text>
|
|
<dees-input-text id="tags" .label=${'Tags (comma-separated)'} .key=${'tags'}></dees-input-text>
|
|
</dees-form>
|
|
`,
|
|
menuOptions: [
|
|
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
|
{
|
|
name: 'Create',
|
|
action: async (modal: any) => {
|
|
const form = modal.shadowRoot!.querySelector('dees-form') as any;
|
|
const data = await form.collectFormData();
|
|
const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined;
|
|
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
|
|
clientId: data.clientId,
|
|
description: data.description || undefined,
|
|
serverDefinedClientTags,
|
|
});
|
|
modal.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}}
|
|
></dees-table>
|
|
`;
|
|
}
|
|
}
|