refactor: make dashboard widgets reactive

This commit is contained in:
divocat
2025-10-07 16:26:06 +03:00
parent 1e6c827f2b
commit e0874c3775
8 changed files with 748 additions and 328 deletions

View File

@@ -3,9 +3,7 @@ import {
getPodkopStatus, getPodkopStatus,
getSingboxStatus, getSingboxStatus,
} from '../../methods'; } from '../../methods';
import { renderOutboundGroup } from './renderer/renderOutboundGroup';
import { getClashWsUrl, onMount } from '../../../helpers'; import { getClashWsUrl, onMount } from '../../../helpers';
import { renderDashboardWidget } from './renderer/renderWidget';
import { import {
triggerLatencyGroupTest, triggerLatencyGroupTest,
triggerLatencyProxyTest, triggerLatencyProxyTest,
@@ -14,63 +12,114 @@ import {
import { store, StoreType } from '../../../store'; import { store, StoreType } from '../../../store';
import { socket } from '../../../socket'; import { socket } from '../../../socket';
import { prettyBytes } from '../../../helpers/prettyBytes'; import { prettyBytes } from '../../../helpers/prettyBytes';
import { renderEmptyOutboundGroup } from './renderer/renderEmptyOutboundGroup'; import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
// Fetchers // Fetchers
async function fetchDashboardSections() { async function fetchDashboardSections() {
const prev = store.get().sectionsWidget;
store.set({ store.set({
dashboardSections: { sectionsWidget: {
...store.get().dashboardSections, ...prev,
failed: false, failed: false,
loading: true,
}, },
}); });
const { data, success } = await getDashboardSections(); const { data, success } = await getDashboardSections();
store.set({ dashboardSections: { loading: false, data, failed: !success } }); store.set({
sectionsWidget: {
loading: false,
failed: !success,
data,
},
});
} }
async function fetchServicesInfo() { async function fetchServicesInfo() {
const podkop = await getPodkopStatus(); const [podkop, singbox] = await Promise.all([
const singbox = await getSingboxStatus(); getPodkopStatus(),
getSingboxStatus(),
]);
store.set({ store.set({
services: { servicesInfoWidget: {
singbox: singbox.running, loading: false,
podkop: podkop.enabled, failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled },
}, },
}); });
} }
async function connectToClashSockets() { async function connectToClashSockets() {
socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { socket.subscribe(
`${getClashWsUrl()}/traffic?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg); const parsedMsg = JSON.parse(msg);
store.set({ store.set({
traffic: { up: parsedMsg.up, down: parsedMsg.down }, bandwidthWidget: {
}); loading: false,
}); failed: false,
data: { up: parsedMsg.up, down: parsedMsg.down },
socket.subscribe(`${getClashWsUrl()}/connections?token=`, (msg) => {
const parsedMsg = JSON.parse(msg);
store.set({
connections: {
connections: parsedMsg.connections,
downloadTotal: parsedMsg.downloadTotal,
uploadTotal: parsedMsg.uploadTotal,
memory: parsedMsg.memory,
}, },
}); });
}); },
(_err) => {
socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => {
store.set({ store.set({
memory: { inuse: msg.inuse, oslimit: msg.oslimit }, bandwidthWidget: {
loading: false,
failed: true,
data: { up: 0, down: 0 },
},
}); });
},
);
socket.subscribe(
`${getClashWsUrl()}/connections?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg);
store.set({
trafficTotalWidget: {
loading: false,
failed: false,
data: {
downloadTotal: parsedMsg.downloadTotal,
uploadTotal: parsedMsg.uploadTotal,
},
},
systemInfoWidget: {
loading: false,
failed: false,
data: {
connections: parsedMsg.connections?.length,
memory: parsedMsg.memory,
},
},
}); });
},
(_err) => {
store.set({
trafficTotalWidget: {
loading: false,
failed: true,
data: { downloadTotal: 0, uploadTotal: 0 },
},
systemInfoWidget: {
loading: false,
failed: true,
data: {
connections: 0,
memory: 0,
},
},
});
},
);
} }
// Handlers // Handlers
@@ -104,18 +153,31 @@ function replaceTestLatencyButtonsWithSkeleton() {
// Renderer // Renderer
async function renderDashboardSections() { async function renderSectionsWidget() {
const dashboardSections = store.get().dashboardSections; console.log('renderSectionsWidget');
const sectionsWidget = store.get().sectionsWidget;
const container = document.getElementById('dashboard-sections-grid'); const container = document.getElementById('dashboard-sections-grid');
if (dashboardSections.failed) { if (sectionsWidget.loading || sectionsWidget.failed) {
const rendered = renderEmptyOutboundGroup(); const renderedWidget = renderSections({
loading: sectionsWidget.loading,
return container!.replaceChildren(rendered); failed: sectionsWidget.failed,
section: {
code: '',
displayName: '',
outbounds: [],
withTagSelect: false,
},
onTestLatency: () => {},
onChooseOutbound: () => {},
});
return container!.replaceChildren(renderedWidget);
} }
const renderedOutboundGroups = dashboardSections.data.map((section) => const renderedWidgets = sectionsWidget.data.map((section) =>
renderOutboundGroup({ renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section, section,
onTestLatency: (tag) => { onTestLatency: (tag) => {
replaceTestLatencyButtonsWithSkeleton(); replaceTestLatencyButtonsWithSkeleton();
@@ -132,18 +194,33 @@ async function renderDashboardSections() {
}), }),
); );
container!.replaceChildren(...renderedOutboundGroups); return container!.replaceChildren(...renderedWidgets);
} }
async function renderTrafficWidget() { async function renderBandwidthWidget() {
const traffic = store.get().traffic; console.log('renderBandwidthWidget');
const traffic = store.get().bandwidthWidget;
const container = document.getElementById('dashboard-widget-traffic'); const container = document.getElementById('dashboard-widget-traffic');
const renderedWidget = renderDashboardWidget({
if (traffic.loading || traffic.failed) {
const renderedWidget = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: 'Traffic', title: 'Traffic',
items: [ items: [
{ key: 'Uplink', value: `${prettyBytes(traffic.up)}/s` }, { key: 'Uplink', value: `${prettyBytes(traffic.data.up)}/s` },
{ key: 'Downlink', value: `${prettyBytes(traffic.down)}/s` }, { key: 'Downlink', value: `${prettyBytes(traffic.data.down)}/s` },
], ],
}); });
@@ -151,16 +228,34 @@ async function renderTrafficWidget() {
} }
async function renderTrafficTotalWidget() { async function renderTrafficTotalWidget() {
const connections = store.get().connections; console.log('renderTrafficTotalWidget');
const trafficTotalWidget = store.get().trafficTotalWidget;
const container = document.getElementById('dashboard-widget-traffic-total'); const container = document.getElementById('dashboard-widget-traffic-total');
const renderedWidget = renderDashboardWidget({
if (trafficTotalWidget.loading || trafficTotalWidget.failed) {
const renderedWidget = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: 'Traffic Total', title: 'Traffic Total',
items: [ items: [
{ key: 'Uplink', value: String(prettyBytes(connections.uploadTotal)) }, {
key: 'Uplink',
value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)),
},
{ {
key: 'Downlink', key: 'Downlink',
value: String(prettyBytes(connections.downloadTotal)), value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)),
}, },
], ],
}); });
@@ -169,44 +264,77 @@ async function renderTrafficTotalWidget() {
} }
async function renderSystemInfoWidget() { async function renderSystemInfoWidget() {
const connections = store.get().connections; console.log('renderSystemInfoWidget');
const systemInfoWidget = store.get().systemInfoWidget;
const container = document.getElementById('dashboard-widget-system-info'); const container = document.getElementById('dashboard-widget-system-info');
const renderedWidget = renderDashboardWidget({
if (systemInfoWidget.loading || systemInfoWidget.failed) {
const renderedWidget = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: 'System info', title: 'System info',
items: [ items: [
{ {
key: 'Active Connections', key: 'Active Connections',
value: String(connections.connections.length), value: String(systemInfoWidget.data.connections),
},
{
key: 'Memory Usage',
value: String(prettyBytes(systemInfoWidget.data.memory)),
}, },
{ key: 'Memory Usage', value: String(prettyBytes(connections.memory)) },
], ],
}); });
container!.replaceChildren(renderedWidget); container!.replaceChildren(renderedWidget);
} }
async function renderServiceInfoWidget() { async function renderServicesInfoWidget() {
const services = store.get().services; console.log('renderServicesInfoWidget');
const servicesInfoWidget = store.get().servicesInfoWidget;
const container = document.getElementById('dashboard-widget-service-info'); const container = document.getElementById('dashboard-widget-service-info');
const renderedWidget = renderDashboardWidget({
if (servicesInfoWidget.loading || servicesInfoWidget.failed) {
const renderedWidget = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: 'Services info', title: 'Services info',
items: [ items: [
{ {
key: 'Podkop', key: 'Podkop',
value: services.podkop ? '✔ Enabled' : '✘ Disabled', value: servicesInfoWidget.data.podkop ? '✔ Enabled' : '✘ Disabled',
attributes: { attributes: {
class: services.podkop class: servicesInfoWidget.data.podkop
? 'pdk_dashboard-page__widgets-section__item__row--success' ? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error', : 'pdk_dashboard-page__widgets-section__item__row--error',
}, },
}, },
{ {
key: 'Sing-box', key: 'Sing-box',
value: services.singbox ? '✔ Running' : '✘ Stopped', value: servicesInfoWidget.data.singbox ? '✔ Running' : '✘ Stopped',
attributes: { attributes: {
class: services.singbox class: servicesInfoWidget.data.singbox
? 'pdk_dashboard-page__widgets-section__item__row--success' ? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error', : 'pdk_dashboard-page__widgets-section__item__row--error',
}, },
@@ -222,21 +350,24 @@ async function onStoreUpdate(
prev: StoreType, prev: StoreType,
diff: Partial<StoreType>, diff: Partial<StoreType>,
) { ) {
if (diff?.dashboardSections) { if (diff.sectionsWidget) {
renderDashboardSections(); renderSectionsWidget();
} }
if (diff?.traffic) { if (diff.bandwidthWidget) {
renderTrafficWidget(); renderBandwidthWidget();
} }
if (diff?.connections) { if (diff.trafficTotalWidget) {
renderTrafficTotalWidget(); renderTrafficTotalWidget();
}
if (diff.systemInfoWidget) {
renderSystemInfoWidget(); renderSystemInfoWidget();
} }
if (diff?.services) { if (diff.servicesInfoWidget) {
renderServiceInfoWidget(); renderServicesInfoWidget();
} }
} }

View File

@@ -1,3 +1,6 @@
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
export function renderDashboard() { export function renderDashboard() {
return E( return E(
'div', 'div',
@@ -8,59 +11,44 @@ export function renderDashboard() {
[ [
// Widgets section // Widgets section
E('div', { class: 'pdk_dashboard-page__widgets-section' }, [ E('div', { class: 'pdk_dashboard-page__widgets-section' }, [
E('div', { id: 'dashboard-widget-traffic' }, [
E( E(
'div', 'div',
{ { id: 'dashboard-widget-traffic' },
id: '', renderWidget({ loading: true, failed: false, title: '', items: [] }),
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item skeleton',
},
'',
), ),
]),
E('div', { id: 'dashboard-widget-traffic-total' }, [
E( E(
'div', 'div',
{ { id: 'dashboard-widget-traffic-total' },
id: '', renderWidget({ loading: true, failed: false, title: '', items: [] }),
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item skeleton',
},
'',
), ),
]),
E('div', { id: 'dashboard-widget-system-info' }, [
E( E(
'div', 'div',
{ { id: 'dashboard-widget-system-info' },
id: '', renderWidget({ loading: true, failed: false, title: '', items: [] }),
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item skeleton',
},
'',
), ),
]),
E('div', { id: 'dashboard-widget-service-info' }, [
E( E(
'div', 'div',
{ { id: 'dashboard-widget-service-info' },
id: '', renderWidget({ loading: true, failed: false, title: '', items: [] }),
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item skeleton',
},
'',
), ),
]), ]),
]),
// All outbounds // All outbounds
E('div', { id: 'dashboard-sections-grid' }, [ E(
E('div', { 'div',
id: 'dashboard-sections-grid-skeleton', { id: 'dashboard-sections-grid' },
class: 'pdk_dashboard-page__outbound-section skeleton', renderSections({
style: 'height: 127px', loading: true,
failed: false,
section: {
code: '',
displayName: '',
outbounds: [],
withTagSelect: false,
},
onTestLatency: () => {},
onChooseOutbound: () => {},
}), }),
]), ),
], ],
); );
} }

View File

@@ -1,16 +1,37 @@
import { Podkop } from '../../../types'; import { Podkop } from '../../types';
interface IRenderOutboundGroupProps { interface IRenderSectionsProps {
loading: boolean;
failed: boolean;
section: Podkop.OutboundGroup; section: Podkop.OutboundGroup;
onTestLatency: (tag: string) => void; onTestLatency: (tag: string) => void;
onChooseOutbound: (selector: string, tag: string) => void; onChooseOutbound: (selector: string, tag: string) => void;
} }
export function renderOutboundGroup({ function renderFailedState() {
return E(
'div',
{
class: 'pdk_dashboard-page__outbound-section centered',
style: 'height: 127px',
},
E('span', {}, 'Dashboard currently unavailable'),
);
}
function renderLoadingState() {
return E('div', {
id: 'dashboard-sections-grid-skeleton',
class: 'pdk_dashboard-page__outbound-section skeleton',
style: 'height: 127px',
});
}
export function renderDefaultState({
section, section,
onTestLatency,
onChooseOutbound, onChooseOutbound,
}: IRenderOutboundGroupProps) { onTestLatency,
}: IRenderSectionsProps) {
function testLatency() { function testLatency() {
if (section.withTagSelect) { if (section.withTagSelect) {
return onTestLatency(section.code); return onTestLatency(section.code);
@@ -90,3 +111,15 @@ export function renderOutboundGroup({
), ),
]); ]);
} }
export function renderSections(props: IRenderSectionsProps) {
if (props.failed) {
return renderFailedState();
}
if (props.loading) {
return renderLoadingState();
}
return renderDefaultState(props);
}

View File

@@ -1,4 +1,6 @@
interface IRenderWidgetParams { interface IRenderWidgetProps {
loading: boolean;
failed: boolean;
title: string; title: string;
items: Array<{ items: Array<{
key: string; key: string;
@@ -9,7 +11,31 @@ interface IRenderWidgetParams {
}>; }>;
} }
export function renderDashboardWidget({ title, items }: IRenderWidgetParams) { function renderFailedState() {
return E(
'div',
{
id: '',
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item centered',
},
'Currently unavailable',
);
}
function renderLoadingState() {
return E(
'div',
{
id: '',
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item skeleton',
},
'',
);
}
function renderDefaultState({ title, items }: IRenderWidgetProps) {
return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [ return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [
E( E(
'b', 'b',
@@ -38,3 +64,15 @@ export function renderDashboardWidget({ title, items }: IRenderWidgetParams) {
), ),
]); ]);
} }
export function renderWidget(props: IRenderWidgetProps) {
if (props.loading) {
return renderLoadingState();
}
if (props.failed) {
return renderFailedState();
}
return renderDefaultState(props);
}

View File

@@ -1,10 +0,0 @@
export function renderEmptyOutboundGroup() {
return E(
'div',
{
class: 'pdk_dashboard-page__outbound-section centered',
style: 'height: 127px',
},
E('span', {}, 'Dashboard currently unavailable'),
);
}

View File

@@ -1,11 +1,13 @@
// eslint-disable-next-line // eslint-disable-next-line
type Listener = (data: any) => void; type Listener = (data: any) => void;
type ErrorListener = (error: Event | string) => void;
class SocketManager { class SocketManager {
private static instance: SocketManager; private static instance: SocketManager;
private sockets = new Map<string, WebSocket>(); private sockets = new Map<string, WebSocket>();
private listeners = new Map<string, Set<Listener>>(); private listeners = new Map<string, Set<Listener>>();
private connected = new Map<string, boolean>(); private connected = new Map<string, boolean>();
private errorListeners = new Map<string, Set<ErrorListener>>();
private constructor() {} private constructor() {}
@@ -23,9 +25,11 @@ class SocketManager {
this.sockets.set(url, ws); this.sockets.set(url, ws);
this.connected.set(url, false); this.connected.set(url, false);
this.listeners.set(url, new Set()); this.listeners.set(url, new Set());
this.errorListeners.set(url, new Set());
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
this.connected.set(url, true); this.connected.set(url, true);
console.info(`Connected: ${url}`);
}); });
ws.addEventListener('message', (event) => { ws.addEventListener('message', (event) => {
@@ -43,23 +47,33 @@ class SocketManager {
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
this.connected.set(url, false); this.connected.set(url, false);
console.warn(`⚠️ Disconnected: ${url}`); console.warn(`Disconnected: ${url}`);
this.triggerError(url, 'Connection closed');
}); });
ws.addEventListener('error', (err) => { ws.addEventListener('error', (err) => {
console.error(`Socket error for ${url}:`, err); console.error(`Socket error for ${url}:`, err);
this.triggerError(url, err);
}); });
} }
subscribe(url: string, listener: Listener): void { subscribe(url: string, listener: Listener, onError?: ErrorListener): void {
if (!this.sockets.has(url)) { if (!this.sockets.has(url)) {
this.connect(url); this.connect(url);
} }
this.listeners.get(url)?.add(listener); this.listeners.get(url)?.add(listener);
if (onError) {
this.errorListeners.get(url)?.add(onError);
}
} }
unsubscribe(url: string, listener: Listener): void { unsubscribe(url: string, listener: Listener, onError?: ErrorListener): void {
this.listeners.get(url)?.delete(listener); this.listeners.get(url)?.delete(listener);
if (onError) {
this.errorListeners.get(url)?.delete(onError);
}
} }
// eslint-disable-next-line // eslint-disable-next-line
@@ -68,7 +82,8 @@ class SocketManager {
if (ws && this.connected.get(url)) { if (ws && this.connected.get(url)) {
ws.send(typeof data === 'string' ? data : JSON.stringify(data)); ws.send(typeof data === 'string' ? data : JSON.stringify(data));
} else { } else {
console.warn(`⚠️ Cannot send: not connected to ${url}`); console.warn(`Cannot send: not connected to ${url}`);
this.triggerError(url, 'Not connected');
} }
} }
@@ -78,6 +93,7 @@ class SocketManager {
ws.close(); ws.close();
this.sockets.delete(url); this.sockets.delete(url);
this.listeners.delete(url); this.listeners.delete(url);
this.errorListeners.delete(url);
this.connected.delete(url); this.connected.delete(url);
} }
} }
@@ -87,6 +103,19 @@ class SocketManager {
this.disconnect(url); this.disconnect(url);
} }
} }
private triggerError(url: string, err: Event | string): void {
const handlers = this.errorListeners.get(url);
if (handlers) {
for (const cb of handlers) {
try {
cb(err);
} catch (e) {
console.error(`Error handler threw for ${url}:`, e);
}
}
}
}
} }
export const socket = SocketManager.getInstance(); export const socket = SocketManager.getInstance();

View File

@@ -117,22 +117,30 @@ export interface StoreType {
current: string; current: string;
all: string[]; all: string[];
}; };
dashboardSections: { bandwidthWidget: {
loading: boolean; loading: boolean;
data: Podkop.OutboundGroup[];
failed: boolean; failed: boolean;
data: { up: number; down: number };
}; };
traffic: { up: number; down: number }; trafficTotalWidget: {
memory: { inuse: number; oslimit: number }; loading: boolean;
connections: { failed: boolean;
connections: unknown[]; data: { downloadTotal: number; uploadTotal: number };
downloadTotal: number;
memory: number;
uploadTotal: number;
}; };
services: { systemInfoWidget: {
singbox: number; loading: boolean;
podkop: number; failed: boolean;
data: { connections: number; memory: number };
};
servicesInfoWidget: {
loading: boolean;
failed: boolean;
data: { singbox: number; podkop: number };
};
sectionsWidget: {
loading: boolean;
failed: boolean;
data: Podkop.OutboundGroup[];
}; };
} }
@@ -141,19 +149,31 @@ const initialStore: StoreType = {
current: '', current: '',
all: [], all: [],
}, },
dashboardSections: { bandwidthWidget: {
data: [],
loading: true, loading: true,
failed: false,
data: { up: 0, down: 0 },
}, },
traffic: { up: -1, down: -1 }, trafficTotalWidget: {
memory: { inuse: -1, oslimit: -1 }, loading: true,
connections: { failed: false,
connections: [], data: { downloadTotal: 0, uploadTotal: 0 },
memory: -1, },
downloadTotal: -1, systemInfoWidget: {
uploadTotal: -1, loading: true,
failed: false,
data: { connections: 0, memory: 0 },
},
servicesInfoWidget: {
loading: true,
failed: false,
data: { singbox: 0, podkop: 0 },
},
sectionsWidget: {
loading: true,
failed: false,
data: [],
}, },
services: { singbox: -1, podkop: -1 },
}; };
export const store = new Store<StoreType>(initialStore); export const store = new Store<StoreType>(initialStore);

View File

@@ -1166,19 +1166,31 @@ var initialStore = {
current: "", current: "",
all: [] all: []
}, },
dashboardSections: { bandwidthWidget: {
data: [], loading: true,
loading: true failed: false,
data: { up: 0, down: 0 }
}, },
traffic: { up: -1, down: -1 }, trafficTotalWidget: {
memory: { inuse: -1, oslimit: -1 }, loading: true,
connections: { failed: false,
connections: [], data: { downloadTotal: 0, uploadTotal: 0 }
memory: -1,
downloadTotal: -1,
uploadTotal: -1
}, },
services: { singbox: -1, podkop: -1 } systemInfoWidget: {
loading: true,
failed: false,
data: { connections: 0, memory: 0 }
},
servicesInfoWidget: {
loading: true,
failed: false,
data: { singbox: 0, podkop: 0 }
},
sectionsWidget: {
loading: true,
failed: false,
data: []
}
}; };
var store = new Store(initialStore); var store = new Store(initialStore);
@@ -1194,79 +1206,28 @@ function coreService() {
}); });
} }
// src/podkop/tabs/dashboard/renderDashboard.ts // src/podkop/tabs/dashboard/renderSections.ts
function renderDashboard() { function renderFailedState() {
return E( return E(
"div", "div",
{ {
id: "dashboard-status", class: "pdk_dashboard-page__outbound-section centered",
class: "pdk_dashboard-page" style: "height: 127px"
}, },
[ E("span", {}, "Dashboard currently unavailable")
// Widgets section );
E("div", { class: "pdk_dashboard-page__widgets-section" }, [ }
E("div", { id: "dashboard-widget-traffic" }, [ function renderLoadingState() {
E( return E("div", {
"div",
{
id: "",
style: "height: 78px",
class: "pdk_dashboard-page__widgets-section__item skeleton"
},
""
)
]),
E("div", { id: "dashboard-widget-traffic-total" }, [
E(
"div",
{
id: "",
style: "height: 78px",
class: "pdk_dashboard-page__widgets-section__item skeleton"
},
""
)
]),
E("div", { id: "dashboard-widget-system-info" }, [
E(
"div",
{
id: "",
style: "height: 78px",
class: "pdk_dashboard-page__widgets-section__item skeleton"
},
""
)
]),
E("div", { id: "dashboard-widget-service-info" }, [
E(
"div",
{
id: "",
style: "height: 78px",
class: "pdk_dashboard-page__widgets-section__item skeleton"
},
""
)
])
]),
// All outbounds
E("div", { id: "dashboard-sections-grid" }, [
E("div", {
id: "dashboard-sections-grid-skeleton", id: "dashboard-sections-grid-skeleton",
class: "pdk_dashboard-page__outbound-section skeleton", class: "pdk_dashboard-page__outbound-section skeleton",
style: "height: 127px" style: "height: 127px"
}) });
])
]
);
} }
function renderDefaultState({
// src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts
function renderOutboundGroup({
section, section,
onTestLatency, onChooseOutbound,
onChooseOutbound onTestLatency
}) { }) {
function testLatency() { function testLatency() {
if (section.withTagSelect) { if (section.withTagSelect) {
@@ -1338,9 +1299,40 @@ function renderOutboundGroup({
) )
]); ]);
} }
function renderSections(props) {
if (props.failed) {
return renderFailedState();
}
if (props.loading) {
return renderLoadingState();
}
return renderDefaultState(props);
}
// src/podkop/tabs/dashboard/renderer/renderWidget.ts // src/podkop/tabs/dashboard/renderWidget.ts
function renderDashboardWidget({ title, items }) { function renderFailedState2() {
return E(
"div",
{
id: "",
style: "height: 78px",
class: "pdk_dashboard-page__widgets-section__item centered"
},
"Currently unavailable"
);
}
function renderLoadingState2() {
return E(
"div",
{
id: "",
style: "height: 78px",
class: "pdk_dashboard-page__widgets-section__item skeleton"
},
""
);
}
function renderDefaultState2({ title, items }) {
return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [ return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [
E( E(
"b", "b",
@@ -1369,6 +1361,70 @@ function renderDashboardWidget({ title, items }) {
) )
]); ]);
} }
function renderWidget(props) {
if (props.loading) {
return renderLoadingState2();
}
if (props.failed) {
return renderFailedState2();
}
return renderDefaultState2(props);
}
// src/podkop/tabs/dashboard/renderDashboard.ts
function renderDashboard() {
return E(
"div",
{
id: "dashboard-status",
class: "pdk_dashboard-page"
},
[
// Widgets section
E("div", { class: "pdk_dashboard-page__widgets-section" }, [
E(
"div",
{ id: "dashboard-widget-traffic" },
renderWidget({ loading: true, failed: false, title: "", items: [] })
),
E(
"div",
{ id: "dashboard-widget-traffic-total" },
renderWidget({ loading: true, failed: false, title: "", items: [] })
),
E(
"div",
{ id: "dashboard-widget-system-info" },
renderWidget({ loading: true, failed: false, title: "", items: [] })
),
E(
"div",
{ id: "dashboard-widget-service-info" },
renderWidget({ loading: true, failed: false, title: "", items: [] })
)
]),
// All outbounds
E(
"div",
{ id: "dashboard-sections-grid" },
renderSections({
loading: true,
failed: false,
section: {
code: "",
displayName: "",
outbounds: [],
withTagSelect: false
},
onTestLatency: () => {
},
onChooseOutbound: () => {
}
})
)
]
);
}
// src/socket.ts // src/socket.ts
var SocketManager = class _SocketManager { var SocketManager = class _SocketManager {
@@ -1376,6 +1432,7 @@ var SocketManager = class _SocketManager {
this.sockets = /* @__PURE__ */ new Map(); this.sockets = /* @__PURE__ */ new Map();
this.listeners = /* @__PURE__ */ new Map(); this.listeners = /* @__PURE__ */ new Map();
this.connected = /* @__PURE__ */ new Map(); this.connected = /* @__PURE__ */ new Map();
this.errorListeners = /* @__PURE__ */ new Map();
} }
static getInstance() { static getInstance() {
if (!_SocketManager.instance) { if (!_SocketManager.instance) {
@@ -1389,8 +1446,10 @@ var SocketManager = class _SocketManager {
this.sockets.set(url, ws); this.sockets.set(url, ws);
this.connected.set(url, false); this.connected.set(url, false);
this.listeners.set(url, /* @__PURE__ */ new Set()); this.listeners.set(url, /* @__PURE__ */ new Set());
this.errorListeners.set(url, /* @__PURE__ */ new Set());
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
this.connected.set(url, true); this.connected.set(url, true);
console.info(`Connected: ${url}`);
}); });
ws.addEventListener("message", (event) => { ws.addEventListener("message", (event) => {
const handlers = this.listeners.get(url); const handlers = this.listeners.get(url);
@@ -1406,20 +1465,28 @@ var SocketManager = class _SocketManager {
}); });
ws.addEventListener("close", () => { ws.addEventListener("close", () => {
this.connected.set(url, false); this.connected.set(url, false);
console.warn(`\u26A0\uFE0F Disconnected: ${url}`); console.warn(`Disconnected: ${url}`);
this.triggerError(url, "Connection closed");
}); });
ws.addEventListener("error", (err) => { ws.addEventListener("error", (err) => {
console.error(`\u274C Socket error for ${url}:`, err); console.error(`Socket error for ${url}:`, err);
this.triggerError(url, err);
}); });
} }
subscribe(url, listener) { subscribe(url, listener, onError) {
if (!this.sockets.has(url)) { if (!this.sockets.has(url)) {
this.connect(url); this.connect(url);
} }
this.listeners.get(url)?.add(listener); this.listeners.get(url)?.add(listener);
if (onError) {
this.errorListeners.get(url)?.add(onError);
} }
unsubscribe(url, listener) { }
unsubscribe(url, listener, onError) {
this.listeners.get(url)?.delete(listener); this.listeners.get(url)?.delete(listener);
if (onError) {
this.errorListeners.get(url)?.delete(onError);
}
} }
// eslint-disable-next-line // eslint-disable-next-line
send(url, data) { send(url, data) {
@@ -1427,7 +1494,8 @@ var SocketManager = class _SocketManager {
if (ws && this.connected.get(url)) { if (ws && this.connected.get(url)) {
ws.send(typeof data === "string" ? data : JSON.stringify(data)); ws.send(typeof data === "string" ? data : JSON.stringify(data));
} else { } else {
console.warn(`\u26A0\uFE0F Cannot send: not connected to ${url}`); console.warn(`Cannot send: not connected to ${url}`);
this.triggerError(url, "Not connected");
} }
} }
disconnect(url) { disconnect(url) {
@@ -1436,6 +1504,7 @@ var SocketManager = class _SocketManager {
ws.close(); ws.close();
this.sockets.delete(url); this.sockets.delete(url);
this.listeners.delete(url); this.listeners.delete(url);
this.errorListeners.delete(url);
this.connected.delete(url); this.connected.delete(url);
} }
} }
@@ -1444,6 +1513,18 @@ var SocketManager = class _SocketManager {
this.disconnect(url); this.disconnect(url);
} }
} }
triggerError(url, err) {
const handlers = this.errorListeners.get(url);
if (handlers) {
for (const cb of handlers) {
try {
cb(err);
} catch (e) {
console.error(`Error handler threw for ${url}:`, e);
}
}
}
}
}; };
var socket = SocketManager.getInstance(); var socket = SocketManager.getInstance();
@@ -1459,63 +1540,101 @@ function prettyBytes(n) {
return n + " " + unit; return n + " " + unit;
} }
// src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts
function renderEmptyOutboundGroup() {
return E(
"div",
{
class: "pdk_dashboard-page__outbound-section centered",
style: "height: 127px"
},
E("span", {}, "Dashboard currently unavailable")
);
}
// src/podkop/tabs/dashboard/initDashboardController.ts // src/podkop/tabs/dashboard/initDashboardController.ts
async function fetchDashboardSections() { async function fetchDashboardSections() {
const prev = store.get().sectionsWidget;
store.set({ store.set({
dashboardSections: { sectionsWidget: {
...store.get().dashboardSections, ...prev,
failed: false, failed: false
loading: true
} }
}); });
const { data, success } = await getDashboardSections(); const { data, success } = await getDashboardSections();
store.set({ dashboardSections: { loading: false, data, failed: !success } }); store.set({
sectionsWidget: {
loading: false,
failed: !success,
data
}
});
} }
async function fetchServicesInfo() { async function fetchServicesInfo() {
const podkop = await getPodkopStatus(); const [podkop, singbox] = await Promise.all([
const singbox = await getSingboxStatus(); getPodkopStatus(),
getSingboxStatus()
]);
store.set({ store.set({
services: { servicesInfoWidget: {
singbox: singbox.running, loading: false,
podkop: podkop.enabled failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled }
} }
}); });
} }
async function connectToClashSockets() { async function connectToClashSockets() {
socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { socket.subscribe(
`${getClashWsUrl()}/traffic?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg); const parsedMsg = JSON.parse(msg);
store.set({ store.set({
traffic: { up: parsedMsg.up, down: parsedMsg.down } bandwidthWidget: {
}); loading: false,
}); failed: false,
socket.subscribe(`${getClashWsUrl()}/connections?token=`, (msg) => { data: { up: parsedMsg.up, down: parsedMsg.down }
const parsedMsg = JSON.parse(msg);
store.set({
connections: {
connections: parsedMsg.connections,
downloadTotal: parsedMsg.downloadTotal,
uploadTotal: parsedMsg.uploadTotal,
memory: parsedMsg.memory
} }
}); });
}); },
socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => { (_err) => {
store.set({ store.set({
memory: { inuse: msg.inuse, oslimit: msg.oslimit } bandwidthWidget: {
loading: false,
failed: true,
data: { up: 0, down: 0 }
}
}); });
}
);
socket.subscribe(
`${getClashWsUrl()}/connections?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg);
store.set({
trafficTotalWidget: {
loading: false,
failed: false,
data: {
downloadTotal: parsedMsg.downloadTotal,
uploadTotal: parsedMsg.uploadTotal
}
},
systemInfoWidget: {
loading: false,
failed: false,
data: {
connections: parsedMsg.connections?.length,
memory: parsedMsg.memory
}
}
}); });
},
(_err) => {
store.set({
trafficTotalWidget: {
loading: false,
failed: true,
data: { downloadTotal: 0, uploadTotal: 0 }
},
systemInfoWidget: {
loading: false,
failed: true,
data: {
connections: 0,
memory: 0
}
}
});
}
);
} }
async function handleChooseOutbound(selector, tag) { async function handleChooseOutbound(selector, tag) {
await triggerProxySelector(selector, tag); await triggerProxySelector(selector, tag);
@@ -1538,15 +1657,31 @@ function replaceTestLatencyButtonsWithSkeleton() {
el.replaceWith(newDiv); el.replaceWith(newDiv);
}); });
} }
async function renderDashboardSections() { async function renderSectionsWidget() {
const dashboardSections = store.get().dashboardSections; console.log("renderSectionsWidget");
const sectionsWidget = store.get().sectionsWidget;
const container = document.getElementById("dashboard-sections-grid"); const container = document.getElementById("dashboard-sections-grid");
if (dashboardSections.failed) { if (sectionsWidget.loading || sectionsWidget.failed) {
const rendered = renderEmptyOutboundGroup(); const renderedWidget = renderSections({
return container.replaceChildren(rendered); loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section: {
code: "",
displayName: "",
outbounds: [],
withTagSelect: false
},
onTestLatency: () => {
},
onChooseOutbound: () => {
} }
const renderedOutboundGroups = dashboardSections.data.map( });
(section) => renderOutboundGroup({ return container.replaceChildren(renderedWidget);
}
const renderedWidgets = sectionsWidget.data.map(
(section) => renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section, section,
onTestLatency: (tag) => { onTestLatency: (tag) => {
replaceTestLatencyButtonsWithSkeleton(); replaceTestLatencyButtonsWithSkeleton();
@@ -1560,68 +1695,122 @@ async function renderDashboardSections() {
} }
}) })
); );
container.replaceChildren(...renderedOutboundGroups); return container.replaceChildren(...renderedWidgets);
} }
async function renderTrafficWidget() { async function renderBandwidthWidget() {
const traffic = store.get().traffic; console.log("renderBandwidthWidget");
const traffic = store.get().bandwidthWidget;
const container = document.getElementById("dashboard-widget-traffic"); const container = document.getElementById("dashboard-widget-traffic");
const renderedWidget = renderDashboardWidget({ if (traffic.loading || traffic.failed) {
const renderedWidget2 = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: "",
items: []
});
return container.replaceChildren(renderedWidget2);
}
const renderedWidget = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: "Traffic", title: "Traffic",
items: [ items: [
{ key: "Uplink", value: `${prettyBytes(traffic.up)}/s` }, { key: "Uplink", value: `${prettyBytes(traffic.data.up)}/s` },
{ key: "Downlink", value: `${prettyBytes(traffic.down)}/s` } { key: "Downlink", value: `${prettyBytes(traffic.data.down)}/s` }
] ]
}); });
container.replaceChildren(renderedWidget); container.replaceChildren(renderedWidget);
} }
async function renderTrafficTotalWidget() { async function renderTrafficTotalWidget() {
const connections = store.get().connections; console.log("renderTrafficTotalWidget");
const trafficTotalWidget = store.get().trafficTotalWidget;
const container = document.getElementById("dashboard-widget-traffic-total"); const container = document.getElementById("dashboard-widget-traffic-total");
const renderedWidget = renderDashboardWidget({ if (trafficTotalWidget.loading || trafficTotalWidget.failed) {
const renderedWidget2 = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: "",
items: []
});
return container.replaceChildren(renderedWidget2);
}
const renderedWidget = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: "Traffic Total", title: "Traffic Total",
items: [ items: [
{ key: "Uplink", value: String(prettyBytes(connections.uploadTotal)) }, {
key: "Uplink",
value: String(prettyBytes(trafficTotalWidget.data.uploadTotal))
},
{ {
key: "Downlink", key: "Downlink",
value: String(prettyBytes(connections.downloadTotal)) value: String(prettyBytes(trafficTotalWidget.data.downloadTotal))
} }
] ]
}); });
container.replaceChildren(renderedWidget); container.replaceChildren(renderedWidget);
} }
async function renderSystemInfoWidget() { async function renderSystemInfoWidget() {
const connections = store.get().connections; console.log("renderSystemInfoWidget");
const systemInfoWidget = store.get().systemInfoWidget;
const container = document.getElementById("dashboard-widget-system-info"); const container = document.getElementById("dashboard-widget-system-info");
const renderedWidget = renderDashboardWidget({ if (systemInfoWidget.loading || systemInfoWidget.failed) {
const renderedWidget2 = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: "",
items: []
});
return container.replaceChildren(renderedWidget2);
}
const renderedWidget = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: "System info", title: "System info",
items: [ items: [
{ {
key: "Active Connections", key: "Active Connections",
value: String(connections.connections.length) value: String(systemInfoWidget.data.connections)
}, },
{ key: "Memory Usage", value: String(prettyBytes(connections.memory)) } {
key: "Memory Usage",
value: String(prettyBytes(systemInfoWidget.data.memory))
}
] ]
}); });
container.replaceChildren(renderedWidget); container.replaceChildren(renderedWidget);
} }
async function renderServiceInfoWidget() { async function renderServicesInfoWidget() {
const services = store.get().services; console.log("renderServicesInfoWidget");
const servicesInfoWidget = store.get().servicesInfoWidget;
const container = document.getElementById("dashboard-widget-service-info"); const container = document.getElementById("dashboard-widget-service-info");
const renderedWidget = renderDashboardWidget({ if (servicesInfoWidget.loading || servicesInfoWidget.failed) {
const renderedWidget2 = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: "",
items: []
});
return container.replaceChildren(renderedWidget2);
}
const renderedWidget = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: "Services info", title: "Services info",
items: [ items: [
{ {
key: "Podkop", key: "Podkop",
value: services.podkop ? "\u2714 Enabled" : "\u2718 Disabled", value: servicesInfoWidget.data.podkop ? "\u2714 Enabled" : "\u2718 Disabled",
attributes: { attributes: {
class: services.podkop ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" class: servicesInfoWidget.data.podkop ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error"
} }
}, },
{ {
key: "Sing-box", key: "Sing-box",
value: services.singbox ? "\u2714 Running" : "\u2718 Stopped", value: servicesInfoWidget.data.singbox ? "\u2714 Running" : "\u2718 Stopped",
attributes: { attributes: {
class: services.singbox ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" class: servicesInfoWidget.data.singbox ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error"
} }
} }
] ]
@@ -1629,18 +1818,20 @@ async function renderServiceInfoWidget() {
container.replaceChildren(renderedWidget); container.replaceChildren(renderedWidget);
} }
async function onStoreUpdate(next, prev, diff) { async function onStoreUpdate(next, prev, diff) {
if (diff?.dashboardSections) { if (diff.sectionsWidget) {
renderDashboardSections(); renderSectionsWidget();
} }
if (diff?.traffic) { if (diff.bandwidthWidget) {
renderTrafficWidget(); renderBandwidthWidget();
} }
if (diff?.connections) { if (diff.trafficTotalWidget) {
renderTrafficTotalWidget(); renderTrafficTotalWidget();
}
if (diff.systemInfoWidget) {
renderSystemInfoWidget(); renderSystemInfoWidget();
} }
if (diff?.services) { if (diff.servicesInfoWidget) {
renderServiceInfoWidget(); renderServicesInfoWidget();
} }
} }
async function initDashboardController() { async function initDashboardController() {