diff --git a/.gitignore b/.gitignore index 703db4d..ff06e12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea fe-app-podkop/node_modules +fe-app-podkop/.env diff --git a/fe-app-podkop/eslint.config.js b/fe-app-podkop/eslint.config.js index 859f377..8ec3a34 100644 --- a/fe-app-podkop/eslint.config.js +++ b/fe-app-podkop/eslint.config.js @@ -7,7 +7,7 @@ export default [ js.configs.recommended, ...tseslint.configs.recommended, { - ignores: ['node_modules'], + ignores: ['node_modules', 'watch-upload.js'], }, { rules: { diff --git a/fe-app-podkop/package.json b/fe-app-podkop/package.json index 92b4f18..6241ec2 100644 --- a/fe-app-podkop/package.json +++ b/fe-app-podkop/package.json @@ -10,14 +10,19 @@ "build": "tsup src/main.ts", "dev": "tsup src/main.ts --watch", "test": "vitest", - "ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build" + "ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build", + "watch:sftp": "node watch-upload.js" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", "@typescript-eslint/parser": "8.45.0", + "chokidar": "4.0.3", + "dotenv": "17.2.3", "eslint": "9.36.0", "eslint-config-prettier": "10.1.8", + "glob": "11.0.3", "prettier": "3.6.2", + "ssh2-sftp-client": "12.0.1", "tsup": "8.5.0", "typescript": "5.9.3", "typescript-eslint": "8.45.0", diff --git a/fe-app-podkop/src/clash/methods/getConfig.ts b/fe-app-podkop/src/clash/methods/getConfig.ts index a782ba1..8f7135a 100644 --- a/fe-app-podkop/src/clash/methods/getConfig.ts +++ b/fe-app-podkop/src/clash/methods/getConfig.ts @@ -1,11 +1,12 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashConfig(): Promise< IBaseApiResponse > { return createBaseApiRequest(() => - fetch('http://192.168.160.129:9090/configs', { + fetch(`${getClashApiUrl()}/configs`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }), diff --git a/fe-app-podkop/src/clash/methods/getGroupDelay.ts b/fe-app-podkop/src/clash/methods/getGroupDelay.ts index bbad3f2..f160bec 100644 --- a/fe-app-podkop/src/clash/methods/getGroupDelay.ts +++ b/fe-app-podkop/src/clash/methods/getGroupDelay.ts @@ -1,12 +1,13 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashGroupDelay( group: string, url = 'https://www.gstatic.com/generate_204', timeout = 2000, ): Promise> { - const endpoint = `http://192.168.160.129:9090/group/${group}/delay?url=${encodeURIComponent( + const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent( url, )}&timeout=${timeout}`; diff --git a/fe-app-podkop/src/clash/methods/getProxies.ts b/fe-app-podkop/src/clash/methods/getProxies.ts index c431b2e..e465c58 100644 --- a/fe-app-podkop/src/clash/methods/getProxies.ts +++ b/fe-app-podkop/src/clash/methods/getProxies.ts @@ -1,11 +1,12 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashProxies(): Promise< IBaseApiResponse > { return createBaseApiRequest(() => - fetch('http://192.168.160.129:9090/proxies', { + fetch(`${getClashApiUrl()}/proxies`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }), diff --git a/fe-app-podkop/src/clash/methods/getVersion.ts b/fe-app-podkop/src/clash/methods/getVersion.ts index 0f99ede..119db9f 100644 --- a/fe-app-podkop/src/clash/methods/getVersion.ts +++ b/fe-app-podkop/src/clash/methods/getVersion.ts @@ -1,11 +1,12 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashVersion(): Promise< IBaseApiResponse > { return createBaseApiRequest(() => - fetch('http://192.168.160.129:9090/version', { + fetch(`${getClashApiUrl()}/version`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }), diff --git a/fe-app-podkop/src/clash/methods/index.ts b/fe-app-podkop/src/clash/methods/index.ts index bce1a71..77f254b 100644 --- a/fe-app-podkop/src/clash/methods/index.ts +++ b/fe-app-podkop/src/clash/methods/index.ts @@ -3,3 +3,4 @@ export * from './getConfig'; export * from './getGroupDelay'; export * from './getProxies'; export * from './getVersion'; +export * from './triggerProxySelector'; diff --git a/fe-app-podkop/src/clash/methods/triggerProxySelector.ts b/fe-app-podkop/src/clash/methods/triggerProxySelector.ts new file mode 100644 index 0000000..16d1f55 --- /dev/null +++ b/fe-app-podkop/src/clash/methods/triggerProxySelector.ts @@ -0,0 +1,16 @@ +import { IBaseApiResponse } from '../types'; +import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; + +export async function triggerProxySelector( + selector: string, + outbound: string, +): Promise> { + return createBaseApiRequest(() => + fetch(`${getClashApiUrl()}/proxies/${selector}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: outbound }), + }), + ); +} diff --git a/fe-app-podkop/src/dashboard/index.ts b/fe-app-podkop/src/dashboard/index.ts new file mode 100644 index 0000000..898949a --- /dev/null +++ b/fe-app-podkop/src/dashboard/index.ts @@ -0,0 +1,2 @@ +export * from './renderDashboard'; +export * from './initDashboardController'; diff --git a/fe-app-podkop/src/dashboard/initDashboardController.ts b/fe-app-podkop/src/dashboard/initDashboardController.ts new file mode 100644 index 0000000..a6e3808 --- /dev/null +++ b/fe-app-podkop/src/dashboard/initDashboardController.ts @@ -0,0 +1,174 @@ +import { + getDashboardSections, + getPodkopStatus, + getSingboxStatus, +} from '../podkop/methods'; +import { renderOutboundGroup } from './renderer/renderOutboundGroup'; +import { getClashWsUrl, onMount } from '../helpers'; +import { store } from '../store'; +import { socket } from '../socket'; +import { renderDashboardWidget } from './renderer/renderWidget'; +import { prettyBytes } from '../helpers/prettyBytes'; + +// Fetchers + +async function fetchDashboardSections() { + const sections = await getDashboardSections(); + + store.set({ sections }); +} + +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 ? '✔ Enabled' : singbox.status, + podkop: podkop.status ? '✔ Enabled' : podkop.status, + }, + }); +} + +async function connectToClashSockets() { + socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { + const parsedMsg = JSON.parse(msg); + + store.set({ + traffic: { 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, + }, + }); + }); + + socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => { + store.set({ + memory: { inuse: msg.inuse, oslimit: msg.oslimit }, + }); + }); +} + +// Renderer + +async function renderDashboardSections() { + const sections = store.get().sections; + console.log('render dashboard sections group'); + const container = document.getElementById('dashboard-sections-grid'); + const renderedOutboundGroups = sections.map(renderOutboundGroup); + + container!.replaceChildren(...renderedOutboundGroups); +} + +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', + items: [ + { key: 'Uplink', value: `${prettyBytes(traffic.up)}/s` }, + { key: 'Downlink', value: `${prettyBytes(traffic.down)}/s` }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +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', + items: [ + { key: 'Uplink', value: String(prettyBytes(connections.uploadTotal)) }, + { + key: 'Downlink', + value: String(prettyBytes(connections.downloadTotal)), + }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +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', + items: [ + { + key: 'Active Connections', + value: String(connections.connections.length), + }, + { key: 'Memory Usage', value: String(prettyBytes(connections.memory)) }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +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', + items: [ + { + key: 'Podkop', + value: String(services.podkop), + }, + { key: 'Sing-box', value: String(services.singbox) }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +export async function initDashboardController(): Promise { + 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'); + // Initial sections fetch + fetchDashboardSections(); + fetchServicesInfo(); + connectToClashSockets(); + }); +} diff --git a/fe-app-podkop/src/dashboard/renderDashboard.ts b/fe-app-podkop/src/dashboard/renderDashboard.ts new file mode 100644 index 0000000..f160ce4 --- /dev/null +++ b/fe-app-podkop/src/dashboard/renderDashboard.ts @@ -0,0 +1,78 @@ +export 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', + }), + ]), + ], + ); +} diff --git a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts new file mode 100644 index 0000000..865dc58 --- /dev/null +++ b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts @@ -0,0 +1,49 @@ +import { Podkop } from '../../podkop/types'; + +export function renderOutboundGroup({ + outbounds, + displayName, +}: Podkop.OutboundGroup) { + function renderOutbound(outbound: Podkop.Outbound) { + return E( + 'div', + { + class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''}`, + }, + [ + E('b', {}, outbound.displayName), + E('div', { class: 'pdk_dashboard-page__outbound-grid__item__footer' }, [ + E( + 'div', + { class: 'pdk_dashboard-page__outbound-grid__item__type' }, + outbound.type, + ), + E( + 'div', + { class: 'pdk_dashboard-page__outbound-grid__item__latency' }, + outbound.latency ? `${outbound.latency}ms` : 'N/A', + ), + ]), + ], + ); + } + + return E('div', { class: 'pdk_dashboard-page__outbound-section' }, [ + // Title with test latency + E('div', { class: 'pdk_dashboard-page__outbound-section__title-section' }, [ + E( + 'div', + { + class: 'pdk_dashboard-page__outbound-section__title-section__title', + }, + displayName, + ), + E('button', { class: 'btn' }, 'Test latency'), + ]), + E( + 'div', + { class: 'pdk_dashboard-page__outbound-grid' }, + outbounds.map((outbound) => renderOutbound(outbound)), + ), + ]); +} diff --git a/fe-app-podkop/src/dashboard/renderer/renderWidget.ts b/fe-app-podkop/src/dashboard/renderer/renderWidget.ts new file mode 100644 index 0000000..850e263 --- /dev/null +++ b/fe-app-podkop/src/dashboard/renderer/renderWidget.ts @@ -0,0 +1,16 @@ +interface IRenderWidgetParams { + title: string; + items: Array<{ + key: string; + value: string; + }>; +} + +export function renderDashboardWidget({ title, items }: IRenderWidgetParams) { + return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [ + E('b', {}, title), + ...items.map((item) => + E('div', {}, [E('span', {}, `${item.key}: `), E('span', {}, item.value)]), + ), + ]); +} diff --git a/fe-app-podkop/src/helpers/getClashApiUrl.ts b/fe-app-podkop/src/helpers/getClashApiUrl.ts new file mode 100644 index 0000000..df52bec --- /dev/null +++ b/fe-app-podkop/src/helpers/getClashApiUrl.ts @@ -0,0 +1,11 @@ +export function getClashApiUrl(): string { + const { protocol, hostname } = window.location; + + return `${protocol}//${hostname}:9090`; +} + +export function getClashWsUrl(): string { + const { hostname } = window.location; + + return `ws://${hostname}:9090`; +} diff --git a/fe-app-podkop/src/helpers/getProxyUrlName.ts b/fe-app-podkop/src/helpers/getProxyUrlName.ts new file mode 100644 index 0000000..f903429 --- /dev/null +++ b/fe-app-podkop/src/helpers/getProxyUrlName.ts @@ -0,0 +1,13 @@ +export function getProxyUrlName(url: string) { + try { + const [_link, hash] = url.split('#'); + + if (!hash) { + return ''; + } + + return decodeURIComponent(hash); + } catch { + return ''; + } +} diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts index 5569d6e..a38f0b5 100644 --- a/fe-app-podkop/src/helpers/index.ts +++ b/fe-app-podkop/src/helpers/index.ts @@ -5,3 +5,6 @@ export * from './withTimeout'; export * from './executeShellCommand'; export * from './copyToClipboard'; export * from './maskIP'; +export * from './getProxyUrlName'; +export * from './onMount'; +export * from './getClashApiUrl'; diff --git a/fe-app-podkop/src/helpers/onMount.ts b/fe-app-podkop/src/helpers/onMount.ts new file mode 100644 index 0000000..48ce5e8 --- /dev/null +++ b/fe-app-podkop/src/helpers/onMount.ts @@ -0,0 +1,30 @@ +export async function onMount(id: string): Promise { + return new Promise((resolve) => { + const el = document.getElementById(id); + + if (el && el.offsetParent !== null) { + return resolve(el); + } + + const observer = new MutationObserver(() => { + const target = document.getElementById(id); + if (target) { + const io = new IntersectionObserver((entries) => { + const visible = entries.some((e) => e.isIntersecting); + if (visible) { + observer.disconnect(); + io.disconnect(); + resolve(target); + } + }); + + io.observe(target); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +} diff --git a/fe-app-podkop/src/helpers/prettyBytes.ts b/fe-app-podkop/src/helpers/prettyBytes.ts new file mode 100644 index 0000000..5572ccb --- /dev/null +++ b/fe-app-podkop/src/helpers/prettyBytes.ts @@ -0,0 +1,12 @@ +// steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js +export function prettyBytes(n: number) { + const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + if (n < 1000) { + return n + ' B'; + } + const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1); + n = Number((n / Math.pow(1000, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + return n + ' ' + unit; +} diff --git a/fe-app-podkop/src/luci.d.ts b/fe-app-podkop/src/luci.d.ts index 2b942de..9b6762e 100644 --- a/fe-app-podkop/src/luci.d.ts +++ b/fe-app-podkop/src/luci.d.ts @@ -1,3 +1,15 @@ +type HtmlTag = keyof HTMLElementTagNameMap; + +type HtmlElement = HTMLElementTagNameMap[T]; + +type HtmlAttributes = Partial< + Omit, 'style' | 'children'> & { + style?: string | Partial; + class?: string; + onclick?: (event: MouseEvent) => void; + } +>; + declare global { const fs: { exec( @@ -10,6 +22,17 @@ declare global { code?: number; }>; }; + + const E: ( + type: T, + attr?: HtmlAttributes | null, + children?: (Node | string)[] | Node | string, + ) => HTMLElementTagNameMap[T]; + + const uci: { + load: (packages: string | string[]) => Promise; + sections: (conf: string, type?: string, cb?: () => void) => Promise; + }; } export {}; diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index 34b2c09..f130254 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -1,8 +1,10 @@ 'use strict'; 'require baseclass'; 'require fs'; +'require uci'; export * from './validators'; export * from './helpers'; export * from './clash'; +export * from './dashboard'; export * from './constants'; diff --git a/fe-app-podkop/src/podkop/methods/getConfigSections.ts b/fe-app-podkop/src/podkop/methods/getConfigSections.ts new file mode 100644 index 0000000..d8883d4 --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getConfigSections.ts @@ -0,0 +1,5 @@ +import { Podkop } from '../types'; + +export async function getConfigSections(): Promise { + return uci.load('podkop').then(() => uci.sections('podkop')); +} diff --git a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts new file mode 100644 index 0000000..1ea8886 --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts @@ -0,0 +1,115 @@ +import { Podkop } from '../types'; +import { getConfigSections } from './getConfigSections'; +import { getClashProxies } from '../../clash'; +import { getProxyUrlName } from '../../helpers'; + +export async function getDashboardSections(): Promise { + const configSections = await getConfigSections(); + const clashProxies = await getClashProxies(); + + const clashProxiesData = clashProxies.success + ? clashProxies.data + : { proxies: [] }; + + const proxies = Object.entries(clashProxiesData.proxies).map( + ([key, value]) => ({ + code: key, + value, + }), + ); + + return configSections + .filter((section) => section.mode !== 'block') + .map((section) => { + if (section.mode === 'proxy') { + if (section.proxy_config_type === 'url') { + const outbound = proxies.find( + (proxy) => proxy.code === `${section['.name']}-out`, + ); + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || section['.name'], + displayName: + getProxyUrlName(section.proxy_string) || + outbound?.value?.name || + '', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + }, + ], + }; + } + + if (section.proxy_config_type === 'outbound') { + const outbound = proxies.find( + (proxy) => proxy.code === `${section['.name']}-out`, + ); + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || section['.name'], + displayName: + decodeURIComponent(JSON.parse(section.outbound_json)?.tag) || + outbound?.value?.name || + '', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + }, + ], + }; + } + + if (section.proxy_config_type === 'urltest') { + const selector = proxies.find( + (proxy) => proxy.code === `${section['.name']}-out`, + ); + const outbound = proxies.find( + (proxy) => proxy.code === `${section['.name']}-urltest-out`, + ); + + const outbounds = (outbound?.value?.all ?? []) + .map((code) => proxies.find((item) => item.code === code)) + .map((item, index) => ({ + code: item?.code || '', + displayName: + getProxyUrlName(section.urltest_proxy_links?.[index]) || + item?.value?.name || + '', + latency: item?.value?.history?.[0]?.delay || 0, + type: item?.value?.type || '', + selected: selector?.value?.now === item?.code, + })); + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || '', + displayName: 'Fastest', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: selector?.value?.now === outbound?.code, + }, + ...outbounds, + ], + }; + } + } + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [], + }; + }); +} diff --git a/fe-app-podkop/src/podkop/methods/getPodkopStatus.ts b/fe-app-podkop/src/podkop/methods/getPodkopStatus.ts new file mode 100644 index 0000000..9286dda --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getPodkopStatus.ts @@ -0,0 +1,21 @@ +import { executeShellCommand } from '../../helpers'; + +export async function getPodkopStatus(): Promise<{ + enabled: number; + status: string; +}> { + const response = await executeShellCommand({ + command: '/usr/bin/podkop', + args: ['get_status'], + timeout: 1000, + }); + + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, '')) as { + enabled: number; + status: string; + }; + } + + return { enabled: 0, status: 'unknown' }; +} diff --git a/fe-app-podkop/src/podkop/methods/getSingboxStatus.ts b/fe-app-podkop/src/podkop/methods/getSingboxStatus.ts new file mode 100644 index 0000000..d65221e --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getSingboxStatus.ts @@ -0,0 +1,23 @@ +import { executeShellCommand } from '../../helpers'; + +export async function getSingboxStatus(): Promise<{ + running: number; + enabled: number; + status: string; +}> { + const response = await executeShellCommand({ + command: '/usr/bin/podkop', + args: ['get_sing_box_status'], + timeout: 1000, + }); + + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, '')) as { + running: number; + enabled: number; + status: string; + }; + } + + return { running: 0, enabled: 0, status: 'unknown' }; +} diff --git a/fe-app-podkop/src/podkop/methods/index.ts b/fe-app-podkop/src/podkop/methods/index.ts new file mode 100644 index 0000000..6b2c1f3 --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/index.ts @@ -0,0 +1,4 @@ +export * from './getConfigSections'; +export * from './getDashboardSections'; +export * from './getPodkopStatus'; +export * from './getSingboxStatus'; diff --git a/fe-app-podkop/src/podkop/types.ts b/fe-app-podkop/src/podkop/types.ts new file mode 100644 index 0000000..c715b61 --- /dev/null +++ b/fe-app-podkop/src/podkop/types.ts @@ -0,0 +1,55 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Podkop { + export interface Outbound { + code: string; + displayName: string; + latency: number; + type: string; + selected: boolean; + } + + export interface OutboundGroup { + code: string; + displayName: string; + outbounds: Outbound[]; + } + + export interface ConfigProxyUrlTestSection { + mode: 'proxy'; + proxy_config_type: 'urltest'; + urltest_proxy_links: string[]; + } + + export interface ConfigProxyUrlSection { + mode: 'proxy'; + proxy_config_type: 'url'; + proxy_string: string; + } + + export interface ConfigProxyOutboundSection { + mode: 'proxy'; + proxy_config_type: 'outbound'; + outbound_json: string; + } + + export interface ConfigVpnSection { + mode: 'vpn'; + interface: string; + } + + export interface ConfigBlockSection { + mode: 'block'; + } + + export type ConfigBaseSection = + | ConfigProxyUrlTestSection + | ConfigProxyUrlSection + | ConfigProxyOutboundSection + | ConfigVpnSection + | ConfigBlockSection; + + export type ConfigSection = ConfigBaseSection & { + '.name': string; + '.type': 'main' | 'extra'; + }; +} diff --git a/fe-app-podkop/src/socket.ts b/fe-app-podkop/src/socket.ts new file mode 100644 index 0000000..ea9aba1 --- /dev/null +++ b/fe-app-podkop/src/socket.ts @@ -0,0 +1,93 @@ +// eslint-disable-next-line +type Listener = (data: any) => void; + +class SocketManager { + private static instance: SocketManager; + private sockets = new Map(); + private listeners = new Map>(); + private connected = new Map(); + + private constructor() {} + + static getInstance(): SocketManager { + if (!SocketManager.instance) { + SocketManager.instance = new SocketManager(); + } + return SocketManager.instance; + } + + connect(url: string): void { + if (this.sockets.has(url)) return; + + const ws = new WebSocket(url); + this.sockets.set(url, ws); + this.connected.set(url, false); + this.listeners.set(url, new Set()); + + ws.addEventListener('open', () => { + this.connected.set(url, true); + console.log(`✅ Connected: ${url}`); + }); + + ws.addEventListener('message', (event) => { + const handlers = this.listeners.get(url); + if (handlers) { + for (const handler of handlers) { + try { + handler(event.data); + } catch (err) { + console.error(`Handler error for ${url}:`, err); + } + } + } + }); + + ws.addEventListener('close', () => { + this.connected.set(url, false); + console.warn(`⚠️ Disconnected: ${url}`); + }); + + ws.addEventListener('error', (err) => { + console.error(`❌ Socket error for ${url}:`, err); + }); + } + + subscribe(url: string, listener: Listener): void { + if (!this.sockets.has(url)) { + this.connect(url); + } + this.listeners.get(url)?.add(listener); + } + + unsubscribe(url: string, listener: Listener): void { + this.listeners.get(url)?.delete(listener); + } + + // eslint-disable-next-line + send(url: string, data: any): void { + const ws = this.sockets.get(url); + if (ws && this.connected.get(url)) { + ws.send(typeof data === 'string' ? data : JSON.stringify(data)); + } else { + console.warn(`⚠️ Cannot send: not connected to ${url}`); + } + } + + disconnect(url: string): void { + const ws = this.sockets.get(url); + if (ws) { + ws.close(); + this.sockets.delete(url); + this.listeners.delete(url); + this.connected.delete(url); + } + } + + disconnectAll(): void { + for (const url of this.sockets.keys()) { + this.disconnect(url); + } + } +} + +export const socket = SocketManager.getInstance(); diff --git a/fe-app-podkop/src/store.ts b/fe-app-podkop/src/store.ts new file mode 100644 index 0000000..7770631 --- /dev/null +++ b/fe-app-podkop/src/store.ts @@ -0,0 +1,82 @@ +import { Podkop } from './podkop/types'; + +type Listener = (next: T, prev: T, diff: Partial) => void; + +// eslint-disable-next-line +class Store> { + private value: T; + private listeners = new Set>(); + + constructor(initial: T) { + this.value = initial; + } + + get(): T { + return this.value; + } + + set(next: Partial): void { + const prev = this.value; + const merged = { ...this.value, ...next }; + if (Object.is(prev, merged)) return; + + this.value = merged; + + const diff: Partial = {}; + 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: Listener): () => void { + this.listeners.add(cb); + cb(this.value, this.value, {}); // первый вызов без diff + return () => this.listeners.delete(cb); + } + + patch(key: K, value: T[K]): void { + this.set({ ...this.value, [key]: value }); + } + + getKey(key: K): T[K] { + return this.value[key]; + } + + subscribeKey( + key: K, + cb: (value: T[K]) => void, + ): () => void { + let prev = this.value[key]; + const unsub = this.subscribe((val) => { + if (val[key] !== prev) { + prev = val[key]; + cb(val[key]); + } + }); + return unsub; + } +} + +export const store = new Store<{ + sections: Podkop.OutboundGroup[]; + traffic: { up: number; down: number }; + memory: { inuse: number; oslimit: number }; + connections: { + connections: unknown[]; + downloadTotal: number; + memory: number; + uploadTotal: number; + }; + services: { + singbox: string; + podkop: string; + }; +}>({ + sections: [], + traffic: { up: 0, down: 0 }, + memory: { inuse: 0, oslimit: 0 }, + connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, + services: { singbox: '', podkop: '' }, +}); diff --git a/fe-app-podkop/src/styles.ts b/fe-app-podkop/src/styles.ts index 6613f9b..9ab5ed5 100644 --- a/fe-app-podkop/src/styles.ts +++ b/fe-app-podkop/src/styles.ts @@ -23,4 +23,139 @@ export const GlobalStyles = ` #cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra { display: none; } + +#cbi-podkop-main-_status > div { + width: 100%; +} + +.pdk_dashboard-page { + width: 100%; + --dashboard-grid-columns: 4; +} + +@media (max-width: 900px) { + .pdk_dashboard-page { + --dashboard-grid-columns: 2; + } +} + +/*@media (max-width: 440px) {*/ +/* .pdk_dashboard-page {*/ +/* --dashboard-grid-columns: 1;*/ +/* }*/ +/*}*/ + +.pdk_dashboard-page__title-section { + display: flex; + align-items: center; + justify-content: space-between; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 0 10px; +} + +.pdk_dashboard-page__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__widgets-section { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__widgets-section__item { + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section { + margin-top: 10px; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section__title-section { + display: flex; + align-items: center; + justify-content: space-between; +} + +.pdk_dashboard-page__outbound-section__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__outbound-grid { + margin-top: 5px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__outbound-grid__item { + cursor: pointer; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; + transition: border 0.2s ease; +} +.pdk_dashboard-page__outbound-grid__item:hover { + border-color: var(--primary-color-high); +} + +.pdk_dashboard-page__outbound-grid__item--active { + border-color: var(--success-color-medium); +} + +.pdk_dashboard-page__outbound-grid__item__footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; +} + +.pdk_dashboard-page__outbound-grid__item__type { + +} + +.pdk_dashboard-page__outbound-grid__item__latency { + +} + + + +/* Skeleton styles*/ +.skeleton { + background-color: var(--background-color-low, #e0e0e0); + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + position: absolute; + top: 0; + left: -150%; + width: 150%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: skeleton-shimmer 1.6s infinite; +} + +@keyframes skeleton-shimmer { + 100% { + left: 150%; + } +} `; diff --git a/fe-app-podkop/watch-upload.js b/fe-app-podkop/watch-upload.js new file mode 100644 index 0000000..9bdd821 --- /dev/null +++ b/fe-app-podkop/watch-upload.js @@ -0,0 +1,84 @@ +import 'dotenv/config'; +import chokidar from 'chokidar'; +import SFTPClient from 'ssh2-sftp-client'; +import path from 'path'; +import fs from 'fs'; +import { glob } from 'glob'; + +const sftp = new SFTPClient(); + +const config = { + host: process.env.SFTP_HOST, + port: Number(process.env.SFTP_PORT || 22), + username: process.env.SFTP_USER, + ...(process.env.SFTP_PRIVATE_KEY + ? { privateKey: fs.readFileSync(process.env.SFTP_PRIVATE_KEY) } + : { password: process.env.SFTP_PASS }), +}; + +const localDir = path.resolve(process.env.LOCAL_DIR || './dist'); +const remoteDir = process.env.REMOTE_DIR || '/www/luci-static/mypkg'; + +async function uploadFile(filePath) { + const relativePath = path.relative(localDir, filePath); + const remotePath = path.posix.join(remoteDir, relativePath); + + console.log(`⬆️ Uploading: ${relativePath} -> ${remotePath}`); + try { + await sftp.fastPut(filePath, remotePath); + console.log(`✅ Uploaded: ${relativePath}`); + } catch (err) { + console.error(`❌ Failed: ${relativePath}: ${err.message}`); + } +} + +async function deleteFile(filePath) { + const relativePath = path.relative(localDir, filePath); + const remotePath = path.posix.join(remoteDir, relativePath); + + console.log(`🗑 Removing: ${relativePath}`); + try { + await sftp.delete(remotePath); + console.log(`✅ Removed: ${relativePath}`); + } catch (err) { + console.warn(`⚠️ Could not delete ${relativePath}: ${err.message}`); + } +} + +async function uploadAllFiles() { + console.log('🚀 Uploading all files from', localDir); + + const files = await glob(`${localDir}/**/*`, { nodir: true }); + for (const file of files) { + await uploadFile(file); + } + + console.log('✅ Initial upload complete!'); +} + +async function main() { + await sftp.connect(config); + console.log(`✅ Connected to ${config.host}`); + + // 🔹 Загрузить всё при старте + await uploadAllFiles(); + + // 🔹 Затем следить за изменениями + chokidar + .watch(localDir, { ignoreInitial: true }) + .on('all', async (event, filePath) => { + if (event === 'add' || event === 'change') { + await uploadFile(filePath); + } else if (event === 'unlink') { + await deleteFile(filePath); + } + }); + + process.on('SIGINT', async () => { + console.log('🔌 Disconnecting...'); + await sftp.end(); + process.exit(); + }); +} + +main().catch(console.error); diff --git a/fe-app-podkop/yarn.lock b/fe-app-podkop/yarn.lock index 6791013..93738ea 100644 --- a/fe-app-podkop/yarn.lock +++ b/fe-app-podkop/yarn.lock @@ -221,6 +221,18 @@ resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -628,6 +640,13 @@ argparse@^2.0.1: resolved "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +asn1@^0.2.6: + version "0.2.6" + resolved "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -638,6 +657,13 @@ balanced-match@^1.0.0: resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" @@ -660,6 +686,16 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buildcheck@~0.0.6: + version "0.0.6" + resolved "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" + integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== + bundle-require@^5.1.0: version "5.1.0" resolved "https://registry.npmmirror.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" @@ -701,7 +737,7 @@ check-error@^2.1.1: resolved "https://registry.npmmirror.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== -chokidar@^4.0.3: +chokidar@4.0.3, chokidar@^4.0.3: version "4.0.3" resolved "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== @@ -730,6 +766,16 @@ concat-map@0.0.1: resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + confbox@^0.1.8: version "0.1.8" resolved "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" @@ -740,6 +786,14 @@ consola@^3.4.0: resolved "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7" integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== +cpu-features@~0.0.10: + version "0.0.10" + resolved "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -766,6 +820,11 @@ deep-is@^0.1.3: resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +dotenv@17.2.3: + version "17.2.3" + resolved "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2" + integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -1014,7 +1073,7 @@ flatted@^3.2.9: resolved "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== -foreground-child@^3.1.0: +foreground-child@^3.1.0, foreground-child@^3.3.1: version "3.3.1" resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -1041,6 +1100,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@11.0.3: + version "11.0.3" + resolved "https://registry.npmmirror.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" + integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA== + dependencies: + foreground-child "^3.3.1" + jackspeak "^4.1.1" + minimatch "^10.0.3" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + glob@^10.3.10: version "10.4.5" resolved "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -1091,6 +1162,11 @@ imurmurhash@^0.1.4: resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +inherits@^2.0.3: + version "2.0.4" + resolved "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1127,6 +1203,13 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^4.1.1: + version "4.1.1" + resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + joycon@^3.1.1: version "3.1.1" resolved "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" @@ -1216,6 +1299,11 @@ lru-cache@^10.2.0: resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0: + version "11.2.2" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24" + integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg== + magic-string@^0.30.17: version "0.30.19" resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" @@ -1236,6 +1324,13 @@ micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" +minimatch@^10.0.3: + version "10.0.3" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -1279,6 +1374,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.19.0, nan@^2.23.0: + version "2.23.0" + resolved "https://registry.npmmirror.com/nan/-/nan-2.23.0.tgz#24aa4ddffcc37613a2d2935b97683c1ec96093c6" + integrity sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ== + nanoid@^3.3.11: version "3.3.11" resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" @@ -1350,6 +1450,14 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + pathe@^2.0.1, pathe@^2.0.3: version "2.0.3" resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" @@ -1425,6 +1533,15 @@ queue-microtask@^1.2.2: resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +readable-stream@^3.0.2: + version "3.6.2" + resolved "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -1483,6 +1600,16 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + semver@^7.6.0: version "7.7.2" resolved "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" @@ -1522,6 +1649,25 @@ source-map@0.8.0-beta.0: dependencies: whatwg-url "^7.0.0" +ssh2-sftp-client@12.0.1: + version "12.0.1" + resolved "https://registry.npmmirror.com/ssh2-sftp-client/-/ssh2-sftp-client-12.0.1.tgz#926764878954dbed85f6f9233ce7980bfc94fdd4" + integrity sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg== + dependencies: + concat-stream "^2.0.0" + ssh2 "^1.16.0" + +ssh2@^1.16.0: + version "1.17.0" + resolved "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c" + integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.10" + nan "^2.23.0" + stackback@0.0.2: version "0.0.2" resolved "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" @@ -1559,6 +1705,13 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -1711,6 +1864,11 @@ tsup@8.5.0: tinyglobby "^0.2.11" tree-kill "^1.2.2" +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -1718,6 +1876,11 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + typescript-eslint@8.45.0: version "8.45.0" resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.45.0.tgz#98ab164234dc04c112747ec0a4ae29a94efe123b" @@ -1745,6 +1908,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + vite-node@3.2.4: version "3.2.4" resolved "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js new file mode 100644 index 0000000..7a7eff1 --- /dev/null +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js @@ -0,0 +1,22 @@ +'use strict'; +'require baseclass'; +'require form'; +'require ui'; +'require uci'; +'require fs'; +'require view.podkop.utils as utils'; +'require view.podkop.main as main'; + +function createDashboardSection(mainSection) { + let o = mainSection.tab('dashboard', _('Dashboard')); + + o = mainSection.taboption('dashboard', form.DummyValue, '_status'); + o.rawhtml = true; + o.cfgvalue = () => main.renderDashboard(); +} + +const EntryPoint = { + createDashboardSection, +} + +return baseclass.extend(EntryPoint); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index cc1c318..8f3a45c 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -2,6 +2,7 @@ "use strict"; "require baseclass"; "require fs"; +"require uci"; // src/validators/validateIp.ts function validateIPV4(ip) { @@ -370,6 +371,141 @@ var GlobalStyles = ` #cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra { display: none; } + +#cbi-podkop-main-_status > div { + width: 100%; +} + +.pdk_dashboard-page { + width: 100%; + --dashboard-grid-columns: 4; +} + +@media (max-width: 900px) { + .pdk_dashboard-page { + --dashboard-grid-columns: 2; + } +} + +/*@media (max-width: 440px) {*/ +/* .pdk_dashboard-page {*/ +/* --dashboard-grid-columns: 1;*/ +/* }*/ +/*}*/ + +.pdk_dashboard-page__title-section { + display: flex; + align-items: center; + justify-content: space-between; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 0 10px; +} + +.pdk_dashboard-page__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__widgets-section { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__widgets-section__item { + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section { + margin-top: 10px; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section__title-section { + display: flex; + align-items: center; + justify-content: space-between; +} + +.pdk_dashboard-page__outbound-section__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__outbound-grid { + margin-top: 5px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__outbound-grid__item { + cursor: pointer; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; + transition: border 0.2s ease; +} +.pdk_dashboard-page__outbound-grid__item:hover { + border-color: var(--primary-color-high); +} + +.pdk_dashboard-page__outbound-grid__item--active { + border-color: var(--success-color-medium); +} + +.pdk_dashboard-page__outbound-grid__item__footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; +} + +.pdk_dashboard-page__outbound-grid__item__type { + +} + +.pdk_dashboard-page__outbound-grid__item__latency { + +} + + + +/* Skeleton styles*/ +.skeleton { + background-color: var(--background-color-low, #e0e0e0); + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + position: absolute; + top: 0; + left: -150%; + width: 150%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: skeleton-shimmer 1.6s infinite; +} + +@keyframes skeleton-shimmer { + 100% { + left: 150%; + } +} `; // src/helpers/injectGlobalStyles.ts @@ -557,6 +693,57 @@ function maskIP(ip = "") { return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); } +// src/helpers/getProxyUrlName.ts +function getProxyUrlName(url) { + try { + const [_link, hash] = url.split("#"); + if (!hash) { + return ""; + } + return decodeURIComponent(hash); + } catch { + return ""; + } +} + +// src/helpers/onMount.ts +async function onMount(id) { + return new Promise((resolve) => { + const el = document.getElementById(id); + if (el && el.offsetParent !== null) { + return resolve(el); + } + const observer = new MutationObserver(() => { + const target = document.getElementById(id); + if (target) { + const io = new IntersectionObserver((entries) => { + const visible = entries.some((e) => e.isIntersecting); + if (visible) { + observer.disconnect(); + io.disconnect(); + resolve(target); + } + }); + io.observe(target); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true + }); + }); +} + +// src/helpers/getClashApiUrl.ts +function getClashApiUrl() { + const { protocol, hostname } = window.location; + return `${protocol}//${hostname}:9090`; +} +function getClashWsUrl() { + const { hostname } = window.location; + return `ws://${hostname}:9090`; +} + // src/clash/methods/createBaseApiRequest.ts async function createBaseApiRequest(fetchFn) { try { @@ -583,7 +770,7 @@ async function createBaseApiRequest(fetchFn) { // src/clash/methods/getConfig.ts async function getClashConfig() { return createBaseApiRequest( - () => fetch("http://192.168.160.129:9090/configs", { + () => fetch(`${getClashApiUrl()}/configs`, { method: "GET", headers: { "Content-Type": "application/json" } }) @@ -592,7 +779,7 @@ async function getClashConfig() { // src/clash/methods/getGroupDelay.ts async function getClashGroupDelay(group, url = "https://www.gstatic.com/generate_204", timeout = 2e3) { - const endpoint = `http://192.168.160.129:9090/group/${group}/delay?url=${encodeURIComponent( + const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent( url )}&timeout=${timeout}`; return createBaseApiRequest( @@ -606,7 +793,7 @@ async function getClashGroupDelay(group, url = "https://www.gstatic.com/generate // src/clash/methods/getProxies.ts async function getClashProxies() { return createBaseApiRequest( - () => fetch("http://192.168.160.129:9090/proxies", { + () => fetch(`${getClashApiUrl()}/proxies`, { method: "GET", headers: { "Content-Type": "application/json" } }) @@ -616,12 +803,553 @@ async function getClashProxies() { // src/clash/methods/getVersion.ts async function getClashVersion() { return createBaseApiRequest( - () => fetch("http://192.168.160.129:9090/version", { + () => fetch(`${getClashApiUrl()}/version`, { method: "GET", headers: { "Content-Type": "application/json" } }) ); } + +// src/clash/methods/triggerProxySelector.ts +async function triggerProxySelector(selector, outbound) { + return createBaseApiRequest( + () => fetch(`${getClashApiUrl()}/proxies/${selector}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: outbound }) + }) + ); +} + +// 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 +async function getConfigSections() { + return uci.load("podkop").then(() => uci.sections("podkop")); +} + +// src/podkop/methods/getDashboardSections.ts +async function getDashboardSections() { + const configSections = await getConfigSections(); + const clashProxies = await getClashProxies(); + const clashProxiesData = clashProxies.success ? clashProxies.data : { proxies: [] }; + const proxies = Object.entries(clashProxiesData.proxies).map( + ([key, value]) => ({ + code: key, + value + }) + ); + return configSections.filter((section) => section.mode !== "block").map((section) => { + if (section.mode === "proxy") { + if (section.proxy_config_type === "url") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: getProxyUrlName(section.proxy_string) || outbound?.value?.name || "", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true + } + ] + }; + } + if (section.proxy_config_type === "outbound") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: decodeURIComponent(JSON.parse(section.outbound_json)?.tag) || outbound?.value?.name || "", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true + } + ] + }; + } + if (section.proxy_config_type === "urltest") { + const selector = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-urltest-out` + ); + const outbounds = (outbound?.value?.all ?? []).map((code) => proxies.find((item) => item.code === code)).map((item, index) => ({ + code: item?.code || "", + displayName: getProxyUrlName(section.urltest_proxy_links?.[index]) || item?.value?.name || "", + latency: item?.value?.history?.[0]?.delay || 0, + type: item?.value?.type || "", + selected: selector?.value?.now === item?.code + })); + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || "", + displayName: "Fastest", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: selector?.value?.now === outbound?.code + }, + ...outbounds + ] + }; + } + } + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [] + }; + }); +} + +// src/podkop/methods/getPodkopStatus.ts +async function getPodkopStatus() { + const response = await executeShellCommand({ + command: "/usr/bin/podkop", + args: ["get_status"], + timeout: 1e3 + }); + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, "")); + } + return { enabled: 0, status: "unknown" }; +} + +// src/podkop/methods/getSingboxStatus.ts +async function getSingboxStatus() { + const response = await executeShellCommand({ + command: "/usr/bin/podkop", + args: ["get_sing_box_status"], + timeout: 1e3 + }); + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, "")); + } + return { running: 0, enabled: 0, status: "unknown" }; +} + +// src/dashboard/renderer/renderOutboundGroup.ts +function renderOutboundGroup({ + outbounds, + displayName +}) { + function renderOutbound(outbound) { + return E( + "div", + { + class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? "pdk_dashboard-page__outbound-grid__item--active" : ""}` + }, + [ + E("b", {}, outbound.displayName), + E("div", { class: "pdk_dashboard-page__outbound-grid__item__footer" }, [ + E( + "div", + { class: "pdk_dashboard-page__outbound-grid__item__type" }, + outbound.type + ), + E( + "div", + { class: "pdk_dashboard-page__outbound-grid__item__latency" }, + outbound.latency ? `${outbound.latency}ms` : "N/A" + ) + ]) + ] + ); + } + return E("div", { class: "pdk_dashboard-page__outbound-section" }, [ + // Title with test latency + E("div", { class: "pdk_dashboard-page__outbound-section__title-section" }, [ + E( + "div", + { + class: "pdk_dashboard-page__outbound-section__title-section__title" + }, + displayName + ), + E("button", { class: "btn" }, "Test latency") + ]), + E( + "div", + { class: "pdk_dashboard-page__outbound-grid" }, + outbounds.map((outbound) => renderOutbound(outbound)) + ) + ]); +} + +// src/store.ts +var Store = class { + constructor(initial) { + this.listeners = /* @__PURE__ */ new Set(); + this.value = initial; + } + 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: "", podkop: "" } +}); + +// src/socket.ts +var SocketManager = class _SocketManager { + constructor() { + this.sockets = /* @__PURE__ */ new Map(); + this.listeners = /* @__PURE__ */ new Map(); + this.connected = /* @__PURE__ */ new Map(); + } + static getInstance() { + if (!_SocketManager.instance) { + _SocketManager.instance = new _SocketManager(); + } + return _SocketManager.instance; + } + connect(url) { + if (this.sockets.has(url)) return; + const ws = new WebSocket(url); + this.sockets.set(url, ws); + this.connected.set(url, false); + this.listeners.set(url, /* @__PURE__ */ new Set()); + ws.addEventListener("open", () => { + this.connected.set(url, true); + console.log(`\u2705 Connected: ${url}`); + }); + ws.addEventListener("message", (event) => { + const handlers = this.listeners.get(url); + if (handlers) { + for (const handler of handlers) { + try { + handler(event.data); + } catch (err) { + console.error(`Handler error for ${url}:`, err); + } + } + } + }); + ws.addEventListener("close", () => { + this.connected.set(url, false); + console.warn(`\u26A0\uFE0F Disconnected: ${url}`); + }); + ws.addEventListener("error", (err) => { + console.error(`\u274C Socket error for ${url}:`, err); + }); + } + subscribe(url, listener) { + if (!this.sockets.has(url)) { + this.connect(url); + } + this.listeners.get(url)?.add(listener); + } + unsubscribe(url, listener) { + this.listeners.get(url)?.delete(listener); + } + // eslint-disable-next-line + send(url, data) { + const ws = this.sockets.get(url); + if (ws && this.connected.get(url)) { + ws.send(typeof data === "string" ? data : JSON.stringify(data)); + } else { + console.warn(`\u26A0\uFE0F Cannot send: not connected to ${url}`); + } + } + disconnect(url) { + const ws = this.sockets.get(url); + if (ws) { + ws.close(); + this.sockets.delete(url); + this.listeners.delete(url); + this.connected.delete(url); + } + } + disconnectAll() { + for (const url of this.sockets.keys()) { + this.disconnect(url); + } + } +}; +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", {}, title), + ...items.map( + (item) => E("div", {}, [E("span", {}, `${item.key}: `), E("span", {}, item.value)]) + ) + ]); +} + +// src/helpers/prettyBytes.ts +function prettyBytes(n) { + const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + if (n < 1e3) { + return n + " B"; + } + const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1); + n = Number((n / Math.pow(1e3, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + return n + " " + unit; +} + +// src/dashboard/initDashboardController.ts +async function fetchDashboardSections() { + const sections = await getDashboardSections(); + store.set({ sections }); +} +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 ? "\u2714 Enabled" : singbox.status, + podkop: podkop.status ? "\u2714 Enabled" : podkop.status + } + }); +} +async function connectToClashSockets() { + socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { + const parsedMsg = JSON.parse(msg); + store.set({ + traffic: { 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 + } + }); + }); + socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => { + store.set({ + memory: { inuse: msg.inuse, oslimit: msg.oslimit } + }); + }); +} +async function renderDashboardSections() { + const sections = store.get().sections; + console.log("render dashboard sections group"); + const container = document.getElementById("dashboard-sections-grid"); + const renderedOutboundGroups = sections.map(renderOutboundGroup); + container.replaceChildren(...renderedOutboundGroups); +} +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", + items: [ + { key: "Uplink", value: `${prettyBytes(traffic.up)}/s` }, + { key: "Downlink", value: `${prettyBytes(traffic.down)}/s` } + ] + }); + container.replaceChildren(renderedWidget); +} +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", + items: [ + { key: "Uplink", value: String(prettyBytes(connections.uploadTotal)) }, + { + key: "Downlink", + value: String(prettyBytes(connections.downloadTotal)) + } + ] + }); + container.replaceChildren(renderedWidget); +} +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", + items: [ + { + key: "Active Connections", + value: String(connections.connections.length) + }, + { key: "Memory Usage", value: String(prettyBytes(connections.memory)) } + ] + }); + container.replaceChildren(renderedWidget); +} +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", + items: [ + { + key: "Podkop", + value: String(services.podkop) + }, + { key: "Sing-box", value: String(services.singbox) } + ] + }); + container.replaceChildren(renderedWidget); +} +async function initDashboardController() { + store.subscribe((next, prev, diff) => { + console.log("Store changed", { prev, next, diff }); + 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"); + fetchDashboardSections(); + fetchServicesInfo(); + connectToClashSockets(); + }); +} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -645,13 +1373,20 @@ return baseclass.extend({ createBaseApiRequest, executeShellCommand, getBaseUrl, + getClashApiUrl, getClashConfig, getClashGroupDelay, getClashProxies, getClashVersion, + getClashWsUrl, + getProxyUrlName, + initDashboardController, injectGlobalStyles, maskIP, + onMount, parseValueList, + renderDashboard, + triggerProxySelector, validateDNS, validateDomain, validateIPV4, diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js index 7607ab2..3472a86 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js @@ -5,6 +5,7 @@ 'require view.podkop.configSection as configSection'; 'require view.podkop.diagnosticTab as diagnosticTab'; 'require view.podkop.additionalTab as additionalTab'; +'require view.podkop.dashboardTab as dashboardTab'; 'require view.podkop.utils as utils'; 'require view.podkop.main as main'; @@ -12,26 +13,31 @@ const EntryNode = { async render() { main.injectGlobalStyles(); - main.getClashVersion() - .then(result => console.log('getClashVersion - then', result)) - .catch(err => console.log('getClashVersion - err', err)) - .finally(() => console.log('getClashVersion - finish')); - - main.getClashConfig() - .then(result => console.log('getClashConfig - then', result)) - .catch(err => console.log('getClashConfig - err', err)) - .finally(() => console.log('getClashConfig - finish')); - - main.getClashProxies() - .then(result => console.log('getClashProxies - then', result)) - .catch(err => console.log('getClashProxies - err', err)) - .finally(() => console.log('getClashProxies - finish')); + // main.getClashVersion() + // .then(result => console.log('getClashVersion - then', result)) + // .catch(err => console.log('getClashVersion - err', err)) + // .finally(() => console.log('getClashVersion - finish')); + // + // main.getClashConfig() + // .then(result => console.log('getClashConfig - then', result)) + // .catch(err => console.log('getClashConfig - err', err)) + // .finally(() => console.log('getClashConfig - finish')); + // + // main.getClashProxies() + // .then(result => console.log('getClashProxies - then', result)) + // .catch(err => console.log('getClashProxies - err', err)) + // .finally(() => console.log('getClashProxies - finish')); const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']); // Main Section const mainSection = podkopFormMap.section(form.TypedSection, 'main'); mainSection.anonymous = true; + + dashboardTab.createDashboardSection(mainSection); + + main.initDashboardController(); + configSection.createConfigSection(mainSection); // Additional Settings Tab (main section)