Візуальний diff конфігів

Збір працював, але подивитись на зібране було ніде: жодної сторінки, а
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 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 12:30:30 +03:00
parent 3816e2cfcf
commit cdf5f1fb54
12 changed files with 1290 additions and 0 deletions

View file

@ -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=`, кнопки немає).

View file

@ -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`, а не порожнім рядком: мовчазна порожнеча
виглядала б як «пристрій віддав порожній конфіг».
## Групи й доступ до хостів
Два незалежні виміри, які не можна змішувати в одному списку прав:

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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))

View file

@ -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
}

View file

@ -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() {
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
<Route path="/configs" element={<Guard perm="ncm:read" element={<ConfigsPage />} />} />
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
@ -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' },
]

View file

@ -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<DiffResult>(`/api/v1/configs/${id}/diff${from ? `?from=${from}` : ''}`),
listCredentials: () =>
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),

View file

@ -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' },

View file

@ -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<string, string> = {
success: 'up',
unchanged: 'maintenance',
queued: 'unknown',
running: 'warning',
failed: 'down',
timeout: 'down',
}
const jobLabel: Record<string, string> = {
success: 'зібрано',
unchanged: 'без змін',
queued: 'у черзі',
running: 'збирається',
failed: 'помилка',
timeout: 'таймаут',
}
/**
* Конфіги: список хостів зліва, історія й diff справа.
*
* Diff головне, заради чого сюди заходять. Питання завжди одне: «що
* змінилось цього разу», тому порівняння з попередньою версією
* відкривається одразу, без вибору «з чим порівняти».
*/
export function ConfigsPage() {
const [devices, setDevices] = useState<DeviceSummary[]>([])
const [selected, setSelected] = useState<DeviceSummary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<>
<PageHeader
title="Конфіги"
subtitle="Історія змін на обладнанні"
actions={
<input
className={`${inputClass} w-40 md:w-56`}
placeholder="пошук хоста…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
}
/>
<div className="min-h-0 flex-1 overflow-hidden">
{error && (
<div className="p-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{loading ? (
<Spinner />
) : (
<div className="flex h-full min-h-0 flex-col md:flex-row">
<div className="shrink-0 overflow-y-auto border-b border-slate-800 md:w-64 md:border-b-0 md:border-r">
<DataTable
rows={rows}
rowKey={(d) => d.id}
onRowClick={setSelected}
empty="Хостів немає"
columns={[
{
key: 'name',
header: 'Хост',
cell: (d) => (
<span
className={
selected?.id === d.id ? 'font-medium text-sky-300' : 'text-slate-200'
}
>
{d.name}
</span>
),
},
]}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
{selected ? (
<DeviceConfigs device={selected} canCollect={canCollect} />
) : (
<p className="py-16 text-center text-sm text-slate-600">Оберіть хост</p>
)}
</div>
</div>
)}
</div>
</>
)
}
function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canCollect: boolean }) {
const [versions, setVersions] = useState<ConfigVersion[]>([])
const [jobs, setJobs] = useState<ConfigJobRow[]>([])
const [open, setOpen] = useState<ConfigVersion | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold text-slate-100">{device.name}</h2>
<span className="text-xs text-slate-600">
{versions.length} версій
{versions[0] && ` · остання ${ago(versions[0].collected_at)} тому`}
</span>
{canCollect && (
<Button
className="ml-auto"
kind="primary"
disabled={busy || running}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await api.collectConfig(device.id)
await reload()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{running ? 'Збирається…' : 'Зібрати зараз'}
</Button>
)}
</div>
<ErrorNote>{err}</ErrorNote>
{jobs[0] && jobs[0].status !== 'success' && jobs[0].status !== 'unchanged' && (
<Card className="p-2.5 text-xs">
<span className="flex flex-wrap items-center gap-2">
<StatusBadge
status={jobTone[jobs[0].status] ?? 'unknown'}
label={jobLabel[jobs[0].status] ?? jobs[0].status}
/>
<span className="text-slate-500">
{jobs[0].trigger} · {ago(jobs[0].created_at)} тому
</span>
{jobs[0].error && <span className="text-red-300">{jobs[0].error}</span>}
</span>
</Card>
)}
{loading ? (
<Spinner />
) : versions.length === 0 ? (
<p className="py-10 text-center text-sm leading-relaxed text-slate-600">
Конфігів ще немає.
{canCollect
? ' Натисніть «Зібрати зараз» — потрібні профіль і доступ ssh або telnet.'
: ''}
</p>
) : (
<div className="grid gap-4 xl:grid-cols-[16rem_1fr]">
<Card className="max-h-96 overflow-y-auto">
<ul className="divide-y divide-slate-800/70">
{versions.map((v) => (
<li key={v.id}>
<button
className={`w-full px-3 py-2 text-left text-xs hover:bg-slate-800/50 ${
open?.id === v.id ? 'bg-slate-800/70' : ''
}`}
onClick={() => setOpen(v)}
>
<div className="text-slate-200">{fmtDate(v.collected_at)}</div>
<div className="mt-0.5 flex gap-2 text-[11px] text-slate-500">
<span>{v.line_count} рядків</span>
{(v.lines_added > 0 || v.lines_removed > 0) && (
<span>
<span className="text-emerald-400">+{v.lines_added}</span>{' '}
<span className="text-red-400">{v.lines_removed}</span>
</span>
)}
{!v.prev_config_id && <span className="text-sky-400">перша</span>}
</div>
</button>
</li>
))}
</ul>
</Card>
{open && <DiffView version={open} />}
</div>
)}
</div>
)
}
function DiffView({ version }: { version: ConfigVersion }) {
const [diff, setDiff] = useState<DiffResult | null>(null)
const [body, setBody] = useState<string | null>(null)
const [mode, setMode] = useState<'diff' | 'full'>('diff')
const [err, setErr] = useState<string | null>(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 (
<Card className="min-w-0">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-800 px-3 py-2">
<span className="text-xs text-slate-400">{fmtDate(version.collected_at)}</span>
{diff && !diff.first && (
<span className="text-xs">
<span className="text-emerald-400">+{diff.lines_added}</span>{' '}
<span className="text-red-400">{diff.lines_removed}</span>
</span>
)}
<div className="ml-auto flex gap-1">
<Button kind={mode === 'diff' ? 'primary' : 'default'} onClick={() => setMode('diff')}>
Зміни
</Button>
<Button kind={mode === 'full' ? 'primary' : 'default'} onClick={() => setMode('full')}>
Повний текст
</Button>
</div>
</div>
{err && (
<div className="p-3">
<ErrorNote>{err}</ErrorNote>
</div>
)}
{mode === 'full' ? (
body === null ? (
<Spinner />
) : (
<pre className="max-h-[32rem] overflow-auto p-3 text-[11px] leading-relaxed text-slate-300">
{body}
</pre>
)
) : diff === null ? (
<Spinner label="Порівняння…" />
) : diff.first ? (
<p className="px-3 py-8 text-center text-xs text-slate-600">
Перша зібрана версія порівнювати нема з чим
</p>
) : diff.hunks.length === 0 ? (
<p className="px-3 py-8 text-center text-xs text-slate-600">Змін немає</p>
) : (
<div className="max-h-[32rem] overflow-auto">
{diff.truncated && (
<p className="border-b border-amber-900/50 bg-amber-950/40 px-3 py-1.5 text-[11px] text-amber-200">
Версії розійшлися надто сильно показано грубу заміну блоку, а не порядкові зміни
</p>
)}
{diff.hunks.map((h, i) => (
<div key={i} className="border-b border-slate-800/70 last:border-0">
<div className="bg-slate-800/40 px-3 py-1 font-mono text-[10px] text-slate-500">
@@ {h.old_start},{h.old_lines} +{h.new_start},{h.new_lines} @@
</div>
<table className="w-full font-mono text-[11px] leading-relaxed">
<tbody>
{h.lines.map((l, j) => (
<tr
key={j}
className={
l.op === '+'
? 'bg-emerald-950/40'
: l.op === '-'
? 'bg-red-950/40'
: undefined
}
>
<td className="w-10 select-none px-1 text-right text-slate-600">
{l.old_num || ''}
</td>
<td className="w-10 select-none px-1 text-right text-slate-600">
{l.new_num || ''}
</td>
<td
className={`w-4 select-none text-center ${
l.op === '+'
? 'text-emerald-400'
: l.op === '-'
? 'text-red-400'
: 'text-slate-700'
}`}
>
{l.op === '=' ? ' ' : l.op}
</td>
<td className="whitespace-pre-wrap break-all px-1 text-slate-300">
{l.text}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)}
</Card>
)
}
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',
})
}

View file

@ -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