Files
syshelper/static/app.js
T
robonen 0223dd817b
CI / Windows x64 (push) Successful in 9m31s
feat: add custom dns resolver
2026-08-09 15:47:38 +07:00

419 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
async function api(path, body) {
const init = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {};
const res = await fetch(path, init);
// сессия истекла — иначе открытая вкладка молча перестала бы обновляться
if (res.status === 401) {
location.href = '/login';
throw new Error('нужен вход');
}
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json();
}
/* ── composables ───────────────────────────────────────────── */
function useRules() {
// форма объекта должна совпадать с серверной с самого начала: `save` шлёт
// правила целиком, и отсутствующее здесь поле сервер прочитал бы как пустое
const rules = ref({
mode: 'blacklist', blocked: [], allowed: [], enforce: true,
blocked_sites: [], sites_enforce: false, dns_lockdown: false,
});
const load = async () => { rules.value = await api('/rules'); };
const block = (name) => api('/rules/block', { name }).then(load);
const allow = (name) => api('/rules/allow', { name }).then(load);
const remove = (name) => api('/rules/remove', { name }).then(load);
// patch мержим в текущее состояние: сервер принимает объект правил целиком
const save = (patch) => api('/rules', { ...rules.value, ...patch }).then(load);
return { rules, load, block, allow, remove, save };
}
function useProcesses() {
const raw = ref([]);
const load = async () => { raw.value = await api('/processes'); };
const kill = (name) => api('/kill', { name }).then(load);
// одноимённые процессы схлопываем — иначе двадцать строк chrome.exe
const groups = computed(() => {
const map = new Map();
for (const p of raw.value) {
const key = p.name.toLowerCase();
if (!map.has(key)) map.set(key, { key, name: p.name, count: 0, mem: 0, blocked: p.blocked });
const g = map.get(key);
g.count += 1;
g.mem += p.memory_mb;
}
return [...map.values()].sort((a, b) => b.mem - a.mem);
});
return { groups, load, kill };
}
function useLog() {
const lines = ref([]);
const load = async () => { lines.value = await api('/log'); };
return { lines, load };
}
function useSites() {
const dns = ref({
listening: false, upstream: '', queries: 0, blocked: 0,
cache_hits: 0, timeouts: 0, error: null, attempts: [],
});
const load = async () => { dns.value = await api('/dns'); };
// сам список закрытых сайтов живёт в правилах, здесь — только состояние
// резолвера и попытки; поэтому после правки перечитываем и то, и другое
const block = (name) => api('/sites/block', { name });
const remove = (name) => api('/sites/remove', { name });
return { dns, load, block, remove };
}
function useUpdate() {
const info = ref({ current: '', latest: null, available: false, phase: 'idle', checked_at: null, error: null });
const load = async () => { info.value = await api('/update'); };
// после ручной проверки состояние меняется не мгновенно — перечитываем чуть позже
const check = () => api('/update/check', {}).then(() => setTimeout(load, 1500));
return { info, load, check };
}
function usePolling(fn, ms) {
let id = null;
onMounted(() => { fn(); id = setInterval(fn, ms); });
onUnmounted(() => clearInterval(id));
}
/* ── компоненты ────────────────────────────────────────────── */
const NameChips = {
props: {
title: { type: String, required: true },
names: { type: Array, default: () => [] },
},
emits: ['remove'],
setup(props) {
return { sorted: computed(() => [...props.names].sort()) };
},
template: `
<div class="chips">
<div class="muted">{{ title }}</div>
<span v-if="!sorted.length" class="muted">пусто</span>
<span v-for="name in sorted" :key="name" class="chip">
{{ name }}
<button title="убрать из списка" @click="$emit('remove', name)">×</button>
</span>
</div>`,
};
const UpdateBar = {
props: { info: { type: Object, required: true } },
emits: ['check'],
setup(props) {
const status = computed(() => {
const i = props.info;
if (i.phase === 'checking') return 'проверка…';
if (i.phase === 'installing') return `устанавливается ${i.latest}, панель перезапустится`;
if (i.error) return i.error;
if (i.available) return `доступна версия ${i.latest}`;
return 'установлена последняя версия';
});
return {
status,
checking: computed(() => props.info.phase === 'checking'),
bad: computed(() => Boolean(props.info.error)),
};
},
template: `
<section class="card">
<div class="row" style="justify-content:space-between">
<div>
<h2 style="margin:0">Версия {{ info.current || '—' }}</h2>
<span :class="bad ? 'warn' : 'muted'">{{ status }}</span>
</div>
<button :disabled="checking" @click="$emit('check')">Проверить обновления</button>
</div>
<div v-if="info.installer" class="muted" style="margin-top:8px">
Установщик, {{ info.installer.time }}:
<span :class="{ warn: !info.installer.ok }">{{ info.installer.message }}</span>
</div>
</section>`,
};
const RulesPanel = {
components: { NameChips },
props: { rules: { type: Object, required: true } },
emits: ['update', 'remove'],
template: `
<section class="card">
<h2>Правила</h2>
<div class="row">
<label>Режим:
<select :value="rules.mode" @change="$emit('update', { mode: $event.target.value })">
<option value="blacklist">чёрный список</option>
<option value="whitelist">белый список</option>
</select>
</label>
<label>
<input type="checkbox" :checked="rules.enforce"
@change="$emit('update', { enforce: $event.target.checked })">
применять правила
</label>
</div>
<p v-if="rules.mode === 'whitelist' && !rules.allowed.length" class="warn">
Белый список пуст — сервер намеренно ничего не завершает, иначе снесло бы систему.
</p>
<name-chips title="Заблокированы" :names="rules.blocked" @remove="$emit('remove', $event)" />
<name-chips title="Разрешены" :names="rules.allowed" @remove="$emit('remove', $event)" />
</section>`,
};
const SitesPanel = {
components: { NameChips },
props: {
sites: { type: Array, default: () => [] },
dns: { type: Object, required: true },
// enabled — сама галочка, active — работает ли блокировка на деле:
// общий выключатель «применять правила» гасит и её
enabled: { type: Boolean, default: false },
active: { type: Boolean, default: false },
lockdown: { type: Boolean, default: false },
},
emits: ['block', 'remove', 'enable', 'lockdown'],
setup(props, { emit }) {
const input = ref('');
const submit = () => {
const value = input.value.trim();
if (!value) return;
emit('block', value);
input.value = '';
};
const status = computed(() => {
const d = props.dns;
if (props.enabled && !props.active) return 'выключено общей галочкой «применять правила»';
if (!props.active) return 'выключено — служба имена не разрешает';
if (d.error) return d.error;
if (!d.listening) return 'резолвер запускается…';
return `спрашиваю ${d.upstream} · запросов ${d.queries}, ` +
`отклонено ${d.blocked}, из кэша ${d.cache_hits}` +
(d.timeouts ? `, без ответа ${d.timeouts}` : '');
});
const ago = (s) => (s < 60 ? `${s} с назад` : `${Math.floor(s / 60)} мин назад`);
return {
input, submit, status, ago,
// тревожно только то, что должно работать, но не работает
bad: computed(() => props.active && (props.dns.error || !props.dns.listening)),
idle: computed(() => !props.active && props.sites.length > 0),
};
},
template: `
<section class="card">
<div class="row" style="justify-content:space-between">
<h2 style="margin:0">Сайты</h2>
<label class="row">
<input type="checkbox" :checked="enabled"
@change="$emit('enable', $event.target.checked)">
блокировать сайты
</label>
</div>
<div :class="bad ? 'warn' : 'muted'" style="margin:8px 0 10px">{{ status }}</div>
<p v-if="idle" class="warn" style="margin:0 0 10px">
Список составлен, но блокировка выключена — сайты открываются.
</p>
<div class="row">
<input v-model="input" placeholder="discord.com" style="flex:1;min-width:140px"
@keyup.enter="submit">
<button @click="submit">Закрыть</button>
</div>
<p class="muted" style="margin:8px 0 0">
Запись закрывает и поддомены: <code>discord.com</code> закроет и
<code>gateway.discord.com</code>.
</p>
<name-chips title="Закрытые сайты" :names="sites" @remove="$emit('remove', $event)" />
<label class="row" style="margin-top:12px">
<input type="checkbox" :checked="lockdown" :disabled="!active"
@change="$emit('lockdown', $event.target.checked)">
<span :class="{ muted: !active }">запретить обход через чужие серверы имён</span>
</label>
<p v-if="lockdown && active" class="warn" style="margin:6px 0 0">
Правило брандмауэра закрывает 53-й порт всем, кроме службы. Если интернет
пропадёт — снимите галочку.
</p>
<div v-if="dns.attempts.length" class="scroll" style="margin-top:12px;max-height:180px">
<table>
<thead><tr><th>Куда ломились</th><th>Раз</th><th>Когда</th></tr></thead>
<tbody>
<tr v-for="a in dns.attempts" :key="a.name">
<td>{{ a.name }}</td>
<td>{{ a.count }}</td>
<td class="muted">{{ ago(a.ago_secs) }}</td>
</tr>
</tbody>
</table>
</div>
</section>`,
};
const RandomKill = {
emits: ['schedule'],
setup(_, { emit }) {
const name = ref('');
const min = ref(5);
const max = ref(30);
const submit = () => {
const value = name.value.trim();
if (!value) return;
emit('schedule', {
name: value,
min_secs: Math.max(0, min.value) * 60,
max_secs: Math.max(1, max.value) * 60,
});
name.value = '';
};
return { name, min, max, submit };
},
template: `
<section class="card">
<h2>Случайное завершение</h2>
<div class="row">
<input v-model="name" placeholder="game.exe" style="flex:1;min-width:140px"
@keyup.enter="submit">
<input v-model.number="min" type="number" min="0" style="width:70px"> —
<input v-model.number="max" type="number" min="1" style="width:70px">
<span class="muted">мин</span>
<button @click="submit">Запланировать</button>
</div>
</section>`,
};
const ProcessTable = {
props: {
groups: { type: Array, default: () => [] },
whitelist: { type: Boolean, default: false },
},
emits: ['toggle', 'kill'],
setup(props) {
const filter = ref('');
const visible = computed(() => {
const q = filter.value.trim().toLowerCase();
return q ? props.groups.filter((g) => g.key.includes(q)) : props.groups;
});
return { filter, visible };
},
template: `
<section class="card">
<h2>Процессы</h2>
<div class="row" style="margin-bottom:10px">
<input v-model="filter" placeholder="фильтр по имени" style="flex:1">
<span class="muted">{{ visible.length }} из {{ groups.length }}</span>
</div>
<div class="scroll">
<table>
<thead>
<tr><th>Имя</th><th>Экз.</th><th>Память</th><th></th></tr>
</thead>
<tbody>
<tr v-for="g in visible" :key="g.key" :class="{ blocked: g.blocked }">
<td>{{ g.name }}</td>
<td>{{ g.count }}</td>
<td>{{ g.mem }} МБ</td>
<td class="right">
<button @click="$emit('toggle', g)">
{{ g.blocked ? (whitelist ? 'Внести в белый список' : 'Разрешить') : 'Блокировать' }}
</button>
<button class="danger" style="margin-left:6px" @click="$emit('kill', g.name)">
Убить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>`,
};
const EventLog = {
props: { lines: { type: Array, default: () => [] } },
setup(props) {
return { recent: computed(() => props.lines.slice(-40).reverse().join('\n')) };
},
template: `
<section class="card">
<h2>Журнал</h2>
<pre>{{ recent || 'пусто' }}</pre>
</section>`,
};
/* ── корневой компонент ────────────────────────────────────── */
const App = {
components: { UpdateBar, RulesPanel, SitesPanel, RandomKill, ProcessTable, EventLog },
setup() {
const { rules, load: loadRules, block, allow, remove, save } = useRules();
const { groups, load: loadProcesses, kill } = useProcesses();
const { lines, load: loadLog } = useLog();
const { info: update, load: loadUpdate, check: checkUpdate } = useUpdate();
const { dns, load: loadDns, block: blockSite, remove: removeSite } = useSites();
onMounted(loadRules);
usePolling(() => { loadProcesses(); loadLog(); loadDns(); }, 3000);
// версия меняется раз в сутки в лучшем случае — частить незачем
usePolling(loadUpdate, 30000);
// в whitelist «заблокирован» означает отсутствие в allowed — действия зеркальные
const toggle = async (g) => {
if (rules.value.mode === 'whitelist') {
await (g.blocked ? allow(g.name) : remove(g.name));
} else {
await (g.blocked ? remove(g.name) : block(g.name));
}
loadProcesses();
};
const scheduleRandom = async (payload) => {
await api('/kill/random', payload);
loadLog();
};
const whitelist = computed(() => rules.value.mode === 'whitelist');
// правка списка сайтов меняет и правила, и показания резолвера
const onSite = async (action, name) => {
await action(name);
await Promise.all([loadRules(), loadDns()]);
};
return {
rules, groups, lines, update, dns, whitelist,
save, remove, toggle, kill, scheduleRandom, checkUpdate,
blockSite: (name) => onSite(blockSite, name),
removeSite: (name) => onSite(removeSite, name),
};
},
template: `
<div class="row" style="justify-content:space-between">
<h1>Родительский контроль</h1>
<form method="post" action="/logout"><button>Выйти</button></form>
</div>
<update-bar :info="update" @check="checkUpdate" />
<rules-panel :rules="rules" @update="save" @remove="remove" />
<sites-panel :sites="rules.blocked_sites" :dns="dns"
:enabled="rules.sites_enforce"
:active="rules.enforce && rules.sites_enforce"
:lockdown="rules.dns_lockdown"
@block="blockSite" @remove="removeSite"
@enable="save({ sites_enforce: $event })"
@lockdown="save({ dns_lockdown: $event })" />
<random-kill @schedule="scheduleRandom" />
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
<event-log :lines="lines" />`,
};
createApp(App).mount('#app');