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

View File

@@ -1,3 +1,6 @@
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
export function renderDashboard() {
return E(
'div',
@@ -8,59 +11,44 @@ export function renderDashboard() {
[
// Widgets section
E('div', { class: 'pdk_dashboard-page__widgets-section' }, [
E('div', { id: 'dashboard-widget-traffic' }, [
E(
'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',
},
'',
),
]),
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' }, [
E('div', {
id: 'dashboard-sections-grid-skeleton',
class: 'pdk_dashboard-page__outbound-section skeleton',
style: 'height: 127px',
E(
'div',
{ id: 'dashboard-sections-grid' },
renderSections({
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;
onTestLatency: (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,
onTestLatency,
onChooseOutbound,
}: IRenderOutboundGroupProps) {
onTestLatency,
}: IRenderSectionsProps) {
function testLatency() {
if (section.withTagSelect) {
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;
items: Array<{
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' }, [
E(
'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
type Listener = (data: any) => void;
type ErrorListener = (error: Event | string) => void;
class SocketManager {
private static instance: SocketManager;
private sockets = new Map<string, WebSocket>();
private listeners = new Map<string, Set<Listener>>();
private connected = new Map<string, boolean>();
private errorListeners = new Map<string, Set<ErrorListener>>();
private constructor() {}
@@ -23,9 +25,11 @@ class SocketManager {
this.sockets.set(url, ws);
this.connected.set(url, false);
this.listeners.set(url, new Set());
this.errorListeners.set(url, new Set());
ws.addEventListener('open', () => {
this.connected.set(url, true);
console.info(`Connected: ${url}`);
});
ws.addEventListener('message', (event) => {
@@ -43,23 +47,33 @@ class SocketManager {
ws.addEventListener('close', () => {
this.connected.set(url, false);
console.warn(`⚠️ Disconnected: ${url}`);
console.warn(`Disconnected: ${url}`);
this.triggerError(url, 'Connection closed');
});
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)) {
this.connect(url);
}
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);
if (onError) {
this.errorListeners.get(url)?.delete(onError);
}
}
// eslint-disable-next-line
@@ -68,7 +82,8 @@ class SocketManager {
if (ws && this.connected.get(url)) {
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
} 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();
this.sockets.delete(url);
this.listeners.delete(url);
this.errorListeners.delete(url);
this.connected.delete(url);
}
}
@@ -87,6 +103,19 @@ class SocketManager {
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();

View File

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