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 './helpers';
export * from './clash';
export * from './dashboard';
export * from './podkop';
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 { 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 clashProxies = await getClashProxies();
const clashProxiesData = clashProxies.success
? clashProxies.data
: { proxies: [] };
if (!clashProxies.success) {
return {
success: false,
data: [],
};
}
const proxies = Object.entries(clashProxiesData.proxies).map(
const proxies = Object.entries(clashProxies.data.proxies).map(
([key, value]) => ({
code: key,
value,
}),
);
return configSections
const data = configSections
.filter((section) => section.mode !== 'block')
.map((section) => {
if (section.mode === 'proxy') {
@@ -137,4 +145,9 @@ export async function getDashboardSections(): Promise<Podkop.OutboundGroup[]> {
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,
getPodkopStatus,
getSingboxStatus,
} from '../podkop/methods';
} from '../../methods';
import { renderOutboundGroup } from './renderer/renderOutboundGroup';
import { getClashWsUrl, onMount } from '../helpers';
import { store } from '../store';
import { socket } from '../socket';
import { getClashWsUrl, onMount } from '../../../helpers';
import { renderDashboardWidget } from './renderer/renderWidget';
import { prettyBytes } from '../helpers/prettyBytes';
import {
triggerLatencyGroupTest,
triggerLatencyProxyTest,
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
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() {
const podkop = await getPodkopStatus();
const singbox = await getSingboxStatus();
console.log('podkop', podkop);
console.log('singbox', singbox);
store.set({
services: {
singbox: singbox.running,
@@ -83,23 +90,31 @@ async function handleTestProxyLatency(tag: string) {
await fetchDashboardSections();
}
function replaceTestLatencyButtonsWithSkeleton () {
document.querySelectorAll('.dashboard-sections-grid-item-test-latency').forEach(el => {
const newDiv = document.createElement('div');
newDiv.className = 'skeleton';
newDiv.style.width = '99px';
newDiv.style.height = '28px';
el.replaceWith(newDiv);
});
function replaceTestLatencyButtonsWithSkeleton() {
document
.querySelectorAll('.dashboard-sections-grid-item-test-latency')
.forEach((el) => {
const newDiv = document.createElement('div');
newDiv.className = 'skeleton';
newDiv.style.width = '99px';
newDiv.style.height = '28px';
el.replaceWith(newDiv);
});
}
// Renderer
async function renderDashboardSections() {
const sections = store.get().sections;
console.log('render dashboard sections group');
const dashboardSections = store.get().dashboardSections;
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({
section,
onTestLatency: (tag) => {
@@ -122,7 +137,7 @@ async function renderDashboardSections() {
async function renderTrafficWidget() {
const traffic = store.get().traffic;
console.log('render dashboard traffic widget');
const container = document.getElementById('dashboard-widget-traffic');
const renderedWidget = renderDashboardWidget({
title: 'Traffic',
@@ -137,7 +152,7 @@ async function renderTrafficWidget() {
async function renderTrafficTotalWidget() {
const connections = store.get().connections;
console.log('render dashboard traffic total widget');
const container = document.getElementById('dashboard-widget-traffic-total');
const renderedWidget = renderDashboardWidget({
title: 'Traffic Total',
@@ -155,7 +170,7 @@ async function renderTrafficTotalWidget() {
async function renderSystemInfoWidget() {
const connections = store.get().connections;
console.log('render dashboard system info widget');
const container = document.getElementById('dashboard-widget-system-info');
const renderedWidget = renderDashboardWidget({
title: 'System info',
@@ -173,7 +188,7 @@ async function renderSystemInfoWidget() {
async function renderServiceInfoWidget() {
const services = store.get().services;
console.log('render dashboard service info widget');
const container = document.getElementById('dashboard-widget-service-info');
const renderedWidget = renderDashboardWidget({
title: 'Services info',
@@ -202,31 +217,39 @@ async function renderServiceInfoWidget() {
container!.replaceChildren(renderedWidget);
}
async function onStoreUpdate(
next: StoreType,
prev: StoreType,
diff: Partial<StoreType>,
) {
if (diff?.dashboardSections) {
renderDashboardSections();
}
if (diff?.traffic) {
renderTrafficWidget();
}
if (diff?.connections) {
renderTrafficTotalWidget();
renderSystemInfoWidget();
}
if (diff?.services) {
renderServiceInfoWidget();
}
}
export async function initDashboardController(): Promise<void> {
store.subscribe((next, prev, diff) => {
console.log('Store changed', { prev, next, diff });
// Update sections render
if (diff?.sections) {
renderDashboardSections();
}
if (diff?.traffic) {
renderTrafficWidget();
}
if (diff?.connections) {
renderTrafficTotalWidget();
renderSystemInfoWidget();
}
if (diff?.services) {
renderServiceInfoWidget();
}
});
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
fetchDashboardSections();
fetchServicesInfo();

View File

@@ -6,18 +6,6 @@ export function renderDashboard() {
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' }, [

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 {
section: Podkop.OutboundGroup;
@@ -74,7 +74,14 @@ export function renderOutboundGroup({
},
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(
'div',

View File

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

View File

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

View File

@@ -1,14 +1,43 @@
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;
// eslint-disable-next-line
class Store<T extends Record<string, any>> {
private value: T;
private readonly initial: T;
private listeners = new Set<Listener<T>>();
private lastHash = '';
constructor(initial: T) {
this.value = initial;
this.initial = structuredClone(initial);
this.lastHash = jsonStableStringify(initial);
}
get(): T {
@@ -17,14 +46,33 @@ class Store<T extends Record<string, any>> {
set(next: Partial<T>): void {
const prev = this.value;
const merged = { ...this.value, ...next };
if (Object.is(prev, merged)) return;
const merged = { ...prev, ...next };
if (jsonEqual(prev, merged)) return;
this.value = merged;
this.lastHash = jsonStableStringify(merged);
const diff: Partial<T> = {};
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));
@@ -32,12 +80,16 @@ class Store<T extends Record<string, any>> {
subscribe(cb: Listener<T>): () => void {
this.listeners.add(cb);
cb(this.value, this.value, {}); // первый вызов без diff
cb(this.value, this.value, {});
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 {
this.set({ ...this.value, [key]: value });
this.set({ [key]: value } as unknown as Partial<T>);
}
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,
): () => void {
let prev = this.value[key];
const unsub = this.subscribe((val) => {
if (val[key] !== prev) {
const wrapper: Listener<T> = (val) => {
if (!jsonEqual(val[key], prev)) {
prev = val[key];
cb(val[key]);
}
});
return unsub;
};
this.listeners.add(wrapper);
return () => this.listeners.delete(wrapper);
}
}
export const store = new Store<{
sections: Podkop.OutboundGroup[];
export interface StoreType {
tabService: {
current: string;
all: string[];
};
dashboardSections: {
loading: boolean;
data: Podkop.OutboundGroup[];
failed: boolean;
};
traffic: { up: number; down: number };
memory: { inuse: number; oslimit: number };
connections: {
@@ -73,10 +134,26 @@ export const store = new Store<{
singbox: number;
podkop: number;
};
}>({
sections: [],
traffic: { up: 0, down: 0 },
memory: { inuse: 0, oslimit: 0 },
connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 },
}
const initialStore: StoreType = {
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 },
});
};
export const store = new Store<StoreType>(initialStore);

View File

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