initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
This commit is contained in:
607
ts_web/elements/sipproxy-view-providers.ts
Normal file
607
ts_web/elements/sipproxy-view-providers.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { appState, type IAppState, type IProviderStatus } from '../state/appstate.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default provider templates
|
||||
// ---------------------------------------------------------------------------
|
||||
const PROVIDER_TEMPLATES = {
|
||||
sipgate: {
|
||||
domain: 'sipgate.de',
|
||||
outboundProxy: { address: 'sipgate.de', port: 5060 },
|
||||
registerIntervalSec: 300,
|
||||
codecs: [9, 0, 8, 101],
|
||||
quirks: { earlyMediaSilence: false },
|
||||
},
|
||||
o2: {
|
||||
domain: 'sip.alice-voip.de',
|
||||
outboundProxy: { address: 'sip.alice-voip.de', port: 5060 },
|
||||
registerIntervalSec: 300,
|
||||
codecs: [9, 0, 8, 101],
|
||||
quirks: { earlyMediaSilence: false },
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || `provider-${Date.now()}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View element
|
||||
// ---------------------------------------------------------------------------
|
||||
@customElement('sipproxy-view-providers')
|
||||
export class SipproxyViewProviders extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
.view-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
}
|
||||
|
||||
// ---- stats tiles ---------------------------------------------------------
|
||||
|
||||
private getStatsTiles(): IStatsTile[] {
|
||||
const providers = this.appData.providers || [];
|
||||
const total = providers.length;
|
||||
const registered = providers.filter((p) => p.registered).length;
|
||||
const unregistered = total - registered;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Providers',
|
||||
value: total,
|
||||
type: 'number',
|
||||
icon: 'lucide:server',
|
||||
description: 'Configured SIP trunks',
|
||||
},
|
||||
{
|
||||
id: 'registered',
|
||||
title: 'Registered',
|
||||
value: registered,
|
||||
type: 'number',
|
||||
icon: 'lucide:check-circle',
|
||||
color: 'hsl(142.1 76.2% 36.3%)',
|
||||
description: 'Active registrations',
|
||||
},
|
||||
{
|
||||
id: 'unregistered',
|
||||
title: 'Unregistered',
|
||||
value: unregistered,
|
||||
type: 'number',
|
||||
icon: 'lucide:alert-circle',
|
||||
color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined,
|
||||
description: unregistered > 0 ? 'Needs attention' : 'All healthy',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table columns -------------------------------------------------------
|
||||
|
||||
private getColumns() {
|
||||
return [
|
||||
{
|
||||
key: 'displayName',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'Domain',
|
||||
value: (row: IProviderStatus) => (row as any).domain || '--',
|
||||
renderer: (val: string) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'registered',
|
||||
header: 'Status',
|
||||
value: (row: IProviderStatus) => (row.registered ? 'Registered' : 'Not Registered'),
|
||||
renderer: (val: string, row: IProviderStatus) => {
|
||||
const on = row.registered;
|
||||
return html`
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;${on ? 'background:#4ade80;box-shadow:0 0 6px #4ade80' : 'background:#f87171;box-shadow:0 0 6px #f87171'}"></span>
|
||||
<span style="color:${on ? '#4ade80' : '#f87171'}">${val}</span>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'publicIp',
|
||||
header: 'Public IP',
|
||||
renderer: (val: any) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val || '--'}</span>`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table actions -------------------------------------------------------
|
||||
|
||||
private getDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.openEditModal(actionData.item.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.confirmDelete(actionData.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add Provider',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add Sipgate',
|
||||
iconName: 'lucide:phone',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal(PROVIDER_TEMPLATES.sipgate, 'Sipgate');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add O2/Alice',
|
||||
iconName: 'lucide:phone',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal(PROVIDER_TEMPLATES.o2, 'O2/Alice');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- add provider modal --------------------------------------------------
|
||||
|
||||
private async openAddModal(
|
||||
template?: typeof PROVIDER_TEMPLATES.sipgate,
|
||||
templateName?: string,
|
||||
) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const formData = {
|
||||
displayName: templateName || '',
|
||||
domain: template?.domain || '',
|
||||
outboundProxyAddress: template?.outboundProxy?.address || '',
|
||||
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060),
|
||||
username: '',
|
||||
password: '',
|
||||
registerIntervalSec: String(template?.registerIntervalSec ?? 300),
|
||||
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101',
|
||||
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false,
|
||||
};
|
||||
|
||||
const heading = template
|
||||
? `Add ${templateName} Provider`
|
||||
: 'Add Provider';
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading,
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-input-text
|
||||
.key=${'displayName'}
|
||||
.label=${'Display Name'}
|
||||
.value=${formData.displayName}
|
||||
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'domain'}
|
||||
.label=${'Domain'}
|
||||
.value=${formData.domain}
|
||||
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyAddress'}
|
||||
.label=${'Outbound Proxy Address'}
|
||||
.value=${formData.outboundProxyAddress}
|
||||
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyPort'}
|
||||
.label=${'Outbound Proxy Port'}
|
||||
.value=${formData.outboundProxyPort}
|
||||
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'username'}
|
||||
.label=${'Username / Auth ID'}
|
||||
.value=${formData.username}
|
||||
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'password'}
|
||||
.label=${'Password'}
|
||||
.isPasswordBool=${true}
|
||||
.value=${formData.password}
|
||||
@input=${(e: Event) => { formData.password = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'registerIntervalSec'}
|
||||
.label=${'Register Interval (sec)'}
|
||||
.value=${formData.registerIntervalSec}
|
||||
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'codecs'}
|
||||
.label=${'Codecs (comma-separated payload types)'}
|
||||
.value=${formData.codecs}
|
||||
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'earlyMediaSilence'}
|
||||
.label=${'Early Media Silence (quirk)'}
|
||||
.value=${formData.earlyMediaSilence}
|
||||
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
if (!formData.displayName.trim() || !formData.domain.trim()) {
|
||||
deesCatalog.DeesToast.error('Display name and domain are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const providerId = slugify(formData.displayName);
|
||||
const codecs = formData.codecs
|
||||
.split(',')
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
|
||||
const newProvider: any = {
|
||||
id: providerId,
|
||||
displayName: formData.displayName.trim(),
|
||||
domain: formData.domain.trim(),
|
||||
outboundProxy: {
|
||||
address: formData.outboundProxyAddress.trim() || formData.domain.trim(),
|
||||
port: parseInt(formData.outboundProxyPort, 10) || 5060,
|
||||
},
|
||||
username: formData.username.trim(),
|
||||
password: formData.password,
|
||||
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
|
||||
codecs,
|
||||
quirks: {
|
||||
earlyMediaSilence: formData.earlyMediaSilence,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await appState.apiSaveConfig({
|
||||
addProvider: newProvider,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success(`Provider "${formData.displayName}" created`);
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to save provider');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create provider:', err);
|
||||
deesCatalog.DeesToast.error('Failed to create provider');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- edit provider modal -------------------------------------------------
|
||||
|
||||
private async openEditModal(providerId: string) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
let cfg: any;
|
||||
try {
|
||||
cfg = await appState.apiGetConfig();
|
||||
} catch {
|
||||
deesCatalog.DeesToast.error('Failed to load configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = cfg.providers?.find((p: any) => p.id === providerId);
|
||||
if (!provider) {
|
||||
deesCatalog.DeesToast.error('Provider not found in configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const allDevices: { id: string; displayName: string }[] = (cfg.devices || []).map((d: any) => ({
|
||||
id: d.id,
|
||||
displayName: d.displayName,
|
||||
}));
|
||||
|
||||
const formData = {
|
||||
displayName: provider.displayName || '',
|
||||
domain: provider.domain || '',
|
||||
outboundProxyAddress: provider.outboundProxy?.address || '',
|
||||
outboundProxyPort: String(provider.outboundProxy?.port ?? 5060),
|
||||
username: provider.username || '',
|
||||
password: provider.password || '',
|
||||
registerIntervalSec: String(provider.registerIntervalSec ?? 300),
|
||||
codecs: (provider.codecs || []).join(', '),
|
||||
earlyMediaSilence: provider.quirks?.earlyMediaSilence ?? false,
|
||||
inboundDevices: [...(cfg.routing?.inbound?.[providerId] || [])] as string[],
|
||||
ringBrowsers: cfg.routing?.ringBrowsers?.[providerId] ?? false,
|
||||
};
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Edit Provider: ${formData.displayName}`,
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-input-text
|
||||
.key=${'displayName'}
|
||||
.label=${'Display Name'}
|
||||
.value=${formData.displayName}
|
||||
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'domain'}
|
||||
.label=${'Domain'}
|
||||
.value=${formData.domain}
|
||||
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyAddress'}
|
||||
.label=${'Outbound Proxy Address'}
|
||||
.value=${formData.outboundProxyAddress}
|
||||
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyPort'}
|
||||
.label=${'Outbound Proxy Port'}
|
||||
.value=${formData.outboundProxyPort}
|
||||
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'username'}
|
||||
.label=${'Username / Auth ID'}
|
||||
.value=${formData.username}
|
||||
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'password'}
|
||||
.label=${'Password'}
|
||||
.isPasswordBool=${true}
|
||||
.value=${formData.password}
|
||||
.description=${'Leave unchanged to keep existing password'}
|
||||
@input=${(e: Event) => { formData.password = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'registerIntervalSec'}
|
||||
.label=${'Register Interval (sec)'}
|
||||
.value=${formData.registerIntervalSec}
|
||||
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'codecs'}
|
||||
.label=${'Codecs (comma-separated payload types)'}
|
||||
.value=${formData.codecs}
|
||||
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'earlyMediaSilence'}
|
||||
.label=${'Early Media Silence (quirk)'}
|
||||
.value=${formData.earlyMediaSilence}
|
||||
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">
|
||||
Inbound Routing
|
||||
</div>
|
||||
<div style="font-size:.8rem;color:#64748b;margin-bottom:12px;">
|
||||
Select which devices should ring when this provider receives an incoming call.
|
||||
</div>
|
||||
${allDevices.map((d) => html`
|
||||
<dees-input-checkbox
|
||||
.key=${`inbound-${d.id}`}
|
||||
.label=${d.displayName}
|
||||
.value=${formData.inboundDevices.includes(d.id)}
|
||||
@newValue=${(e: CustomEvent) => {
|
||||
if (e.detail) {
|
||||
if (!formData.inboundDevices.includes(d.id)) {
|
||||
formData.inboundDevices = [...formData.inboundDevices, d.id];
|
||||
}
|
||||
} else {
|
||||
formData.inboundDevices = formData.inboundDevices.filter((id) => id !== d.id);
|
||||
}
|
||||
}}
|
||||
></dees-input-checkbox>
|
||||
`)}
|
||||
<dees-input-checkbox
|
||||
.key=${'ringBrowsers'}
|
||||
.label=${'Also ring all connected browsers'}
|
||||
.value=${formData.ringBrowsers}
|
||||
@newValue=${(e: CustomEvent) => { formData.ringBrowsers = e.detail; }}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
const codecs = formData.codecs
|
||||
.split(',')
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
|
||||
const updates: any = {
|
||||
providers: [{
|
||||
id: providerId,
|
||||
displayName: formData.displayName.trim(),
|
||||
domain: formData.domain.trim(),
|
||||
outboundProxy: {
|
||||
address: formData.outboundProxyAddress.trim() || formData.domain.trim(),
|
||||
port: parseInt(formData.outboundProxyPort, 10) || 5060,
|
||||
},
|
||||
username: formData.username.trim(),
|
||||
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
|
||||
codecs,
|
||||
quirks: {
|
||||
earlyMediaSilence: formData.earlyMediaSilence,
|
||||
},
|
||||
}] as any[],
|
||||
routing: {
|
||||
inbound: { [providerId]: formData.inboundDevices },
|
||||
ringBrowsers: { [providerId]: formData.ringBrowsers },
|
||||
},
|
||||
};
|
||||
|
||||
// Only send password if it was changed (not the masked placeholder).
|
||||
if (formData.password && !formData.password.match(/^\*+$/)) {
|
||||
updates.providers[0].password = formData.password;
|
||||
}
|
||||
|
||||
const result = await appState.apiSaveConfig(updates);
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success('Provider updated');
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to save changes');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Save failed:', err);
|
||||
deesCatalog.DeesToast.error('Failed to save changes');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- delete confirmation -------------------------------------------------
|
||||
|
||||
private async confirmDelete(provider: IProviderStatus) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Delete Provider',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
|
||||
Are you sure you want to delete
|
||||
<strong style="color:#f87171;">${provider.displayName}</strong>?
|
||||
This action cannot be undone.
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
const result = await appState.apiSaveConfig({
|
||||
removeProvider: provider.id,
|
||||
});
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success(`Provider "${provider.displayName}" deleted`);
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to delete provider');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Delete failed:', err);
|
||||
deesCatalog.DeesToast.error('Failed to delete provider');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
|
||||
public render(): TemplateResult {
|
||||
const providers = this.appData.providers || [];
|
||||
|
||||
return html`
|
||||
<div class="view-section">
|
||||
<dees-statsgrid
|
||||
.tiles=${this.getStatsTiles()}
|
||||
.minTileWidth=${220}
|
||||
.gap=${16}
|
||||
></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="Providers"
|
||||
heading2="${providers.length} configured"
|
||||
dataName="providers"
|
||||
.data=${providers}
|
||||
.rowKey=${'id'}
|
||||
.searchable=${true}
|
||||
.columns=${this.getColumns()}
|
||||
.dataActions=${this.getDataActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user