498 lines
671 KiB
HTML
498 lines
671 KiB
HTML
<!doctype html>
|
||||
|
|
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<title>Аналитика запусков</title>
|
|||
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|||
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap">
|
|||
|
|
<style>
|
|||
|
|
/* Layout: sidebar shell like the real dashboard; one scrolling page, summary first, detail below.
|
|||
|
|
Tokens are the dashboard's own (internal/dashboard/static/dashboard.css); heat ramp = one blue hue, validated sequential steps. */
|
|||
|
|
:root {
|
|||
|
|
--bg: #EEF1F5; --surface: #FFFFFF; --surface-alt: #E4E9F0;
|
|||
|
|
--border: #CBD3DE; --border-soft: #DCE2EA;
|
|||
|
|
--text: #12161F; --text-muted: #566073; --text-faint: #8992A3;
|
|||
|
|
--accent: #2F5FE0; --accent-strong: #1F45B0; --accent-soft: #E1E9FC;
|
|||
|
|
--success: #157F45; --success-soft: #DFF3E7;
|
|||
|
|
--warning: #9A6400; --warning-soft: #F8EDD4;
|
|||
|
|
--danger: #B4291D; --danger-soft: #FADEDA;
|
|||
|
|
--neutral: #5B6576; --neutral-soft: #E4E8EE;
|
|||
|
|
--bar: #3987e5; --bar-track: #E4E9F0;
|
|||
|
|
--h1:#cde2fb; --h2:#9ec5f4; --h3:#6da7ec; --h4:#3987e5; --h5:#256abf; --h6:#184f95; --h7:#0d366b;
|
|||
|
|
--hi1:#12161F; --hi2:#12161F; --hi3:#12161F; --hi4:#FFFFFF; --hi5:#FFFFFF; --hi6:#FFFFFF; --hi7:#FFFFFF;
|
|||
|
|
--radius: 4px; --shadow: 0 1px 2px rgba(12,18,30,.06), 0 1px 1px rgba(12,18,30,.05);
|
|||
|
|
--font-ui: "IBM Plex Sans", -apple-system, "Segoe UI", sans-serif;
|
|||
|
|
--font-mono: "JetBrains Mono", ui-monospace, Consolas, monospace;
|
|||
|
|
}
|
|||
|
|
@media (prefers-color-scheme: dark) {
|
|||
|
|
:root:not([data-theme="light"]) {
|
|||
|
|
--bg:#0A0D13; --surface:#10141C; --surface-alt:#161B25; --border:#262E3B; --border-soft:#1D2430;
|
|||
|
|
--text:#E7ECF5; --text-muted:#8D97AC; --text-faint:#626D82;
|
|||
|
|
--accent:#5C8CFF; --accent-strong:#82A6FF; --accent-soft:#182140;
|
|||
|
|
--success:#3DDC8A; --success-soft:#10261C; --warning:#F0B429; --warning-soft:#2B2210;
|
|||
|
|
--danger:#FF6B5E; --danger-soft:#2C1613; --neutral:#A3ACBE; --neutral-soft:#1B212B;
|
|||
|
|
--bar:#5C8CFF; --bar-track:#1D2430;
|
|||
|
|
--h1:#0d366b; --h2:#104281; --h3:#184f95; --h4:#256abf; --h5:#3987e5; --h6:#6da7ec; --h7:#9ec5f4;
|
|||
|
|
--hi1:#B8C9E8; --hi2:#C3D6F5; --hi3:#E7ECF5; --hi4:#FFFFFF; --hi5:#0A0D13; --hi6:#0A0D13; --hi7:#0A0D13;
|
|||
|
|
--shadow: 0 1px 2px rgba(0,0,0,.4); color-scheme: dark;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
:root[data-theme="dark"] {
|
|||
|
|
--bg:#0A0D13; --surface:#10141C; --surface-alt:#161B25; --border:#262E3B; --border-soft:#1D2430;
|
|||
|
|
--text:#E7ECF5; --text-muted:#8D97AC; --text-faint:#626D82;
|
|||
|
|
--accent:#5C8CFF; --accent-strong:#82A6FF; --accent-soft:#182140;
|
|||
|
|
--success:#3DDC8A; --success-soft:#10261C; --warning:#F0B429; --warning-soft:#2B2210;
|
|||
|
|
--danger:#FF6B5E; --danger-soft:#2C1613; --neutral:#A3ACBE; --neutral-soft:#1B212B;
|
|||
|
|
--bar:#5C8CFF; --bar-track:#1D2430;
|
|||
|
|
--h1:#0d366b; --h2:#104281; --h3:#184f95; --h4:#256abf; --h5:#3987e5; --h6:#6da7ec; --h7:#9ec5f4;
|
|||
|
|
--hi1:#B8C9E8; --hi2:#C3D6F5; --hi3:#E7ECF5; --hi4:#FFFFFF; --hi5:#0A0D13; --hi6:#0A0D13; --hi7:#0A0D13;
|
|||
|
|
--shadow: 0 1px 2px rgba(0,0,0,.4); color-scheme: dark;
|
|||
|
|
}
|
|||
|
|
* { box-sizing: border-box; }
|
|||
|
|
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font-ui); }
|
|||
|
|
.shell { display: grid; grid-template-columns: 208px minmax(0,1fr); min-height: 100vh; }
|
|||
|
|
.side { background: var(--surface); border-right: 1px solid var(--border); padding: 16px 12px; display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh; }
|
|||
|
|
.brand { font: 700 13px var(--font-mono); letter-spacing: .02em; padding: 4px 8px 14px; }
|
|||
|
|
.nav a { display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text-muted); font: 500 12.5px var(--font-mono); text-decoration: none; }
|
|||
|
|
.nav a.on { background: var(--accent-soft); color: var(--accent-strong); }
|
|||
|
|
.nav a:focus-visible, button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|||
|
|
main { min-width: 0; padding: 20px 24px 40px; display: grid; gap: 16px; align-content: start; }
|
|||
|
|
.mock { background: var(--warning-soft); color: var(--warning); border: 1px solid var(--warning); border-radius: var(--radius); padding: 8px 12px; font-size: 12.5px; }
|
|||
|
|
h1 { font: 700 18px var(--font-mono); margin: 0; }
|
|||
|
|
h2 { font: 700 12px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin: 0; }
|
|||
|
|
.sub { color: var(--text-muted); font-size: 12.5px; margin: 0; }
|
|||
|
|
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; display: grid; gap: 12px; min-width: 0; align-content: start; }
|
|||
|
|
.head { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; justify-content: space-between; }
|
|||
|
|
.runbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|||
|
|
.runbar label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
|||
|
|
select, .btn { font: 500 13px var(--font-mono); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 10px; }
|
|||
|
|
select { min-width: 0; max-width: 100%; flex: 1 1 340px; }
|
|||
|
|
.btn { cursor: pointer; } .btn:hover { background: var(--surface-alt); }
|
|||
|
|
.btn[disabled] { opacity: .4; cursor: default; }
|
|||
|
|
.tabs { display: inline-flex; gap: 0; }
|
|||
|
|
.tabs button { font: 500 12px var(--font-mono); background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); padding: 5px 12px; cursor: pointer; }
|
|||
|
|
.tabs button + button { border-left: 0; }
|
|||
|
|
.tabs button:first-child { border-radius: var(--radius) 0 0 var(--radius); } .tabs button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
|||
|
|
.tabs button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); border-color: var(--accent); }
|
|||
|
|
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
|||
|
|
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; display: grid; gap: 2px; box-shadow: var(--shadow); }
|
|||
|
|
.kpi .k { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
|
|||
|
|
.kpi .v { font: 700 26px var(--font-mono); font-variant-numeric: tabular-nums; }
|
|||
|
|
.kpi .d { font-size: 12px; color: var(--text-muted); }
|
|||
|
|
.tag { display: inline-flex; align-items: center; gap: 5px; font: 700 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: .03em; padding: 2px 7px; border-radius: 2px; }
|
|||
|
|
.tag::before { content: ""; width: 5px; height: 5px; background: currentColor; border-radius: 1px; }
|
|||
|
|
.t-ok { background: var(--success-soft); color: var(--success); } .t-warn { background: var(--warning-soft); color: var(--warning); } .t-bad { background: var(--danger-soft); color: var(--danger); } .t-n { background: var(--neutral-soft); color: var(--neutral); }
|
|||
|
|
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 16px; }
|
|||
|
|
.rows { display: grid; gap: 7px; }
|
|||
|
|
.bar-row { display: grid; grid-template-columns: minmax(110px, 38%) minmax(0,1fr) auto; gap: 10px; align-items: center; font-size: 13px; }
|
|||
|
|
.bar-row .n { overflow-wrap: anywhere; }
|
|||
|
|
.track { height: 12px; background: var(--bar-track); border-radius: 2px; position: relative; }
|
|||
|
|
.fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; }
|
|||
|
|
.num { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
|||
|
|
.scroll { overflow-x: auto; }
|
|||
|
|
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|||
|
|
th .hint { cursor: help; color: var(--accent-strong); margin-left: 3px; }
|
|||
|
|
th { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|||
|
|
th.r, td.r { text-align: right; }
|
|||
|
|
td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
|||
|
|
td.a { font-family: var(--font-mono); font-size: 12.5px; white-space: nowrap; }
|
|||
|
|
td.a a { color: var(--accent-strong); text-decoration: none; } td.a a:hover { text-decoration: underline; }
|
|||
|
|
.pb { display: grid; grid-template-columns: 70px 40px; gap: 6px; align-items: center; justify-content: end; }
|
|||
|
|
.pb .track { height: 8px; }
|
|||
|
|
.heat td.c { padding: 2px; }
|
|||
|
|
.cell { display: block; min-width: 76px; text-align: center; font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; padding: 7px 4px; border-radius: 2px; cursor: default; }
|
|||
|
|
.h1{background:var(--h1);color:var(--hi1)} .h2{background:var(--h2);color:var(--hi2)} .h3{background:var(--h3);color:var(--hi3)} .h4{background:var(--h4);color:var(--hi4)} .h5{background:var(--h5);color:var(--hi5)} .h6{background:var(--h6);color:var(--hi6)} .h7{background:var(--h7);color:var(--hi7)}
|
|||
|
|
.legend { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: var(--text-muted); }
|
|||
|
|
.legend i { display: inline-block; width: 28px; height: 10px; border-radius: 2px; }
|
|||
|
|
.vals { display: flex; align-items: flex-end; gap: 4px; height: 120px; border-bottom: 1px solid var(--border); position: relative; }
|
|||
|
|
.vals .col { flex: 1 1 0; min-width: 0; background: var(--bar); border-radius: 3px 3px 0 0; }
|
|||
|
|
.vals .avg { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--text-muted); }
|
|||
|
|
.vlab { display: flex; justify-content: space-between; font: 500 11px var(--font-mono); color: var(--text-faint); }
|
|||
|
|
.dq { display: grid; gap: 8px; }
|
|||
|
|
.dq .row { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--border-soft); padding-bottom: 6px; font-size: 13px; }
|
|||
|
|
.dq .row span:last-child { font: 700 13px var(--font-mono); white-space: nowrap; }
|
|||
|
|
.note { font-size: 12px; color: var(--text-muted); }
|
|||
|
|
button.kpi { font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%; }
|
|||
|
|
button.kpi:hover { border-color: var(--accent); }
|
|||
|
|
button.kpi:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|||
|
|
.kpi .go { font: 700 11px var(--font-mono); color: var(--accent-strong); }
|
|||
|
|
dialog { width: min(760px, calc(100% - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
|
|||
|
|
dialog[open] { display: flex; flex-direction: column; }
|
|||
|
|
dialog::backdrop { background: rgba(10,14,20,.5); }
|
|||
|
|
.dlg { display: flex; flex-direction: column; gap: 12px; padding: 16px; min-height: 0; flex: 1 1 auto; max-height: calc(100vh - 34px); max-height: calc(100dvh - 34px); }
|
|||
|
|
.dlg h3 { font: 700 15px var(--font-mono); margin: 0; overflow-wrap: anywhere; flex: 0 0 auto; }
|
|||
|
|
.dlg .note { flex: 0 0 auto; max-height: 6.5em; overflow: auto; margin: 0; }
|
|||
|
|
.dlg .dist { flex: 0 0 auto; }
|
|||
|
|
.dlg .list { flex: 0 1 auto; min-height: 96px; overflow: auto; border: 1px solid var(--border-soft); border-radius: var(--radius); }
|
|||
|
|
.dlg table { font-size: 12.5px; } .dlg th { position: sticky; top: 0; background: var(--surface); }
|
|||
|
|
.dlg .foot { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; padding-top: 4px; border-top: 1px solid var(--border-soft); }
|
|||
|
|
.dlg .foot .acts { display: flex; gap: 8px; flex-wrap: wrap; }
|
|||
|
|
@media (max-width: 520px) { .dlg { padding: 12px; gap: 10px; } .dlg .foot .acts { width: 100%; } .dlg .foot .acts .btn { flex: 1 1 auto; } }
|
|||
|
|
@media (max-height: 540px) { .dlg .dist { display: none; } }
|
|||
|
|
@media (max-height: 640px) { .dist .cols20 { height: 44px; } .dlg .note { max-height: 3em; } .dlg .dist .chips { display: none; } }
|
|||
|
|
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|||
|
|
.btn.primary:hover { background: var(--accent-strong); }
|
|||
|
|
button.bar-row { font: inherit; color: inherit; text-align: left; width: 100%; background: none; border: 0; border-radius: var(--radius); padding: 3px 4px; margin: -3px -4px; cursor: pointer; }
|
|||
|
|
button.bar-row:hover { background: var(--surface-alt); }
|
|||
|
|
button.bar-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|||
|
|
.dist { display: grid; gap: 8px; }
|
|||
|
|
.dist .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|||
|
|
.dist .cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
|
|||
|
|
.dist .cols20 div { background: var(--bar); border-radius: 2px 2px 0 0; min-height: 1px; }
|
|||
|
|
.dist .lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
|
|||
|
|
#tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
|
|||
|
|
@media (max-width: 860px) {
|
|||
|
|
.shell { grid-template-columns: minmax(0,1fr); }
|
|||
|
|
.side { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--border); padding: 10px 16px; }
|
|||
|
|
.brand { padding: 0 10px 0 0; } .nav { display: flex; flex-wrap: wrap; gap: 2px; }
|
|||
|
|
main { padding: 16px; }
|
|||
|
|
}
|
|||
|
|
@media (prefers-reduced-motion: reduce) { #tip { transition: none; } }
|
|||
|
|
</style>
|
|||
|
|
|
|||
|
|
</head><body style="margin:0">
|
|||
|
|
<div class="shell">
|
|||
|
|
<aside class="side">
|
|||
|
|
<div class="brand">CLOUD IP VALIDATOR</div>
|
|||
|
|
<nav class="nav" aria-label="Разделы">
|
|||
|
|
<a href="#" onclick="return false">Обзор</a>
|
|||
|
|
<a href="#" onclick="return false">Очередь</a>
|
|||
|
|
<a href="#" onclick="return false">Реестр</a>
|
|||
|
|
<a href="#" class="on" aria-current="page" onclick="return false">Аналитика</a>
|
|||
|
|
<a href="#" onclick="return false">Валидаторы</a>
|
|||
|
|
<a href="#" onclick="return false">Настройки</a>
|
|||
|
|
</nav>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<main>
|
|||
|
|
<div class="mock" role="note"><b>Макет.</b> Запуск от 02.10 построен на реальных числах из отчётов <code>analysis/</code> (6440 адресов). Два других запуска и идущий — <b>демонстрационные</b>, нужны только для проверки переключателя.</div>
|
|||
|
|
|
|||
|
|
<div class="head">
|
|||
|
|
<h1>Аналитика</h1>
|
|||
|
|
<p class="sub">Один выбранный запуск. Данные других запусков на странице не участвуют.</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section class="panel" aria-label="Выбор запуска">
|
|||
|
|
<div class="runbar">
|
|||
|
|
<label for="run">Запуск</label>
|
|||
|
|
<button class="btn" id="prev" aria-label="Предыдущий запуск">◀</button>
|
|||
|
|
<select id="run"></select>
|
|||
|
|
<button class="btn" id="next" aria-label="Следующий запуск">▶</button>
|
|||
|
|
</div>
|
|||
|
|
<p class="note" id="runnote"></p>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<div class="kpis" id="kpis"></div>
|
|||
|
|
|
|||
|
|
<div class="cols">
|
|||
|
|
<section class="panel" aria-labelledby="h-reasons">
|
|||
|
|
<h2 id="h-reasons">Почему partial</h2>
|
|||
|
|
<div class="rows" id="reasons"></div>
|
|||
|
|
<p class="note">Адрес считается один раз, по главной причине. Всё, где есть egress, учитывается как egress.</p>
|
|||
|
|
</section>
|
|||
|
|
<section class="panel" aria-labelledby="h-dq">
|
|||
|
|
<h2 id="h-dq">Качество данных</h2>
|
|||
|
|
<div class="dq" id="dq"></div>
|
|||
|
|
<p class="note">Вердикт ставится при агрегации. Результаты, пришедшие позже, остаются в этом же запуске, но в вердикт не входят.</p>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section class="panel" aria-labelledby="h-sub">
|
|||
|
|
<div class="head">
|
|||
|
|
<h2 id="h-sub">Подсети</h2>
|
|||
|
|
<div class="runbar">
|
|||
|
|
<div class="tabs" role="group" aria-label="Сортировка подсетей">
|
|||
|
|
<button type="button" data-sort="worst" aria-pressed="true">хуже всего</button>
|
|||
|
|
<button type="button" data-sort="size" aria-pressed="false">больше адресов</button>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn" id="allsub" type="button"></button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="scroll"><table id="subtbl"></table></div>
|
|||
|
|
<p class="note">Строка ведёт в «Реестр» с фильтром по запуску и подсети.</p>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section class="panel" aria-labelledby="h-eg">
|
|||
|
|
<div class="head">
|
|||
|
|
<h2 id="h-eg">Egress по целям</h2>
|
|||
|
|
<div class="tabs" role="group" aria-label="Тип проверки">
|
|||
|
|
<button type="button" data-type="https" aria-pressed="true">https</button>
|
|||
|
|
<button type="button" data-type="icmp" aria-pressed="false">icmp</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="rows" id="targets"></div>
|
|||
|
|
<div id="matrixwrap">
|
|||
|
|
<div class="head"><h2 style="margin-top:6px">Подсеть × цель</h2>
|
|||
|
|
<div class="legend" id="hlegend"></div></div>
|
|||
|
|
<div class="scroll"><table class="heat" id="matrix"></table></div>
|
|||
|
|
</div>
|
|||
|
|
<p class="note" id="matrixnote"></p>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<div class="cols">
|
|||
|
|
<section class="panel" aria-labelledby="h-in">
|
|||
|
|
<h2 id="h-in">Ingress по площадкам</h2>
|
|||
|
|
<div class="scroll"><table id="sites"></table></div>
|
|||
|
|
</section>
|
|||
|
|
<section class="panel" aria-labelledby="h-err">
|
|||
|
|
<h2 id="h-err">Классы ошибок ingress</h2>
|
|||
|
|
<div class="rows" id="errs"></div>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section class="panel" aria-labelledby="h-val">
|
|||
|
|
<h2 id="h-val">Валидаторы: доля провалов egress https</h2>
|
|||
|
|
<div class="vals" id="vals" role="img" aria-label="Доля проваленных https-проверок по 20 валидаторам"></div>
|
|||
|
|
<div class="vlab"><span>validator 1</span><span id="vavg"></span><span>validator 20</span></div>
|
|||
|
|
<p class="note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
|
|||
|
|
</section>
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
<dialog id="dlg" aria-labelledby="dlg-title">
|
|||
|
|
<div class="dlg">
|
|||
|
|
<h3 id="dlg-title"></h3>
|
|||
|
|
<p class="note" id="dlg-note"></p>
|
|||
|
|
<div class="dist" id="dlg-dist" hidden></div>
|
|||
|
|
<div class="list"><table id="dlg-tbl"></table></div>
|
|||
|
|
<div class="foot">
|
|||
|
|
<span class="note" id="dlg-msg" role="status"></span>
|
|||
|
|
<div class="acts">
|
|||
|
|
<button class="btn" type="button" id="dlg-copy">Копировать</button>
|
|||
|
|
<button class="btn primary" type="button" id="dlg-csv">Скачать CSV</button>
|
|||
|
|
<button class="btn" type="button" id="dlg-close" autofocus>Закрыть</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</dialog>
|
|||
|
|
<div id="tip" role="tooltip"></div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const REAL = {"subnets": [{"c": "37.139.32.0/22", "n": 327, "p": 240, "e": 240, "i": 327}, {"c": "185.130.112.0/22", "n": 360, "p": 169, "e": 169, "i": 360}, {"c": "161.104.104.0/22", "n": 452, "p": 124, "e": 124, "i": 452}, {"c": "87.239.104.0/21", "n": 369, "p": 115, "e": 115, "i": 365}, {"c": "212.233.72.0/21", "n": 471, "p": 105, "e": 105, "i": 460}, {"c": "212.111.84.0/22", "n": 218, "p": 102, "e": 102, "i": 215}, {"c": "146.185.240.0/22", "n": 197, "p": 81, "e": 81, "i": 190}, {"c": "83.166.248.0/21", "n": 659, "p": 71, "e": 82, "i": 598}, {"c": "89.208.196.0/22", "n": 165, "p": 60, "e": 60, "i": 164}, {"c": "89.208.84.0/22", "n": 151, "p": 58, "e": 60, "i": 140}, {"c": "213.219.212.0/22", "n": 84, "p": 53, "e": 53, "i": 84}, {"c": "91.219.226.0/23", "n": 106, "p": 52, "e": 53, "i": 93}, {"c": "89.208.208.0/22", "n": 157, "p": 50, "e": 50, "i": 157}, {"c": "37.139.40.0/22", "n": 108, "p": 50, "e": 50, "i": 108}, {"c": "90.156.216.0/22", "n": 110, "p": 46, "e": 46, "i": 110}, {"c": "79.137.174.0/23", "n": 117, "p": 43, "e": 43, "i": 117}, {"c": "185.86.144.0/22", "n": 105, "p": 43, "e": 43, "i": 105}, {"c": "212.233.88.0/21", "n": 237, "p": 42, "e": 45, "i": 230}, {"c": "95.163.208.0/22", "n": 210, "p": 41, "e": 41, "i": 210}, {"c": "217.16.24.0/22", "n": 79, "p": 39, "e": 53, "i": 70}, {"c": "95.163.212.0/22", "n": 118, "p": 35, "e": 35, "i": 118}, {"c": "90.156.212.0/22", "n": 236, "p": 32, "e": 32, "i": 235}, {"c": "84.23.52.0/22", "n": 154, "p": 32, "e": 32, "i": 154}, {"c": "85.192.32.0/22", "n": 75, "p": 31, "e": 31, "i": 74}, {"c": "212.233.120.0/22", "n": 131, "p": 30, "e": 30, "i": 126}, {"c": "83.166.232.0/21", "n": 263, "p": 29, "e": 30, "i": 227}, {"c": "185.241.192.0/22", "n": 90, "p": 25, "e": 25, "i": 85}, {"c": "94.139.244.0/22", "n": 69, "p": 25, "e": 26, "i": 69}, {"c": "217.16.16.0/21", "n": 32, "p": 23, "e": 27, "i": 20}, {"c": "5.188.140.0/22", "n": 22, "p": 22, "e": 22, "i": 22}, {"c": "217.16.28.0/22", "n": 28, "p": 18, "e": 18, "i": 28}, {"c": "89.208.216.0/24", "n": 34, "p": 17, "e": 17, "i": 34}, {"c": "109.120.188.0/22", "n": 14, "p": 11, "e": 11, "i": 14}, {"c": "89.208.220.0/22", "n": 144, "p": 10, "e": 10, "i": 144}, {"c": "95.163.182.0/23", "n": 132, "p": 10, "e": 11, "i": 117}, {"c": "212.233.96.0/22", "n": 13, "p": 6, "e": 6, "i": 13}, {"c": "89.208.228.0/22", "n": 12, "p": 6, "e": 6, "i": 12}, {"c": "109.120.180.0/22", "n": 91, "p": 5, "e": 6, "i": 80}, {"c": "90.156.150.0/23", "n": 10, "p": 4, "e": 4, "i": 10}, {"c": "95.163.180.0/23", "n": 36, "p": 3, "e": 5, "i": 24}, {"c": "146.185.208.0/22", "n": 8, "p": 3, "e": 3, "i": 8}, {"c": "95.163.248.0/22", "n": 1, "p": 1, "e": 1, "i": 1}, {"c": "161.104.108.0/23", "n": 45, "p": 0, "e": 0, "i": 45}], "matrix": [{"c": "83.166.248.0/21", "partial": 588, "v": [81, 57, 51, 86, 46]}, {"c": "212.233.72.0/21", "partial": 366, "v": [82, 65, 57, 0, 57]}, {"c": "161.104.104.0/22", "partial": 328, "v": [88, 53, 48, 0, 40]}, {"c": "87.239.104.0/21", "partial": 254, "v": [91, 37, 34, 56, 30]}, {"c": "83.166.232.0/21", "partial": 234, "v": [78, 58, 46, 89, 44]}, {"c": "90.156.212.0/22", "partial": 204, "v": [91, 48, 50, 69, 42]}, {"c": "212.233.88.0/21", "partial": 195, "v": [81, 70, 56, 0, 67]}, {"c": "185.130.112.0/22", "partial": 191, "v": [97, 19, 20, 0, 15]}, {"c": "95.163.208.0/22", "partial": 169, "v": [85, 51, 43, 42, 38]}, {"c": "89.208.220.0/22", "partial": 134, "v": [87, 54, 50, 78, 38]}, {"c": "95.163.182.0/23", "partial": 122, "v": [74, 69, 48, 73, 45]}, {"c": "84.23.52.0/22", "partial": 122, "v": [88, 63, 48, 80, 41]}, {"c": "212.111.84.0/22", "partial": 116, "v": [95, 23, 15, 0, 16]}, {"c": "146.185.240.0/22", "partial": 116, "v": [87, 38, 30, 7, 24]}, {"c": "89.208.208.0/22", "partial": 107, "v": [88, 43, 33, 57, 28]}, {"c": "89.208.196.0/22", "partial": 105, "v": [92, 36, 31, 52, 29]}, {"c": "212.233.120.0/22", "partial": 101, "v": [83, 62, 54, 0, 71]}, {"c": "89.208.84.0/22", "partial": 93, "v": [83, 33, 27, 47, 31]}, {"c": "37.139.32.0/22", "partial": 87, "v": [86, 33, 30, 57, 26]}, {"c": "109.120.180.0/22", "partial": 86, "v": [9
|
|||
|
|
const TARGETS = ['packages.ubuntu.com','dl-cdn.alpinelinux.org','repo.almalinux.org','hub.docker.com','github.com'];
|
|||
|
|
const fmt = n => Math.round(n).toLocaleString('ru-RU');
|
|||
|
|
const pct = (a, b) => b ? Math.round(a / b * 100) : 0;
|
|||
|
|
const pct1 = (a, b) => b ? (a / b * 100).toLocaleString('ru-RU', {maximumFractionDigits: 1}) : '0';
|
|||
|
|
|
|||
|
|
/* Base run: real numbers. Demo runs: the same shape, scaled and shifted with a fixed pattern. */
|
|||
|
|
function baseRun() {
|
|||
|
|
const subs = REAL.subnets.map(s => ({c: s.c, n: s.n, pass: s.p, eg: s.e, ing: s.i}));
|
|||
|
|
return {
|
|||
|
|
id: 'r3', demo: false, label: '02.10 13:47 → 22:29 · ручной · 6 440 адр. · 30% pass', kind: 'ручной (скан Floating IP)',
|
|||
|
|
start: '02.10.2026 13:47', end: '02.10.2026 22:29', dur: '8 ч 42 мин', rechecked: 9,
|
|||
|
|
subs, matrix: REAL.matrix.map(m => ({c: m.c, partial: m.partial, v: m.v.slice()})),
|
|||
|
|
reasons: [['Только egress', 4146], ['Ingress и egress', 157], ['Egress и неполный набор', 125], ['Ingress, egress и неполный набор', 9], ['Только неполный набор', 33], ['Только ingress', 8]],
|
|||
|
|
late: 246, lateAddr: 50, incomplete: 167, mismatch: 50,
|
|||
|
|
errLate: 844, egHttpsAny: 4409, egHttpsAll: 307, egHttpsAllFull: 290, ingSshAll: 7, ingSshAny: 222,
|
|||
|
|
targets: {https: [3841, 2193, 1872, 1805, 1728], icmp: [79, 0, 0, 1, 0]},
|
|||
|
|
sites: [['rxmsk', 6319, 6165, 6319, 6128, 6319, 6179], ['rxyc', 6439, 6435, 6439, 6429, 6439, 6434], ['misha-v', 6364, 6257, 6364, 6207, 6364, 6266], ['vk-kaz', 6426, 6400, 6426, 6392, 6426, 6402]],
|
|||
|
|
errs: [['SSH: таймаут', 327], ['ICMP: нет ответа', 276], ['TCP-22: таймаут', 260], ['SSH: баннер «Not allowed»', 47], ['SSH: нет маршрута', 18], ['ICMP: time exceeded', 15], ['TCP-22: нет маршрута', 7]],
|
|||
|
|
vals: [[1595,999],[1630,1071],[1618,1046],[1613,1042],[1597,998],[1622,1093],[1615,1045],[1599,1012],[1594,991],[1602,1049],[1610,1043],[1595,1014],[1630,1069],[1576,974],[1634,1073],[1589,1006],[1620,1062],[1613,1021],[1610,1047],[1614,1082]]
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function variant(b, id, label, kind, start, end, dur, scale, phase, shift) {
|
|||
|
|
const r = JSON.parse(JSON.stringify(b));
|
|||
|
|
Object.assign(r, {id, demo: true, label, kind, start, end, dur, rechecked: 0});
|
|||
|
|
r.subs = b.subs.map((s, i) => {
|
|||
|
|
const k = 1 + 0.22 * Math.sin(i * 1.7 + phase);
|
|||
|
|
const n = Math.max(1, Math.round(s.n * scale * (1 + 0.1 * Math.cos(i + phase))));
|
|||
|
|
const pass = Math.min(n, Math.max(0, Math.round(n * (s.pass / s.n) * k + n * shift)));
|
|||
|
|
return {c: s.c, n, pass, eg: Math.min(n, Math.round(n * (s.eg / s.n) * k + n * shift)), ing: Math.min(n, Math.round(n * (s.ing / s.n)))};
|
|||
|
|
});
|
|||
|
|
const total = r.subs.reduce((a, s) => a + s.n, 0);
|
|||
|
|
const f = total / 6440;
|
|||
|
|
r.matrix = b.matrix.map((m, i) => ({c: m.c, partial: Math.max(1, Math.round(m.partial * scale)), v: m.v.map((x, j) => Math.max(0, Math.min(100, Math.round(x * (1 + 0.2 * Math.sin(i + j + phase)) + shift * 100))))}));
|
|||
|
|
r.reasons = b.reasons.map(([k, v]) => [k, Math.round(v * f)]);
|
|||
|
|
r.late = Math.round(b.late * f); r.lateAddr = Math.round(b.lateAddr * f); r.incomplete = Math.round(b.incomplete * f); r.mismatch = Math.round(b.mismatch * f);
|
|||
|
|
r.errLate = Math.round(b.errLate * f); r.egHttpsAny = Math.round(b.egHttpsAny * f); r.egHttpsAll = Math.round(b.egHttpsAll * f); r.egHttpsAllFull = Math.round(b.egHttpsAllFull * f); r.ingSshAll = Math.round(b.ingSshAll * f); r.ingSshAny = Math.round(b.ingSshAny * f);
|
|||
|
|
r.targets = {https: b.targets.https.map(x => Math.round(x * f * (1 - shift * 2))), icmp: b.targets.icmp.map(x => Math.round(x * f))};
|
|||
|
|
r.sites = b.sites.map(s => s.map((x, j) => j === 0 ? x : Math.round(x * f)));
|
|||
|
|
r.errs = b.errs.map(([k, v]) => [k, Math.round(v * f)]);
|
|||
|
|
r.vals = b.vals.map(([t, o]) => [Math.round(t * f), Math.round(o * f * (1 + shift * 0.5))]);
|
|||
|
|
return r;
|
|||
|
|
}
|
|||
|
|
const BASE = baseRun();
|
|||
|
|
const RUNS = [
|
|||
|
|
variant(BASE, 'r1', '01.10 07:59 → 08:41 · авто · демо', 'автоцикл (демо)', '01.10.2026 07:59', '01.10.2026 08:41', '42 мин', 0.04, 0.6, 0.12),
|
|||
|
|
variant(BASE, 'r2', '03.10 10:05 → 12:20 · ручной · демо', 'перепроверка (демо)', '03.10.2026 10:05', '03.10.2026 12:20', '2 ч 15 мин', 0.16, 2.1, 0.06),
|
|||
|
|
BASE
|
|||
|
|
];
|
|||
|
|
const OPEN = {label: '03.10 15:30 → идёт · ручной · 120 из 800 · недоступен', disabled: true};
|
|||
|
|
|
|||
|
|
const $ = id => document.getElementById(id);
|
|||
|
|
const state = {run: RUNS[RUNS.length - 1], sort: 'worst', all: false, type: 'https'};
|
|||
|
|
|
|||
|
|
function sum(run) {
|
|||
|
|
const n = run.subs.reduce((a, s) => a + s.n, 0), pass = run.subs.reduce((a, s) => a + s.pass, 0);
|
|||
|
|
const eg = run.subs.reduce((a, s) => a + s.eg, 0), ing = run.subs.reduce((a, s) => a + s.ing, 0);
|
|||
|
|
return {n, pass, partial: n - pass, eg, ing};
|
|||
|
|
}
|
|||
|
|
function heatClass(v) { return 'h' + (v >= 90 ? 7 : v >= 75 ? 6 : v >= 60 ? 5 : v >= 45 ? 4 : v >= 30 ? 3 : v >= 15 ? 2 : 1); }
|
|||
|
|
function bar(v, max) { return `<div class="track"><div class="fill" style="width:${max ? Math.max(1, v / max * 100) : 0}%"></div></div>`; }
|
|||
|
|
function tipAttr(t) { return `data-tip="${t.replace(/"/g, '"')}"`; }
|
|||
|
|
|
|||
|
|
function render() {
|
|||
|
|
const r = state.run, s = sum(r);
|
|||
|
|
$('run').value = r.id;
|
|||
|
|
$('prev').disabled = RUNS.indexOf(r) === 0; $('next').disabled = RUNS.indexOf(r) === RUNS.length - 1;
|
|||
|
|
$('runnote').innerHTML = `Тип: ${r.kind}. Начало ${r.start}, завершён ${r.end}, длительность ${r.dur}.` + (r.rechecked ? ` Перепроверено внутри запуска: ${r.rechecked} адр. (берётся последний цикл).` : '') + (r.demo ? ' <b>Демонстрационные данные.</b>' : '');
|
|||
|
|
$('kpis').innerHTML = [
|
|||
|
|
['Адресов', fmt(s.n), 'последний цикл каждого адреса', ''],
|
|||
|
|
['pass', fmt(s.pass), `${pct(s.pass, s.n)}% адресов`, '<span class="tag t-ok">успех</span>'],
|
|||
|
|
['partial', fmt(s.partial), `${pct(s.partial, s.n)}% адресов`, '<span class="tag t-warn">частично</span>'],
|
|||
|
|
['fail', '0', 'ни одной полностью проваленной', '<span class="tag t-n">нет</span>'],
|
|||
|
|
['Egress OK', pct1(s.eg, s.n) + '%', 'все egress-проверки адреса успешны', ''],
|
|||
|
|
['Ingress OK', pct1(s.ing, s.n) + '%', 'все ingress-проверки адреса успешны', ''],
|
|||
|
|
['Egress https: есть провалы', fmt(r.egHttpsAny), `${pct1(r.egHttpsAny, s.n)}% адресов, хотя бы одна цель недоступна`, '<span class="tag t-warn">прикладной</span> <span class="go">список →</span>', 'egany'],
|
|||
|
|
['Ingress ssh: есть провалы', fmt(r.ingSshAny), `${pct1(r.ingSshAny, s.n)}% адресов, хотя бы с одной площадки`, '<span class="tag t-warn">прикладной</span> <span class="go">список →</span>', 'sshany'],
|
|||
|
|
['Egress https: все провалены', fmt(r.egHttpsAll), `${pct1(r.egHttpsAll, s.n)}% адресов, по всем 5 целям: ${fmt(r.egHttpsAllFull)}`, '<span class="tag t-bad">прикладной</span> <span class="go">список →</span>', 'eg'],
|
|||
|
|
['Ingress ssh: все провалены', fmt(r.ingSshAll), `${pct1(r.ingSshAll, s.n)}% адресов, ssh провален со всех площадок`, '<span class="tag t-bad">прикладной</span> <span class="go">список →</span>', 'ssh'],
|
|||
|
|
['Длительность', r.dur, `${fmt(s.n / Math.max(1, parseDur(r.dur)))} адр./мин`, ''],
|
|||
|
|
['Поздние результаты', fmt(r.late), `у ${fmt(r.lateAddr)} адресов, после вердикта`, '<span class="tag t-warn">внимание</span>']
|
|||
|
|
].map(([k, v, d, t, key]) => { const inner = `<div class="k">${k}</div><div class="v">${v}</div><div class="d">${d} ${t}</div>`; return key ? `<button type="button" class="kpi" data-list="${key}" aria-haspopup="dialog">${inner}</button>` : `<div class="kpi">${inner}</div>`; }).join('');
|
|||
|
|
|
|||
|
|
const rmax = Math.max(...r.reasons.map(x => x[1]));
|
|||
|
|
$('reasons').innerHTML = r.reasons.map(([k, v]) => `<div class="bar-row" ${tipAttr(k + ': ' + fmt(v) + ' адр. (' + pct(v, s.partial) + '% от partial)')}><span class="n">${k}</span>${bar(v, rmax)}<span class="num">${fmt(v)}</span></div>`).join('');
|
|||
|
|
$('dq').innerHTML = [
|
|||
|
|
['Поздние провалы у адресов с вердиктом pass', `${fmt(r.late)} / ${fmt(r.lateAddr)} адр.`],
|
|||
|
|
['Провалы ingress, пришедшие после вердикта', `${fmt(r.errLate)} из ${fmt(r.errs.reduce((a, e) => a + e[1], 0))}`],
|
|||
|
|
['Неполный набор проверок', `${fmt(r.incomplete)} адр.`],
|
|||
|
|
['Вердикт pass, но есть проваленные проверки', `${fmt(r.mismatch)} адр.`],
|
|||
|
|
['pass по вердикту → по фактическим проверкам', `${fmt(s.pass)} → ${fmt(s.pass - r.mismatch)}`]
|
|||
|
|
].map(([k, v]) => `<div class="row"><span>${k}</span><span>${v}</span></div>`).join('');
|
|||
|
|
|
|||
|
|
renderSubnets(r); renderTargets(r); renderSites(r); renderErrs(r); renderVals(r);
|
|||
|
|
}
|
|||
|
|
function parseDur(d) { const h = /(\d+) ч/.exec(d), m = /(\d+) мин/.exec(d); return (h ? +h[1] * 60 : 0) + (m ? +m[1] : 0) || 1; }
|
|||
|
|
|
|||
|
|
function pbar(a, n) { const p = pct(a, n); return `<div class="pb"><div class="track">${'<div class="fill" style="width:' + p + '%"></div>'}</div><span class="num">${p}%</span></div>`; }
|
|||
|
|
function renderSubnets(r) {
|
|||
|
|
let list = r.subs.slice();
|
|||
|
|
list.sort(state.sort === 'worst' ? (a, b) => a.pass / a.n - b.pass / b.n || b.n - a.n : (a, b) => b.n - a.n);
|
|||
|
|
const shown = state.all ? list : list.slice(0, 10);
|
|||
|
|
$('allsub').textContent = state.all ? 'свернуть до 10' : `показать все ${list.length}`;
|
|||
|
|
$('subtbl').innerHTML = `<thead><tr><th>Подсеть</th><th class="r">Адресов</th><th class="r">pass</th><th class="r">Egress OK</th><th class="r">Ingress OK</th></tr></thead><tbody>` +
|
|||
|
|
shown.map(s => `<tr><td class="a"><a href="#" onclick="return false" title="Открыть в реестре: запуск ${r.id}, подсеть ${s.c}">${s.c}</a></td><td class="num r">${fmt(s.n)}</td><td class="r" ${tipAttr(s.c + ': pass ' + fmt(s.pass) + ' из ' + fmt(s.n))}>${pbar(s.pass, s.n)}</td><td class="r" ${tipAttr(s.c + ': egress OK ' + fmt(s.eg) + ' из ' + fmt(s.n))}>${pbar(s.eg, s.n)}</td><td class="r" ${tipAttr(s.c + ': ingress OK ' + fmt(s.ing) + ' из ' + fmt(s.n))}>${pbar(s.ing, s.n)}</td></tr>`).join('') + '</tbody>';
|
|||
|
|
}
|
|||
|
|
function renderTargets(r) {
|
|||
|
|
const t = state.type, vals = r.targets[t], s = sum(r), max = Math.max(...vals, 1);
|
|||
|
|
$('targets').innerHTML = TARGETS.map((n, i) => `<div class="bar-row" ${tipAttr(n + ' · ' + t + ': провал у ' + fmt(vals[i]) + ' адр. (' + pct(vals[i], s.n) + '% всех)')}><span class="n">${n}</span>${bar(vals[i], max)}<span class="num">${fmt(vals[i])} · ${pct(vals[i], s.n)}%</span></div>`).join('');
|
|||
|
|
if (t === 'https') {
|
|||
|
|
$('matrixwrap').hidden = false;
|
|||
|
|
$('hlegend').innerHTML = ['0–14', '15–29', '30–44', '45–59', '60–74', '75–89', '90–100'].map((l, i) => `<span><i style="background:var(--h${i + 1})"></i> ${l}%</span>`).join('');
|
|||
|
|
const list = r.matrix.slice().sort((a, b) => b.partial - a.partial).slice(0, state.all ? 99 : 10);
|
|||
|
|
$('matrix').innerHTML = `<thead><tr><th>Подсеть</th><th class="r">partial</th>${TARGETS.map(x => `<th class="r">${x.replace('.com', '').replace('.org', '')}</th>`).join('')}</tr></thead><tbody>` +
|
|||
|
|
list.map(m => `<tr><td class="a"><a href="#" onclick="return false" title="Открыть адреса в реестре">${m.c}</a></td><td class="num r">${fmt(m.partial)}</td>${m.v.map((v, j) => `<td class="c"><span class="cell ${heatClass(v)}" ${tipAttr(m.c + ' → ' + TARGETS[j] + ': ' + v + '% адресов partial провалили https')}>${v}%</span></td>`).join('')}</tr>`).join('') + '</tbody>';
|
|||
|
|
$('matrixnote').textContent = 'Доля адресов partial подсети, проваливших https к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.';
|
|||
|
|
} else {
|
|||
|
|
$('matrixwrap').hidden = true;
|
|||
|
|
$('matrixnote').textContent = 'Тип icmp: провалов почти нет, почти все относятся к packages.ubuntu.com. Матрица по подсетям для icmp в макете не показана, в рабочей странице она строится для любого типа.';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function renderSites(r) {
|
|||
|
|
$('sites').innerHTML = `<thead><tr><th>Площадка</th><th class="r">icmp</th><th class="r">ssh</th><th class="r">tcp</th></tr></thead><tbody>` +
|
|||
|
|
r.sites.map(s => `<tr><td class="a">${s[0]}</td>${[1, 3, 5].map(i => `<td class="r"><span class="cell h${heatClass(100 - pct(s[i + 1], s[i]) * 8 > 100 ? 100 : Math.min(100, (100 - pct(s[i + 1], s[i])) * 8)).slice(1)}" ${tipAttr(s[0] + ' · ' + ['icmp', 'ssh', 'tcp'][(i - 1) / 2] + ': ' + fmt(s[i + 1]) + ' из ' + fmt(s[i]) + ' успешно')}>${fmt(s[i + 1])} / ${fmt(s[i])}</span></td>`).join('')}</tr>`).join('') + '</tbody>';
|
|||
|
|
}
|
|||
|
|
function renderErrs(r) {
|
|||
|
|
const max = Math.max(...r.errs.map(e => e[1]), 1);
|
|||
|
|
$('errs').innerHTML = r.errs.map(([k, v]) => `<button type="button" class="bar-row" data-cls="${k}" aria-haspopup="dialog" ${tipAttr(k + ': ' + fmt(v) + ' проверок. Нажмите, чтобы открыть список')}><span class="n">${k}</span>${bar(v, max)}<span class="num">${fmt(v)}</span></button>`).join('');
|
|||
|
|
}
|
|||
|
|
function renderVals(r) {
|
|||
|
|
const f = r.vals.map(([t, o]) => t ? (1 - o / t) : 0), avg = f.reduce((a, b) => a + b, 0) / f.length;
|
|||
|
|
$('vals').innerHTML = f.map((x, i) => `<div class="col" style="height:${x * 100}%" ${tipAttr('validator ' + (i + 1) + ': провал ' + Math.round(x * 100) + '% из ' + fmt(r.vals[i][0]) + ' https-проверок')}></div>`).join('') + `<div class="avg" style="bottom:${avg * 100}%"></div>`;
|
|||
|
|
$('vavg').textContent = `среднее ${Math.round(avg * 100)}% (шкала 0–100%)`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* controls */
|
|||
|
|
const sel = $('run');
|
|||
|
|
sel.innerHTML = RUNS.slice().reverse().map(r => `<option value="${r.id}">${r.label}</option>`).join('') + `<option disabled>${OPEN.label}</option>`;
|
|||
|
|
sel.addEventListener('change', () => { state.run = RUNS.find(r => r.id === sel.value); render(); });
|
|||
|
|
$('prev').addEventListener('click', () => { const i = RUNS.indexOf(state.run); if (i > 0) { state.run = RUNS[i - 1]; render(); } });
|
|||
|
|
$('next').addEventListener('click', () => { const i = RUNS.indexOf(state.run); if (i < RUNS.length - 1) { state.run = RUNS[i + 1]; render(); } });
|
|||
|
|
document.querySelectorAll('[data-sort]').forEach(b => b.addEventListener('click', () => { state.sort = b.dataset.sort; document.querySelectorAll('[data-sort]').forEach(x => x.setAttribute('aria-pressed', x === b)); render(); }));
|
|||
|
|
document.querySelectorAll('[data-type]').forEach(b => b.addEventListener('click', () => { state.type = b.dataset.type; document.querySelectorAll('[data-type]').forEach(x => x.setAttribute('aria-pressed', x === b)); render(); }));
|
|||
|
|
$('allsub').addEventListener('click', () => { state.all = !state.all; render(); });
|
|||
|
|
|
|||
|
|
|
|||
|
|
/* modal: address lists (two indicators) and failed checks of one ingress error class */
|
|||
|
|
const LISTS = {
|
|||
|
|
egany: {title: 'Egress https: адреса с проваленными проверками', file: 'egress-https-any-failed', cols: ['Адрес', 'Подсеть', 'Валидатор', 'Провалено https', 'Проваленные цели'], hints: {2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок адреса провалено из всех записанных в цикле.'}, rows: () => REAL.egAnyList, n: r => r.egHttpsAny,
|
|||
|
|
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.'},
|
|||
|
|
sshany: {title: 'Ingress ssh: адреса с проваленными проверками', file: 'ingress-ssh-any-failed', cols: ['Адрес', 'Подсеть', 'Провалено ssh', 'Площадки с провалом', 'Ошибка'], hints: {2: 'Сколько площадок не смогли выполнить ssh-проверку адреса из всех, где она выполнялась.', 3: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 4: 'Класс ошибки: таймаут, баннер «Not allowed», нет маршрута.'}, rows: () => REAL.sshAnyList, n: r => r.ingSshAny,
|
|||
|
|
note: 'ssh провален хотя бы с одной площадки в последнем цикле запуска.'},
|
|||
|
|
eg: {title: 'Egress https: адреса, провалившие все проверки', file: 'egress-https-all-failed', cols: ['Адрес', 'Подсеть', 'Валидатор', 'https-проверок', 'Проваленные цели'], hints: {2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок записано у адреса в цикле. Все они провалены. Меньше 5 значит, что часть результатов не пришла.'}, rows: () => REAL.egList, n: r => r.egHttpsAll,
|
|||
|
|
note: 'Все записанные egress-проверки https адреса провалены в последнем цикле запуска.'},
|
|||
|
|
ssh: {title: 'Ingress ssh: адреса, провалившие все проверки', file: 'ingress-ssh-all-failed', cols: ['Адрес', 'Подсеть', 'Площадки с провалом ssh', 'Ошибка'], hints: {2: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 3: 'Класс ошибки ssh-проверки: таймаут, баннер «Not allowed», нет маршрута.'}, rows: () => REAL.sshList, n: r => r.ingSshAll,
|
|||
|
|
note: 'ssh провален со всех площадок, на которых он проверялся, в последнем цикле запуска.'}
|
|||
|
|
};
|
|||
|
|
const VERDICT_HINT = 'Итог всего адреса за цикл, который система выставила при агрегации. pass: все проверки (egress и ingress), полученные к этому моменту, успешны. partial: часть проверок провалена или результатов не хватает. fail: все проверки провалены. Это оценка адреса, а не этой проверки.';
|
|||
|
|
const STATUS_HINT = 'Состояние именно этой проверки (она всегда проваленная). «в вердикте»: результат пришёл до того, как система выставила вердикт адресу, и повлиял на него. «после вердикта»: результат пришёл позже, вердикт уже был выставлен и не пересчитывался. Поэтому у адреса с вердиктом pass может быть проваленная проверка.';
|
|||
|
|
const VERDICT_VAL = {pass: 'pass: к моменту вердикта все полученные проверки адреса были успешны. Эта проверка провалилась позже и в вердикт не вошла.', partial: 'partial: часть проверок адреса провалена или результатов не хватило, вердикт не pass.', fail: 'fail: все проверки адреса провалены.'};
|
|||
|
|
const STATUS_VAL = v => String(v).includes('после') ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.';
|
|||
|
|
const ERRCOLS = ['Адрес', 'Подсеть', 'Площадка', 'Валидатор', 'Вердикт адреса', 'Статус проверки'];
|
|||
|
|
let cur = null;
|
|||
|
|
function showModal(m) {
|
|||
|
|
cur = m;
|
|||
|
|
$('dlg-title').textContent = `${m.title} · ${fmt(m.rows.length)}`;
|
|||
|
|
$('dlg-note').textContent = `Запуск: ${m.r.label}. ${m.note}` + (m.r.demo ? ' Демонстрационный запуск: список — первые строки реального запуска от 02.10.' : '');
|
|||
|
|
$('dlg-dist').hidden = !m.dist; $('dlg-dist').innerHTML = m.dist || '';
|
|||
|
|
const hint = (m.hints || {});
|
|||
|
|
$('dlg-tbl').innerHTML = `<thead><tr>${m.cols.map((c, i) => hint[i] ? `<th ${tipAttr(hint[i])} tabindex="0">${c}<span class="hint" aria-hidden="true">ⓘ</span></th>` : `<th>${c}</th>`).join('')}</tr></thead><tbody>` + m.rows.map(x => `<tr>${x.map((v, i) => { const h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : ''; return `<td class="${i < 2 ? 'a' : ''}"${h ? ' ' + tipAttr(h) : ''}>${v}</td>`; }).join('')}</tr>`).join('') + '</tbody>';
|
|||
|
|
$('dlg-msg').textContent = '';
|
|||
|
|
const d = $('dlg'); if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
|||
|
|
}
|
|||
|
|
function openList(key) {
|
|||
|
|
const L = LISTS[key], r = state.run, all = L.rows(), n = Math.min(all.length, L.n(r));
|
|||
|
|
showModal({title: L.title, note: L.note, file: L.file, cols: L.cols, hints: L.hints, rows: r.demo ? all.slice(0, n) : all, r});
|
|||
|
|
}
|
|||
|
|
function openErr(cls) {
|
|||
|
|
const r = state.run, n = (r.errs.find(e => e[0] === cls) || [0, 0])[1];
|
|||
|
|
const all = REAL.errRows.filter(x => x[0] === cls), rows = (r.demo ? all.slice(0, n) : all).map(x => x.slice(1));
|
|||
|
|
const byVal = new Array(20).fill(0); rows.forEach(x => { byVal[+x[3].slice(1) - 1]++; });
|
|||
|
|
const max = Math.max(...byVal, 1), late = rows.filter(x => x[5] === 1).length, pass = rows.filter(x => x[4] === 'pass').length;
|
|||
|
|
const dist = `<h2>Распределение по валидаторам</h2>
|
|||
|
|
<div class="cols20" role="img" aria-label="Число проваленных проверок по валидаторам">${byVal.map((v, i) => `<div style="height:${Math.max(1, v / max * 100)}%" ${tipAttr('валидатор ' + (i + 1) + ': ' + v + ' проверок')}></div>`).join('')}</div>
|
|||
|
|
<div class="lab20">${byVal.map((v, i) => `<span>${i + 1}</span>`).join('')}</div>
|
|||
|
|
<div class="chips"><span class="tag t-n">всего ${fmt(rows.length)}</span><span class="tag t-ok">вердикт pass: ${fmt(pass)}</span><span class="tag t-warn">вердикт partial: ${fmt(rows.length - pass)}</span><span class="tag t-bad">после вердикта: ${fmt(late)}</span><span class="tag t-n">в вердикте: ${fmt(rows.length - late)}</span></div>`;
|
|||
|
|
const view = rows.map(x => [x[0], x[1], x[2], x[3], x[4], x[5] ? 'провал, после вердикта' : 'провал, в вердикте']);
|
|||
|
|
showModal({title: 'Ingress: ' + cls, note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
|
|||
|
|
file: 'ingress-errors-' + cls.toLowerCase().replace(/[^a-z0-9а-я]+/g, '-').replace(/^-|-$/g, ''), cols: ERRCOLS, rows: view, r, dist,
|
|||
|
|
hints: {3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT},
|
|||
|
|
cellHints: {4: v => VERDICT_VAL[v] || '', 5: STATUS_VAL}});
|
|||
|
|
}
|
|||
|
|
function csvText(cols, rows) {
|
|||
|
|
const q = v => '"' + String(v).replace(/"/g, '""') + '"';
|
|||
|
|
return '\ufeff' + [cols, ...rows].map(row => row.map(q).join(',')).join('\r\n') + '\r\n';
|
|||
|
|
}
|
|||
|
|
async function saveCsv() {
|
|||
|
|
const name = `${cur.file}_${cur.r.id}.csv`, text = csvText(cur.cols, cur.rows), msg = $('dlg-msg');
|
|||
|
|
let dl = null;
|
|||
|
|
try { dl = window.claude && window.claude.use ? await window.claude.use('downloads') : null; } catch (e) { dl = null; }
|
|||
|
|
if (dl) {
|
|||
|
|
try { await dl.save({filename: name, data: text}); msg.textContent = 'Файл ' + name + ' сохранён.'; }
|
|||
|
|
catch (e) { msg.textContent = e && e.code === 'declined' ? 'Сохранение отменено.' : 'Сохранить не удалось. Используйте «Копировать».'; }
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], {type: 'text/csv;charset=utf-8'})); a.download = name;
|
|||
|
|
document.body.appendChild(a); a.click(); a.remove(); msg.textContent = 'Файл ' + name + ' отправлен на скачивание.';
|
|||
|
|
} catch (e) { msg.textContent = 'Сохранить не удалось. Используйте «Копировать».'; }
|
|||
|
|
}
|
|||
|
|
async function copyCsv() {
|
|||
|
|
const msg = $('dlg-msg');
|
|||
|
|
try { await navigator.clipboard.writeText(csvText(cur.cols, cur.rows).replace('\ufeff', '')); msg.textContent = 'CSV скопирован в буфер обмена.'; }
|
|||
|
|
catch (e) { msg.textContent = 'Буфер обмена недоступен в этом окне.'; }
|
|||
|
|
}
|
|||
|
|
$('kpis').addEventListener('click', e => { const b = e.target.closest('[data-list]'); if (b) openList(b.dataset.list); });
|
|||
|
|
$('errs').addEventListener('click', e => { const b = e.target.closest('[data-cls]'); if (b) openErr(b.dataset.cls); });
|
|||
|
|
$('dlg-csv').addEventListener('click', saveCsv);
|
|||
|
|
$('dlg-copy').addEventListener('click', copyCsv);
|
|||
|
|
$('dlg-close').addEventListener('click', () => $('dlg').close());
|
|||
|
|
$('dlg').addEventListener('click', e => { if (e.target === $('dlg')) $('dlg').close(); });
|
|||
|
|
|
|||
|
|
/* tooltip (moved into the open dialog, otherwise the modal's top layer would cover it) */
|
|||
|
|
const tip = $('tip');
|
|||
|
|
function tipHost() { const d = $('dlg'); return d && d.open ? d : document.body; }
|
|||
|
|
document.addEventListener('mouseover', e => { const t = e.target.closest('[data-tip]'); if (t) { const h = tipHost(); if (tip.parentNode !== h) h.appendChild(tip); tip.textContent = t.dataset.tip; tip.style.opacity = 1; } });
|
|||
|
|
document.addEventListener('mousemove', e => { tip.style.left = Math.max(8, Math.min(e.clientX + 14, innerWidth - 376)) + 'px'; tip.style.top = (e.clientY + 16) + 'px'; });
|
|||
|
|
document.addEventListener('mouseout', e => { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
|
|||
|
|
render();
|
|||
|
|
</script>
|
|||
|
|
</body></html>
|