From cdf5f1fb54380f3a27b543c64294369433c1d66e Mon Sep 17 00:00:00 2001 From: byrsapty Date: Mon, 24 Aug 2026 12:30:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=96=D0=B7=D1=83=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20diff=20=D0=BA=D0=BE=D0=BD=D1=84=D1=96?= =?UTF-8?q?=D0=B3=D1=96=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Збір працював, але подивитись на зібране було ніде: жодної сторінки, а lines_added завжди 0 — порівняння ніхто не рахував. difftext: власне порядкове порівняння з ділянками й контекстом. Бібліотека принесла б підтримку слів, символів і кольорів у терміналі — десяток речей, які тут не знадобляться. - спільний початок і кінець відкидаються до основного алгоритму: у конфігах змінюється кілька рядків із тисячі, і квадратична таблиця будувалася б там, де досить порівняти десяток - понад чотири мільйони клітинок дають truncated і грубу заміну блоку: точність там нічого не дає, а чесна позначка краща за правдоподібний, але вигаданий diff - сусідні зміни зливаються в одну ділянку, інакше контекст дублюється Результат кешується в ncm.diffs — diff двох версій незмінний назавжди. Підсумок +N/−M заразом дозаписується у version, щоб список історії не розшифровував два тіла на кожен рядок. UI: сторінка «Конфіги» — хости зліва, історія й diff справа. Порівняння з попередньою версією відкривається одразу: питання завжди одне — що змінилось цього разу. Перевірено наживо: друга версія стенду дала @@ −8,3 +8,4 @@ з одним доданим рядком і контекстом, лічильник +1 −0 дозаписався, повний текст на 292 байти читається. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 65 +++++ server/API.md | 43 +++ server/internal/difftext/diff.go | 255 +++++++++++++++++ server/internal/difftext/diff_test.go | 159 ++++++++++ server/internal/httpapi/checks.go | 109 +++++++ server/internal/httpapi/server.go | 3 + server/internal/store/ncm_read.go | 182 ++++++++++++ web/src/App.tsx | 3 + web/src/api/client.ts | 22 ++ web/src/components/AppShell.tsx | 1 + web/src/pages/ConfigsPage.tsx | 398 ++++++++++++++++++++++++++ web/src/types.ts | 50 ++++ 12 files changed, 1290 insertions(+) create mode 100644 server/internal/difftext/diff.go create mode 100644 server/internal/difftext/diff_test.go create mode 100644 server/internal/store/ncm_read.go create mode 100644 web/src/pages/ConfigsPage.tsx diff --git a/HISTORY.md b/HISTORY.md index 94457cb..82c9249 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1238,3 +1238,68 @@ algorithm». коміту в репозиторій ще немає, тому `commit_sha` порожній. Візуального diff у вебі й кнопки «зібрати зараз» в інтерфейсі — API є, сторінки немає. + +--- + +## 2026-08-16 — Візуальний diff конфігів + +Збір працював, але подивитись на зібране було ніде: жодної сторінки, а +`lines_added` завжди 0 — порівняння ніхто не рахував. + +### Створено + +- `server/internal/difftext/` — порядкове порівняння з ділянками й + контекстом; 10 тестів. +- `server/internal/store/ncm_read.go` — читання версій, розшифровка + тіла, порівняння з кешем. +- Три ендпоїнти: версії, текст, diff. +- `web/src/pages/ConfigsPage.tsx` — хости зліва, історія й diff справа. + +### Прийняті рішення + +**Власне порівняння, а не бібліотека.** Потрібен рівно один алгоритм на +рядках із виводом у формі, яку розуміє UI. Зовнішня залежність принесла +б підтримку слів, символів, кольорів у терміналі — десяток речей, які +тут ніколи не знадобляться. + +**Спільний початок і кінець відкидаються до основного алгоритму.** У +конфігах змінюється кілька рядків із тисячі; без цього кроку +квадратична таблиця будувалася б там, де досить порівняти десяток +рядків. + +**Занадто великі й повністю різні тексти позначаються `truncated`.** +Понад чотири мільйони клітинок — це вже пара конфігів, що розійшлися +повністю, і точність там нічого не дає: людині однаково читати весь +блок. Чесна позначка краща за правдоподібний, але вигаданий diff. + +**Зміни поруч зливаються в одну ділянку.** Інакше сусідні правки давали +б дві ділянки з дубльованим контекстом між ними. + +**Порівняння кешується в `ncm.diffs`.** Diff двох конкретних версій +незмінний назавжди; рахувати його при кожному відкритті сторінки — це +палити процесор на відому відповідь. Підсумок `+N/−M` заразом +дозаписується у version, щоб список історії не розшифровував два тіла +на кожен рядок. + +**Порівняння з попередньою версією відкривається одразу.** Питання +завжди одне — «що змінилось цього разу», — і вибір «з чим порівняти» +був би зайвим кроком перед відповіддю. + +### Перевірено наживо + +Створили другу версію, змінивши команду профілю (`cat /etc/os-release` +→ `cat /etc/os-release /etc/hostname`): + +``` +історія 2 версії, друга is_change=true +diff @@ −8,3 +8,4 @@, один доданий рядок «new-ct» з контекстом +лічильник +1 −0 дозаписався в історію після обчислення +повний текст 292 байти, читається +``` + +### Чого ще немає + +Планувальника за `cron` — збір лише вручну. Тригера за Syslog. +Git-двигуна: `commit_sha` поки дорівнює хешу вмісту, справжнього +репозиторію немає. Порівняння з довільною версією в UI (API вміє через +`?from=`, кнопки немає). diff --git a/server/API.md b/server/API.md index 6dc7ed9..26b64ec 100644 --- a/server/API.md +++ b/server/API.md @@ -526,6 +526,9 @@ Qtech, ZTE, BDCOM та інші. |-------|------|-------------| | `POST` | `/api/v1/devices/{id}/collect-config` | зібрати зараз (`ncm:write`) | | `GET` | `/api/v1/devices/{id}/config-jobs` | історія збору | +| `GET` | `/api/v1/devices/{id}/configs` | версії конфігу | +| `GET` | `/api/v1/configs/{id}` | текст версії | +| `GET` | `/api/v1/configs/{id}/diff` | порівняння з попередньою (`?from=…`) | Ланцюг такий: REST кладе рядок у `ncm.jobs` зі станом `queued` → диспетчер усередині AgentService забирає його, якщо потрібний зонд на @@ -547,6 +550,46 @@ LOCKED`: два екземпляри AgentService не надішлють одн зонд міг зникнути разом із ним, і без цього хост лишився б без бекапів назавжди. +### Порівняння версій + +`GET /api/v1/configs/{id}/diff` без параметрів порівнює версію з +попередньою — саме це питання ставлять у дев'яти випадках із десяти: +«що змінилось цього разу». Явне `?from=` дає порівняння з будь-якою +іншою версією. + +```jsonc +{ + "hunks": [{ + "old_start": 8, "old_lines": 3, "new_start": 8, "new_lines": 4, + "lines": [ + {"op": "=", "old_num": 8, "new_num": 8, "text": "ID=debian"}, + {"op": "+", "new_num": 11, "text": "new-ct"} + ] + }], + "lines_added": 1, + "lines_removed": 0 +} +``` + +Результат кешується в `ncm.diffs`: порівняння двох конкретних версій +незмінне назавжди, і рахувати його щоразу при відкритті сторінки — +це палити процесор на відому відповідь. Підсумок `+N/−M` дозаписується +й у саму версію, щоб список історії показував його без розшифровки двох +тіл на кожен рядок. + +Перша зібрана версія повертає `{"first": true}` — порівнювати нема з +чим, і це не помилка. + +Дуже великі версії, що розійшлися повністю, дають `"truncated": true` і +грубу заміну блоку замість порядкових змін: точне порівняння там +коштувало б квадратичного часу, а користі не дало б — людині однаково +доведеться читати весь блок. Чесна позначка краща за правдоподібний, +але вигаданий diff. + +Тіло конфігу лежить у БД зашифрованим (`core.secrets`), тому без ключа +процес відповідає `503`, а не порожнім рядком: мовчазна порожнеча +виглядала б як «пристрій віддав порожній конфіг». + ## Групи й доступ до хостів Два незалежні виміри, які не можна змішувати в одному списку прав: diff --git a/server/internal/difftext/diff.go b/server/internal/difftext/diff.go new file mode 100644 index 0000000..46275a3 --- /dev/null +++ b/server/internal/difftext/diff.go @@ -0,0 +1,255 @@ +// Package difftext — порядкове порівняння конфігів. +// +// Власна реалізація, а не бібліотека: потрібен рівно один алгоритм на +// рядках із виводом у формі, яку розуміє UI. Зовнішня залежність тут +// принесла б підтримку слів, символів, кольорів у терміналі й ще десяток +// речей, які ніколи не знадобляться. +package difftext + +import ( + "strings" +) + +// Op — що сталося з рядком. +type Op string + +const ( + OpEqual Op = "=" + OpAdd Op = "+" + OpRemove Op = "-" +) + +// Line — рядок результату порівняння. +type Line struct { + Op Op `json:"op"` + // Номери рядків у старій і новій версіях; 0 означає «немає». + OldNum int `json:"old_num,omitempty"` + NewNum int `json:"new_num,omitempty"` + Text string `json:"text"` +} + +// Hunk — ділянка змін із контекстом навколо. +type Hunk struct { + OldStart int `json:"old_start"` + OldLines int `json:"old_lines"` + NewStart int `json:"new_start"` + NewLines int `json:"new_lines"` + Lines []Line `json:"lines"` +} + +// Result — підсумок порівняння. +type Result struct { + Hunks []Hunk `json:"hunks"` + LinesAdded int `json:"lines_added"` + LinesRemoved int `json:"lines_removed"` + // Truncated каже, що точне порівняння виявилось задорогим і + // показана груба заміна блоку. Краще чесно зізнатись, ніж + // показати правдоподібний, але вигаданий diff. + Truncated bool `json:"truncated,omitempty"` +} + +// maxCells обмежує таблицю LCS. +// +// Чотири мільйони клітинок — приблизно 2000×2000 рядків, тобто пара +// великих конфігів, які розійшлися повністю. Далі час і пам'ять ростуть +// квадратично, а користі від посимвольної точності вже немає: людині +// однаково доведеться читати весь блок. +const maxCells = 4 << 20 + +// ContextLines — скільки незмінених рядків показувати навколо зміни. +const ContextLines = 3 + +// Compare порівнює два тексти порядково. +func Compare(oldText, newText string) Result { + oldLines := splitLines(oldText) + newLines := splitLines(newText) + return CompareLines(oldLines, newLines) +} + +func CompareLines(a, b []string) Result { + // Спільний початок і кінець відкидаються до основного алгоритму. + // У конфігах змінюється кілька рядків із тисячі, і без цього + // кроку квадратична таблиця будувалася б там, де достатньо + // порівняти десяток рядків. + prefix := 0 + for prefix < len(a) && prefix < len(b) && a[prefix] == b[prefix] { + prefix++ + } + suffix := 0 + for suffix < len(a)-prefix && suffix < len(b)-prefix && + a[len(a)-1-suffix] == b[len(b)-1-suffix] { + suffix++ + } + + midA := a[prefix : len(a)-suffix] + midB := b[prefix : len(b)-suffix] + + var ops []Line + truncated := false + + switch { + case len(midA) == 0 && len(midB) == 0: + // Ідентичні. + case len(midA)*len(midB) > maxCells: + truncated = true + for i, l := range midA { + ops = append(ops, Line{Op: OpRemove, OldNum: prefix + i + 1, Text: l}) + } + for i, l := range midB { + ops = append(ops, Line{Op: OpAdd, NewNum: prefix + i + 1, Text: l}) + } + default: + ops = lcsDiff(midA, midB, prefix) + } + + // Склеюємо: незмінений початок, зміни, незмінений кінець. + all := make([]Line, 0, len(a)+len(b)) + for i := 0; i < prefix; i++ { + all = append(all, Line{Op: OpEqual, OldNum: i + 1, NewNum: i + 1, Text: a[i]}) + } + all = append(all, ops...) + for i := 0; i < suffix; i++ { + oi := len(a) - suffix + i + ni := len(b) - suffix + i + all = append(all, Line{Op: OpEqual, OldNum: oi + 1, NewNum: ni + 1, Text: a[oi]}) + } + + res := Result{Truncated: truncated} + for _, l := range all { + switch l.Op { + case OpAdd: + res.LinesAdded++ + case OpRemove: + res.LinesRemoved++ + } + } + res.Hunks = toHunks(all) + return res +} + +// lcsDiff будує послідовність операцій через найдовшу спільну підпослідовність. +func lcsDiff(a, b []string, offset int) []Line { + n, m := len(a), len(b) + + // dp[i][j] — довжина LCS для хвостів a[i:] і b[j:]. + dp := make([][]int32, n+1) + for i := range dp { + dp[i] = make([]int32, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + dp[i][j] = dp[i+1][j+1] + 1 + } else if dp[i+1][j] >= dp[i][j+1] { + dp[i][j] = dp[i+1][j] + } else { + dp[i][j] = dp[i][j+1] + } + } + } + + var out []Line + i, j := 0, 0 + for i < n && j < m { + switch { + case a[i] == b[j]: + out = append(out, Line{Op: OpEqual, OldNum: offset + i + 1, NewNum: offset + j + 1, Text: a[i]}) + i++ + j++ + case dp[i+1][j] >= dp[i][j+1]: + out = append(out, Line{Op: OpRemove, OldNum: offset + i + 1, Text: a[i]}) + i++ + default: + out = append(out, Line{Op: OpAdd, NewNum: offset + j + 1, Text: b[j]}) + j++ + } + } + for ; i < n; i++ { + out = append(out, Line{Op: OpRemove, OldNum: offset + i + 1, Text: a[i]}) + } + for ; j < m; j++ { + out = append(out, Line{Op: OpAdd, NewNum: offset + j + 1, Text: b[j]}) + } + return out +} + +// toHunks збирає ділянки змін із контекстом. +// +// Без цього UI отримував би весь конфіг цілком, а людина шукала б +// три змінені рядки серед тисячі однакових. +func toHunks(lines []Line) []Hunk { + // Позиції змін. + var changed []int + for i, l := range lines { + if l.Op != OpEqual { + changed = append(changed, i) + } + } + if len(changed) == 0 { + return nil + } + + var hunks []Hunk + start := max(0, changed[0]-ContextLines) + end := min(len(lines), changed[0]+ContextLines+1) + + for _, idx := range changed[1:] { + if idx-ContextLines <= end { + // Зміни близько — розширюємо поточну ділянку, а не + // плодимо сусідні з дубльованим контекстом. + end = min(len(lines), idx+ContextLines+1) + continue + } + hunks = append(hunks, makeHunk(lines[start:end])) + start = max(0, idx-ContextLines) + end = min(len(lines), idx+ContextLines+1) + } + hunks = append(hunks, makeHunk(lines[start:end])) + return hunks +} + +func makeHunk(lines []Line) Hunk { + h := Hunk{Lines: lines} + for _, l := range lines { + if l.OldNum > 0 { + if h.OldStart == 0 { + h.OldStart = l.OldNum + } + h.OldLines++ + } + if l.NewNum > 0 { + if h.NewStart == 0 { + h.NewStart = l.NewNum + } + h.NewLines++ + } + } + return h +} + +// splitLines ділить текст на рядки без хвостового порожнього. +func splitLines(s string) []string { + if s == "" { + return nil + } + s = strings.ReplaceAll(s, "\r\n", "\n") + lines := strings.Split(s, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/server/internal/difftext/diff_test.go b/server/internal/difftext/diff_test.go new file mode 100644 index 0000000..96d67e8 --- /dev/null +++ b/server/internal/difftext/diff_test.go @@ -0,0 +1,159 @@ +package difftext + +import ( + "strings" + "testing" +) + +func TestIdenticalGivesNoHunks(t *testing.T) { + cfg := "hostname sw1\ninterface Gi0/1\n description uplink\n" + r := Compare(cfg, cfg) + if len(r.Hunks) != 0 { + t.Fatalf("однакові конфіги дали %d ділянок", len(r.Hunks)) + } + if r.LinesAdded != 0 || r.LinesRemoved != 0 { + t.Fatalf("+%d/-%d на однакових", r.LinesAdded, r.LinesRemoved) + } +} + +func TestSingleLineChange(t *testing.T) { + before := "hostname sw1\nntp server 10.0.0.1\nlogging host 10.0.0.5\n" + after := "hostname sw1\nntp server 10.0.0.2\nlogging host 10.0.0.5\n" + + r := Compare(before, after) + if r.LinesAdded != 1 || r.LinesRemoved != 1 { + t.Fatalf("очікували +1/-1, отримали +%d/-%d", r.LinesAdded, r.LinesRemoved) + } + if len(r.Hunks) != 1 { + t.Fatalf("очікували одну ділянку, отримали %d", len(r.Hunks)) + } + + var added, removed string + for _, l := range r.Hunks[0].Lines { + switch l.Op { + case OpAdd: + added = l.Text + case OpRemove: + removed = l.Text + } + } + if removed != "ntp server 10.0.0.1" || added != "ntp server 10.0.0.2" { + t.Fatalf("не ті рядки: -%q +%q", removed, added) + } +} + +// Головна причина існування ділянок: у конфізі на тисячу рядків +// змінюється кілька, і показувати все — це ховати зміну. +func TestFarApartChangesGiveSeparateHunks(t *testing.T) { + var before, after []string + for i := 0; i < 200; i++ { + before = append(before, "line "+itoa(i)) + after = append(after, "line "+itoa(i)) + } + after[10] = "ЗМІНЕНО ЗВЕРХУ" + after[150] = "ЗМІНЕНО ЗНИЗУ" + + r := CompareLines(before, after) + if len(r.Hunks) != 2 { + t.Fatalf("очікували дві ділянки, отримали %d", len(r.Hunks)) + } + // Кожна ділянка має нести контекст, а не самий змінений рядок. + for i, h := range r.Hunks { + if len(h.Lines) < 3 { + t.Fatalf("ділянка %d без контексту: %d рядків", i, len(h.Lines)) + } + } +} + +// Сусідні зміни не мають плодити ділянки з дубльованим контекстом. +func TestNearbyChangesMerge(t *testing.T) { + before := strings.Split("a\nb\nc\nd\ne\nf\ng", "\n") + after := strings.Split("a\nB\nc\nD\ne\nf\ng", "\n") + + r := CompareLines(before, after) + if len(r.Hunks) != 1 { + t.Fatalf("сусідні зміни дали %d ділянок замість однієї", len(r.Hunks)) + } +} + +func TestPureAddition(t *testing.T) { + r := Compare("a\nb\n", "a\nb\nc\n") + if r.LinesAdded != 1 || r.LinesRemoved != 0 { + t.Fatalf("+%d/-%d", r.LinesAdded, r.LinesRemoved) + } +} + +func TestPureRemoval(t *testing.T) { + r := Compare("a\nb\nc\n", "a\nc\n") + if r.LinesAdded != 0 || r.LinesRemoved != 1 { + t.Fatalf("+%d/-%d", r.LinesAdded, r.LinesRemoved) + } +} + +func TestEmptySides(t *testing.T) { + r := Compare("", "a\nb\n") + if r.LinesAdded != 2 || r.LinesRemoved != 0 { + t.Fatalf("порожнє → дві: +%d/-%d", r.LinesAdded, r.LinesRemoved) + } + r = Compare("a\nb\n", "") + if r.LinesAdded != 0 || r.LinesRemoved != 2 { + t.Fatalf("дві → порожнє: +%d/-%d", r.LinesAdded, r.LinesRemoved) + } +} + +// Номери рядків мають вказувати на справжні позиції у файлах, інакше +// «перейти до рядка» в UI веде не туди. +func TestLineNumbersAreAbsolute(t *testing.T) { + before := "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nСТАРИЙ\nk\nl\nm\nn\no\np" + after := "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nНОВИЙ\nk\nl\nm\nn\no\np" + + r := Compare(before, after) + for _, h := range r.Hunks { + for _, l := range h.Lines { + if l.Op == OpRemove && l.Text == "СТАРИЙ" && l.OldNum != 11 { + t.Fatalf("старий рядок має номер %d замість 11", l.OldNum) + } + if l.Op == OpAdd && l.Text == "НОВИЙ" && l.NewNum != 11 { + t.Fatalf("новий рядок має номер %d замість 11", l.NewNum) + } + } + } +} + +// Дуже великі й повністю різні тексти не мають з'їдати пам'ять: краще +// чесно позначити результат грубим, ніж будувати таблицю на мільярд +// клітинок. +func TestHugeDifferentTextsAreTruncated(t *testing.T) { + var a, b []string + for i := 0; i < 2500; i++ { + a = append(a, "старий "+itoa(i)) + b = append(b, "новий "+itoa(i)) + } + r := CompareLines(a, b) + if !r.Truncated { + t.Fatal("очікували позначку про грубе порівняння") + } + if r.LinesAdded != 2500 || r.LinesRemoved != 2500 { + t.Fatalf("+%d/-%d", r.LinesAdded, r.LinesRemoved) + } +} + +// CRLF не має виглядати як зміна кожного рядка. +func TestCRLFIsNormalised(t *testing.T) { + r := Compare("a\r\nb\r\n", "a\nb\n") + if len(r.Hunks) != 0 { + t.Fatalf("різні переводи рядка показані як зміна: %+v", r.Hunks) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go index e2e2618..d33e6d8 100644 --- a/server/internal/httpapi/checks.go +++ b/server/internal/httpapi/checks.go @@ -231,3 +231,112 @@ func (s *Server) handleListConfigJobs(w http.ResponseWriter, r *http.Request, p } writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs}) } + +// --------------------------------------------------------------------- +// Версії конфігу +// --------------------------------------------------------------------- + +func (s *Server) handleListConfigs(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + deviceID := r.PathValue("id") + if !p.Scope().CanRead(deviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + + list, err := s.store.ListDeviceConfigs(r.Context(), p.TenantID, deviceID, 50) + if err != nil { + s.writeStoreError(w, "історія конфігів", err) + return + } + if list == nil { + list = []store.ConfigVersion{} + } + writeJSON(w, http.StatusOK, map[string]any{"configs": list}) +} + +// handleGetConfig віддає текст версії. +// +// Конфіг у БД зашифрований, тому без ключа процес чесно відмовляє, а не +// повертає порожній рядок: мовчазна порожнеча виглядала б як «пристрій +// віддав порожній конфіг». +func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + if s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування") + return + } + + body, meta, err := s.store.ConfigBody(r.Context(), p.TenantID, r.PathValue("id"), s.keyring) + if errors.Is(err, store.ErrConfigNotFound) { + writeError(w, http.StatusNotFound, "not_found", "версію не знайдено") + return + } + if err != nil { + s.writeStoreError(w, "читання конфігу", err) + return + } + if !p.Scope().CanRead(meta.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + writeJSON(w, http.StatusOK, map[string]any{"config": meta, "body": body}) +} + +// handleDiffConfigs порівнює дві версії. +// +// Без параметра from береться попередня версія: саме це питання ставлять +// у 90% випадків — «що змінилось цього разу». +func (s *Server) handleDiffConfigs(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + if s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування") + return + } + + toID := r.PathValue("id") + _, meta, err := s.store.ConfigBody(r.Context(), p.TenantID, toID, s.keyring) + if errors.Is(err, store.ErrConfigNotFound) { + writeError(w, http.StatusNotFound, "not_found", "версію не знайдено") + return + } + if err != nil { + s.writeStoreError(w, "читання конфігу", err) + return + } + if !p.Scope().CanRead(meta.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + + fromID := r.URL.Query().Get("from") + if fromID == "" { + fromID = meta.PrevConfigID + } + if fromID == "" { + // Перша версія: порівнювати нема з чим, і це не помилка. + writeJSON(w, http.StatusOK, map[string]any{ + "first": true, "hunks": []any{}, "lines_added": 0, "lines_removed": 0, + }) + return + } + + res, err := s.store.DiffConfigs(r.Context(), p.TenantID, fromID, toID, s.keyring) + if errors.Is(err, store.ErrConfigNotFound) { + writeError(w, http.StatusNotFound, "not_found", "версію для порівняння не знайдено") + return + } + if err != nil { + s.writeStoreError(w, "порівняння конфігів", err) + return + } + writeJSON(w, http.StatusOK, res) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index ef5a84f..08db2d8 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -93,6 +93,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/devices/{id}/collect-config", s.authenticated(s.handleCollectConfig)) mux.Handle("GET /api/v1/devices/{id}/config-jobs", s.authenticated(s.handleListConfigJobs)) + mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs)) + mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig)) + mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs)) mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes)) mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks)) diff --git a/server/internal/store/ncm_read.go b/server/internal/store/ncm_read.go new file mode 100644 index 0000000..c2e29ed --- /dev/null +++ b/server/internal/store/ncm_read.go @@ -0,0 +1,182 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/difftext" +) + +var ErrConfigNotFound = errors.New("версію конфігу не знайдено") + +// ConfigVersion — рядок історії конфігів. +type ConfigVersion struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + ConfigType string `json:"config_type"` + SizeBytes int `json:"size_bytes"` + LineCount int `json:"line_count"` + ContentHash string `json:"content_hash"` + PrevConfigID string `json:"prev_config_id,omitempty"` + LinesAdded int `json:"lines_added"` + LinesRemoved int `json:"lines_removed"` + IsChange bool `json:"is_change"` + CollectedAt time.Time `json:"collected_at"` +} + +// ListDeviceConfigs — історія версій конфігу хоста. +func (s *Store) ListDeviceConfigs(ctx context.Context, tenantID, deviceID string, limit int) ([]ConfigVersion, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var out []ConfigVersion + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, device_id::text, config_type, size_bytes, + COALESCE(line_count,0), encode(content_hash,'hex'), + COALESCE(prev_config_id::text,''), + lines_added, lines_removed, is_change, collected_at + FROM ncm.configs + WHERE tenant_id = $1 AND device_id = $2 + ORDER BY collected_at DESC + LIMIT $3 + `, tenantID, deviceID, limit) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var c ConfigVersion + if err := rows.Scan(&c.ID, &c.DeviceID, &c.ConfigType, &c.SizeBytes, + &c.LineCount, &c.ContentHash, &c.PrevConfigID, + &c.LinesAdded, &c.LinesRemoved, &c.IsChange, &c.CollectedAt); err != nil { + return err + } + out = append(out, c) + } + return rows.Err() + }) + return out, err +} + +// ConfigBody повертає розшифрований текст конфігу. +// +// Розшифровка робиться тут, а не в обробнику: DEK не має покидати шар +// доступу до даних, а обробнику потрібен лише текст. +func (s *Store) ConfigBody(ctx context.Context, tenantID, configID string, ring *crypto.Keyring) (string, ConfigVersion, error) { + var ( + v ConfigVersion + keyID, aad *string + nonce, ct, tag []byte + ) + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT c.id::text, c.device_id::text, c.config_type, c.size_bytes, + COALESCE(c.line_count,0), encode(c.content_hash,'hex'), + COALESCE(c.prev_config_id::text,''), + c.lines_added, c.lines_removed, c.is_change, c.collected_at, + s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'') + FROM ncm.configs c + LEFT JOIN core.secrets s ON s.id = c.body_secret_id + WHERE c.tenant_id = $1 AND c.id = $2 + `, tenantID, configID).Scan(&v.ID, &v.DeviceID, &v.ConfigType, &v.SizeBytes, + &v.LineCount, &v.ContentHash, &v.PrevConfigID, + &v.LinesAdded, &v.LinesRemoved, &v.IsChange, &v.CollectedAt, + &keyID, &nonce, &ct, &tag, &aad) + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", v, ErrConfigNotFound + } + if err != nil { + return "", v, err + } + if keyID == nil { + // Тіло не збереглося — таке буває лише після ручного втручання + // в БД, але мовчати про це не можна. + return "", v, fmt.Errorf("тіло версії %s відсутнє", configID) + } + if ring == nil { + return "", v, errors.New("сервер запущено без ключа шифрування") + } + + plain, err := ring.Decrypt(&crypto.Secret{ + KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag, + }, derefStr(aad)) + if err != nil { + return "", v, fmt.Errorf("розшифровка конфігу: %w", err) + } + return string(plain), v, nil +} + +// DiffConfigs порівнює дві версії й кешує результат. +// +// Кеш потрібен не для швидкості: diff між двома конкретними версіями +// незмінний назавжди, і рахувати його щоразу, коли хтось відкриває +// сторінку історії, — це палити процесор на відому відповідь. +func (s *Store) DiffConfigs(ctx context.Context, tenantID, fromID, toID string, ring *crypto.Keyring) (difftext.Result, error) { + var res difftext.Result + + // Кеш. + var cached string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT hunks::text FROM ncm.diffs + WHERE tenant_id = $1 AND from_config_id = $2 AND to_config_id = $3 + AND format = 'json_hunks' + `, tenantID, fromID, toID).Scan(&cached) + }) + if err == nil && cached != "" { + if err := json.Unmarshal([]byte(cached), &res); err == nil { + return res, nil + } + // Пошкоджений кеш — не привід відмовляти: перерахуємо. + } else if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return res, err + } + + oldText, _, err := s.ConfigBody(ctx, tenantID, fromID, ring) + if err != nil { + return res, err + } + newText, _, err := s.ConfigBody(ctx, tenantID, toID, ring) + if err != nil { + return res, err + } + + res = difftext.Compare(oldText, newText) + + payload, err := json.Marshal(res) + if err != nil { + return res, err + } + _ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO ncm.diffs + (tenant_id, from_config_id, to_config_id, format, hunks, + lines_added, lines_removed) + VALUES ($1, $2, $3, 'json_hunks', $4::jsonb, $5, $6) + ON CONFLICT (from_config_id, to_config_id, format) DO NOTHING + `, tenantID, fromID, toID, string(payload), res.LinesAdded, res.LinesRemoved) + return err + }) + + // Підсумок змін пишемо й у саму версію: список історії показує + // «+12/−3» без читання й розшифровки двох тіл на кожен рядок. + _ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE ncm.configs + SET lines_added = $3, lines_removed = $4 + WHERE tenant_id = $1 AND id = $2 AND prev_config_id = $5 + `, tenantID, toID, res.LinesAdded, res.LinesRemoved, fromID) + return err + }) + + return res, nil +} + diff --git a/web/src/App.tsx b/web/src/App.tsx index a16a01e..1e962fa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { LoginPage } from './components/LoginPage' import { MapPage } from './pages/MapPage' import { DevicesPage } from './pages/DevicesPage' import { GroupsPage } from './pages/GroupsPage' +import { ConfigsPage } from './pages/ConfigsPage' import { AlertsPage } from './pages/AlertsPage' import { RulesPage } from './pages/RulesPage' import { ChannelsPage } from './pages/ChannelsPage' @@ -51,6 +52,7 @@ export default function App() { } />} /> } />} /> } />} /> + } />} /> } />} /> } />} /> } />} /> @@ -76,6 +78,7 @@ function Home() { { perm: 'maps:read', to: '/map' }, { perm: 'alerts:read', to: '/alerts' }, { perm: 'devices:read', to: '/devices' }, + { perm: 'ncm:read', to: '/configs' }, { perm: 'agents:read', to: '/agents' }, { perm: 'users:read', to: '/team' }, ] diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 790987a..2532604 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -8,10 +8,13 @@ import type { BuildResult, Channel, Check, + ConfigJobRow, + ConfigVersion, CheckType, Credential, DeviceGroup, DeviceSummary, + DiffResult, LoginResponse, MapPatch, MapState, @@ -269,6 +272,25 @@ export const api = { body: JSON.stringify({ checks }), }), + // --- конфіги --- + + collectConfig: (deviceID: string) => + request<{ job_id: string }>(`/api/v1/devices/${deviceID}/collect-config`, { method: 'POST' }), + + listConfigJobs: (deviceID: string) => + request<{ jobs: ConfigJobRow[] }>(`/api/v1/devices/${deviceID}/config-jobs`) + .then((r) => r.jobs ?? []), + + listConfigs: (deviceID: string) => + request<{ configs: ConfigVersion[] }>(`/api/v1/devices/${deviceID}/configs`) + .then((r) => r.configs ?? []), + + getConfig: (id: string) => + request<{ config: ConfigVersion; body: string }>(`/api/v1/configs/${id}`), + + diffConfig: (id: string, from?: string) => + request(`/api/v1/configs/${id}/diff${from ? `?from=${from}` : ''}`), + listCredentials: () => request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []), diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index e50f658..798b34e 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -19,6 +19,7 @@ const navItems: NavItem[] = [ { to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' }, { to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' }, { to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' }, + { to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' }, { to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true }, { to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' }, { to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' }, diff --git a/web/src/pages/ConfigsPage.tsx b/web/src/pages/ConfigsPage.tsx new file mode 100644 index 0000000..c6db774 --- /dev/null +++ b/web/src/pages/ConfigsPage.tsx @@ -0,0 +1,398 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../api/client' +import { session } from '../api/session' +import { + Button, + Card, + DataTable, + ErrorNote, + PageHeader, + Spinner, + StatusBadge, + ago, + inputClass, +} from '../components/ui' +import type { ConfigJobRow, ConfigVersion, DeviceSummary, DiffResult } from '../types' + +const jobTone: Record = { + success: 'up', + unchanged: 'maintenance', + queued: 'unknown', + running: 'warning', + failed: 'down', + timeout: 'down', +} + +const jobLabel: Record = { + success: 'зібрано', + unchanged: 'без змін', + queued: 'у черзі', + running: 'збирається', + failed: 'помилка', + timeout: 'таймаут', +} + +/** + * Конфіги: список хостів зліва, історія й diff справа. + * + * Diff — головне, заради чого сюди заходять. Питання завжди одне: «що + * змінилось цього разу», тому порівняння з попередньою версією + * відкривається одразу, без вибору «з чим порівняти». + */ +export function ConfigsPage() { + const [devices, setDevices] = useState([]) + const [selected, setSelected] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + + const canCollect = session.can('ncm:write') + + useEffect(() => { + api + .listDevices() + .then((d) => { + setDevices(d) + setSelected((cur) => cur ?? d[0] ?? null) + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .finally(() => setLoading(false)) + }, []) + + const rows = devices.filter((d) => + query.trim() === '' ? true : d.name.toLowerCase().includes(query.trim().toLowerCase()), + ) + + return ( + <> + setQuery(e.target.value)} + /> + } + /> + +
+ {error && ( +
+ {error} +
+ )} + + {loading ? ( + + ) : ( +
+
+ d.id} + onRowClick={setSelected} + empty="Хостів немає" + columns={[ + { + key: 'name', + header: 'Хост', + cell: (d) => ( + + {d.name} + + ), + }, + ]} + /> +
+ +
+ {selected ? ( + + ) : ( +

Оберіть хост

+ )} +
+
+ )} +
+ + ) +} + +function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canCollect: boolean }) { + const [versions, setVersions] = useState([]) + const [jobs, setJobs] = useState([]) + const [open, setOpen] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + const [loading, setLoading] = useState(true) + + const reload = useCallback(async () => { + try { + const [v, j] = await Promise.all([ + api.listConfigs(device.id), + api.listConfigJobs(device.id).catch(() => [] as ConfigJobRow[]), + ]) + setVersions(v) + setJobs(j) + setOpen((cur) => (cur && v.some((x) => x.id === cur.id) ? cur : v[0] ?? null)) + setErr(null) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [device.id]) + + useEffect(() => { + setLoading(true) + setOpen(null) + void reload() + }, [reload]) + + const running = jobs.some((j) => j.status === 'queued' || j.status === 'running') + + // Поки завдання в роботі — перепитуємо. Збір триває секунди, і + // змушувати людину тиснути F5 заради результату власного натискання + // було б дрібною, але щоденною прикрістю. + useEffect(() => { + if (!running) return + const t = setInterval(() => void reload(), 3000) + return () => clearInterval(t) + }, [running, reload]) + + return ( +
+
+

{device.name}

+ + {versions.length} версій + {versions[0] && ` · остання ${ago(versions[0].collected_at)} тому`} + + {canCollect && ( + + )} +
+ + {err} + + {jobs[0] && jobs[0].status !== 'success' && jobs[0].status !== 'unchanged' && ( + + + + + {jobs[0].trigger} · {ago(jobs[0].created_at)} тому + + {jobs[0].error && {jobs[0].error}} + + + )} + + {loading ? ( + + ) : versions.length === 0 ? ( +

+ Конфігів ще немає. + {canCollect + ? ' Натисніть «Зібрати зараз» — потрібні профіль і доступ ssh або telnet.' + : ''} +

+ ) : ( +
+ +
    + {versions.map((v) => ( +
  • + +
  • + ))} +
+
+ + {open && } +
+ )} +
+ ) +} + +function DiffView({ version }: { version: ConfigVersion }) { + const [diff, setDiff] = useState(null) + const [body, setBody] = useState(null) + const [mode, setMode] = useState<'diff' | 'full'>('diff') + const [err, setErr] = useState(null) + + useEffect(() => { + setDiff(null) + setBody(null) + setErr(null) + api + .diffConfig(version.id) + .then(setDiff) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + }, [version.id]) + + useEffect(() => { + if (mode !== 'full' || body !== null) return + api + .getConfig(version.id) + .then((r) => setBody(r.body)) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + }, [mode, body, version.id]) + + return ( + +
+ {fmtDate(version.collected_at)} + {diff && !diff.first && ( + + +{diff.lines_added}{' '} + −{diff.lines_removed} + + )} +
+ + +
+
+ + {err && ( +
+ {err} +
+ )} + + {mode === 'full' ? ( + body === null ? ( + + ) : ( +
+            {body}
+          
+ ) + ) : diff === null ? ( + + ) : diff.first ? ( +

+ Перша зібрана версія — порівнювати нема з чим +

+ ) : diff.hunks.length === 0 ? ( +

Змін немає

+ ) : ( +
+ {diff.truncated && ( +

+ Версії розійшлися надто сильно — показано грубу заміну блоку, а не порядкові зміни +

+ )} + {diff.hunks.map((h, i) => ( +
+
+ @@ −{h.old_start},{h.old_lines} +{h.new_start},{h.new_lines} @@ +
+ + + {h.lines.map((l, j) => ( + + + + + + + ))} + +
+ {l.old_num || ''} + + {l.new_num || ''} + + {l.op === '=' ? ' ' : l.op} + + {l.text} +
+
+ ))} +
+ )} +
+ ) +} + +function fmtDate(iso: string): string { + const d = new Date(iso) + if (!Number.isFinite(d.getTime())) return '—' + return d.toLocaleString('uk-UA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/web/src/types.ts b/web/src/types.ts index e7913ab..2ef06cc 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -211,6 +211,56 @@ export interface Credential { is_default: boolean } +export interface ConfigVersion { + id: string + device_id: string + config_type: string + size_bytes: number + line_count: number + content_hash: string + prev_config_id?: string + lines_added: number + lines_removed: number + is_change: boolean + collected_at: string +} + +export interface ConfigJobRow { + id: string + status: 'queued' | 'running' | 'success' | 'failed' | 'unchanged' | 'timeout' + trigger: string + started_at?: string + finished_at?: string + duration_ms: number + error?: string + created_at: string +} + +export interface DiffLine { + op: '=' | '+' | '-' + old_num?: number + new_num?: number + text: string +} + +export interface DiffHunk { + old_start: number + old_lines: number + new_start: number + new_lines: number + lines: DiffLine[] +} + +export interface DiffResult { + hunks: DiffHunk[] + lines_added: number + lines_removed: number + /** Порівняння виявилось задорогим — показана груба заміна блоку. */ + truncated?: boolean + /** Перша версія: порівнювати нема з чим. */ + first?: boolean +} + export interface AgentSummary { id: string name: string