feat: initial commit

This commit is contained in:
2026-08-02 13:47:00 +07:00
commit 32d2553380
9 changed files with 3078 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
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');
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Родительский контроль</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
[v-cloak] { display: none; }
body { margin:0; padding:16px; background:#14161a; color:#e6e8eb;
font:14px/1.5 system-ui, "Segoe UI", sans-serif; }
h1 { font-size:18px; margin:0 0 16px; }
h2 { font-size:14px; margin:0 0 10px; color:#9aa4b2; font-weight:600; }
.card { background:#1c1f26; border:1px solid #2a2f3a; border-radius:10px;
padding:14px; margin-bottom:14px; }
button { background:#2a2f3a; color:#e6e8eb; border:1px solid #3a4150;
border-radius:6px; padding:4px 10px; cursor:pointer; font-size:13px; }
button:hover { background:#343a47; }
button.danger { border-color:#6b2b2b; color:#ff9b9b; }
button.danger:hover { background:#3a1f1f; }
input, select { background:#14161a; color:#e6e8eb; border:1px solid #3a4150;
border-radius:6px; padding:5px 8px; font-size:13px; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; color:#9aa4b2; font-weight:500; padding:4px 6px;
border-bottom:1px solid #2a2f3a; font-size:12px; }
td { padding:4px 6px; border-bottom:1px solid #22262f; }
tr.blocked td:first-child { color:#ff9b9b; }
.scroll { max-height:340px; overflow:auto; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.chips { margin-top:12px; }
.chip { display:inline-flex; align-items:center; gap:4px; background:#232833;
border:1px solid #3a4150; border-radius:20px; padding:3px 6px 3px 12px;
margin:6px 6px 0 0; font-size:13px; }
.chip button { border:0; background:none; color:#9aa4b2; padding:0 4px; }
.right { text-align:right; white-space:nowrap; }
.muted { color:#6b7484; }
.warn { color:#e0b872; }
pre { margin:0; font:12px/1.6 ui-monospace, Consolas, monospace; color:#9aa4b2;
white-space:pre-wrap; max-height:200px; overflow:auto; }
</style>
</head>
<body>
<div id="app" v-cloak></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/app.js"></script>
</body>
</html>