feat: implement dashboard tab

This commit is contained in:
divocat
2025-10-07 00:36:36 +03:00
parent 1e4cda9400
commit 7cb43ffb65
19 changed files with 697 additions and 287 deletions

View File

@@ -6,5 +6,5 @@
export * from './validators'; export * from './validators';
export * from './helpers'; export * from './helpers';
export * from './clash'; export * from './clash';
export * from './dashboard'; export * from './podkop';
export * from './constants'; export * from './constants';

View File

@@ -0,0 +1,3 @@
export * from './methods';
export * from './services';
export * from './tabs';

View File

@@ -3,22 +3,30 @@ import { getConfigSections } from './getConfigSections';
import { getClashProxies } from '../../clash'; import { getClashProxies } from '../../clash';
import { getProxyUrlName } from '../../helpers'; import { getProxyUrlName } from '../../helpers';
export async function getDashboardSections(): Promise<Podkop.OutboundGroup[]> { interface IGetDashboardSectionsResponse {
success: boolean;
data: Podkop.OutboundGroup[];
}
export async function getDashboardSections(): Promise<IGetDashboardSectionsResponse> {
const configSections = await getConfigSections(); const configSections = await getConfigSections();
const clashProxies = await getClashProxies(); const clashProxies = await getClashProxies();
const clashProxiesData = clashProxies.success if (!clashProxies.success) {
? clashProxies.data return {
: { proxies: [] }; success: false,
data: [],
};
}
const proxies = Object.entries(clashProxiesData.proxies).map( const proxies = Object.entries(clashProxies.data.proxies).map(
([key, value]) => ({ ([key, value]) => ({
code: key, code: key,
value, value,
}), }),
); );
return configSections const data = configSections
.filter((section) => section.mode !== 'block') .filter((section) => section.mode !== 'block')
.map((section) => { .map((section) => {
if (section.mode === 'proxy') { if (section.mode === 'proxy') {
@@ -137,4 +145,9 @@ export async function getDashboardSections(): Promise<Podkop.OutboundGroup[]> {
outbounds: [], outbounds: [],
}; };
}); });
return {
success: true,
data,
};
} }

View File

@@ -0,0 +1,13 @@
import { TabServiceInstance } from './tab.service';
import { store } from '../../store';
export function coreService() {
TabServiceInstance.onChange((activeId, tabs) => {
store.set({
tabService: {
current: activeId || '',
all: tabs.map((tab) => tab.id),
},
});
});
}

View File

@@ -0,0 +1,2 @@
export * from './tab.service';
export * from './core.service';

View File

@@ -0,0 +1,92 @@
type TabInfo = {
el: HTMLElement;
id: string;
active: boolean;
};
type TabChangeCallback = (activeId: string | null, allTabs: TabInfo[]) => void;
export class TabService {
private static instance: TabService;
private observer: MutationObserver | null = null;
private callback?: TabChangeCallback;
private lastActiveId: string | null = null;
private constructor() {
this.init();
}
public static getInstance(): TabService {
if (!TabService.instance) {
TabService.instance = new TabService();
}
return TabService.instance;
}
private init() {
this.observer = new MutationObserver(() => this.handleMutations());
this.observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class'],
});
// initial check
this.notify();
}
private handleMutations() {
this.notify();
}
private getTabsInfo(): TabInfo[] {
const tabs = Array.from(
document.querySelectorAll<HTMLElement>('.cbi-tab, .cbi-tab-disabled'),
);
return tabs.map((el) => ({
el,
id: el.dataset.tab || '',
active:
el.classList.contains('cbi-tab') &&
!el.classList.contains('cbi-tab-disabled'),
}));
}
private getActiveTabId(): string | null {
const active = document.querySelector<HTMLElement>(
'.cbi-tab:not(.cbi-tab-disabled)',
);
return active?.dataset.tab || null;
}
private notify() {
const tabs = this.getTabsInfo();
const activeId = this.getActiveTabId();
if (activeId !== this.lastActiveId) {
this.lastActiveId = activeId;
this.callback?.(activeId, tabs);
}
}
public onChange(callback: TabChangeCallback) {
this.callback = callback;
this.notify();
}
public getAllTabs(): TabInfo[] {
return this.getTabsInfo();
}
public getActiveTab(): string | null {
return this.getActiveTabId();
}
public disconnect() {
this.observer?.disconnect();
this.observer = null;
}
}
export const TabServiceInstance = TabService.getInstance();

View File

@@ -2,33 +2,40 @@ import {
getDashboardSections, getDashboardSections,
getPodkopStatus, getPodkopStatus,
getSingboxStatus, getSingboxStatus,
} from '../podkop/methods'; } from '../../methods';
import { renderOutboundGroup } from './renderer/renderOutboundGroup'; import { renderOutboundGroup } from './renderer/renderOutboundGroup';
import { getClashWsUrl, onMount } from '../helpers'; import { getClashWsUrl, onMount } from '../../../helpers';
import { store } from '../store';
import { socket } from '../socket';
import { renderDashboardWidget } from './renderer/renderWidget'; import { renderDashboardWidget } from './renderer/renderWidget';
import { prettyBytes } from '../helpers/prettyBytes';
import { import {
triggerLatencyGroupTest, triggerLatencyGroupTest,
triggerLatencyProxyTest, triggerLatencyProxyTest,
triggerProxySelector, triggerProxySelector,
} from '../clash'; } from '../../../clash';
import { store, StoreType } from '../../../store';
import { socket } from '../../../socket';
import { prettyBytes } from '../../../helpers/prettyBytes';
import { renderEmptyOutboundGroup } from './renderer/renderEmptyOutboundGroup';
// Fetchers // Fetchers
async function fetchDashboardSections() { async function fetchDashboardSections() {
const sections = await getDashboardSections(); store.set({
dashboardSections: {
...store.get().dashboardSections,
failed: false,
loading: true,
},
});
store.set({ sections }); const { data, success } = await getDashboardSections();
store.set({ dashboardSections: { loading: false, data, failed: !success } });
} }
async function fetchServicesInfo() { async function fetchServicesInfo() {
const podkop = await getPodkopStatus(); const podkop = await getPodkopStatus();
const singbox = await getSingboxStatus(); const singbox = await getSingboxStatus();
console.log('podkop', podkop);
console.log('singbox', singbox);
store.set({ store.set({
services: { services: {
singbox: singbox.running, singbox: singbox.running,
@@ -84,7 +91,9 @@ async function handleTestProxyLatency(tag: string) {
} }
function replaceTestLatencyButtonsWithSkeleton() { function replaceTestLatencyButtonsWithSkeleton() {
document.querySelectorAll('.dashboard-sections-grid-item-test-latency').forEach(el => { document
.querySelectorAll('.dashboard-sections-grid-item-test-latency')
.forEach((el) => {
const newDiv = document.createElement('div'); const newDiv = document.createElement('div');
newDiv.className = 'skeleton'; newDiv.className = 'skeleton';
newDiv.style.width = '99px'; newDiv.style.width = '99px';
@@ -96,10 +105,16 @@ function replaceTestLatencyButtonsWithSkeleton () {
// Renderer // Renderer
async function renderDashboardSections() { async function renderDashboardSections() {
const sections = store.get().sections; const dashboardSections = store.get().dashboardSections;
console.log('render dashboard sections group');
const container = document.getElementById('dashboard-sections-grid'); const container = document.getElementById('dashboard-sections-grid');
const renderedOutboundGroups = sections.map((section) =>
if (dashboardSections.failed) {
const rendered = renderEmptyOutboundGroup();
return container!.replaceChildren(rendered);
}
const renderedOutboundGroups = dashboardSections.data.map((section) =>
renderOutboundGroup({ renderOutboundGroup({
section, section,
onTestLatency: (tag) => { onTestLatency: (tag) => {
@@ -122,7 +137,7 @@ async function renderDashboardSections() {
async function renderTrafficWidget() { async function renderTrafficWidget() {
const traffic = store.get().traffic; const traffic = store.get().traffic;
console.log('render dashboard traffic widget');
const container = document.getElementById('dashboard-widget-traffic'); const container = document.getElementById('dashboard-widget-traffic');
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: 'Traffic', title: 'Traffic',
@@ -137,7 +152,7 @@ async function renderTrafficWidget() {
async function renderTrafficTotalWidget() { async function renderTrafficTotalWidget() {
const connections = store.get().connections; const connections = store.get().connections;
console.log('render dashboard traffic total widget');
const container = document.getElementById('dashboard-widget-traffic-total'); const container = document.getElementById('dashboard-widget-traffic-total');
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: 'Traffic Total', title: 'Traffic Total',
@@ -155,7 +170,7 @@ async function renderTrafficTotalWidget() {
async function renderSystemInfoWidget() { async function renderSystemInfoWidget() {
const connections = store.get().connections; const connections = store.get().connections;
console.log('render dashboard system info widget');
const container = document.getElementById('dashboard-widget-system-info'); const container = document.getElementById('dashboard-widget-system-info');
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: 'System info', title: 'System info',
@@ -173,7 +188,7 @@ async function renderSystemInfoWidget() {
async function renderServiceInfoWidget() { async function renderServiceInfoWidget() {
const services = store.get().services; const services = store.get().services;
console.log('render dashboard service info widget');
const container = document.getElementById('dashboard-widget-service-info'); const container = document.getElementById('dashboard-widget-service-info');
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: 'Services info', title: 'Services info',
@@ -202,12 +217,12 @@ async function renderServiceInfoWidget() {
container!.replaceChildren(renderedWidget); container!.replaceChildren(renderedWidget);
} }
export async function initDashboardController(): Promise<void> { async function onStoreUpdate(
store.subscribe((next, prev, diff) => { next: StoreType,
console.log('Store changed', { prev, next, diff }); prev: StoreType,
diff: Partial<StoreType>,
// Update sections render ) {
if (diff?.sections) { if (diff?.dashboardSections) {
renderDashboardSections(); renderDashboardSections();
} }
@@ -223,10 +238,18 @@ export async function initDashboardController(): Promise<void> {
if (diff?.services) { if (diff?.services) {
renderServiceInfoWidget(); renderServiceInfoWidget();
} }
}); }
export async function initDashboardController(): Promise<void> {
onMount('dashboard-status').then(() => { onMount('dashboard-status').then(() => {
console.log('Mounting dashboard'); // Remove old listener
store.unsubscribe(onStoreUpdate);
// Clear store
store.reset();
// Add new listener
store.subscribe(onStoreUpdate);
// Initial sections fetch // Initial sections fetch
fetchDashboardSections(); fetchDashboardSections();
fetchServicesInfo(); fetchServicesInfo();

View File

@@ -6,18 +6,6 @@ export function renderDashboard() {
class: 'pdk_dashboard-page', class: 'pdk_dashboard-page',
}, },
[ [
// Title section
E('div', { class: 'pdk_dashboard-page__title-section' }, [
E(
'h3',
{ class: 'pdk_dashboard-page__title-section__title' },
'Overall (alpha)',
),
E('label', {}, [
E('input', { type: 'checkbox', disabled: true, checked: true }),
' Runtime',
]),
]),
// 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('div', { id: 'dashboard-widget-traffic' }, [

View File

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

View File

@@ -1,4 +1,4 @@
import { Podkop } from '../../podkop/types'; import { Podkop } from '../../../types';
interface IRenderOutboundGroupProps { interface IRenderOutboundGroupProps {
section: Podkop.OutboundGroup; section: Podkop.OutboundGroup;
@@ -74,7 +74,14 @@ export function renderOutboundGroup({
}, },
section.displayName, section.displayName,
), ),
E('button', { class: 'btn dashboard-sections-grid-item-test-latency', click: () => testLatency() }, 'Test latency'), E(
'button',
{
class: 'btn dashboard-sections-grid-item-test-latency',
click: () => testLatency(),
},
'Test latency',
),
]), ]),
E( E(
'div', 'div',

View File

@@ -0,0 +1 @@
export * from './dashboard';

View File

@@ -26,7 +26,6 @@ class SocketManager {
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
this.connected.set(url, true); this.connected.set(url, true);
console.log(`✅ Connected: ${url}`);
}); });
ws.addEventListener('message', (event) => { ws.addEventListener('message', (event) => {

View File

@@ -1,14 +1,43 @@
import { Podkop } from './podkop/types'; import { Podkop } from './podkop/types';
function jsonStableStringify<T, V>(obj: T): string {
return JSON.stringify(obj, (_, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value)
.sort()
.reduce(
(acc, key) => {
acc[key] = value[key];
return acc;
},
{} as Record<string, V>,
);
}
return value;
});
}
function jsonEqual<A, B>(a: A, b: B): boolean {
try {
return jsonStableStringify(a) === jsonStableStringify(b);
} catch {
return false;
}
}
type Listener<T> = (next: T, prev: T, diff: Partial<T>) => void; type Listener<T> = (next: T, prev: T, diff: Partial<T>) => void;
// eslint-disable-next-line // eslint-disable-next-line
class Store<T extends Record<string, any>> { class Store<T extends Record<string, any>> {
private value: T; private value: T;
private readonly initial: T;
private listeners = new Set<Listener<T>>(); private listeners = new Set<Listener<T>>();
private lastHash = '';
constructor(initial: T) { constructor(initial: T) {
this.value = initial; this.value = initial;
this.initial = structuredClone(initial);
this.lastHash = jsonStableStringify(initial);
} }
get(): T { get(): T {
@@ -17,14 +46,33 @@ class Store<T extends Record<string, any>> {
set(next: Partial<T>): void { set(next: Partial<T>): void {
const prev = this.value; const prev = this.value;
const merged = { ...this.value, ...next }; const merged = { ...prev, ...next };
if (Object.is(prev, merged)) return;
if (jsonEqual(prev, merged)) return;
this.value = merged; this.value = merged;
this.lastHash = jsonStableStringify(merged);
const diff: Partial<T> = {}; const diff: Partial<T> = {};
for (const key in merged) { for (const key in merged) {
if (merged[key] !== prev[key]) diff[key] = merged[key]; if (!jsonEqual(merged[key], prev[key])) diff[key] = merged[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
reset(): void {
const prev = this.value;
const next = structuredClone(this.initial);
if (jsonEqual(prev, next)) return;
this.value = next;
this.lastHash = jsonStableStringify(next);
const diff: Partial<T> = {};
for (const key in next) {
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
} }
this.listeners.forEach((cb) => cb(this.value, prev, diff)); this.listeners.forEach((cb) => cb(this.value, prev, diff));
@@ -32,12 +80,16 @@ class Store<T extends Record<string, any>> {
subscribe(cb: Listener<T>): () => void { subscribe(cb: Listener<T>): () => void {
this.listeners.add(cb); this.listeners.add(cb);
cb(this.value, this.value, {}); // первый вызов без diff cb(this.value, this.value, {});
return () => this.listeners.delete(cb); return () => this.listeners.delete(cb);
} }
unsubscribe(cb: Listener<T>): void {
this.listeners.delete(cb);
}
patch<K extends keyof T>(key: K, value: T[K]): void { patch<K extends keyof T>(key: K, value: T[K]): void {
this.set({ ...this.value, [key]: value }); this.set({ [key]: value } as unknown as Partial<T>);
} }
getKey<K extends keyof T>(key: K): T[K] { getKey<K extends keyof T>(key: K): T[K] {
@@ -49,18 +101,27 @@ class Store<T extends Record<string, any>> {
cb: (value: T[K]) => void, cb: (value: T[K]) => void,
): () => void { ): () => void {
let prev = this.value[key]; let prev = this.value[key];
const unsub = this.subscribe((val) => { const wrapper: Listener<T> = (val) => {
if (val[key] !== prev) { if (!jsonEqual(val[key], prev)) {
prev = val[key]; prev = val[key];
cb(val[key]); cb(val[key]);
} }
}); };
return unsub; this.listeners.add(wrapper);
return () => this.listeners.delete(wrapper);
} }
} }
export const store = new Store<{ export interface StoreType {
sections: Podkop.OutboundGroup[]; tabService: {
current: string;
all: string[];
};
dashboardSections: {
loading: boolean;
data: Podkop.OutboundGroup[];
failed: boolean;
};
traffic: { up: number; down: number }; traffic: { up: number; down: number };
memory: { inuse: number; oslimit: number }; memory: { inuse: number; oslimit: number };
connections: { connections: {
@@ -73,10 +134,26 @@ export const store = new Store<{
singbox: number; singbox: number;
podkop: number; podkop: number;
}; };
}>({ }
sections: [],
traffic: { up: 0, down: 0 }, const initialStore: StoreType = {
memory: { inuse: 0, oslimit: 0 }, tabService: {
connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, current: '',
all: [],
},
dashboardSections: {
data: [],
loading: true,
},
traffic: { up: -1, down: -1 },
memory: { inuse: -1, oslimit: -1 },
connections: {
connections: [],
memory: -1,
downloadTotal: -1,
uploadTotal: -1,
},
services: { singbox: -1, podkop: -1 }, services: { singbox: -1, podkop: -1 },
}); };
export const store = new Store<StoreType>(initialStore);

View File

@@ -167,6 +167,12 @@ export const GlobalStyles = `
color: var(--error-color-medium); color: var(--error-color-medium);
} }
.centered {
display: flex;
align-items: center;
justify-content: center;
}
/* Skeleton styles*/ /* Skeleton styles*/
.skeleton { .skeleton {
background-color: var(--background-color-low, #e0e0e0); background-color: var(--background-color-low, #e0e0e0);

View File

@@ -12,7 +12,11 @@ function createDashboardSection(mainSection) {
o = mainSection.taboption('dashboard', form.DummyValue, '_status'); o = mainSection.taboption('dashboard', form.DummyValue, '_status');
o.rawhtml = true; o.rawhtml = true;
o.cfgvalue = () => main.renderDashboard(); o.cfgvalue = () => {
main.initDashboardController()
return main.renderDashboard()
};
} }
const EntryPoint = { const EntryPoint = {

View File

@@ -515,6 +515,12 @@ var GlobalStyles = `
color: var(--error-color-medium); color: var(--error-color-medium);
} }
.centered {
display: flex;
align-items: center;
justify-content: center;
}
/* Skeleton styles*/ /* Skeleton styles*/
.skeleton { .skeleton {
background-color: var(--background-color-low, #e0e0e0); background-color: var(--background-color-low, #e0e0e0);
@@ -883,86 +889,6 @@ async function triggerLatencyProxyTest(tag, timeout = 2e3, url = "https://www.gs
); );
} }
// src/dashboard/renderDashboard.ts
function renderDashboard() {
return E(
"div",
{
id: "dashboard-status",
class: "pdk_dashboard-page"
},
[
// Title section
E("div", { class: "pdk_dashboard-page__title-section" }, [
E(
"h3",
{ class: "pdk_dashboard-page__title-section__title" },
"Overall (alpha)"
),
E("label", {}, [
E("input", { type: "checkbox", disabled: true, checked: true }),
" Runtime"
])
]),
// 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"
},
""
)
])
]),
// 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"
})
])
]
);
}
// src/podkop/methods/getConfigSections.ts // src/podkop/methods/getConfigSections.ts
async function getConfigSections() { async function getConfigSections() {
return uci.load("podkop").then(() => uci.sections("podkop")); return uci.load("podkop").then(() => uci.sections("podkop"));
@@ -972,14 +898,19 @@ async function getConfigSections() {
async function getDashboardSections() { async function getDashboardSections() {
const configSections = await getConfigSections(); const configSections = await getConfigSections();
const clashProxies = await getClashProxies(); const clashProxies = await getClashProxies();
const clashProxiesData = clashProxies.success ? clashProxies.data : { proxies: [] }; if (!clashProxies.success) {
const proxies = Object.entries(clashProxiesData.proxies).map( return {
success: false,
data: []
};
}
const proxies = Object.entries(clashProxies.data.proxies).map(
([key, value]) => ({ ([key, value]) => ({
code: key, code: key,
value value
}) })
); );
return configSections.filter((section) => section.mode !== "block").map((section) => { const data = configSections.filter((section) => section.mode !== "block").map((section) => {
if (section.mode === "proxy") { if (section.mode === "proxy") {
if (section.proxy_config_type === "url") { if (section.proxy_config_type === "url") {
const outbound = proxies.find( const outbound = proxies.find(
@@ -1076,6 +1007,10 @@ async function getDashboardSections() {
outbounds: [] outbounds: []
}; };
}); });
return {
success: true,
data
};
} }
// src/podkop/methods/getPodkopStatus.ts // src/podkop/methods/getPodkopStatus.ts
@@ -1104,7 +1039,258 @@ async function getSingboxStatus() {
return { running: 0, enabled: 0, status: "unknown" }; return { running: 0, enabled: 0, status: "unknown" };
} }
// src/dashboard/renderer/renderOutboundGroup.ts // src/podkop/services/tab.service.ts
var TabService = class _TabService {
constructor() {
this.observer = null;
this.lastActiveId = null;
this.init();
}
static getInstance() {
if (!_TabService.instance) {
_TabService.instance = new _TabService();
}
return _TabService.instance;
}
init() {
this.observer = new MutationObserver(() => this.handleMutations());
this.observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["class"]
});
this.notify();
}
handleMutations() {
this.notify();
}
getTabsInfo() {
const tabs = Array.from(
document.querySelectorAll(".cbi-tab, .cbi-tab-disabled")
);
return tabs.map((el) => ({
el,
id: el.dataset.tab || "",
active: el.classList.contains("cbi-tab") && !el.classList.contains("cbi-tab-disabled")
}));
}
getActiveTabId() {
const active = document.querySelector(
".cbi-tab:not(.cbi-tab-disabled)"
);
return active?.dataset.tab || null;
}
notify() {
const tabs = this.getTabsInfo();
const activeId = this.getActiveTabId();
if (activeId !== this.lastActiveId) {
this.lastActiveId = activeId;
this.callback?.(activeId, tabs);
}
}
onChange(callback) {
this.callback = callback;
this.notify();
}
getAllTabs() {
return this.getTabsInfo();
}
getActiveTab() {
return this.getActiveTabId();
}
disconnect() {
this.observer?.disconnect();
this.observer = null;
}
};
var TabServiceInstance = TabService.getInstance();
// src/store.ts
function jsonStableStringify(obj) {
return JSON.stringify(obj, (_, value) => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return Object.keys(value).sort().reduce(
(acc, key) => {
acc[key] = value[key];
return acc;
},
{}
);
}
return value;
});
}
function jsonEqual(a, b) {
try {
return jsonStableStringify(a) === jsonStableStringify(b);
} catch {
return false;
}
}
var Store = class {
constructor(initial) {
this.listeners = /* @__PURE__ */ new Set();
this.lastHash = "";
this.value = initial;
this.initial = structuredClone(initial);
this.lastHash = jsonStableStringify(initial);
}
get() {
return this.value;
}
set(next) {
const prev = this.value;
const merged = { ...prev, ...next };
if (jsonEqual(prev, merged)) return;
this.value = merged;
this.lastHash = jsonStableStringify(merged);
const diff = {};
for (const key in merged) {
if (!jsonEqual(merged[key], prev[key])) diff[key] = merged[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
reset() {
const prev = this.value;
const next = structuredClone(this.initial);
if (jsonEqual(prev, next)) return;
this.value = next;
this.lastHash = jsonStableStringify(next);
const diff = {};
for (const key in next) {
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
subscribe(cb) {
this.listeners.add(cb);
cb(this.value, this.value, {});
return () => this.listeners.delete(cb);
}
unsubscribe(cb) {
this.listeners.delete(cb);
}
patch(key, value) {
this.set({ [key]: value });
}
getKey(key) {
return this.value[key];
}
subscribeKey(key, cb) {
let prev = this.value[key];
const wrapper = (val) => {
if (!jsonEqual(val[key], prev)) {
prev = val[key];
cb(val[key]);
}
};
this.listeners.add(wrapper);
return () => this.listeners.delete(wrapper);
}
};
var initialStore = {
tabService: {
current: "",
all: []
},
dashboardSections: {
data: [],
loading: true
},
traffic: { up: -1, down: -1 },
memory: { inuse: -1, oslimit: -1 },
connections: {
connections: [],
memory: -1,
downloadTotal: -1,
uploadTotal: -1
},
services: { singbox: -1, podkop: -1 }
};
var store = new Store(initialStore);
// src/podkop/services/core.service.ts
function coreService() {
TabServiceInstance.onChange((activeId, tabs) => {
store.set({
tabService: {
current: activeId || "",
all: tabs.map((tab) => tab.id)
}
});
});
}
// 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" }, [
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"
},
""
)
])
]),
// 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"
})
])
]
);
}
// src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts
function renderOutboundGroup({ function renderOutboundGroup({
section, section,
onTestLatency, onTestLatency,
@@ -1164,7 +1350,14 @@ function renderOutboundGroup({
}, },
section.displayName section.displayName
), ),
E("button", { class: "btn dashboard-sections-grid-item-test-latency", click: () => testLatency() }, "Test latency") E(
"button",
{
class: "btn dashboard-sections-grid-item-test-latency",
click: () => testLatency()
},
"Test latency"
)
]), ]),
E( E(
"div", "div",
@@ -1174,55 +1367,36 @@ function renderOutboundGroup({
]); ]);
} }
// src/store.ts // src/podkop/tabs/dashboard/renderer/renderWidget.ts
var Store = class { function renderDashboardWidget({ title, items }) {
constructor(initial) { return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [
this.listeners = /* @__PURE__ */ new Set(); E(
this.value = initial; "b",
{ class: "pdk_dashboard-page__widgets-section__item__title" },
title
),
...items.map(
(item) => E(
"div",
{
class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ""}`
},
[
E(
"span",
{ class: "pdk_dashboard-page__widgets-section__item__row__key" },
`${item.key}: `
),
E(
"span",
{ class: "pdk_dashboard-page__widgets-section__item__row__value" },
item.value
)
]
)
)
]);
} }
get() {
return this.value;
}
set(next) {
const prev = this.value;
const merged = { ...this.value, ...next };
if (Object.is(prev, merged)) return;
this.value = merged;
const diff = {};
for (const key in merged) {
if (merged[key] !== prev[key]) diff[key] = merged[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
subscribe(cb) {
this.listeners.add(cb);
cb(this.value, this.value, {});
return () => this.listeners.delete(cb);
}
patch(key, value) {
this.set({ ...this.value, [key]: value });
}
getKey(key) {
return this.value[key];
}
subscribeKey(key, cb) {
let prev = this.value[key];
const unsub = this.subscribe((val) => {
if (val[key] !== prev) {
prev = val[key];
cb(val[key]);
}
});
return unsub;
}
};
var store = new Store({
sections: [],
traffic: { up: 0, down: 0 },
memory: { inuse: 0, oslimit: 0 },
connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 },
services: { singbox: -1, podkop: -1 }
});
// src/socket.ts // src/socket.ts
var SocketManager = class _SocketManager { var SocketManager = class _SocketManager {
@@ -1245,7 +1419,6 @@ var SocketManager = class _SocketManager {
this.listeners.set(url, /* @__PURE__ */ new Set()); this.listeners.set(url, /* @__PURE__ */ new Set());
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
this.connected.set(url, true); this.connected.set(url, true);
console.log(`\u2705 Connected: ${url}`);
}); });
ws.addEventListener("message", (event) => { ws.addEventListener("message", (event) => {
const handlers = this.listeners.get(url); const handlers = this.listeners.get(url);
@@ -1302,37 +1475,6 @@ var SocketManager = class _SocketManager {
}; };
var socket = SocketManager.getInstance(); var socket = SocketManager.getInstance();
// src/dashboard/renderer/renderWidget.ts
function renderDashboardWidget({ title, items }) {
return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [
E(
"b",
{ class: "pdk_dashboard-page__widgets-section__item__title" },
title
),
...items.map(
(item) => E(
"div",
{
class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ""}`
},
[
E(
"span",
{ class: "pdk_dashboard-page__widgets-section__item__row__key" },
`${item.key}: `
),
E(
"span",
{ class: "pdk_dashboard-page__widgets-section__item__row__value" },
item.value
)
]
)
)
]);
}
// src/helpers/prettyBytes.ts // src/helpers/prettyBytes.ts
function prettyBytes(n) { function prettyBytes(n) {
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
@@ -1345,16 +1487,33 @@ function prettyBytes(n) {
return n + " " + unit; return n + " " + unit;
} }
// src/dashboard/initDashboardController.ts // 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
async function fetchDashboardSections() { async function fetchDashboardSections() {
const sections = await getDashboardSections(); store.set({
store.set({ sections }); dashboardSections: {
...store.get().dashboardSections,
failed: false,
loading: true
}
});
const { data, success } = await getDashboardSections();
store.set({ dashboardSections: { loading: false, data, failed: !success } });
} }
async function fetchServicesInfo() { async function fetchServicesInfo() {
const podkop = await getPodkopStatus(); const podkop = await getPodkopStatus();
const singbox = await getSingboxStatus(); const singbox = await getSingboxStatus();
console.log("podkop", podkop);
console.log("singbox", singbox);
store.set({ store.set({
services: { services: {
singbox: singbox.running, singbox: singbox.running,
@@ -1408,10 +1567,13 @@ function replaceTestLatencyButtonsWithSkeleton() {
}); });
} }
async function renderDashboardSections() { async function renderDashboardSections() {
const sections = store.get().sections; const dashboardSections = store.get().dashboardSections;
console.log("render dashboard sections group");
const container = document.getElementById("dashboard-sections-grid"); const container = document.getElementById("dashboard-sections-grid");
const renderedOutboundGroups = sections.map( if (dashboardSections.failed) {
const rendered = renderEmptyOutboundGroup();
return container.replaceChildren(rendered);
}
const renderedOutboundGroups = dashboardSections.data.map(
(section) => renderOutboundGroup({ (section) => renderOutboundGroup({
section, section,
onTestLatency: (tag) => { onTestLatency: (tag) => {
@@ -1430,7 +1592,6 @@ async function renderDashboardSections() {
} }
async function renderTrafficWidget() { async function renderTrafficWidget() {
const traffic = store.get().traffic; const traffic = store.get().traffic;
console.log("render dashboard traffic widget");
const container = document.getElementById("dashboard-widget-traffic"); const container = document.getElementById("dashboard-widget-traffic");
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: "Traffic", title: "Traffic",
@@ -1443,7 +1604,6 @@ async function renderTrafficWidget() {
} }
async function renderTrafficTotalWidget() { async function renderTrafficTotalWidget() {
const connections = store.get().connections; const connections = store.get().connections;
console.log("render dashboard traffic total widget");
const container = document.getElementById("dashboard-widget-traffic-total"); const container = document.getElementById("dashboard-widget-traffic-total");
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: "Traffic Total", title: "Traffic Total",
@@ -1459,7 +1619,6 @@ async function renderTrafficTotalWidget() {
} }
async function renderSystemInfoWidget() { async function renderSystemInfoWidget() {
const connections = store.get().connections; const connections = store.get().connections;
console.log("render dashboard system info widget");
const container = document.getElementById("dashboard-widget-system-info"); const container = document.getElementById("dashboard-widget-system-info");
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: "System info", title: "System info",
@@ -1475,7 +1634,6 @@ async function renderSystemInfoWidget() {
} }
async function renderServiceInfoWidget() { async function renderServiceInfoWidget() {
const services = store.get().services; const services = store.get().services;
console.log("render dashboard service info widget");
const container = document.getElementById("dashboard-widget-service-info"); const container = document.getElementById("dashboard-widget-service-info");
const renderedWidget = renderDashboardWidget({ const renderedWidget = renderDashboardWidget({
title: "Services info", title: "Services info",
@@ -1498,10 +1656,8 @@ async function renderServiceInfoWidget() {
}); });
container.replaceChildren(renderedWidget); container.replaceChildren(renderedWidget);
} }
async function initDashboardController() { async function onStoreUpdate(next, prev, diff) {
store.subscribe((next, prev, diff) => { if (diff?.dashboardSections) {
console.log("Store changed", { prev, next, diff });
if (diff?.sections) {
renderDashboardSections(); renderDashboardSections();
} }
if (diff?.traffic) { if (diff?.traffic) {
@@ -1514,9 +1670,12 @@ async function initDashboardController() {
if (diff?.services) { if (diff?.services) {
renderServiceInfoWidget(); renderServiceInfoWidget();
} }
}); }
async function initDashboardController() {
onMount("dashboard-status").then(() => { onMount("dashboard-status").then(() => {
console.log("Mounting dashboard"); store.unsubscribe(onStoreUpdate);
store.reset();
store.subscribe(onStoreUpdate);
fetchDashboardSections(); fetchDashboardSections();
fetchServicesInfo(); fetchServicesInfo();
connectToClashSockets(); connectToClashSockets();
@@ -1539,9 +1698,12 @@ return baseclass.extend({
IP_CHECK_DOMAIN, IP_CHECK_DOMAIN,
REGIONAL_OPTIONS, REGIONAL_OPTIONS,
STATUS_COLORS, STATUS_COLORS,
TabService,
TabServiceInstance,
UPDATE_INTERVAL_OPTIONS, UPDATE_INTERVAL_OPTIONS,
bulkValidate, bulkValidate,
copyToClipboard, copyToClipboard,
coreService,
createBaseApiRequest, createBaseApiRequest,
executeShellCommand, executeShellCommand,
getBaseUrl, getBaseUrl,
@@ -1551,7 +1713,11 @@ return baseclass.extend({
getClashProxies, getClashProxies,
getClashVersion, getClashVersion,
getClashWsUrl, getClashWsUrl,
getConfigSections,
getDashboardSections,
getPodkopStatus,
getProxyUrlName, getProxyUrlName,
getSingboxStatus,
initDashboardController, initDashboardController,
injectGlobalStyles, injectGlobalStyles,
maskIP, maskIP,

View File

@@ -34,10 +34,6 @@ const EntryNode = {
const mainSection = podkopFormMap.section(form.TypedSection, 'main'); const mainSection = podkopFormMap.section(form.TypedSection, 'main');
mainSection.anonymous = true; mainSection.anonymous = true;
dashboardTab.createDashboardSection(mainSection);
main.initDashboardController();
configSection.createConfigSection(mainSection); configSection.createConfigSection(mainSection);
// Additional Settings Tab (main section) // Additional Settings Tab (main section)
@@ -84,6 +80,16 @@ const EntryNode = {
extraSection.multiple = true; extraSection.multiple = true;
configSection.createConfigSection(extraSection); configSection.createConfigSection(extraSection);
// Initial dashboard render
dashboardTab.createDashboardSection(mainSection);
// Inject dashboard actualizer logic
// main.initDashboardController();
// Inject core service
main.coreService();
return podkopFormMapPromise; return podkopFormMapPromise;
} }
} }