mirror of
https://github.com/itdoginfo/podkop.git
synced 2026-01-30 22:32:49 +03:00
feat: implement dashboard prototype
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { ClashAPI, IBaseApiResponse } from '../types';
|
||||
import { createBaseApiRequest } from './createBaseApiRequest';
|
||||
import { getClashApiUrl } from '../../helpers';
|
||||
|
||||
export async function getClashConfig(): Promise<
|
||||
IBaseApiResponse<ClashAPI.Config>
|
||||
> {
|
||||
return createBaseApiRequest<ClashAPI.Config>(() =>
|
||||
fetch('http://192.168.160.129:9090/configs', {
|
||||
fetch(`${getClashApiUrl()}/configs`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
|
||||
@@ -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<IBaseApiResponse<ClashAPI.Delays>> {
|
||||
const endpoint = `http://192.168.160.129:9090/group/${group}/delay?url=${encodeURIComponent(
|
||||
const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent(
|
||||
url,
|
||||
)}&timeout=${timeout}`;
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ClashAPI, IBaseApiResponse } from '../types';
|
||||
import { createBaseApiRequest } from './createBaseApiRequest';
|
||||
import { getClashApiUrl } from '../../helpers';
|
||||
|
||||
export async function getClashProxies(): Promise<
|
||||
IBaseApiResponse<ClashAPI.Proxies>
|
||||
> {
|
||||
return createBaseApiRequest<ClashAPI.Proxies>(() =>
|
||||
fetch('http://192.168.160.129:9090/proxies', {
|
||||
fetch(`${getClashApiUrl()}/proxies`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ClashAPI, IBaseApiResponse } from '../types';
|
||||
import { createBaseApiRequest } from './createBaseApiRequest';
|
||||
import { getClashApiUrl } from '../../helpers';
|
||||
|
||||
export async function getClashVersion(): Promise<
|
||||
IBaseApiResponse<ClashAPI.Version>
|
||||
> {
|
||||
return createBaseApiRequest<ClashAPI.Version>(() =>
|
||||
fetch('http://192.168.160.129:9090/version', {
|
||||
fetch(`${getClashApiUrl()}/version`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './getConfig';
|
||||
export * from './getGroupDelay';
|
||||
export * from './getProxies';
|
||||
export * from './getVersion';
|
||||
export * from './triggerProxySelector';
|
||||
|
||||
16
fe-app-podkop/src/clash/methods/triggerProxySelector.ts
Normal file
16
fe-app-podkop/src/clash/methods/triggerProxySelector.ts
Normal file
@@ -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<IBaseApiResponse<void>> {
|
||||
return createBaseApiRequest<void>(() =>
|
||||
fetch(`${getClashApiUrl()}/proxies/${selector}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: outbound }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
2
fe-app-podkop/src/dashboard/index.ts
Normal file
2
fe-app-podkop/src/dashboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './renderDashboard';
|
||||
export * from './initDashboardController';
|
||||
174
fe-app-podkop/src/dashboard/initDashboardController.ts
Normal file
174
fe-app-podkop/src/dashboard/initDashboardController.ts
Normal file
@@ -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<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');
|
||||
// Initial sections fetch
|
||||
fetchDashboardSections();
|
||||
fetchServicesInfo();
|
||||
connectToClashSockets();
|
||||
});
|
||||
}
|
||||
78
fe-app-podkop/src/dashboard/renderDashboard.ts
Normal file
78
fe-app-podkop/src/dashboard/renderDashboard.ts
Normal file
@@ -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',
|
||||
}),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
49
fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts
Normal file
49
fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts
Normal file
@@ -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)),
|
||||
),
|
||||
]);
|
||||
}
|
||||
16
fe-app-podkop/src/dashboard/renderer/renderWidget.ts
Normal file
16
fe-app-podkop/src/dashboard/renderer/renderWidget.ts
Normal file
@@ -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)]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
11
fe-app-podkop/src/helpers/getClashApiUrl.ts
Normal file
11
fe-app-podkop/src/helpers/getClashApiUrl.ts
Normal file
@@ -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`;
|
||||
}
|
||||
13
fe-app-podkop/src/helpers/getProxyUrlName.ts
Normal file
13
fe-app-podkop/src/helpers/getProxyUrlName.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function getProxyUrlName(url: string) {
|
||||
try {
|
||||
const [_link, hash] = url.split('#');
|
||||
|
||||
if (!hash) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(hash);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
30
fe-app-podkop/src/helpers/onMount.ts
Normal file
30
fe-app-podkop/src/helpers/onMount.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export async function onMount(id: string): Promise<HTMLElement> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
12
fe-app-podkop/src/helpers/prettyBytes.ts
Normal file
12
fe-app-podkop/src/helpers/prettyBytes.ts
Normal file
@@ -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;
|
||||
}
|
||||
23
fe-app-podkop/src/luci.d.ts
vendored
23
fe-app-podkop/src/luci.d.ts
vendored
@@ -1,3 +1,15 @@
|
||||
type HtmlTag = keyof HTMLElementTagNameMap;
|
||||
|
||||
type HtmlElement<T extends HtmlTag> = HTMLElementTagNameMap[T];
|
||||
|
||||
type HtmlAttributes<T extends HtmlTag = 'div'> = Partial<
|
||||
Omit<HtmlElement<T>, 'style' | 'children'> & {
|
||||
style?: string | Partial<CSSStyleDeclaration>;
|
||||
class?: string;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
>;
|
||||
|
||||
declare global {
|
||||
const fs: {
|
||||
exec(
|
||||
@@ -10,6 +22,17 @@ declare global {
|
||||
code?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const E: <T extends HtmlTag>(
|
||||
type: T,
|
||||
attr?: HtmlAttributes<T> | null,
|
||||
children?: (Node | string)[] | Node | string,
|
||||
) => HTMLElementTagNameMap[T];
|
||||
|
||||
const uci: {
|
||||
load: (packages: string | string[]) => Promise<string>;
|
||||
sections: (conf: string, type?: string, cb?: () => void) => Promise<T>;
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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';
|
||||
|
||||
5
fe-app-podkop/src/podkop/methods/getConfigSections.ts
Normal file
5
fe-app-podkop/src/podkop/methods/getConfigSections.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Podkop } from '../types';
|
||||
|
||||
export async function getConfigSections(): Promise<Podkop.ConfigSection[]> {
|
||||
return uci.load('podkop').then(() => uci.sections('podkop'));
|
||||
}
|
||||
115
fe-app-podkop/src/podkop/methods/getDashboardSections.ts
Normal file
115
fe-app-podkop/src/podkop/methods/getDashboardSections.ts
Normal file
@@ -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<Podkop.OutboundGroup[]> {
|
||||
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: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
21
fe-app-podkop/src/podkop/methods/getPodkopStatus.ts
Normal file
21
fe-app-podkop/src/podkop/methods/getPodkopStatus.ts
Normal file
@@ -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' };
|
||||
}
|
||||
23
fe-app-podkop/src/podkop/methods/getSingboxStatus.ts
Normal file
23
fe-app-podkop/src/podkop/methods/getSingboxStatus.ts
Normal file
@@ -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' };
|
||||
}
|
||||
4
fe-app-podkop/src/podkop/methods/index.ts
Normal file
4
fe-app-podkop/src/podkop/methods/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './getConfigSections';
|
||||
export * from './getDashboardSections';
|
||||
export * from './getPodkopStatus';
|
||||
export * from './getSingboxStatus';
|
||||
55
fe-app-podkop/src/podkop/types.ts
Normal file
55
fe-app-podkop/src/podkop/types.ts
Normal file
@@ -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';
|
||||
};
|
||||
}
|
||||
93
fe-app-podkop/src/socket.ts
Normal file
93
fe-app-podkop/src/socket.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// eslint-disable-next-line
|
||||
type Listener = (data: any) => void;
|
||||
|
||||
class SocketManager {
|
||||
private static instance: SocketManager;
|
||||
private sockets = new Map<string, WebSocket>();
|
||||
private listeners = new Map<string, Set<Listener>>();
|
||||
private connected = new Map<string, boolean>();
|
||||
|
||||
private 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();
|
||||
82
fe-app-podkop/src/store.ts
Normal file
82
fe-app-podkop/src/store.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Podkop } from './podkop/types';
|
||||
|
||||
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 listeners = new Set<Listener<T>>();
|
||||
|
||||
constructor(initial: T) {
|
||||
this.value = initial;
|
||||
}
|
||||
|
||||
get(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
set(next: Partial<T>): void {
|
||||
const prev = this.value;
|
||||
const merged = { ...this.value, ...next };
|
||||
if (Object.is(prev, merged)) return;
|
||||
|
||||
this.value = merged;
|
||||
|
||||
const diff: Partial<T> = {};
|
||||
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<T>): () => void {
|
||||
this.listeners.add(cb);
|
||||
cb(this.value, this.value, {}); // первый вызов без diff
|
||||
return () => this.listeners.delete(cb);
|
||||
}
|
||||
|
||||
patch<K extends keyof T>(key: K, value: T[K]): void {
|
||||
this.set({ ...this.value, [key]: value });
|
||||
}
|
||||
|
||||
getKey<K extends keyof T>(key: K): T[K] {
|
||||
return this.value[key];
|
||||
}
|
||||
|
||||
subscribeKey<K extends keyof T>(
|
||||
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: '' },
|
||||
});
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user