Rebuild vpn proxy around gateway mode
This commit is contained in:
451
src/web/App.jsx
Normal file
451
src/web/App.jsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
function formatBytes(value) {
|
||||
if (!value) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function maskUrl(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.hostname}/...`;
|
||||
} catch {
|
||||
return value.length > 48 ? `${value.slice(0, 48)}...` : value;
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState(null);
|
||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||
const [servers, setServers] = useState([]);
|
||||
const [customRules, setCustomRules] = useState([]);
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [log, setLog] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
||||
const rulesDirtyRef = useRef(false);
|
||||
const rulesSaveTimerRef = useRef(null);
|
||||
const rulesRevisionRef = useRef(0);
|
||||
|
||||
const userTraffic = useMemo(() => {
|
||||
const info = state?.userInfo;
|
||||
if (!info) return 'нет данных';
|
||||
const used = formatBytes((info.upload || 0) + (info.download || 0));
|
||||
const total = info.total ? formatBytes(info.total) : 'без лимита';
|
||||
return `${used} / ${total}`;
|
||||
}, [state]);
|
||||
|
||||
function addLog(message) {
|
||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
||||
setLog((items) => [{ time, message }, ...items].slice(0, 8));
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const response = await fetch('/api/state');
|
||||
const data = await response.json();
|
||||
setState(data);
|
||||
setServers(data.servers || []);
|
||||
if (!rulesDirtyRef.current) {
|
||||
setCustomRules(data.customRules || []);
|
||||
}
|
||||
setSelectedTag(data.selectedTag || '');
|
||||
if (data.subscriptionUrl && !subscriptionUrl) setSubscriptionUrl(data.subscriptionUrl);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadState().catch(() => {});
|
||||
const timer = setInterval(() => loadState().catch(() => {}), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function fetchServers() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
addLog(`SYNC ${maskUrl(subscriptionUrl)}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/subscription/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ url: subscriptionUrl }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || 'sync failed');
|
||||
|
||||
setServers(data.servers || []);
|
||||
setSelectedTag(data.servers?.[0]?.tag || '');
|
||||
addLog(`FOUND ${data.servers.length} servers`);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
addLog(`ERROR ${err.message}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyServer() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
addLog(`APPLY ${selectedTag}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ selectedTag }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || 'apply failed');
|
||||
|
||||
addLog(`SING-BOX ${data.singboxRunning ? 'RUNNING' : 'STOPPED'}`);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
addLog(`ERROR ${err.message}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function emptyRule() {
|
||||
return {
|
||||
id: `rule-${Date.now()}`,
|
||||
name: 'Новый список',
|
||||
enabled: true,
|
||||
outbound: 'direct',
|
||||
domains: [],
|
||||
domainSuffixes: [],
|
||||
domainKeywords: [],
|
||||
ipCidrs: [],
|
||||
ports: [],
|
||||
networks: [],
|
||||
};
|
||||
}
|
||||
|
||||
function listToText(value) {
|
||||
return Array.isArray(value) ? value.join('\n') : '';
|
||||
}
|
||||
|
||||
function textToList(value) {
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function updateRule(id, patch) {
|
||||
setCustomRules((rules) => {
|
||||
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
|
||||
queueRulesSave(nextRules);
|
||||
return nextRules;
|
||||
});
|
||||
}
|
||||
|
||||
function queueRulesSave(nextRules) {
|
||||
rulesDirtyRef.current = true;
|
||||
const revision = rulesRevisionRef.current + 1;
|
||||
rulesRevisionRef.current = revision;
|
||||
setRulesSaveStatus('pending');
|
||||
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
rulesSaveTimerRef.current = setTimeout(() => {
|
||||
saveRules(nextRules, { silent: true, revision });
|
||||
}, 700);
|
||||
}
|
||||
|
||||
async function saveRules(nextRules = customRules, options = {}) {
|
||||
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
||||
if (!silent) setBusy(true);
|
||||
setError('');
|
||||
if (!silent) addLog('SAVE ROUTING RULES');
|
||||
setRulesSaveStatus('saving');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/rules', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ rules: nextRules }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || 'rules save failed');
|
||||
|
||||
if (rulesRevisionRef.current === revision) {
|
||||
rulesDirtyRef.current = false;
|
||||
setCustomRules(data.rules || []);
|
||||
setRulesSaveStatus('saved');
|
||||
addLog(`RULES SAVED ${data.rules.length}`);
|
||||
await loadState();
|
||||
} else {
|
||||
setRulesSaveStatus('pending');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setRulesSaveStatus('error');
|
||||
addLog(`ERROR ${err.message}`);
|
||||
} finally {
|
||||
if (!silent) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function saveRulesNow() {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
rulesDirtyRef.current = true;
|
||||
const revision = rulesRevisionRef.current + 1;
|
||||
rulesRevisionRef.current = revision;
|
||||
saveRules(customRules, { silent: false, revision });
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
setCustomRules((rules) => {
|
||||
const nextRules = [emptyRule(), ...rules];
|
||||
queueRulesSave(nextRules);
|
||||
return nextRules;
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(id) {
|
||||
setCustomRules((rules) => {
|
||||
const nextRules = rules.filter((rule) => rule.id !== id);
|
||||
queueRulesSave(nextRules);
|
||||
return nextRules;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="hero panel">
|
||||
<div>
|
||||
<p className="eyebrow">VPN Proxy / Gateway Mode</p>
|
||||
<h1>Transparent gateway for the whole network</h1>
|
||||
<p className="lead">
|
||||
Вставь subscription URL, выбери outbound, и контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
|
||||
</p>
|
||||
</div>
|
||||
<div className="status-card">
|
||||
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
|
||||
<div>
|
||||
<strong>{state?.singboxRunning ? 'sing-box running' : 'sing-box standby'}</strong>
|
||||
<small>{state?.selectedTag || 'сервер не выбран'}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<div className="panel primary-flow">
|
||||
<div className="section-title">
|
||||
<span>1</span>
|
||||
<h2>Subscription</h2>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>Subscription URL</span>
|
||||
<input
|
||||
value={subscriptionUrl}
|
||||
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||
placeholder="https://provider.example/sub/..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="button" disabled={busy || !subscriptionUrl} onClick={fetchServers}>
|
||||
{busy ? 'Working...' : 'Parse subscription'}
|
||||
</button>
|
||||
|
||||
<div className="section-title compact">
|
||||
<span>2</span>
|
||||
<h2>Servers</h2>
|
||||
</div>
|
||||
|
||||
<div className="server-list">
|
||||
{servers.length === 0 && <div className="empty">Серверы еще не загружены</div>}
|
||||
{servers.map((server) => (
|
||||
<button
|
||||
key={server.tag}
|
||||
className={server.tag === selectedTag ? 'server active' : 'server'}
|
||||
onClick={() => setSelectedTag(server.tag)}
|
||||
>
|
||||
<strong>{server.tag}</strong>
|
||||
<small>{server.type} / {server.server}:{server.server_port}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="button apply" disabled={busy || !selectedTag} onClick={applyServer}>
|
||||
Apply selected gateway route
|
||||
</button>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<aside className="panel details">
|
||||
<div className="section-title">
|
||||
<span>3</span>
|
||||
<h2>Gateway runtime</h2>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<div><dt>UI</dt><dd>:{state?.port || 3456}</dd></div>
|
||||
<div><dt>Mixed proxy</dt><dd>:{state?.proxyPort || 8080}</dd></div>
|
||||
<div><dt>TProxy</dt><dd>:{state?.tproxyPort || 7895}</dd></div>
|
||||
<div><dt>RU direct</dt><dd>{state?.routingRuDirect ? 'enabled' : 'disabled'}</dd></div>
|
||||
<div><dt>Traffic</dt><dd>{userTraffic}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div className="route-card">
|
||||
<span>Routing policy</span>
|
||||
<p>private IP -> direct</p>
|
||||
<p>geoip-ru/geosite-category-ru -> direct</p>
|
||||
<p>everything else -> selected VPN outbound</p>
|
||||
</div>
|
||||
|
||||
<div className="logs">
|
||||
{log.length === 0 && <p>Waiting for actions...</p>}
|
||||
{log.map((entry) => (
|
||||
<p key={`${entry.time}-${entry.message}`}><span>{entry.time}</span> {entry.message}</p>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="panel rules-panel">
|
||||
<div className="rules-header">
|
||||
<div className="section-title">
|
||||
<span>4</span>
|
||||
<h2>Routing lists</h2>
|
||||
</div>
|
||||
<div className="rules-actions">
|
||||
<button className="ghost-button" type="button" onClick={addRule}>Add list</button>
|
||||
<button className="ghost-button solid" type="button" disabled={busy || rulesSaveStatus === 'saving'} onClick={saveRulesNow}>
|
||||
{rulesSaveStatus === 'saving' ? 'Saving...' : rulesSaveStatus === 'pending' ? 'Save now' : rulesSaveStatus === 'error' ? 'Retry save' : 'Saved'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="rules-note">
|
||||
Эти правила автосохраняются после изменений и вставляются после safety private-direct и до стандартного RU-direct. Для игр в gateway-режиме указывай домены, suffix, CIDR или порты: процесс на клиентском ПК gateway не видит.
|
||||
</p>
|
||||
|
||||
<div className="rule-grid">
|
||||
{customRules.length === 0 && (
|
||||
<div className="empty rule-empty">
|
||||
Нет пользовательских списков. Добавь список, например `League direct`, и отправь его в `direct`.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customRules.map((rule) => (
|
||||
<article className="rule-card" key={rule.id}>
|
||||
<div className="rule-top">
|
||||
<input
|
||||
value={rule.name}
|
||||
onChange={(event) => updateRule(rule.id, { name: event.target.value })}
|
||||
placeholder="Название списка"
|
||||
/>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled}
|
||||
onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
|
||||
/>
|
||||
enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>Route to</span>
|
||||
<select value={rule.outbound} onChange={(event) => updateRule(rule.id, { outbound: event.target.value })}>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">vpn</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="rule-fields">
|
||||
<label className="field">
|
||||
<span>Domains exact</span>
|
||||
<textarea
|
||||
value={listToText(rule.domains)}
|
||||
onChange={(event) => updateRule(rule.id, { domains: textToList(event.target.value) })}
|
||||
placeholder="riotgames.com"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Domain suffixes</span>
|
||||
<textarea
|
||||
value={listToText(rule.domainSuffixes)}
|
||||
onChange={(event) => updateRule(rule.id, { domainSuffixes: textToList(event.target.value) })}
|
||||
placeholder={'leagueoflegends.com\nriotcdn.net'}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>IP CIDR</span>
|
||||
<textarea
|
||||
value={listToText(rule.ipCidrs)}
|
||||
onChange={(event) => updateRule(rule.id, { ipCidrs: textToList(event.target.value) })}
|
||||
placeholder="104.160.128.0/19"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Ports</span>
|
||||
<textarea
|
||||
value={listToText(rule.ports)}
|
||||
onChange={(event) => updateRule(rule.id, { ports: textToList(event.target.value) })}
|
||||
placeholder={'5000\n5223'}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rule-footer">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(rule.networks || []).includes('tcp')}
|
||||
onChange={(event) => {
|
||||
const set = new Set(rule.networks || []);
|
||||
event.target.checked ? set.add('tcp') : set.delete('tcp');
|
||||
updateRule(rule.id, { networks: Array.from(set) });
|
||||
}}
|
||||
/>
|
||||
tcp
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(rule.networks || []).includes('udp')}
|
||||
onChange={(event) => {
|
||||
const set = new Set(rule.networks || []);
|
||||
event.target.checked ? set.add('udp') : set.delete('udp');
|
||||
updateRule(rule.id, { networks: Array.from(set) });
|
||||
}}
|
||||
/>
|
||||
udp
|
||||
</label>
|
||||
<button className="danger-button" type="button" onClick={() => removeRule(rule.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
440
src/web/styles.css
Normal file
440
src/web/styles.css
Normal file
@@ -0,0 +1,440 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #07110d;
|
||||
--panel: rgba(12, 28, 22, 0.86);
|
||||
--panel-strong: rgba(18, 45, 34, 0.94);
|
||||
--line: rgba(129, 255, 188, 0.2);
|
||||
--text: #e8fff2;
|
||||
--muted: #91b8a2;
|
||||
--green: #7cffb2;
|
||||
--amber: #ffd166;
|
||||
--red: #ff6b6b;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(124, 255, 178, 0.16), transparent 32rem),
|
||||
radial-gradient(circle at 85% 0%, rgba(255, 209, 102, 0.1), transparent 26rem),
|
||||
linear-gradient(140deg, #06100c 0%, #0a1711 48%, #050806 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
padding: 32px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--green);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 780px;
|
||||
font-size: clamp(38px, 7vw, 76px);
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 740px;
|
||||
color: var(--muted);
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-card strong,
|
||||
.status-card small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status-card small {
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 18px rgba(255, 209, 102, 0.65);
|
||||
}
|
||||
|
||||
.dot.on {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 18px rgba(124, 255, 178, 0.75);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.primary-flow,
|
||||
.details {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-title.compact {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.section-title span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
color: #07110d;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(1, 8, 5, 0.72);
|
||||
color: var(--text);
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 4px rgba(124, 255, 178, 0.1);
|
||||
}
|
||||
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(1, 8, 5, 0.72);
|
||||
color: var(--text);
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 4px rgba(124, 255, 178, 0.1);
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
background: var(--green);
|
||||
color: #07110d;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.button.apply {
|
||||
background: linear-gradient(135deg, var(--green), #d8ff78);
|
||||
}
|
||||
|
||||
.server-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server,
|
||||
.empty {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(1, 8, 5, 0.48);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.server {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server.active {
|
||||
border-color: var(--green);
|
||||
background: rgba(124, 255, 178, 0.12);
|
||||
}
|
||||
|
||||
.server strong,
|
||||
.server small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.server small {
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 14px;
|
||||
color: var(--red);
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid rgba(255, 107, 107, 0.24);
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.route-card,
|
||||
.logs {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(1, 8, 5, 0.36);
|
||||
}
|
||||
|
||||
.route-card span {
|
||||
color: var(--green);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.route-card p,
|
||||
.logs p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.logs span {
|
||||
color: var(--green);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rules-panel {
|
||||
margin-top: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.rules-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rules-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.danger-button {
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
background: rgba(1, 8, 5, 0.45);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button.solid {
|
||||
background: var(--green);
|
||||
color: #07110d;
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
border-color: rgba(255, 107, 107, 0.35);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.rules-note {
|
||||
margin: -6px 0 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rule-empty {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: rgba(1, 8, 5, 0.36);
|
||||
}
|
||||
|
||||
.rule-top,
|
||||
.rule-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-top input {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.rule-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero,
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rules-header,
|
||||
.rule-top,
|
||||
.rule-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rules-actions,
|
||||
.rule-fields {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user