276 lines
10 KiB
JavaScript
276 lines
10 KiB
JavaScript
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.ok) throw new Error(`${path}: ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
/* ── composables ───────────────────────────────────────────── */
|
||
|
||
function useRules() {
|
||
const rules = ref({ mode: 'blacklist', blocked: [], allowed: [], enforce: true });
|
||
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 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 busy = computed(() => ['checking', 'downloading', 'installing'].includes(props.info.phase));
|
||
const status = computed(() => {
|
||
const i = props.info;
|
||
const phases = { checking: 'проверка…', downloading: 'загрузка…', installing: 'установка…' };
|
||
if (phases[i.phase]) return phases[i.phase];
|
||
if (i.error) return 'не удалось проверить обновления';
|
||
if (i.available && i.latest && i.latest !== i.current) return 'доступна версия ' + i.latest;
|
||
return 'установлена последняя версия';
|
||
});
|
||
return { busy, status };
|
||
},
|
||
template: `
|
||
<section class="card">
|
||
<div class="row" style="justify-content:space-between">
|
||
<div>
|
||
<h2 style="margin:0">Версия {{ info.current || '—' }}</h2>
|
||
<span :class="info.error ? 'warn' : 'muted'">{{ status }}</span>
|
||
</div>
|
||
<button :disabled="busy" @click="$emit('check')">Проверить обновления</button>
|
||
</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 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, 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();
|
||
|
||
onMounted(loadRules);
|
||
usePolling(() => { loadProcesses(); loadLog(); loadUpdate(); }, 3000);
|
||
|
||
// в 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');
|
||
|
||
return { rules, groups, lines, update, whitelist, save, remove, toggle, kill, scheduleRandom, checkUpdate };
|
||
},
|
||
template: `
|
||
<h1>Родительский контроль</h1>
|
||
<update-bar :info="update" @check="checkUpdate" />
|
||
<rules-panel :rules="rules" @update="save" @remove="remove" />
|
||
<random-kill @schedule="scheduleRandom" />
|
||
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
|
||
<event-log :lines="lines" />`,
|
||
};
|
||
|
||
createApp(App).mount('#app'); |