feat: initial commit
This commit is contained in:
+276
@@ -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');
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user