Візуальний 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:
parent
3816e2cfcf
commit
cdf5f1fb54
12 changed files with 1290 additions and 0 deletions
65
HISTORY.md
65
HISTORY.md
|
|
@ -1238,3 +1238,68 @@ algorithm».
|
||||||
коміту в репозиторій ще немає, тому `commit_sha` порожній. Візуального
|
коміту в репозиторій ще немає, тому `commit_sha` порожній. Візуального
|
||||||
diff у вебі й кнопки «зібрати зараз» в інтерфейсі — API є, сторінки
|
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=`, кнопки немає).
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,9 @@ Qtech, ZTE, BDCOM та інші.
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `POST` | `/api/v1/devices/{id}/collect-config` | зібрати зараз (`ncm:write`) |
|
| `POST` | `/api/v1/devices/{id}/collect-config` | зібрати зараз (`ncm:write`) |
|
||||||
| `GET` | `/api/v1/devices/{id}/config-jobs` | історія збору |
|
| `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` →
|
Ланцюг такий: REST кладе рядок у `ncm.jobs` зі станом `queued` →
|
||||||
диспетчер усередині AgentService забирає його, якщо потрібний зонд на
|
диспетчер усередині 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`, а не порожнім рядком: мовчазна порожнеча
|
||||||
|
виглядала б як «пристрій віддав порожній конфіг».
|
||||||
|
|
||||||
## Групи й доступ до хостів
|
## Групи й доступ до хостів
|
||||||
|
|
||||||
Два незалежні виміри, які не можна змішувати в одному списку прав:
|
Два незалежні виміри, які не можна змішувати в одному списку прав:
|
||||||
|
|
|
||||||
255
server/internal/difftext/diff.go
Normal file
255
server/internal/difftext/diff.go
Normal 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
|
||||||
|
}
|
||||||
159
server/internal/difftext/diff_test.go
Normal file
159
server/internal/difftext/diff_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -231,3 +231,112 @@ func (s *Server) handleListConfigJobs(w http.ResponseWriter, r *http.Request, p
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("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}/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/check-types", s.authenticated(s.handleListCheckTypes))
|
||||||
mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks))
|
mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks))
|
||||||
|
|
|
||||||
182
server/internal/store/ncm_read.go
Normal file
182
server/internal/store/ncm_read.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { LoginPage } from './components/LoginPage'
|
||||||
import { MapPage } from './pages/MapPage'
|
import { MapPage } from './pages/MapPage'
|
||||||
import { DevicesPage } from './pages/DevicesPage'
|
import { DevicesPage } from './pages/DevicesPage'
|
||||||
import { GroupsPage } from './pages/GroupsPage'
|
import { GroupsPage } from './pages/GroupsPage'
|
||||||
|
import { ConfigsPage } from './pages/ConfigsPage'
|
||||||
import { AlertsPage } from './pages/AlertsPage'
|
import { AlertsPage } from './pages/AlertsPage'
|
||||||
import { RulesPage } from './pages/RulesPage'
|
import { RulesPage } from './pages/RulesPage'
|
||||||
import { ChannelsPage } from './pages/ChannelsPage'
|
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="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
||||||
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
||||||
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
|
<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="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
|
||||||
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
||||||
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
|
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
|
||||||
|
|
@ -76,6 +78,7 @@ function Home() {
|
||||||
{ perm: 'maps:read', to: '/map' },
|
{ perm: 'maps:read', to: '/map' },
|
||||||
{ perm: 'alerts:read', to: '/alerts' },
|
{ perm: 'alerts:read', to: '/alerts' },
|
||||||
{ perm: 'devices:read', to: '/devices' },
|
{ perm: 'devices:read', to: '/devices' },
|
||||||
|
{ perm: 'ncm:read', to: '/configs' },
|
||||||
{ perm: 'agents:read', to: '/agents' },
|
{ perm: 'agents:read', to: '/agents' },
|
||||||
{ perm: 'users:read', to: '/team' },
|
{ perm: 'users:read', to: '/team' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@ import type {
|
||||||
BuildResult,
|
BuildResult,
|
||||||
Channel,
|
Channel,
|
||||||
Check,
|
Check,
|
||||||
|
ConfigJobRow,
|
||||||
|
ConfigVersion,
|
||||||
CheckType,
|
CheckType,
|
||||||
Credential,
|
Credential,
|
||||||
DeviceGroup,
|
DeviceGroup,
|
||||||
DeviceSummary,
|
DeviceSummary,
|
||||||
|
DiffResult,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
MapPatch,
|
MapPatch,
|
||||||
MapState,
|
MapState,
|
||||||
|
|
@ -269,6 +272,25 @@ export const api = {
|
||||||
body: JSON.stringify({ checks }),
|
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: () =>
|
listCredentials: () =>
|
||||||
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),
|
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const navItems: NavItem[] = [
|
||||||
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
||||||
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
|
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
|
||||||
{ to: '/groups', 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: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
||||||
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
||||||
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
||||||
|
|
|
||||||
398
web/src/pages/ConfigsPage.tsx
Normal file
398
web/src/pages/ConfigsPage.tsx
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -211,6 +211,56 @@ export interface Credential {
|
||||||
is_default: boolean
|
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 {
|
export interface AgentSummary {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue