Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9550aad4f8 | |||
| 2b8e0cb701 |
@@ -3,6 +3,31 @@
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
versioning follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.4.0] — 2026-08-09
|
||||
|
||||
"You can now see what the resolver is actually being asked."
|
||||
|
||||
### Added
|
||||
|
||||
- A live view of the last 200 queries the resolver handled, each marked as
|
||||
blocked, served from cache, or sent upstream, with a filter by name.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The system DNS cache is no longer flushed on every periodic re-apply. Adapters
|
||||
that already point at the resolver are left untouched, and the cache is only
|
||||
cleared when something actually changed — previously the whole machine's cache
|
||||
was discarded every five minutes for nothing.
|
||||
|
||||
### Notes
|
||||
|
||||
- The view is held in memory only. It is never written to the log file and is
|
||||
discarded when blocking is switched off, because it records every name looked
|
||||
up on the machine and not only the blocked ones — it exists to answer "what
|
||||
just happened", not to accumulate a browsing history.
|
||||
- It costs a fixed 56 KB of memory and roughly 8 ns per query, so it is on by
|
||||
default with nothing to configure.
|
||||
|
||||
## [0.3.0] — 2026-08-09
|
||||
|
||||
"It now closes websites, not just programs."
|
||||
@@ -91,6 +116,7 @@ browser."
|
||||
- Runs as a Windows SYSTEM service and requires 64-bit Windows.
|
||||
- Configuration is stored in `C:\ProgramData\SysHelper`.
|
||||
|
||||
[0.4.0]: https://git.robonen.ru/robonen/syshelper/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.robonen.ru/robonen/syshelper/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://git.robonen.ru/robonen/syshelper/compare/v0.1.1...v0.2.0
|
||||
[0.1.1]: https://git.robonen.ru/robonen/syshelper/compare/v0.1.0...v0.1.1
|
||||
|
||||
Generated
+1
-1
@@ -1224,7 +1224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syshelper"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "syshelper"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+139
@@ -89,6 +89,10 @@ const REPORT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const FAILOVER_AFTER: u64 = 5;
|
||||
/// Сколько доменов помнить для панели.
|
||||
const STATS_CAP: usize = 256;
|
||||
/// Сколько последних запросов держать для панели. Двести — это примерно
|
||||
/// полминуты жизни машины под нагрузкой и несколько часов простоя, то есть
|
||||
/// ровно то окно, в котором ещё имеет смысл спрашивать «а что сейчас было».
|
||||
const RECENT_CAP: usize = 200;
|
||||
/// Потолок одновременных TCP-запросов. По TCP ходят единицы клиентов, но
|
||||
/// принимать их без счёта — способ остаться без дескрипторов.
|
||||
const MAX_TCP: usize = 32;
|
||||
@@ -419,6 +423,112 @@ impl Attempts {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── лента последних запросов ──────────────────────────────── */
|
||||
|
||||
/// Что резолвер сделал с запросом.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Verdict {
|
||||
/// Отсечён по списку, наверх не пошёл.
|
||||
Blocked,
|
||||
/// Ответ взят из своего кэша.
|
||||
Cached,
|
||||
/// Ушёл вышестоящему серверу.
|
||||
Upstream,
|
||||
}
|
||||
|
||||
/// Одна запись ленты.
|
||||
///
|
||||
/// Имя лежит прямо в структуре массивом, а не отдельной строкой в куче. Кольцо
|
||||
/// заводится один раз при запуске, и запись в него не трогает распределитель
|
||||
/// памяти — иначе на каждый запрос в системе приходилось бы выделение и
|
||||
/// освобождение, а горячий путь резолвера построен ровно на том, чтобы их не
|
||||
/// было. Ценой идут постоянные 56 КБ, что на фоне кэша ответов незаметно.
|
||||
struct Seen {
|
||||
name: [u8; MAX_NAME],
|
||||
len: u8,
|
||||
qtype: u16,
|
||||
verdict: Verdict,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RecentQuery {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub verdict: Verdict,
|
||||
pub ago_secs: u64,
|
||||
}
|
||||
|
||||
/// Кольцо: доходит до предела и начинает затирать самое старое.
|
||||
///
|
||||
/// Живёт только в памяти и умирает вместе со службой. На диск не попадает
|
||||
/// намеренно — это перечень всего, что человек открывал, и хранить такое
|
||||
/// дольше, чем нужно для ответа на вопрос «что сейчас произошло», незачем.
|
||||
#[derive(Default)]
|
||||
struct Recent {
|
||||
items: Vec<Seen>,
|
||||
/// Куда писать следующую запись; она же — самая старая, когда кольцо полно.
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl Recent {
|
||||
fn record(&mut self, name: &str, qtype: u16, verdict: Verdict, at: Instant) {
|
||||
if self.items.len() < RECENT_CAP {
|
||||
self.items.push(Seen {
|
||||
name: [0; MAX_NAME],
|
||||
len: 0,
|
||||
qtype: 0,
|
||||
verdict: Verdict::Upstream,
|
||||
at,
|
||||
});
|
||||
}
|
||||
// Пишем на месте, а не собираем запись целиком и не двигаем её: имя
|
||||
// копируется ровно на свою длину, а не на все 255 байт массива.
|
||||
let slot = &mut self.items[self.next];
|
||||
let len = name.len().min(MAX_NAME);
|
||||
slot.name[..len].copy_from_slice(&name.as_bytes()[..len]);
|
||||
slot.len = len as u8;
|
||||
slot.qtype = qtype;
|
||||
slot.verdict = verdict;
|
||||
slot.at = at;
|
||||
|
||||
self.next = (self.next + 1) % RECENT_CAP;
|
||||
}
|
||||
|
||||
/// Отдаёт ленту от свежего к старому — в том порядке, в каком её читают.
|
||||
fn snapshot(&self, now: Instant) -> Vec<RecentQuery> {
|
||||
let n = self.items.len();
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
let slot = &self.items[(self.next + n - 1 - k) % n];
|
||||
RecentQuery {
|
||||
name: String::from_utf8_lossy(&slot.name[..slot.len as usize]).into_owned(),
|
||||
kind: type_name(slot.qtype),
|
||||
verdict: slot.verdict,
|
||||
ago_secs: now.saturating_duration_since(slot.at).as_secs(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Название типа записи для панели. Считается только на запрос из панели, на
|
||||
/// горячем пути хранится сырое число.
|
||||
fn type_name(qtype: u16) -> String {
|
||||
match qtype {
|
||||
TYPE_A => "A".to_string(),
|
||||
TYPE_AAAA => "AAAA".to_string(),
|
||||
5 => "CNAME".to_string(),
|
||||
12 => "PTR".to_string(),
|
||||
15 => "MX".to_string(),
|
||||
16 => "TXT".to_string(),
|
||||
33 => "SRV".to_string(),
|
||||
65 => "HTTPS".to_string(),
|
||||
other => format!("#{other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Счётчики для панели. Обновляются на горячем пути, поэтому атомарные:
|
||||
/// блокировка ради статистики — худшая из возможных причин ждать.
|
||||
#[derive(Default)]
|
||||
@@ -441,6 +551,7 @@ pub struct DnsStatus {
|
||||
pub timeouts: u64,
|
||||
pub error: Option<String>,
|
||||
pub attempts: Vec<BlockedDomain>,
|
||||
pub recent: Vec<RecentQuery>,
|
||||
}
|
||||
|
||||
/* ── общее состояние ───────────────────────────────────────── */
|
||||
@@ -450,6 +561,7 @@ pub struct Dns {
|
||||
blocklist: watch::Sender<Arc<Blocklist>>,
|
||||
counters: Arc<Counters>,
|
||||
attempts: Arc<Mutex<Attempts>>,
|
||||
recent: Arc<Mutex<Recent>>,
|
||||
/// Строка с адресом вышестоящего сервера либо причина, почему резолвер не
|
||||
/// работает. Панель должна показывать это раньше, чем родитель заметит, что
|
||||
/// блокировка молчит.
|
||||
@@ -463,6 +575,7 @@ impl Dns {
|
||||
blocklist,
|
||||
counters: Arc::default(),
|
||||
attempts: Arc::default(),
|
||||
recent: Arc::default(),
|
||||
state: Mutex::new((false, String::new(), None)),
|
||||
}
|
||||
}
|
||||
@@ -486,6 +599,11 @@ impl Dns {
|
||||
if let Ok(mut s) = self.state.lock() {
|
||||
*s = (false, String::new(), None);
|
||||
}
|
||||
// Лента уходит вместе с резолвером: выключенная блокировка не должна
|
||||
// оставлять после себя перечень того, что человек открывал.
|
||||
if let Ok(mut recent) = self.recent.lock() {
|
||||
*recent = Recent::default();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> DnsStatus {
|
||||
@@ -512,6 +630,12 @@ impl Dns {
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or((false, String::new(), None));
|
||||
|
||||
let recent = self
|
||||
.recent
|
||||
.lock()
|
||||
.map(|r| r.snapshot(now))
|
||||
.unwrap_or_default();
|
||||
|
||||
DnsStatus {
|
||||
listening,
|
||||
upstream,
|
||||
@@ -521,6 +645,7 @@ impl Dns {
|
||||
timeouts: self.counters.timeouts.load(Ordering::Relaxed),
|
||||
error,
|
||||
attempts,
|
||||
recent,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,6 +675,7 @@ struct Resolver {
|
||||
blocklist: watch::Receiver<Arc<Blocklist>>,
|
||||
counters: Arc<Counters>,
|
||||
attempts: Arc<Mutex<Attempts>>,
|
||||
recent: Arc<Mutex<Recent>>,
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
@@ -557,6 +683,14 @@ impl Resolver {
|
||||
self.upstreams[self.active.load(Ordering::Relaxed) % self.upstreams.len()]
|
||||
}
|
||||
|
||||
/// Заносит запрос в ленту последних. Кольцо заведено заранее, поэтому
|
||||
/// вызов не выделяет памяти и держит мьютекс считаные наносекунды.
|
||||
fn note(&self, name: &str, qtype: u16, verdict: Verdict, now: Instant) {
|
||||
if let Ok(mut recent) = self.recent.lock() {
|
||||
recent.record(name, qtype, verdict, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Решает судьбу запроса. Возвращает готовый ответ, если отвечаем сами.
|
||||
///
|
||||
/// Здесь не должно появиться ни одного `await`: пока задача разбирает
|
||||
@@ -579,6 +713,7 @@ impl Resolver {
|
||||
attempts.record(name, now);
|
||||
}
|
||||
self.counters.blocked.fetch_add(1, Ordering::Relaxed);
|
||||
self.note(name, q.qtype, Verdict::Blocked, now);
|
||||
return match write_blocked(pkt, &q) {
|
||||
Some(n) => Decision::Answer(n),
|
||||
None => Decision::Drop,
|
||||
@@ -606,9 +741,12 @@ impl Resolver {
|
||||
if let Some(n) = cache.get(key, packet_id(pkt), pkt, now) {
|
||||
pkt[HEADER..q.end].copy_from_slice(&asked[..asked_len]);
|
||||
self.counters.cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||
drop(cache); // мьютекс кэша не держим на время записи в ленту
|
||||
self.note(name, q.qtype, Verdict::Cached, now);
|
||||
return Decision::Answer(n);
|
||||
}
|
||||
}
|
||||
self.note(name, q.qtype, Verdict::Upstream, now);
|
||||
Decision::Forward(Some(key.into()))
|
||||
}
|
||||
}
|
||||
@@ -965,6 +1103,7 @@ pub async fn run(state: Shared, upstreams: Vec<SocketAddr>, shutdown: watch::Rec
|
||||
blocklist: state.dns.blocklist.subscribe(),
|
||||
counters: Arc::clone(&state.dns.counters),
|
||||
attempts: Arc::clone(&state.dns.attempts),
|
||||
recent: Arc::clone(&state.dns.recent),
|
||||
upstreams,
|
||||
});
|
||||
|
||||
|
||||
+86
-1
@@ -282,6 +282,80 @@ fn a_list_set_before_the_resolver_starts_is_not_lost() {
|
||||
assert!(rx.borrow().matches("gateway.discord.com"));
|
||||
}
|
||||
|
||||
/* ── лента последних запросов ──────────────────────────────── */
|
||||
|
||||
#[test]
|
||||
fn the_recent_ring_reports_newest_first() {
|
||||
let mut ring = Recent::default();
|
||||
let now = Instant::now();
|
||||
for name in ["one.example", "two.example", "three.example"] {
|
||||
ring.record(name, TYPE_A, Verdict::Upstream, now);
|
||||
}
|
||||
|
||||
let seen: Vec<String> = ring.snapshot(now).into_iter().map(|q| q.name).collect();
|
||||
assert_eq!(seen, ["three.example", "two.example", "one.example"]);
|
||||
}
|
||||
|
||||
/// Кольцо обязано затирать самое старое, а не расти: иначе запись о каждом
|
||||
/// запросе в системе съедала бы память тем быстрее, чем активнее машина.
|
||||
#[test]
|
||||
fn the_recent_ring_overwrites_the_oldest() {
|
||||
let mut ring = Recent::default();
|
||||
let now = Instant::now();
|
||||
for i in 0..RECENT_CAP + 50 {
|
||||
ring.record(&format!("n{i}.example"), TYPE_A, Verdict::Upstream, now);
|
||||
}
|
||||
|
||||
let seen = ring.snapshot(now);
|
||||
assert_eq!(seen.len(), RECENT_CAP, "длина упёрлась в предел");
|
||||
assert_eq!(seen[0].name, format!("n{}.example", RECENT_CAP + 49));
|
||||
assert_eq!(seen[RECENT_CAP - 1].name, format!("n{}.example", 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recent_ring_keeps_the_record_type() {
|
||||
let mut ring = Recent::default();
|
||||
let now = Instant::now();
|
||||
ring.record("a.example", TYPE_AAAA, Verdict::Blocked, now);
|
||||
ring.record("b.example", 65, Verdict::Cached, now);
|
||||
|
||||
let seen = ring.snapshot(now);
|
||||
assert_eq!(seen[0].kind, "HTTPS");
|
||||
assert_eq!(seen[1].kind, "AAAA");
|
||||
}
|
||||
|
||||
/// Имя длиннее массива обязано обрезаться, а не сломать запись.
|
||||
#[test]
|
||||
fn an_overlong_name_does_not_break_the_ring() {
|
||||
let mut ring = Recent::default();
|
||||
let now = Instant::now();
|
||||
let long = "x".repeat(MAX_NAME + 20);
|
||||
ring.record(&long, TYPE_A, Verdict::Upstream, now);
|
||||
|
||||
assert_eq!(ring.snapshot(now)[0].name.len(), MAX_NAME);
|
||||
}
|
||||
|
||||
/// Все три исхода обязаны попадать в ленту разными пометками — иначе по ней
|
||||
/// нельзя отличить закрытое имя от разрешённого.
|
||||
#[tokio::test]
|
||||
async fn every_verdict_reaches_the_recent_ring() {
|
||||
let hits = Arc::new(AtomicU64::new(0));
|
||||
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
|
||||
let (addr, _stop, ring) = spawn_resolver_watched(upstream, &["discord.com"]).await;
|
||||
|
||||
let _ = ask(addr, "discord.com", 1).await;
|
||||
let _ = ask(addr, "example.com", 2).await; // уходит наверх
|
||||
let _ = ask(addr, "example.com", 3).await; // и возвращается из кэша
|
||||
|
||||
let seen = ring.lock().unwrap().snapshot(Instant::now());
|
||||
assert_eq!(seen.len(), 3);
|
||||
assert_eq!(seen[0].name, "example.com");
|
||||
assert_eq!(seen[0].verdict, Verdict::Cached, "самый свежий — из кэша");
|
||||
assert_eq!(seen[1].verdict, Verdict::Upstream);
|
||||
assert_eq!(seen[2].name, "discord.com");
|
||||
assert_eq!(seen[2].verdict, Verdict::Blocked, "самый старый — закрытый");
|
||||
}
|
||||
|
||||
/* ── сквозная проверка ─────────────────────────────────────── */
|
||||
|
||||
/// Адрес, которым отвечает поддельный сервер: по нему видно, что ответ пришёл
|
||||
@@ -328,19 +402,30 @@ async fn build_resolver(upstream: SocketAddr, blocked: &[&str]) -> Arc<Resolver>
|
||||
blocklist: rx,
|
||||
counters: Arc::default(),
|
||||
attempts: Arc::default(),
|
||||
recent: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Поднимает резолвер на случайном порту и возвращает его адрес.
|
||||
async fn spawn_resolver(upstream: SocketAddr, blocked: &[&str]) -> (SocketAddr, watch::Sender<bool>) {
|
||||
let (addr, stop, _) = spawn_resolver_watched(upstream, blocked).await;
|
||||
(addr, stop)
|
||||
}
|
||||
|
||||
/// То же, но отдаёт ещё и ленту последних запросов — она общая с резолвером.
|
||||
async fn spawn_resolver_watched(
|
||||
upstream: SocketAddr,
|
||||
blocked: &[&str],
|
||||
) -> (SocketAddr, watch::Sender<bool>, Arc<Mutex<Recent>>) {
|
||||
let client = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
|
||||
let addr = client.local_addr().unwrap();
|
||||
let resolver = build_resolver(upstream, blocked).await;
|
||||
let ring = Arc::clone(&resolver.recent);
|
||||
|
||||
let (stop_tx, stop_rx) = watch::channel(false);
|
||||
tokio::spawn(client_loop(Arc::clone(&resolver), client, stop_rx.clone()));
|
||||
tokio::spawn(upstream_loop(resolver, stop_rx));
|
||||
(addr, stop_tx)
|
||||
(addr, stop_tx, ring)
|
||||
}
|
||||
|
||||
/// Спрашивает резолвер и возвращает ответ целиком.
|
||||
|
||||
+29
-5
@@ -179,6 +179,15 @@ fn parse_addrs(text: &str) -> Vec<IpAddr> {
|
||||
/// достаточно проставить его один раз и обновлять изредка.
|
||||
#[cfg(windows)]
|
||||
pub fn redirect_to_local() -> Result<(), String> {
|
||||
// Адаптер, уже смотрящий на нас, не трогаем вовсе, а кэш имён сбрасываем,
|
||||
// только если что-то действительно изменилось. Вызов приходит и по таймеру,
|
||||
// раз в пять минут, и безусловный сброс выбрасывал бы кэш всей системы на
|
||||
// ровном месте — при том, что менять там обычно нечего.
|
||||
//
|
||||
// Сравниваем склейкой в строку, а не массивом: у пустого списка серверов
|
||||
// сравнение массива с адресом даёт «равно», и адаптер без настройки молча
|
||||
// остался бы ненастроенным.
|
||||
//
|
||||
// Счётчик обязателен. Отдельная попытка на адаптер завёрнута в `try`, иначе
|
||||
// одна отключённая виртуальная карта срывала бы всю настройку, — но тогда
|
||||
// PowerShell завершается успешно, даже не тронув ни одного адаптера, и
|
||||
@@ -186,17 +195,32 @@ pub fn redirect_to_local() -> Result<(), String> {
|
||||
// идёт: на машине без IPv6 она падает законно.
|
||||
const SCRIPT: &str = "\
|
||||
$ErrorActionPreference='Stop';\
|
||||
$n=0;\
|
||||
$n=0;$changed=0;\
|
||||
foreach($a in Get-NetAdapter){\
|
||||
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('127.0.0.1');$n++}catch{};\
|
||||
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('::1')}catch{}\
|
||||
try{\
|
||||
$cur=((Get-DnsClientServerAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4).ServerAddresses -join ',');\
|
||||
if($cur -ne '127.0.0.1'){\
|
||||
Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('127.0.0.1');\
|
||||
$changed++\
|
||||
};\
|
||||
$n++\
|
||||
}catch{};\
|
||||
try{\
|
||||
$cur6=((Get-DnsClientServerAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv6).ServerAddresses -join ',');\
|
||||
if($cur6 -ne '::1'){\
|
||||
Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('::1');\
|
||||
$changed++\
|
||||
}\
|
||||
}catch{}\
|
||||
};\
|
||||
Clear-DnsClientCache;\
|
||||
if($changed -gt 0){Clear-DnsClientCache};\
|
||||
$n";
|
||||
|
||||
// Считаются адаптеры, которые в итоге смотрят на нас, а не только те, что
|
||||
// пришлось переставить: холостой проход обязан заканчиваться успехом.
|
||||
let done = count_from(&powershell(SCRIPT)?);
|
||||
if done == 0 {
|
||||
return Err("ни один адаптер не переключился".to_string());
|
||||
return Err("ни один адаптер не удалось настроить".to_string());
|
||||
}
|
||||
set_marker(true);
|
||||
Ok(())
|
||||
|
||||
+59
-2
@@ -62,7 +62,7 @@ function useLog() {
|
||||
function useSites() {
|
||||
const dns = ref({
|
||||
listening: false, upstream: '', queries: 0, blocked: 0,
|
||||
cache_hits: 0, timeouts: 0, error: null, attempts: [],
|
||||
cache_hits: 0, timeouts: 0, error: null, attempts: [], recent: [],
|
||||
});
|
||||
const load = async () => { dns.value = await api('/dns'); };
|
||||
// сам список закрытых сайтов живёт в правилах, здесь — только состояние
|
||||
@@ -259,6 +259,62 @@ const SitesPanel = {
|
||||
</section>`,
|
||||
};
|
||||
|
||||
const QueryLog = {
|
||||
props: {
|
||||
entries: { type: Array, default: () => [] },
|
||||
active: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
const filter = ref('');
|
||||
const visible = computed(() => {
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
return q ? props.entries.filter((e) => e.name.includes(q)) : props.entries;
|
||||
});
|
||||
|
||||
const verdicts = { blocked: 'закрыт', cached: 'из кэша', upstream: 'наверх' };
|
||||
const ago = (s) => (s < 60 ? `${s} с` : `${Math.floor(s / 60)} мин`);
|
||||
|
||||
return { filter, visible, verdicts, ago };
|
||||
},
|
||||
template: `
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<h2 style="margin:0">Последние запросы</h2>
|
||||
<span class="muted">{{ visible.length }} из {{ entries.length }}</span>
|
||||
</div>
|
||||
<p v-if="!active" class="muted" style="margin:8px 0 0">
|
||||
Блокировка выключена — резолвер запросов не видит.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="row" style="margin:10px 0">
|
||||
<input v-model="filter" placeholder="фильтр по имени" style="flex:1">
|
||||
</div>
|
||||
<p class="muted" style="margin:0 0 8px">
|
||||
Хранится в памяти, на диск не пишется и пропадает вместе с выключением.
|
||||
</p>
|
||||
<div class="scroll" style="max-height:260px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Когда</th><th>Имя</th><th>Тип</th><th>Что было</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(e, i) in visible" :key="i"
|
||||
:class="{ blocked: e.verdict === 'blocked' }">
|
||||
<td class="muted">{{ ago(e.ago_secs) }}</td>
|
||||
<td>{{ e.name }}</td>
|
||||
<td class="muted">{{ e.kind }}</td>
|
||||
<td>{{ verdicts[e.verdict] || e.verdict }}</td>
|
||||
</tr>
|
||||
<tr v-if="!visible.length">
|
||||
<td colspan="4" class="muted">пусто</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</section>`,
|
||||
};
|
||||
|
||||
const RandomKill = {
|
||||
emits: ['schedule'],
|
||||
setup(_, { emit }) {
|
||||
@@ -354,7 +410,7 @@ const EventLog = {
|
||||
/* ── корневой компонент ────────────────────────────────────── */
|
||||
|
||||
const App = {
|
||||
components: { UpdateBar, RulesPanel, SitesPanel, RandomKill, ProcessTable, EventLog },
|
||||
components: { UpdateBar, RulesPanel, SitesPanel, QueryLog, RandomKill, ProcessTable, EventLog },
|
||||
setup() {
|
||||
const { rules, load: loadRules, block, allow, remove, save } = useRules();
|
||||
const { groups, load: loadProcesses, kill } = useProcesses();
|
||||
@@ -411,6 +467,7 @@ const App = {
|
||||
@block="blockSite" @remove="removeSite"
|
||||
@enable="save({ sites_enforce: $event })"
|
||||
@lockdown="save({ dns_lockdown: $event })" />
|
||||
<query-log :entries="dns.recent" :active="rules.enforce && rules.sites_enforce" />
|
||||
<random-kill @schedule="scheduleRandom" />
|
||||
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
|
||||
<event-log :lines="lines" />`,
|
||||
|
||||
Reference in New Issue
Block a user