From 2816c94cc8ec52f679f613e66290da71080df9bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:43:21 +0000 Subject: [PATCH 1/8] One-click releases report themselves as official builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release made from Actions → Run workflow builds on main, not on a tag, so its binaries said "community build". A version entered by hand is a release too. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71d08d1..dbc37f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,9 @@ jobs: if [[ -n "${{ matrix.goarch }}" ]]; then export GOARCH="${{ matrix.goarch }}" CC="${{ matrix.cc }}"; fi # one self-contained binary per platform out="dist/traffic66-${{ matrix.target }}/$exe" - kind=community; [[ "$GITHUB_REF" == refs/tags/* ]] && kind=official + # a tag push and a one-click release (a version entered by hand) + # are both releases; only the builds of every push stay community + kind=community; [[ "$GITHUB_REF" == refs/tags/* || -n "${{ inputs.version }}" ]] && kind=official ldflags="-s -w -X main.version=${ver} -X main.buildKind=${kind}" tags="" export CGO_ENABLED=1 From af9edb1a29c962bbd6066184acff7746ef7283a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:47:04 +0000 Subject: [PATCH 2/8] The evaluation mark a step darker and a size larger Opacity .16 on screen (was .12) and .25 in print (was .2); the word is 26 px in its tile instead of 23. The tile and so the density stay. --- internal/web/static/app.css | 4 ++-- internal/web/static/app.js | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 8902a29..756aa14 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -476,11 +476,11 @@ body.mustchange #nav,body.mustchange #acct,body.mustchange .ifsel{display:none} body.marked .view.on{position:relative} body.marked .view.on::after{ content:"";position:absolute;inset:0;z-index:40;pointer-events:none; - background-image:var(--mark);background-repeat:repeat;opacity:.12} + background-image:var(--mark);background-repeat:repeat;opacity:.16} @media print{ /* Print flattens and lightens, so the mark is given a little more to survive it — still far below what would obscure a number. */ - body.marked .view.on::after{opacity:.2;-webkit-print-color-adjust:exact;print-color-adjust:exact} + body.marked .view.on::after{opacity:.25;-webkit-print-color-adjust:exact;print-color-adjust:exact} } /* The mark's clickable part. The tile itself cannot take clicks without diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 40f97ff..aa4df75 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -27,11 +27,12 @@ function markTile() { // across the whole view, and the tile's size is what sets how often the // word appears. 360x235 puts about half again as many of them on a // screen as the 460x300 it started at, which is what makes the mark - // legible as a mark; the glyph itself is the same size and the same - // faint ink, so nothing underneath became harder to read. + // legible as a mark. The glyph is a size up from the 23 it started at + // and the ink a step darker (see app.css), and still light enough that + // nothing underneath becomes harder to read. const svg = `` + `${esc(text)}`; return `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}")`; } From 128b032e04e5dd488c4c6d23cb27fd87a98d46a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:48:02 +0000 Subject: [PATCH 3/8] The names page is called Inventory again, in every language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is the inventory file's own page (台账, 台帳, Inventar, Реестр …), and it sits in the sidebar now, so the hints that sent people to Settings → Names point at it directly. --- docs/MANUAL.ar.md | 2 +- docs/MANUAL.bn.md | 2 +- docs/MANUAL.de.md | 2 +- docs/MANUAL.es.md | 2 +- docs/MANUAL.fr.md | 2 +- docs/MANUAL.hi.md | 2 +- docs/MANUAL.id.md | 2 +- docs/MANUAL.ja.md | 2 +- docs/MANUAL.ko.md | 2 +- docs/MANUAL.md | 2 +- docs/MANUAL.pt.md | 2 +- docs/MANUAL.ru.md | 2 +- docs/MANUAL.ur.md | 2 +- docs/MANUAL.vi.md | 2 +- docs/MANUAL.zh.md | 2 +- internal/web/static/i18n/ar.json | 10 +++++----- internal/web/static/i18n/bn.json | 10 +++++----- internal/web/static/i18n/de.json | 10 +++++----- internal/web/static/i18n/en.json | 10 +++++----- internal/web/static/i18n/es.json | 10 +++++----- internal/web/static/i18n/fr.json | 10 +++++----- internal/web/static/i18n/hi.json | 10 +++++----- internal/web/static/i18n/id.json | 10 +++++----- internal/web/static/i18n/ja.json | 10 +++++----- internal/web/static/i18n/ko.json | 10 +++++----- internal/web/static/i18n/pt.json | 10 +++++----- internal/web/static/i18n/ru.json | 10 +++++----- internal/web/static/i18n/ur.json | 10 +++++----- internal/web/static/i18n/vi.json | 10 +++++----- internal/web/static/i18n/zh.json | 10 +++++----- inventory.txt.example | 2 +- 31 files changed, 91 insertions(+), 91 deletions(-) diff --git a/docs/MANUAL.ar.md b/docs/MANUAL.ar.md index e80a52d..745da5a 100644 --- a/docs/MANUAL.ar.md +++ b/docs/MANUAL.ar.md @@ -328,7 +328,7 @@ or udp port 4739`: إن لم يظهر شيء فالمشكلة في التوجي ## 7. الأسماء والدول وقوائم التهديدات -انقر على أي عنوان واختر **تسمية…**، أو استخدم **الإعدادات ← الأسماء**. +انقر على أي عنوان واختر **تسمية…**، أو استخدم **السجل**. تُحفظ الأسماء في `inventory.txt` داخل دليل البيانات: ``` diff --git a/docs/MANUAL.bn.md b/docs/MANUAL.bn.md index 6515cf2..ce2c63e 100644 --- a/docs/MANUAL.bn.md +++ b/docs/MANUAL.bn.md @@ -329,7 +329,7 @@ traffic66 আগে থেকেই ডিভাইস যে rate প্রয ## 7. নাম, দেশ ও threat list -যেকোনো address-এ ক্লিক করে **নাম দিন…** বেছে নিন, অথবা **সেটিংস → নাম** +যেকোনো address-এ ক্লিক করে **নাম দিন…** বেছে নিন, অথবা **ইনভেন্টরি** ব্যবহার করুন। নামগুলো data directory-র `inventory.txt`-এ থাকে: ``` diff --git a/docs/MANUAL.de.md b/docs/MANUAL.de.md index 2413691..e5ec025 100644 --- a/docs/MANUAL.de.md +++ b/docs/MANUAL.de.md @@ -339,7 +339,7 @@ hinzu (`-l2-overhead`). ## 7. Namen, Länder und Bedrohungslisten Klicken Sie auf eine beliebige Adresse und wählen Sie **Benennen…**, oder -verwenden Sie **Einstellungen → Namen**. Namen werden in `inventory.txt` im +verwenden Sie **Inventar**. Namen werden in `inventory.txt` im Datenverzeichnis gespeichert: ``` diff --git a/docs/MANUAL.es.md b/docs/MANUAL.es.md index 7c16106..4076bdd 100644 --- a/docs/MANUAL.es.md +++ b/docs/MANUAL.es.md @@ -337,7 +337,7 @@ Ethernet a NetFlow/IPFIX (`-l2-overhead`). ## 7. Nombres, países y listas de amenazas Haga clic en cualquier dirección y elija **Ponerle nombre…**, o use -**Configuración → Nombres**. Los nombres se guardan en `inventory.txt` +**Inventario**. Los nombres se guardan en `inventory.txt` dentro del directorio de datos: ``` diff --git a/docs/MANUAL.fr.md b/docs/MANUAL.fr.md index 741c692..7c0fc8b 100644 --- a/docs/MANUAL.fr.md +++ b/docs/MANUAL.fr.md @@ -343,7 +343,7 @@ minutes et ajoute 18 octets par paquet de surcoût Ethernet à NetFlow/IPFIX ## 7. Noms, pays et listes de menaces Cliquez sur n'importe quelle adresse et choisissez **Nommer…**, ou utilisez -**Paramètres → Noms**. Les noms sont enregistrés dans `inventory.txt` dans +**Inventaire**. Les noms sont enregistrés dans `inventory.txt` dans le répertoire de données : ``` diff --git a/docs/MANUAL.hi.md b/docs/MANUAL.hi.md index 79954f2..4344fa1 100644 --- a/docs/MANUAL.hi.md +++ b/docs/MANUAL.hi.md @@ -329,7 +329,7 @@ traffic66 पहले से डिवाइस द्वारा लगाय ## 7. नाम, देश और threat lists -किसी भी address पर क्लिक करें और **नाम दें…** चुनें, या **सेटिंग्स → नाम** +किसी भी address पर क्लिक करें और **नाम दें…** चुनें, या **इन्वेंटरी** इस्तेमाल करें। नाम data directory में `inventory.txt` में रखे जाते हैं: ``` diff --git a/docs/MANUAL.id.md b/docs/MANUAL.id.md index c67b3be..30ce405 100644 --- a/docs/MANUAL.id.md +++ b/docs/MANUAL.id.md @@ -335,7 +335,7 @@ NetFlow/IPFIX (`-l2-overhead`). ## 7. Nama, negara, dan daftar ancaman Klik alamat mana pun lalu pilih **Beri nama…**, atau gunakan -**Pengaturan → Nama**. Nama disimpan di `inventory.txt` di direktori data: +**Inventaris**. Nama disimpan di `inventory.txt` di direktori data: ``` net 10.10.0.0/16 Office LAN # your networks diff --git a/docs/MANUAL.ja.md b/docs/MANUAL.ja.md index 8b60643..8128bae 100644 --- a/docs/MANUAL.ja.md +++ b/docs/MANUAL.ja.md @@ -289,7 +289,7 @@ traffic66 はすでに、機器が適用したレートを使い、不明なレ ## 7. 名前、国・地域、脅威リスト -任意のアドレスをクリックして **名前を付ける…** を選ぶか、**設定 → 名前** を使います。名前はデータディレクトリの `inventory.txt` に保存されます: +任意のアドレスをクリックして **名前を付ける…** を選ぶか、**台帳** を使います。名前はデータディレクトリの `inventory.txt` に保存されます: ``` net 10.10.0.0/16 Office LAN # your networks diff --git a/docs/MANUAL.ko.md b/docs/MANUAL.ko.md index a434ecb..f003cdb 100644 --- a/docs/MANUAL.ko.md +++ b/docs/MANUAL.ko.md @@ -289,7 +289,7 @@ traffic66은 이미 장비가 적용한 비율을 사용하고, 알 수 없는 ## 7. 이름, 국가, 위협 목록 -아무 주소나 클릭해 **이름 붙이기…**를 선택하거나, **설정 → 이름**을 사용하십시오. 이름은 데이터 디렉터리의 `inventory.txt`에 저장됩니다: +아무 주소나 클릭해 **이름 붙이기…**를 선택하거나, **대장**을 사용하십시오. 이름은 데이터 디렉터리의 `inventory.txt`에 저장됩니다: ``` net 10.10.0.0/16 Office LAN # your networks diff --git a/docs/MANUAL.md b/docs/MANUAL.md index b4b7007..2d3c0fa 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -305,7 +305,7 @@ adds 18 bytes per packet of Ethernet overhead to NetFlow/IPFIX ## 7. Names, countries/regions and threat lists -Click any address and choose **Name it…**, or use **Settings → Names**. +Click any address and choose **Name it…**, or use **Inventory**. Names are kept in `inventory.txt` in the data directory: ``` diff --git a/docs/MANUAL.pt.md b/docs/MANUAL.pt.md index 2af870d..32dbd04 100644 --- a/docs/MANUAL.pt.md +++ b/docs/MANUAL.pt.md @@ -334,7 +334,7 @@ seus minutos e soma 18 bytes de overhead Ethernet por pacote ao NetFlow/IPFIX ## 7. Nomes, países e listas de ameaças Clique em qualquer endereço e escolha **Dar um nome…**, ou use -**Configurações → Nomes**. Os nomes ficam em `inventory.txt` no diretório +**Inventário**. Os nomes ficam em `inventory.txt` no diretório de dados: ``` diff --git a/docs/MANUAL.ru.md b/docs/MANUAL.ru.md index 1c6e19a..52786d9 100644 --- a/docs/MANUAL.ru.md +++ b/docs/MANUAL.ru.md @@ -336,7 +336,7 @@ traffic66 уже использует частоту, применённую у ## 7. Названия, страны и списки угроз Щёлкните любой адрес и выберите **Дать название…** или используйте -**Настройки → Названия**. Названия хранятся в `inventory.txt` в каталоге +**Реестр**. Названия хранятся в `inventory.txt` в каталоге данных: ``` diff --git a/docs/MANUAL.ur.md b/docs/MANUAL.ur.md index 1abc850..38b356b 100644 --- a/docs/MANUAL.ur.md +++ b/docs/MANUAL.ur.md @@ -331,7 +331,7 @@ traffic66 پہلے ہی وہ rate استعمال کرتا ہے جو ڈیوائس ## 7. نام، ممالک اور threat lists -کسی بھی address پر کلک کر کے **نام دیں…** چنیں، یا **ترتیبات → نام** استعمال +کسی بھی address پر کلک کر کے **نام دیں…** چنیں، یا **رجسٹر** استعمال کریں۔ نام data directory میں `inventory.txt` میں رکھے جاتے ہیں: ``` diff --git a/docs/MANUAL.vi.md b/docs/MANUAL.vi.md index 3a8ce41..4d5b181 100644 --- a/docs/MANUAL.vi.md +++ b/docs/MANUAL.vi.md @@ -331,7 +331,7 @@ overhead Ethernet cho mỗi gói tin với NetFlow/IPFIX (`-l2-overhead`). ## 7. Tên, quốc gia và danh sách mối đe dọa Nhấp vào bất kỳ địa chỉ nào và chọn **Đặt tên…**, hoặc dùng -**Cài đặt → Tên**. Tên được lưu trong `inventory.txt` ở thư mục dữ liệu: +**Sổ quản lý**. Tên được lưu trong `inventory.txt` ở thư mục dữ liệu: ``` net 10.10.0.0/16 Office LAN # your networks diff --git a/docs/MANUAL.zh.md b/docs/MANUAL.zh.md index 83fe75e..7bae6b7 100644 --- a/docs/MANUAL.zh.md +++ b/docs/MANUAL.zh.md @@ -289,7 +289,7 @@ traffic66 已经做了这些处理:使用设备实际生效的采样率,等 ## 7. 名称、国家/地区与威胁情报列表 -点击任意地址并选择 **起个名字…**,或使用 **设定 → 名称**。名称保存在数据目录下的 `inventory.txt` 中: +点击任意地址并选择 **起个名字…**,或使用 **台账**。名称保存在数据目录下的 `inventory.txt` 中: ``` net 10.10.0.0/16 Office LAN # your networks diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index 67e0ff2..f54851c 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -174,7 +174,7 @@ "if.in": "وارد", "if.out": "صادر", "if.no_ctr_short": "بلا عدّادات", -"if.no_counters": "هذا الجهاز لا يرسل عدادات الواجهات، لذلك لا يوجد ما نقارن به. أضف سطرًا مثل snmp 10.0.0.1 public في الإعدادات ← الأسماء لقراءتها عبر SNMP.", +"if.no_counters": "هذا الجهاز لا يرسل عدادات الواجهات، لذلك لا يوجد ما نقارن به. أضف سطرًا مثل snmp 10.0.0.1 public في السجل لقراءتها عبر SNMP.", "if.no_flow": "لم تصل أي بيانات تدفق لهذه الواجهة — تحقق من تصدير التدفقات في الجهاز (هل أُعدّ أخذ العينات دون تصدير؟)", "if.no_flow_short": "لا تدفقات", "if.ok": "متطابق. يختلف التقدير عن عدّاد الجهاز بمقدار {dev}، ضمن الخطأ الإحصائي لأخذ العينات ({err}).", @@ -201,7 +201,7 @@ "src.title": "المصادر", "src.sub": "هل يرسل كل جهاز، وهل يمكن فك البيانات، وهل معدل أخذ العينات صحيح", "src.listeners": "الاستماع على", -"src.names": "الأسماء", +"src.names": "السجل", "src.names_sub": "اختياري. سمِّ الشبكات والأجهزة والواجهات والمضيفين، سطرًا لكل واحد.", "src.save": "حفظ", "src.saved": "تم الحفظ", @@ -214,7 +214,7 @@ "st.bad": "لا استقبال", "issue.silent": "لم يصل شيء منذ أكثر من دقيقتين. تحقق من وجهة التصدير على الجهاز ومن سماح الجدران النارية بـ UDP.", "issue.waiting_sampling": "وصلت القوالب لكن معدل أخذ العينات لم يصل بعد. هناك {n} سجلًا محجوزًا وسيُعاد حسابها عند وصوله، ولا تُحسب 1:1. تعيد الأجهزة إرساله عادةً خلال دقائق.", -"issue.assumed_unsampled": "لم يعلن هذا الجهاز معدل أخذ العينات، لذا تُحسب تدفقاته 1:1. إذا كان يأخذ عينات فاجعله يصدّر خيارات أداة العينات، أو اكتب المعدل في الإعدادات ← الأسماء، مثل device {a} الاسم sampling=1000. وإذا كان يصدّر كل الحزم فأضف unsampled إلى ذلك السطر.", +"issue.assumed_unsampled": "لم يعلن هذا الجهاز معدل أخذ العينات، لذا تُحسب تدفقاته 1:1. إذا كان يأخذ عينات فاجعله يصدّر خيارات أداة العينات، أو اكتب المعدل في السجل، مثل device {a} الاسم sampling=1000. وإذا كان يصدّر كل الحزم فأضف unsampled إلى ذلك السطر.", "issue.loss": "فُقد {p} من حزم التصدير في الطريق (فجوات في الأرقام التسلسلية). المجاميع معوَّضة، أما تفاصيل كل مضيف فلا. إذا أظهر “netstat -su” على هذا الجهاز تزايد receive buffer errors فارفع net.core.rmem_max، وإلا فافحص مسار الشبكة.", "issue.no_template": "البيانات تصل لكن قالبها لم يصل بعد. حُجزت البيانات وستُفك فور وصول القالب.", "issue.clock_skew": "ساعة الجهاز منحرفة بمقدار {s}. صُحِّحت الأوقات إلى وقت الوصول.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "الحلقة الداخلية: أكثر العملاء حركة؛ الخارجية: الخوادم التي يستخدمها كل منهم", "rg.svc_sub_v": "الحلقة الداخلية: أكثر الخدمات حركة؛ الخارجية: الخوادم التي تقدمها", "rg.svc_sub_s": "الحلقة الداخلية: أكثر الخوادم حركة؛ الخارجية: خدمات كل منها", -"geo.lines_hint": "حدّد دولة شبكاتك الداخلية (الإعدادات ← الأسماء، شبكة) لترى خطوطاً منها إلى الدول التي تتواصل معها.", +"geo.lines_hint": "حدّد دولة شبكاتك الداخلية (السجل، شبكة) لترى خطوطاً منها إلى الدول التي تتواصل معها.", "nm.country_none": "الدولة/المنطقة/المنطقة (اختياري)", "lic.free": "يظل تحليل الحركة والكشف يعملان بالكامل، ولا تُحذف أي بيانات؛ أما الميزات التي تتطلب التسجيل فمُعلَّمة في أماكنها.", "lic.ok": "مرخّص لـ {c}: بقي {n} يوماً", @@ -754,7 +754,7 @@ "src.wait_templates": "في انتظار القوالب", "src.wait_sampling": "في انتظار معدّل أخذ العيّنات", "wait.settling": "ليس بعد — الدقائق الأخيرة ما زالت تصل. تُبلغ الأجهزة عن التدفق بعد انتهائه، لذا تُستكمل آخر {n} دقيقة تباعًا. لا حاجة لأي إجراء.", -"nav.names": "الأسماء", +"nav.names": "السجل", "wait.settling_one": "ليس بعد — الدقيقة الأخيرة ما زالت تصل. تُبلغ الأجهزة عن التدفق بعد انتهائه، لذا تُستكمل الدقيقة الأخيرة تباعًا. لا حاجة لأي إجراء.", "fd.kind.fastscan": "فحص سريع", "fd.kind.beacon": "اتصال دوري", diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 767202e..0152ba1 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -174,7 +174,7 @@ "if.in": "আগত", "if.out": "বহির্গামী", "if.no_ctr_short": "কাউন্টার নেই", -"if.no_counters": "এই ডিভাইস ইন্টারফেস কাউন্টার পাঠায় না, তাই তুলনা করার কিছু নেই। SNMP দিয়ে পড়তে সেটিংস → নাম-এ snmp 10.0.0.1 public-এর মতো একটি লাইন যোগ করুন।", +"if.no_counters": "এই ডিভাইস ইন্টারফেস কাউন্টার পাঠায় না, তাই তুলনা করার কিছু নেই। SNMP দিয়ে পড়তে ইনভেন্টরি-এ snmp 10.0.0.1 public-এর মতো একটি লাইন যোগ করুন।", "if.no_flow": "এই ইন্টারফেসের কোনো ফ্লো ডেটা পাওয়া যায়নি — ডিভাইসের ফ্লো এক্সপোর্ট দেখুন (স্যাম্পলিং সেট করা কিন্তু এক্সপোর্ট হচ্ছে না?)", "if.no_flow_short": "ফ্লো নেই", "if.ok": "মিলে যাচ্ছে। অনুমান ও ডিভাইস কাউন্টারের পার্থক্য {dev}, যা স্যাম্পলিংয়ের পরিসংখ্যানগত ত্রুটির ({err}) মধ্যে।", @@ -201,7 +201,7 @@ "src.title": "উৎস", "src.sub": "প্রতিটি ডিভাইস পাঠাচ্ছে কি না, ডেটা পড়া যাচ্ছে কি না, স্যাম্পলিং হার ঠিক কি না", "src.listeners": "যেখানে শুনছে", -"src.names": "নাম", +"src.names": "ইনভেন্টরি", "src.names_sub": "ঐচ্ছিক। নেটওয়ার্ক, ডিভাইস, ইন্টারফেস ও হোস্টের নাম দিন, প্রতি লাইনে একটি।", "src.save": "সংরক্ষণ", "src.saved": "সংরক্ষিত হয়েছে", @@ -214,7 +214,7 @@ "st.bad": "ডেটা আসছে না", "issue.silent": "২ মিনিটের বেশি কিছু আসেনি। ডিভাইসে এক্সপোর্টের গন্তব্য দেখুন এবং ফায়ারওয়াল UDP অনুমতি দেয় কি না যাচাই করুন।", "issue.waiting_sampling": "টেমপ্লেট এসেছে, কিন্তু স্যাম্পলিং হার এখনো আসেনি। {n}টি রেকর্ড আটকে রাখা হয়েছে; হার এলে সঠিক মাপে গোনা হবে, ১:১ নয়। ডিভাইস সাধারণত কয়েক মিনিটে আবার পাঠায়।", -"issue.assumed_unsampled": "এই ডিভাইস তার sampling হার জানায়নি, তাই এর flow 1:1 ধরে গোনা হচ্ছে। এটি sampling করলে sampler options export করান, অথবা সেটিংস → নাম-এ হার লিখুন, যেমন device {a} নাম sampling=1000। এটি প্রতিটি প্যাকেট export করলে সেই লাইনে unsampled যোগ করুন।", +"issue.assumed_unsampled": "এই ডিভাইস তার sampling হার জানায়নি, তাই এর flow 1:1 ধরে গোনা হচ্ছে। এটি sampling করলে sampler options export করান, অথবা ইনভেন্টরি-এ হার লিখুন, যেমন device {a} নাম sampling=1000। এটি প্রতিটি প্যাকেট export করলে সেই লাইনে unsampled যোগ করুন।", "issue.loss": "রপ্তানি প্যাকেটের {p} পথে হারিয়েছে (sequence নম্বরে ফাঁক)। মোট পরিমাণ পূরণ করা হয়েছে, কিন্তু প্রতিটি host-এর বিবরণ ফেরানো যায় না। এই মেশিনে “netstat -su”-তে receive buffer errors বাড়লে net.core.rmem_max বাড়ান; না হলে নেটওয়ার্ক পথ দেখুন।", "issue.no_template": "ডেটা আসছে, কিন্তু টেমপ্লেট এখনো আসেনি। ডেটা রাখা হয়েছে, টেমপ্লেট এলেই পড়া হবে।", "issue.clock_skew": "ডিভাইসের ঘড়ি {s} এদিক-ওদিক। সময় পৌঁছানোর সময় অনুযায়ী ঠিক করা হয়েছে।", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "ভেতরের বলয়: সবচেয়ে ব্যস্ত ক্লায়েন্ট; বাইরে: প্রতিটির ব্যবহৃত সার্ভার", "rg.svc_sub_v": "ভেতরের বলয়: সবচেয়ে ব্যস্ত সেবা; বাইরে: যে সার্ভার তা দেয়", "rg.svc_sub_s": "ভেতরের বলয়: সবচেয়ে ব্যস্ত সার্ভার; বাইরে: প্রতিটির সেবা", -"geo.lines_hint": "অভ্যন্তরীণ নেটওয়ার্কের দেশ ঠিক করুন (সেটিংস → নাম, নেটওয়ার্ক), তাহলে সেখান থেকে অন্য দেশগুলোতে রেখা দেখা যাবে।", +"geo.lines_hint": "অভ্যন্তরীণ নেটওয়ার্কের দেশ ঠিক করুন (ইনভেন্টরি, নেটওয়ার্ক), তাহলে সেখান থেকে অন্য দেশগুলোতে রেখা দেখা যাবে।", "nm.country_none": "দেশ/অঞ্চল/অঞ্চল (ঐচ্ছিক)", "lic.free": "ট্রাফিক বিশ্লেষণ ও শনাক্তকরণ সম্পূর্ণভাবে কাজ করতে থাকে, এবং কোনও ডেটা মোছা হয় না; যেসব সুবিধার জন্য নিবন্ধন দরকার, সেগুলি নিজ নিজ জায়গায় চিহ্নিত থাকে।", "lic.ok": "{c}-কে লাইসেন্স: {n} দিন বাকি", @@ -755,7 +755,7 @@ "src.wait_sampling": "স্যাম্পলিং হারের অপেক্ষায়", "wait.settling": "এখনও নয় — সাম্প্রতিক মিনিটগুলোর তথ্য এখনও আসছে। এক্সপোর্টার ফ্লো শেষ হওয়ার পর রিপোর্ট করে, তাই শেষ {n} মিনিট ক্রমে পূর্ণ হয়। কিছু করার নেই।", "wait.settling_one": "এখনও নয় — সাম্প্রতিক মিনিটগুলোর তথ্য এখনও আসছে। এক্সপোর্টার ফ্লো শেষ হওয়ার পর রিপোর্ট করে, তাই শেষ 1 মিনিট ক্রমে পূর্ণ হয়। কিছু করার নেই।", -"nav.names": "নাম", +"nav.names": "ইনভেন্টরি", "fd.kind.fastscan": "দ্রুত স্ক্যান", "fd.kind.beacon": "নিয়মিত চেক-ইন", "fd.kind.chain": "হপ চেইন", diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index f1c0e9b..be02ab6 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -174,7 +174,7 @@ "if.in": "Eingehend", "if.out": "Ausgehend", "if.no_ctr_short": "keine Zähler", -"if.no_counters": "Dieses Gerät sendet keine Schnittstellenzähler, daher gibt es nichts zu vergleichen. Fügen Sie unter Einstellungen → Namen eine Zeile wie snmp 10.0.0.1 public hinzu, um sie per SNMP auszulesen.", +"if.no_counters": "Dieses Gerät sendet keine Schnittstellenzähler, daher gibt es nichts zu vergleichen. Fügen Sie unter Inventar eine Zeile wie snmp 10.0.0.1 public hinzu, um sie per SNMP auszulesen.", "if.no_flow": "Für diese Schnittstelle kommen keine Flow-Daten an — prüfen Sie den Flow-Export des Geräts (Sampling eingerichtet, aber nicht exportiert?)", "if.no_flow_short": "keine Flows", "if.ok": "Konsistent. Die Flow-Schätzung weicht um {dev} vom Gerätezähler ab, innerhalb des statistischen Sampling-Fehlers ({err}).", @@ -201,7 +201,7 @@ "src.title": "Quellen", "src.sub": "Sendet jedes Gerät, lässt es sich dekodieren, stimmt die Abtastrate", "src.listeners": "Lauscht auf", -"src.names": "Namen", +"src.names": "Inventar", "src.names_sub": "Optional. Benennen Sie Netze, Geräte, Schnittstellen und Hosts, ein Eintrag pro Zeile.", "src.save": "Speichern", "src.saved": "Gespeichert", @@ -214,7 +214,7 @@ "st.bad": "Kein Empfang", "issue.silent": "Seit mehr als 2 Minuten nichts empfangen. Prüfen Sie das Exportziel auf dem Gerät und ob UDP durch Firewalls erlaubt ist.", "issue.waiting_sampling": "Templates empfangen, aber noch keine Abtastrate. {n} Datensätze werden zurückgehalten und bei deren Eintreffen hochgerechnet, nicht 1:1 gezählt. Geräte senden sie meist innerhalb weniger Minuten erneut.", -"issue.assumed_unsampled": "Dieses Gerät hat seine Abtastrate nicht gemeldet, daher zählen seine Flows 1:1. Falls es sampelt, lassen Sie es seine Sampler-Optionen exportieren oder geben Sie die Rate unter Einstellungen → Namen an, z. B. device {a} name sampling=1000. Exportiert es jedes Paket, ergänzen Sie unsampled in dieser Zeile.", +"issue.assumed_unsampled": "Dieses Gerät hat seine Abtastrate nicht gemeldet, daher zählen seine Flows 1:1. Falls es sampelt, lassen Sie es seine Sampler-Optionen exportieren oder geben Sie die Rate unter Inventar an, z. B. device {a} name sampling=1000. Exportiert es jedes Paket, ergänzen Sie unsampled in dieser Zeile.", "issue.loss": "{p} der Exportpakete gingen auf dem Weg hierher verloren (Lücken in den Sequenznummern). Summen werden ausgeglichen, Details pro Host nicht. Zeigt „netstat -su“ auf diesem Rechner steigende Empfangspufferfehler, erhöhen Sie net.core.rmem_max; andernfalls prüfen Sie den Netzwerkpfad.", "issue.no_template": "Daten kommen an, ihr Template aber noch nicht. Sie werden zurückgehalten und dekodiert, sobald das Template eintrifft.", "issue.clock_skew": "Die Geräteuhr weicht um {s} ab. Zeiten wurden auf die Ankunftszeit korrigiert.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Innerer Ring: die aktivsten Clients; äußerer Ring: die Server, die jeder nutzt", "rg.svc_sub_v": "Innerer Ring: die aktivsten Dienste; äußerer Ring: die Server, die sie anbieten", "rg.svc_sub_s": "Innerer Ring: die aktivsten Server; äußerer Ring: deren Dienste", -"geo.lines_hint": "Legen Sie ein Land für Ihre internen Netze fest (Einstellungen → Namen, Netz), um Linien von dort zu den Ländern zu sehen, mit denen sie kommunizieren.", +"geo.lines_hint": "Legen Sie ein Land für Ihre internen Netze fest (Inventar, Netz), um Linien von dort zu den Ländern zu sehen, mit denen sie kommunizieren.", "nm.country_none": "Land/Region (optional)", "lic.free": "Verkehrsanalyse und Erkennung laufen vollständig weiter, und es werden keine Daten gelöscht; die Funktionen, die eine Registrierung brauchen, sind dort gekennzeichnet, wo sie sind.", "lic.ok": "Lizenziert für {c}: noch {n} Tage", @@ -754,7 +754,7 @@ "src.wait_templates": "wartet auf Templates", "src.wait_sampling": "wartet auf Sampling-Rate", "wait.settling": "Noch nicht — die letzten Minuten treffen noch ein. Exporter melden einen Fluss, nachdem er endet, daher füllen sich die letzten {n} Minuten nach und nach. Nichts zu tun.", -"nav.names": "Namen", +"nav.names": "Inventar", "wait.settling_one": "Noch nicht — die letzte Minute trifft noch ein. Exporter melden einen Fluss, nachdem er endet, daher füllt sich die letzte Minute nach und nach. Nichts zu tun.", "fd.kind.fastscan": "Schneller Scan", "fd.kind.beacon": "Regelmäßige Meldung", diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index 91042c9..3170a98 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -174,7 +174,7 @@ "if.in": "Inbound", "if.out": "Outbound", "if.no_ctr_short": "no counters", -"if.no_counters": "This device sends no interface counters, so there is nothing to compare with. Add a line like snmp 10.0.0.1 public under Settings → Names to read them over SNMP.", +"if.no_counters": "This device sends no interface counters, so there is nothing to compare with. Add a line like snmp 10.0.0.1 public under Inventory to read them over SNMP.", "if.no_flow": "No flow data received for this interface — check the device's flow export (sampling configured but not exported?)", "if.no_flow_short": "no flows", "if.ok": "Consistent. The flow estimate differs from the device counter by {dev}, within the statistical error of sampling ({err}).", @@ -201,7 +201,7 @@ "src.title": "Sources", "src.sub": "Is every device sending, can it be decoded, is the sampling rate right", "src.listeners": "Listening on", -"src.names": "Names", +"src.names": "Inventory", "src.names_sub": "Optional. Name networks, devices, interfaces and hosts, one per line.", "src.save": "Save", "src.saved": "Saved", @@ -214,7 +214,7 @@ "st.bad": "Not receiving", "issue.silent": "Nothing received for more than 2 minutes. Check the export destination on the device and that UDP is allowed through firewalls.", "issue.waiting_sampling": "Templates received but no sampling rate yet. {n} records are held and will be scaled when it arrives, not counted as 1:1. Devices usually resend it within a few minutes.", -"issue.assumed_unsampled": "This device has not declared its sampling rate, so its flows count 1:1. If it samples, have it export its sampler options, or give the rate under Settings → Names, e.g. device {a} name sampling=1000. If it exports every packet, add unsampled to that line.", +"issue.assumed_unsampled": "This device has not declared its sampling rate, so its flows count 1:1. If it samples, have it export its sampler options, or give the rate under Inventory, e.g. device {a} name sampling=1000. If it exports every packet, add unsampled to that line.", "issue.loss": "{p} of the export packets were lost on the way here (gaps in their sequence numbers). Totals are compensated; per-host detail cannot be. If “netstat -su” on this machine shows receive buffer errors rising, raise net.core.rmem_max; otherwise check the network path.", "issue.no_template": "Data is arriving but its template has not. It is held and decoded as soon as the template arrives.", "issue.clock_skew": "The device clock is off by {s}. Times were corrected to arrival time.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Inner ring: the busiest clients; outer ring: the servers each one uses", "rg.svc_sub_v": "Inner ring: the busiest services; outer ring: the servers offering each", "rg.svc_sub_s": "Inner ring: the busiest servers; outer ring: the services of each", -"geo.lines_hint": "Set a country/region for your internal networks (Settings → Names, Network) to see lines from them to the countries/regions they talk to.", +"geo.lines_hint": "Set a country/region for your internal networks (Inventory, Network) to see lines from them to the countries/regions they talk to.", "nm.country_none": "country/region (optional)", "lic.free": "Traffic analysis and detection keep working in full, and no data is deleted; the features that need registering are marked where they are.", "lic.ok": "Licensed to {c}: {n} days left", @@ -754,7 +754,7 @@ "src.wait_templates": "waiting for templates", "src.wait_sampling": "waiting for sampling rate", "wait.settling": "Not yet — the most recent minutes are still arriving. Exporters report a flow after it ends, so the last {n} minutes fill in as they come. Nothing to do.", -"nav.names": "Names", +"nav.names": "Inventory", "wait.settling_one": "Not yet — the most recent minute is still arriving. Exporters report a flow after it ends, so the last minute fills in as it comes. Nothing to do.", "fd.kind.fastscan": "Fast scan", "fd.kind.beacon": "Regular check-in", diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index fa98766..c40373c 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -174,7 +174,7 @@ "if.in": "Entrante", "if.out": "Saliente", "if.no_ctr_short": "sin contadores", -"if.no_counters": "Este dispositivo no envía contadores de interfaz, así que no hay con qué comparar. Añade una línea como snmp 10.0.0.1 public en Configuración → Nombres para leerlos por SNMP.", +"if.no_counters": "Este dispositivo no envía contadores de interfaz, así que no hay con qué comparar. Añade una línea como snmp 10.0.0.1 public en Inventario para leerlos por SNMP.", "if.no_flow": "No se reciben datos de flujo de esta interfaz: revise la exportación de flujos del equipo (¿muestreo configurado pero sin exportar?)", "if.no_flow_short": "sin flujos", "if.ok": "Coinciden. La estimación difiere del contador del dispositivo en {dev}, dentro del error estadístico del muestreo ({err}).", @@ -201,7 +201,7 @@ "src.title": "Fuentes", "src.sub": "¿Envía cada dispositivo, se puede decodificar, es correcta la tasa de muestreo?", "src.listeners": "Escuchando en", -"src.names": "Nombres", +"src.names": "Inventario", "src.names_sub": "Opcional. Ponga nombre a redes, dispositivos, interfaces y equipos, uno por línea.", "src.save": "Guardar", "src.saved": "Guardado", @@ -214,7 +214,7 @@ "st.bad": "Sin recibir", "issue.silent": "No se ha recibido nada en más de 2 minutos. Revise el destino de exportación del dispositivo y que los cortafuegos permitan UDP.", "issue.waiting_sampling": "Se recibieron plantillas pero aún no la tasa de muestreo. Hay {n} registros en espera; se escalarán cuando llegue y no se contarán como 1:1. Los dispositivos suelen reenviarla en pocos minutos.", -"issue.assumed_unsampled": "Este equipo no ha declarado su tasa de muestreo, así que sus flujos cuentan 1:1. Si muestrea, haga que exporte las opciones del muestreador o indique la tasa en Configuración → Nombres, p. ej. device {a} nombre sampling=1000. Si exporta todos los paquetes, añada unsampled a esa línea.", +"issue.assumed_unsampled": "Este equipo no ha declarado su tasa de muestreo, así que sus flujos cuentan 1:1. Si muestrea, haga que exporte las opciones del muestreador o indique la tasa en Inventario, p. ej. device {a} nombre sampling=1000. Si exporta todos los paquetes, añada unsampled a esa línea.", "issue.loss": "Se perdió un {p} de los paquetes exportados por el camino (huecos en los números de secuencia). Los totales están compensados; el detalle por host no. Si “netstat -su” en esta máquina muestra receive buffer errors en aumento, suba net.core.rmem_max; si no, revise la ruta de red.", "issue.no_template": "Llegan datos pero aún no su plantilla. Se guardan y se decodifican en cuanto llegue la plantilla.", "issue.clock_skew": "El reloj del dispositivo tiene un desfase de {s}. Las horas se corrigieron a la hora de llegada.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Anillo interior: los clientes con más tráfico; exterior: los servidores que usa cada uno", "rg.svc_sub_v": "Anillo interior: los servicios con más tráfico; exterior: los servidores que los ofrecen", "rg.svc_sub_s": "Anillo interior: los servidores con más tráfico; exterior: los servicios de cada uno", -"geo.lines_hint": "Asigne un país a sus redes internas (Configuración → Nombres, Red) para ver líneas desde ellas hasta los países con los que hablan.", +"geo.lines_hint": "Asigne un país a sus redes internas (Inventario, Red) para ver líneas desde ellas hasta los países con los que hablan.", "nm.country_none": "país/región (opcional)", "lic.free": "El análisis de tráfico y la detección siguen funcionando por completo, y no se borra ningún dato; las funciones que requieren registro están señaladas donde se encuentran.", "lic.ok": "Licenciado a {c}: quedan {n} días", @@ -754,7 +754,7 @@ "src.wait_templates": "esperando plantillas", "src.wait_sampling": "esperando la tasa de muestreo", "wait.settling": "Todavía no: los últimos minutos aún están llegando. Los exportadores informan un flujo cuando termina, así que los últimos {n} minutos se van completando. No hay nada que hacer.", -"nav.names": "Nombres", +"nav.names": "Inventario", "wait.settling_one": "Todavía no: el último minuto aún está llegando. Los exportadores informan un flujo cuando termina, así que el último minuto se va completando. No hay nada que hacer.", "fd.kind.fastscan": "Escaneo rápido", "fd.kind.beacon": "Conexión periódica", diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index d1a5152..c32b00b 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -174,7 +174,7 @@ "if.in": "Entrant", "if.out": "Sortant", "if.no_ctr_short": "sans compteurs", -"if.no_counters": "Cet équipement n'envoie pas de compteurs d'interface : rien à comparer. Ajoutez une ligne comme snmp 10.0.0.1 public dans Paramètres → Noms pour les lire en SNMP.", +"if.no_counters": "Cet équipement n'envoie pas de compteurs d'interface : rien à comparer. Ajoutez une ligne comme snmp 10.0.0.1 public dans Inventaire pour les lire en SNMP.", "if.no_flow": "Aucune donnée de flux reçue pour cette interface — vérifiez l'export de flux de l'équipement (échantillonnage configuré mais non exporté ?)", "if.no_flow_short": "aucun flux", "if.ok": "Cohérent. L'estimation diffère du compteur de l'équipement de {dev}, dans la marge d'erreur statistique de l'échantillonnage ({err}).", @@ -201,7 +201,7 @@ "src.title": "Sources", "src.sub": "Chaque équipement envoie-t-il, peut-on décoder, le taux d'échantillonnage est-il juste ?", "src.listeners": "En écoute sur", -"src.names": "Noms", +"src.names": "Inventaire", "src.names_sub": "Facultatif. Nommez réseaux, équipements, interfaces et hôtes, un par ligne.", "src.save": "Enregistrer", "src.saved": "Enregistré", @@ -214,7 +214,7 @@ "st.bad": "Rien reçu", "issue.silent": "Rien reçu depuis plus de 2 minutes. Vérifiez la destination d'export sur l'équipement et que les pare-feu laissent passer l'UDP.", "issue.waiting_sampling": "Modèles reçus mais pas encore le taux d'échantillonnage. {n} enregistrements sont en attente ; ils seront mis à l'échelle à son arrivée et ne sont pas comptés en 1:1. Les équipements le renvoient généralement en quelques minutes.", -"issue.assumed_unsampled": "Cet équipement n'a pas déclaré son taux d'échantillonnage, ses flux comptent donc 1:1. S'il échantillonne, faites-lui exporter les options de l'échantillonneur, ou indiquez le taux dans Paramètres → Noms, par ex. device {a} nom sampling=1000. S'il exporte tous les paquets, ajoutez unsampled à cette ligne.", +"issue.assumed_unsampled": "Cet équipement n'a pas déclaré son taux d'échantillonnage, ses flux comptent donc 1:1. S'il échantillonne, faites-lui exporter les options de l'échantillonneur, ou indiquez le taux dans Inventaire, par ex. device {a} nom sampling=1000. S'il exporte tous les paquets, ajoutez unsampled à cette ligne.", "issue.loss": "{p} des paquets exportés ont été perdus en route (trous dans les numéros de séquence). Les totaux sont compensés, pas le détail par hôte. Si « netstat -su » sur cette machine montre des receive buffer errors en hausse, augmentez net.core.rmem_max ; sinon, vérifiez le chemin réseau.", "issue.no_template": "Des données arrivent mais pas encore leur modèle. Elles sont conservées et décodées dès l'arrivée du modèle.", "issue.clock_skew": "L'horloge de l'équipement est décalée de {s}. Les heures ont été corrigées sur l'heure de réception.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Anneau intérieur : les clients les plus actifs ; extérieur : les serveurs utilisés par chacun", "rg.svc_sub_v": "Anneau intérieur : les services les plus chargés ; extérieur : les serveurs qui les offrent", "rg.svc_sub_s": "Anneau intérieur : les serveurs les plus chargés ; extérieur : les services de chacun", -"geo.lines_hint": "Indiquez le pays de vos réseaux internes (Paramètres → Noms, Réseau) pour voir les lignes vers les pays avec lesquels ils échangent.", +"geo.lines_hint": "Indiquez le pays de vos réseaux internes (Inventaire, Réseau) pour voir les lignes vers les pays avec lesquels ils échangent.", "nm.country_none": "pays/région (facultatif)", "lic.free": "L'analyse du trafic et la détection fonctionnent intégralement, et aucune donnée n'est supprimée ; les fonctions qui demandent un enregistrement sont signalées à leur place.", "lic.ok": "Licence accordée à {c} : {n} jours restants", @@ -754,7 +754,7 @@ "src.wait_templates": "en attente des modèles", "src.wait_sampling": "en attente du taux d'échantillonnage", "wait.settling": "Pas encore : les dernières minutes arrivent encore. Les exportateurs signalent un flux après sa fin, donc les {n} dernières minutes se complètent au fur et à mesure. Rien à faire.", -"nav.names": "Noms", +"nav.names": "Inventaire", "wait.settling_one": "Pas encore : la dernière minute arrive encore. Les exportateurs signalent un flux après sa fin, donc la dernière minute se complète au fur et à mesure. Rien à faire.", "fd.kind.fastscan": "Balayage rapide", "fd.kind.beacon": "Connexion périodique", diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index 304c6d8..f5b081f 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -174,7 +174,7 @@ "if.in": "अंदर", "if.out": "बाहर", "if.no_ctr_short": "काउंटर नहीं", -"if.no_counters": "यह डिवाइस इंटरफ़ेस काउंटर नहीं भेजता, इसलिए तुलना के लिए कुछ नहीं है। SNMP से पढ़ने के लिए सेटिंग्स → नाम में snmp 10.0.0.1 public जैसी पंक्ति जोड़ें।", +"if.no_counters": "यह डिवाइस इंटरफ़ेस काउंटर नहीं भेजता, इसलिए तुलना के लिए कुछ नहीं है। SNMP से पढ़ने के लिए इन्वेंटरी में snmp 10.0.0.1 public जैसी पंक्ति जोड़ें।", "if.no_flow": "इस इंटरफ़ेस का कोई फ़्लो डेटा नहीं मिला — डिवाइस का फ़्लो एक्सपोर्ट जाँचें (सैंपलिंग सेट है पर एक्सपोर्ट नहीं हो रहा?)", "if.no_flow_short": "फ़्लो नहीं", "if.ok": "मेल खाता है। अनुमान और डिवाइस काउंटर में {dev} का अंतर है, जो सैंपलिंग की सांख्यिकीय त्रुटि ({err}) के भीतर है।", @@ -201,7 +201,7 @@ "src.title": "स्रोत", "src.sub": "क्या हर डिवाइस भेज रहा है, क्या डेटा पढ़ा जा सकता है, क्या सैंपलिंग दर सही है", "src.listeners": "यहाँ सुन रहा है", -"src.names": "नाम", +"src.names": "इन्वेंटरी", "src.names_sub": "वैकल्पिक। नेटवर्क, डिवाइस, इंटरफ़ेस और होस्ट के नाम दें, हर पंक्ति में एक।", "src.save": "सहेजें", "src.saved": "सहेज लिया गया", @@ -214,7 +214,7 @@ "st.bad": "डेटा नहीं आ रहा", "issue.silent": "2 मिनट से ज़्यादा समय से कुछ नहीं आया। डिवाइस पर एक्सपोर्ट का पता जाँचें और देखें कि फ़ायरवॉल UDP की अनुमति देता है।", "issue.waiting_sampling": "टेम्पलेट मिल गए, पर सैंपलिंग दर अभी नहीं मिली। {n} रिकॉर्ड रोके गए हैं; दर मिलते ही उन्हें सही पैमाने पर गिना जाएगा, 1:1 नहीं। डिवाइस आमतौर पर कुछ मिनटों में इसे फिर भेजते हैं।", -"issue.assumed_unsampled": "इस डिवाइस ने अपनी sampling दर नहीं बताई, इसलिए उसके flow 1:1 गिने जा रहे हैं। अगर यह sampling करता है तो इससे sampler options export करवाएँ, या सेटिंग्स → नाम में दर लिखें, जैसे device {a} नाम sampling=1000। अगर यह हर पैकेट export करता है तो उस पंक्ति में unsampled जोड़ें।", +"issue.assumed_unsampled": "इस डिवाइस ने अपनी sampling दर नहीं बताई, इसलिए उसके flow 1:1 गिने जा रहे हैं। अगर यह sampling करता है तो इससे sampler options export करवाएँ, या इन्वेंटरी में दर लिखें, जैसे device {a} नाम sampling=1000। अगर यह हर पैकेट export करता है तो उस पंक्ति में unsampled जोड़ें।", "issue.loss": "एक्सपोर्ट पैकेटों में से {p} रास्ते में खो गए (sequence नंबरों में अंतर)। कुल मात्रा की भरपाई कर दी गई है, पर हर host का विवरण वापस नहीं आ सकता। अगर इस मशीन पर “netstat -su” में receive buffer errors बढ़ रहे हैं तो net.core.rmem_max बढ़ाएँ; नहीं तो नेटवर्क पथ जाँचें।", "issue.no_template": "डेटा आ रहा है, पर उसका टेम्पलेट अभी नहीं आया। डेटा रोका गया है और टेम्पलेट आते ही पढ़ा जाएगा।", "issue.clock_skew": "डिवाइस की घड़ी {s} आगे-पीछे है। समय को आने के समय के अनुसार ठीक किया गया।", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "अंदर का घेरा: सबसे व्यस्त क्लाइंट; बाहर: हर क्लाइंट के सर्वर", "rg.svc_sub_v": "अंदर का घेरा: सबसे व्यस्त सेवाएँ; बाहर: उन्हें देने वाले सर्वर", "rg.svc_sub_s": "अंदर का घेरा: सबसे व्यस्त सर्वर; बाहर: हर सर्वर की सेवाएँ", -"geo.lines_hint": "अपने आंतरिक नेटवर्क का देश सेट करें (सेटिंग्स → नाम, नेटवर्क), ताकि उनसे अन्य देशों तक की रेखाएँ दिखें।", +"geo.lines_hint": "अपने आंतरिक नेटवर्क का देश सेट करें (इन्वेंटरी, नेटवर्क), ताकि उनसे अन्य देशों तक की रेखाएँ दिखें।", "nm.country_none": "देश/क्षेत्र/क्षेत्र (वैकल्पिक)", "lic.free": "ट्रैफ़िक विश्लेषण और पहचान पूरी तरह काम करते रहते हैं, और कोई डेटा नहीं हटाया जाता; जिन सुविधाओं के लिए पंजीकरण चाहिए, वे अपनी जगह पर चिह्नित हैं।", "lic.ok": "{c} को लाइसेंस: {n} दिन बचे", @@ -755,7 +755,7 @@ "src.wait_sampling": "सैंपलिंग दर की प्रतीक्षा", "wait.settling": "अभी नहीं — हाल के मिनटों का डेटा अभी आ रहा है। एक्सपोर्टर प्रवाह समाप्त होने के बाद रिपोर्ट करते हैं, इसलिए अंतिम {n} मिनट धीरे-धीरे भरते हैं। कुछ करने की आवश्यकता नहीं।", "wait.settling_one": "अभी नहीं — हाल के मिनटों का डेटा अभी आ रहा है। एक्सपोर्टर प्रवाह समाप्त होने के बाद रिपोर्ट करते हैं, इसलिए अंतिम 1 मिनट धीरे-धीरे भरते हैं। कुछ करने की आवश्यकता नहीं।", -"nav.names": "नाम", +"nav.names": "इन्वेंटरी", "fd.kind.fastscan": "तेज़ स्कैन", "fd.kind.beacon": "नियमित चेक-इन", "fd.kind.chain": "होप शृंखला", diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index 275554d..eda9e32 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -174,7 +174,7 @@ "if.in": "Masuk", "if.out": "Keluar", "if.no_ctr_short": "tanpa penghitung", -"if.no_counters": "Perangkat ini tidak mengirim penghitung antarmuka, jadi tidak ada pembanding. Tambahkan baris seperti snmp 10.0.0.1 public di Pengaturan → Nama untuk membacanya lewat SNMP.", +"if.no_counters": "Perangkat ini tidak mengirim penghitung antarmuka, jadi tidak ada pembanding. Tambahkan baris seperti snmp 10.0.0.1 public di Inventaris untuk membacanya lewat SNMP.", "if.no_flow": "Tidak ada data flow yang diterima untuk antarmuka ini — periksa ekspor flow perangkat (sampling dikonfigurasi tetapi tidak diekspor?)", "if.no_flow_short": "tanpa flow", "if.ok": "Cocok. Perkiraan berbeda {dev} dari penghitung perangkat, masih dalam galat statistik sampling ({err}).", @@ -201,7 +201,7 @@ "src.title": "Sumber", "src.sub": "Apakah setiap perangkat mengirim, bisa didekode, dan laju sampling-nya benar", "src.listeners": "Mendengarkan di", -"src.names": "Nama", +"src.names": "Inventaris", "src.names_sub": "Opsional. Beri nama jaringan, perangkat, antarmuka, dan host, satu per baris.", "src.save": "Simpan", "src.saved": "Tersimpan", @@ -214,7 +214,7 @@ "st.bad": "Tidak menerima", "issue.silent": "Tidak ada data lebih dari 2 menit. Periksa tujuan ekspor di perangkat dan apakah firewall mengizinkan UDP.", "issue.waiting_sampling": "Template sudah diterima, tetapi laju sampling belum. {n} catatan ditahan dan akan diskalakan saat laju itu tiba, bukan dihitung 1:1. Perangkat biasanya mengirim ulang dalam beberapa menit.", -"issue.assumed_unsampled": "Perangkat ini belum menyatakan laju sampling-nya, jadi flow-nya dihitung 1:1. Jika ia melakukan sampling, minta ia mengekspor opsi sampler, atau tulis lajunya di Pengaturan → Nama, misalnya device {a} nama sampling=1000. Jika ia mengekspor setiap paket, tambahkan unsampled pada baris itu.", +"issue.assumed_unsampled": "Perangkat ini belum menyatakan laju sampling-nya, jadi flow-nya dihitung 1:1. Jika ia melakukan sampling, minta ia mengekspor opsi sampler, atau tulis lajunya di Inventaris, misalnya device {a} nama sampling=1000. Jika ia mengekspor setiap paket, tambahkan unsampled pada baris itu.", "issue.loss": "{p} paket ekspor hilang di jalan (ada celah nomor urut). Total sudah dikompensasi; rincian per host tidak. Jika “netstat -su” di mesin ini menunjukkan receive buffer errors naik, besarkan net.core.rmem_max; jika tidak, periksa jalur jaringan.", "issue.no_template": "Data masuk, tetapi template-nya belum. Data ditahan dan didekode begitu template tiba.", "issue.clock_skew": "Jam perangkat meleset {s}. Waktu sudah dikoreksi ke waktu kedatangan.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Cincin dalam: klien tersibuk; luar: server yang dipakai masing-masing", "rg.svc_sub_v": "Cincin dalam: layanan tersibuk; luar: server yang menyediakannya", "rg.svc_sub_s": "Cincin dalam: server tersibuk; luar: layanan masing-masing", -"geo.lines_hint": "Tetapkan negara untuk jaringan internal (Pengaturan → Nama, Jaringan) agar terlihat garis darinya ke negara-negara tujuan.", +"geo.lines_hint": "Tetapkan negara untuk jaringan internal (Inventaris, Jaringan) agar terlihat garis darinya ke negara-negara tujuan.", "nm.country_none": "negara/wilayah (opsional)", "lic.free": "Analisis trafik dan deteksi tetap berjalan sepenuhnya, dan tidak ada data yang dihapus; fitur yang memerlukan pendaftaran ditandai di tempatnya masing-masing.", "lic.ok": "Dilisensikan untuk {c}: tinggal {n} hari", @@ -755,7 +755,7 @@ "src.wait_sampling": "menunggu laju sampling", "wait.settling": "Belum — menit-menit terakhir masih berdatangan. Eksportir melaporkan aliran setelah selesai, jadi {n} menit terakhir terisi bertahap. Tidak perlu tindakan.", "wait.settling_one": "Belum — menit-menit terakhir masih berdatangan. Eksportir melaporkan aliran setelah selesai, jadi 1 menit terakhir terisi bertahap. Tidak perlu tindakan.", -"nav.names": "Nama", +"nav.names": "Inventaris", "fd.kind.fastscan": "Pemindaian cepat", "fd.kind.beacon": "Check-in berkala", "fd.kind.chain": "Rantai lompatan", diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index 9a6674f..27d5031 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -174,7 +174,7 @@ "if.in": "受信方向", "if.out": "送信方向", "if.no_ctr_short": "カウンターなし", -"if.no_counters": "この機器はインターフェースカウンタを送ってこないため、比較できません。「設定 → 名前」に snmp 10.0.0.1 public のような行を追加すると SNMP で読み取ります。", +"if.no_counters": "この機器はインターフェースカウンタを送ってこないため、比較できません。「台帳」に snmp 10.0.0.1 public のような行を追加すると SNMP で読み取ります。", "if.no_flow": "このインターフェースのフローデータを受信していません。デバイスのフローエクスポートを確認してください(サンプリングは設定済みでもエクスポートされていない?)", "if.no_flow_short": "フローなし", "if.ok": "一致しています。フロー推定値と機器カウンターの差は {dev} で、サンプリングの統計誤差({err})の範囲内です。", @@ -201,7 +201,7 @@ "src.title": "受信状況", "src.sub": "各機器が送っているか、解読できるか、サンプリングレートは正しいか", "src.listeners": "待ち受け", -"src.names": "名前", +"src.names": "台帳", "src.names_sub": "任意。ネットワーク、機器、インターフェース、ホストに名前を付けます。1 行に 1 件。", "src.save": "保存", "src.saved": "保存しました", @@ -214,7 +214,7 @@ "st.bad": "未受信", "issue.silent": "2 分以上何も届いていません。機器のエクスポート先と、ファイアウォールで UDP が許可されているかを確認してください。", "issue.waiting_sampling": "テンプレートは届きましたが、サンプリングレートがまだです。{n} 件のフローを保留中で、レートが届いた時点で換算して取り込みます(1:1 では数えません)。通常は数分以内に再送されます。", -"issue.assumed_unsampled": "この機器はサンプリングレートを宣言していないため、フローは 1:1 で数えています。サンプリングしているなら、サンプラーのオプションをエクスポートさせるか、「設定 → 名前」で device {a} 名前 sampling=1000 のように指定してください。全パケットをエクスポートしているなら、その行に unsampled を付けてください。", +"issue.assumed_unsampled": "この機器はサンプリングレートを宣言していないため、フローは 1:1 で数えています。サンプリングしているなら、サンプラーのオプションをエクスポートさせるか、「台帳」で device {a} 名前 sampling=1000 のように指定してください。全パケットをエクスポートしているなら、その行に unsampled を付けてください。", "issue.loss": "エクスポートパケットの {p} が途中で失われました(シーケンス番号の欠け)。合計は補正済みですが、ホストごとの明細は戻りません。このマシンの “netstat -su” で receive buffer errors が増えていれば net.core.rmem_max を上げてください。増えていなければ経路を確認してください。", "issue.no_template": "データは届いていますが、テンプレートがまだです。データは保留し、テンプレートが届き次第解読します。", "issue.clock_skew": "機器の時計が {s} ずれています。時刻は受信時刻に補正しました。", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "内側:通信量の多いクライアント、外側:各クライアントが使うサーバー", "rg.svc_sub_v": "内側:通信量の多いサービス、外側:そのサービスを提供するサーバー", "rg.svc_sub_s": "内側:通信量の多いサーバー、外側:各サーバーのサービス", -"geo.lines_hint": "設定 → 名前 でネットワークに国・地域を設定すると、そこから各国・地域への線が地図に描かれます。", +"geo.lines_hint": "台帳 でネットワークに国・地域を設定すると、そこから各国・地域への線が地図に描かれます。", "nm.country_none": "国・地域(任意)", "lic.free": "トラフィック分析と検知はすべてそのまま動作し、データが消えることもありません。登録が必要な機能は、その場所に明示されます。", "lic.ok": "{c} にライセンス済み:残り {n} 日", @@ -755,7 +755,7 @@ "src.wait_sampling": "サンプリングレート待ち", "wait.settling": "まだです — 直近の数分はまだ届いている最中です。エクスポーターはフローの終了後に報告するため、直近 {n} 分は順次埋まります。操作は不要です。", "wait.settling_one": "まだです — 直近の数分はまだ届いている最中です。エクスポーターはフローの終了後に報告するため、直近 1 分は順次埋まります。操作は不要です。", -"nav.names": "名前", +"nav.names": "台帳", "fd.kind.fastscan": "高速スキャン", "fd.kind.beacon": "定期的な通信", "fd.kind.chain": "踏み台の連鎖", diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index 739075c..bf9d4e4 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -174,7 +174,7 @@ "if.in": "인바운드", "if.out": "아웃바운드", "if.no_ctr_short": "카운터 없음", -"if.no_counters": "이 장비는 인터페이스 카운터를 보내지 않아 비교할 대상이 없습니다. 설정 → 이름에 snmp 10.0.0.1 public 같은 줄을 추가하면 SNMP로 읽어 옵니다.", +"if.no_counters": "이 장비는 인터페이스 카운터를 보내지 않아 비교할 대상이 없습니다. 대장에 snmp 10.0.0.1 public 같은 줄을 추가하면 SNMP로 읽어 옵니다.", "if.no_flow": "이 인터페이스의 플로 데이터를 받지 못했습니다. 장비의 플로 내보내기를 확인하세요(샘플링은 설정됐지만 내보내지 않는 경우?)", "if.no_flow_short": "플로 없음", "if.ok": "일치합니다. 플로 추정치와 장비 카운터의 차이는 {dev}로, 샘플링 통계 오차({err}) 이내입니다.", @@ -201,7 +201,7 @@ "src.title": "수집 상태", "src.sub": "각 장비가 보내고 있는지, 해석되는지, 샘플링 비율이 맞는지", "src.listeners": "수신 포트", -"src.names": "이름", +"src.names": "대장", "src.names_sub": "선택 사항. 네트워크, 장비, 인터페이스, 호스트의 이름을 한 줄에 하나씩 적습니다.", "src.save": "저장", "src.saved": "저장했습니다", @@ -214,7 +214,7 @@ "st.bad": "수신 안 됨", "issue.silent": "2분 넘게 아무것도 받지 못했습니다. 장비의 export 대상 설정과 방화벽에서 UDP가 허용되는지 확인하세요.", "issue.waiting_sampling": "템플릿은 받았지만 샘플링 비율이 아직 없습니다. 레코드 {n}건을 보류 중이며, 비율이 도착하면 1:1이 아니라 올바른 배율로 집계합니다. 장비는 보통 몇 분 안에 다시 보냅니다.", -"issue.assumed_unsampled": "이 장비는 샘플링 비율을 알려 주지 않아 플로를 1:1로 계산합니다. 샘플링을 한다면 샘플러 옵션을 내보내게 하거나, 설정 → 이름에서 device {a} 이름 sampling=1000 처럼 지정하세요. 모든 패킷을 내보낸다면 그 줄에 unsampled를 붙이세요.", +"issue.assumed_unsampled": "이 장비는 샘플링 비율을 알려 주지 않아 플로를 1:1로 계산합니다. 샘플링을 한다면 샘플러 옵션을 내보내게 하거나, 대장에서 device {a} 이름 sampling=1000 처럼 지정하세요. 모든 패킷을 내보낸다면 그 줄에 unsampled를 붙이세요.", "issue.loss": "내보낸 패킷의 {p}가 오는 길에 유실되었습니다(시퀀스 번호 누락). 합계는 보정했지만 호스트별 상세는 되찾을 수 없습니다. 이 서버의 “netstat -su”에서 receive buffer errors가 늘고 있으면 net.core.rmem_max를 올리고, 아니면 네트워크 경로를 확인하세요.", "issue.no_template": "데이터는 오지만 템플릿이 아직 오지 않았습니다. 보류해 두었다가 템플릿이 도착하는 즉시 해석합니다.", "issue.clock_skew": "장비 시계가 {s} 어긋나 있습니다. 시간을 수신 시각으로 보정했습니다.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "안쪽: 트래픽이 많은 클라이언트, 바깥쪽: 각 클라이언트가 쓰는 서버", "rg.svc_sub_v": "안쪽: 트래픽이 많은 서비스, 바깥쪽: 그 서비스를 제공하는 서버", "rg.svc_sub_s": "안쪽: 트래픽이 많은 서버, 바깥쪽: 각 서버의 서비스", -"geo.lines_hint": "설정 → 이름에서 내부 네트워크에 국가를 지정하면, 거기서 각 국가로 가는 선이 지도에 그려집니다.", +"geo.lines_hint": "대장에서 내부 네트워크에 국가를 지정하면, 거기서 각 국가로 가는 선이 지도에 그려집니다.", "nm.country_none": "국가/지역/지역(선택)", "lic.free": "트래픽 분석과 탐지는 모두 그대로 작동하며 데이터가 삭제되지도 않습니다. 등록이 필요한 기능은 해당 위치에 표시됩니다.", "lic.ok": "{c}에 라이선스됨: {n}일 남음", @@ -755,7 +755,7 @@ "src.wait_sampling": "샘플링 레이트 대기 중", "wait.settling": "아직입니다 — 최근 몇 분의 데이터가 계속 도착하는 중입니다. 익스포터는 플로가 끝난 뒤 보고하므로 마지막 {n}분은 순차적으로 채워집니다. 조치할 것은 없습니다.", "wait.settling_one": "아직입니다 — 최근 몇 분의 데이터가 계속 도착하는 중입니다. 익스포터는 플로가 끝난 뒤 보고하므로 마지막 1분은 순차적으로 채워집니다. 조치할 것은 없습니다.", -"nav.names": "이름", +"nav.names": "대장", "fd.kind.fastscan": "고속 스캔", "fd.kind.beacon": "주기적 통신", "fd.kind.chain": "경유 연쇄", diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index a64e1ae..8a6cf40 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -174,7 +174,7 @@ "if.in": "Entrada", "if.out": "Saída", "if.no_ctr_short": "sem contadores", -"if.no_counters": "Este dispositivo não envia contadores de interface, então não há com o que comparar. Adicione uma linha como snmp 10.0.0.1 public em Configurações → Nomes para lê-los via SNMP.", +"if.no_counters": "Este dispositivo não envia contadores de interface, então não há com o que comparar. Adicione uma linha como snmp 10.0.0.1 public em Inventário para lê-los via SNMP.", "if.no_flow": "Nenhum dado de fluxo recebido desta interface — verifique a exportação de fluxos do equipamento (amostragem configurada, mas não exportada?)", "if.no_flow_short": "sem fluxos", "if.ok": "Consistente. A estimativa difere do contador do equipamento em {dev}, dentro do erro estatístico da amostragem ({err}).", @@ -201,7 +201,7 @@ "src.title": "Fontes", "src.sub": "Cada equipamento está enviando, dá para decodificar, a taxa de amostragem está certa?", "src.listeners": "Escutando em", -"src.names": "Nomes", +"src.names": "Inventário", "src.names_sub": "Opcional. Dê nomes a redes, equipamentos, interfaces e hosts, um por linha.", "src.save": "Salvar", "src.saved": "Salvo", @@ -214,7 +214,7 @@ "st.bad": "Sem recebimento", "issue.silent": "Nada recebido há mais de 2 minutos. Confira o destino de exportação no equipamento e se os firewalls liberam UDP.", "issue.waiting_sampling": "Os modelos chegaram, mas a taxa de amostragem ainda não. {n} registros estão retidos e serão escalados quando ela chegar, sem contar como 1:1. Os equipamentos costumam reenviá-la em poucos minutos.", -"issue.assumed_unsampled": "Este equipamento não declarou sua taxa de amostragem, então seus fluxos contam 1:1. Se ele amostra, faça-o exportar as opções do amostrador ou informe a taxa em Configurações → Nomes, ex. device {a} nome sampling=1000. Se exporta todos os pacotes, adicione unsampled a essa linha.", +"issue.assumed_unsampled": "Este equipamento não declarou sua taxa de amostragem, então seus fluxos contam 1:1. Se ele amostra, faça-o exportar as opções do amostrador ou informe a taxa em Inventário, ex. device {a} nome sampling=1000. Se exporta todos os pacotes, adicione unsampled a essa linha.", "issue.loss": "{p} dos pacotes exportados se perderam no caminho (lacunas nos números de sequência). Os totais são compensados; o detalhe por host, não. Se “netstat -su” nesta máquina mostrar receive buffer errors aumentando, aumente net.core.rmem_max; senão, verifique o caminho de rede.", "issue.no_template": "Os dados estão chegando, mas o modelo ainda não. Eles ficam retidos e são decodificados assim que o modelo chegar.", "issue.clock_skew": "O relógio do equipamento está {s} fora. Os horários foram corrigidos para a hora de chegada.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Anel interno: os clientes com mais tráfego; externo: os servidores que cada um usa", "rg.svc_sub_v": "Anel interno: os serviços com mais tráfego; externo: os servidores que os oferecem", "rg.svc_sub_s": "Anel interno: os servidores com mais tráfego; externo: os serviços de cada um", -"geo.lines_hint": "Defina um país para suas redes internas (Configurações → Nomes, Rede) para ver linhas delas até os países com que conversam.", +"geo.lines_hint": "Defina um país para suas redes internas (Inventário, Rede) para ver linhas delas até os países com que conversam.", "nm.country_none": "país/região (opcional)", "lic.free": "A análise de tráfego e a detecção continuam funcionando por completo, e nenhum dado é apagado; os recursos que exigem registro estão assinalados onde ficam.", "lic.ok": "Licenciado para {c}: faltam {n} dias", @@ -754,7 +754,7 @@ "src.wait_templates": "à espera dos modelos", "src.wait_sampling": "à espera da taxa de amostragem", "wait.settling": "Ainda não — os minutos mais recentes ainda estão a chegar. Os exportadores reportam um fluxo depois de ele terminar, por isso os últimos {n} minutos vão-se preenchendo. Nada a fazer.", -"nav.names": "Nomes", +"nav.names": "Inventário", "wait.settling_one": "Ainda não — o minuto mais recente ainda está a chegar. Os exportadores reportam um fluxo depois de ele terminar, por isso o último minuto vai-se preenchendo. Nada a fazer.", "fd.kind.fastscan": "Varrimento rápido", "fd.kind.beacon": "Ligação periódica", diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index 18cbb8f..a4f6d37 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -174,7 +174,7 @@ "if.in": "Входящий", "if.out": "Исходящий", "if.no_ctr_short": "нет счётчиков", -"if.no_counters": "Это устройство не присылает счётчики интерфейсов, сравнивать не с чем. Добавьте строку вида snmp 10.0.0.1 public в разделе Настройки → Имена, чтобы читать их по SNMP.", +"if.no_counters": "Это устройство не присылает счётчики интерфейсов, сравнивать не с чем. Добавьте строку вида snmp 10.0.0.1 public в разделе Реестр, чтобы читать их по SNMP.", "if.no_flow": "По этому интерфейсу не приходит ни одного потока — проверьте экспорт потоков на устройстве (сэмплирование настроено, но экспорт не идёт?)", "if.no_flow_short": "нет потоков", "if.ok": "Совпадает. Оценка отличается от счётчика устройства на {dev}, в пределах статистической погрешности сэмплирования ({err}).", @@ -201,7 +201,7 @@ "src.title": "Источники", "src.sub": "Все ли устройства присылают данные, удаётся ли их разобрать, верна ли частота сэмплирования", "src.listeners": "Слушаем", -"src.names": "Названия", +"src.names": "Реестр", "src.names_sub": "Необязательно. Дайте имена сетям, устройствам, интерфейсам и узлам, по одному в строке.", "src.save": "Сохранить", "src.saved": "Сохранено", @@ -214,7 +214,7 @@ "st.bad": "Нет приёма", "issue.silent": "Более 2 минут ничего не приходит. Проверьте адрес экспорта на устройстве и пропускают ли межсетевые экраны UDP.", "issue.waiting_sampling": "Шаблоны получены, а частота сэмплирования ещё нет. {n} записей отложено; они будут пересчитаны, когда она придёт, и не считаются как 1:1. Обычно устройства повторяют её в течение нескольких минут.", -"issue.assumed_unsampled": "Устройство не сообщило частоту сэмплирования, поэтому его потоки считаются 1:1. Если оно сэмплирует, включите экспорт опций сэмплера или укажите частоту в Настройки → Имена, например device {a} имя sampling=1000. Если оно экспортирует каждый пакет, добавьте в эту строку unsampled.", +"issue.assumed_unsampled": "Устройство не сообщило частоту сэмплирования, поэтому его потоки считаются 1:1. Если оно сэмплирует, включите экспорт опций сэмплера или укажите частоту в разделе Реестр, например device {a} имя sampling=1000. Если оно экспортирует каждый пакет, добавьте в эту строку unsampled.", "issue.loss": "{p} экспортных пакетов потеряно по пути (пропуски в порядковых номерах). Итоги скомпенсированы, детализация по хостам — нет. Если в «netstat -su» на этой машине растут receive buffer errors, увеличьте net.core.rmem_max; иначе проверьте сетевой путь.", "issue.no_template": "Данные приходят, а шаблона ещё нет. Они отложены и будут разобраны, как только придёт шаблон.", "issue.clock_skew": "Часы устройства расходятся на {s}. Время исправлено на время приёма.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Внутреннее кольцо: самые активные клиенты; внешнее: серверы, которыми они пользуются", "rg.svc_sub_v": "Внутреннее кольцо: самые нагруженные сервисы; внешнее: серверы, на которых они работают", "rg.svc_sub_s": "Внутреннее кольцо: самые нагруженные серверы; внешнее: их сервисы", -"geo.lines_hint": "Укажите страну для внутренних сетей (Настройки → Имена, Сеть), чтобы увидеть линии от них к странам, с которыми они общаются.", +"geo.lines_hint": "Укажите страну для внутренних сетей (Реестр, Сеть), чтобы увидеть линии от них к странам, с которыми они общаются.", "nm.country_none": "страна/регион (необязательно)", "lic.free": "Анализ трафика и обнаружение работают полностью, и никакие данные не удаляются; функции, требующие регистрации, отмечены там, где они находятся.", "lic.ok": "Лицензия выдана {c}: осталось {n} дн.", @@ -754,7 +754,7 @@ "src.wait_templates": "ожидание шаблонов", "src.wait_sampling": "ожидание частоты выборки", "wait.settling": "Пока нет — последние минуты ещё поступают. Экспортёры сообщают о потоке после его завершения, поэтому последние {n} мин. заполняются постепенно. Делать ничего не нужно.", -"nav.names": "Названия", +"nav.names": "Реестр", "wait.settling_one": "Пока нет — последняя минута ещё поступает. Экспортёры сообщают о потоке после его завершения, поэтому последняя минута заполняется постепенно. Делать ничего не нужно.", "fd.kind.fastscan": "Быстрое сканирование", "fd.kind.beacon": "Регулярные обращения", diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index cae9cb3..ce07ad8 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -174,7 +174,7 @@ "if.in": "اندر آنے والی", "if.out": "باہر جانے والی", "if.no_ctr_short": "کاؤنٹر نہیں", -"if.no_counters": "یہ آلہ انٹرفیس کاؤنٹر نہیں بھیجتا، اس لیے موازنہ ممکن نہیں۔ ترتیبات ← نام میں snmp 10.0.0.1 public جیسی سطر شامل کریں تاکہ SNMP سے کاؤنٹر پڑھے جا سکیں۔", +"if.no_counters": "یہ آلہ انٹرفیس کاؤنٹر نہیں بھیجتا، اس لیے موازنہ ممکن نہیں۔ رجسٹر میں snmp 10.0.0.1 public جیسی سطر شامل کریں تاکہ SNMP سے کاؤنٹر پڑھے جا سکیں۔", "if.no_flow": "اس انٹرفیس کا کوئی فلو ڈیٹا موصول نہیں ہوا — ڈیوائس کی فلو ایکسپورٹ چیک کریں (سیمپلنگ سیٹ ہے مگر ایکسپورٹ نہیں ہو رہی؟)", "if.no_flow_short": "فلو نہیں", "if.ok": "مطابقت ہے۔ فلو کا تخمینہ آلے کے کاؤنٹر سے {dev} مختلف ہے، جو سیمپلنگ کی شماریاتی غلطی ({err}) کے اندر ہے۔", @@ -201,7 +201,7 @@ "src.title": "ذرائع", "src.sub": "کیا ہر آلہ بھیج رہا ہے، کیا ڈیکوڈ ہو رہا ہے، کیا سیمپلنگ کی شرح درست ہے", "src.listeners": "سن رہا ہے", -"src.names": "نام", +"src.names": "رجسٹر", "src.names_sub": "اختیاری۔ نیٹ ورک، آلات، انٹرفیس اور ہوسٹ کے نام، ہر سطر پر ایک۔", "src.save": "محفوظ کریں", "src.saved": "محفوظ ہو گیا", @@ -214,7 +214,7 @@ "st.bad": "وصول نہیں ہو رہا", "issue.silent": "2 منٹ سے زیادہ کچھ وصول نہیں ہوا۔ آلے پر ایکسپورٹ کی منزل چیک کریں اور یقینی بنائیں کہ فائر وال UDP کی اجازت دیتی ہے۔", "issue.waiting_sampling": "ٹیمپلیٹ مل گئے لیکن سیمپلنگ کی شرح ابھی نہیں ملی۔ {n} ریکارڈ روکے گئے ہیں اور شرح ملنے پر درست پیمانے پر گنے جائیں گے، 1:1 نہیں۔ آلات عموماً چند منٹ میں دوبارہ بھیج دیتے ہیں۔", -"issue.assumed_unsampled": "اس آلے نے اپنی sampling شرح نہیں بتائی، اس لیے اس کے فلو 1:1 گنے جا رہے ہیں۔ اگر یہ sampling کرتا ہے تو اس سے sampler options export کروائیں، یا ترتیبات ← نام میں شرح لکھیں، مثلاً device {a} نام sampling=1000۔ اگر یہ ہر پیکٹ export کرتا ہے تو اس سطر میں unsampled شامل کریں۔", +"issue.assumed_unsampled": "اس آلے نے اپنی sampling شرح نہیں بتائی، اس لیے اس کے فلو 1:1 گنے جا رہے ہیں۔ اگر یہ sampling کرتا ہے تو اس سے sampler options export کروائیں، یا رجسٹر میں شرح لکھیں، مثلاً device {a} نام sampling=1000۔ اگر یہ ہر پیکٹ export کرتا ہے تو اس سطر میں unsampled شامل کریں۔", "issue.loss": "ایکسپورٹ پیکٹوں میں سے {p} راستے میں کھو گئے (sequence نمبروں میں خلا)۔ کل مقدار کی تلافی کر دی گئی ہے، مگر ہر host کی تفصیل واپس نہیں آ سکتی۔ اگر اس مشین پر “netstat -su” میں receive buffer errors بڑھ رہے ہیں تو net.core.rmem_max بڑھائیں، ورنہ نیٹ ورک کا راستہ دیکھیں۔", "issue.no_template": "ڈیٹا آ رہا ہے لیکن اس کا ٹیمپلیٹ نہیں آیا۔ ڈیٹا روکا گیا ہے اور ٹیمپلیٹ آتے ہی ڈیکوڈ ہو گا۔", "issue.clock_skew": "آلے کی گھڑی {s} آگے پیچھے ہے۔ اوقات کو وصولی کے وقت پر درست کیا گیا۔", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "اندرونی دائرہ: سب سے مصروف کلائنٹس؛ بیرونی: ہر ایک کے استعمال کردہ سرورز", "rg.svc_sub_v": "اندرونی دائرہ: سب سے مصروف سروسز؛ بیرونی: انہیں فراہم کرنے والے سرورز", "rg.svc_sub_s": "اندرونی دائرہ: سب سے مصروف سرورز؛ بیرونی: ہر ایک کی سروسز", -"geo.lines_hint": "اپنے اندرونی نیٹ ورکس کا ملک مقرر کریں (ترتیبات ← نام، نیٹ ورک) تاکہ ان سے دوسرے ممالک تک لکیریں نظر آئیں۔", +"geo.lines_hint": "اپنے اندرونی نیٹ ورکس کا ملک مقرر کریں (رجسٹر، نیٹ ورک) تاکہ ان سے دوسرے ممالک تک لکیریں نظر آئیں۔", "nm.country_none": "ملک/خطہ/خطہ (اختیاری)", "lic.free": "ٹریفک تجزیہ اور شناخت مکمل طور پر کام کرتے رہتے ہیں، اور کوئی ڈیٹا حذف نہیں ہوتا؛ جن خصوصیات کے لیے رجسٹریشن درکار ہے، وہ اپنی جگہ پر نشان زد ہیں۔", "lic.ok": "{c} کو لائسنس: {n} دن باقی", @@ -755,7 +755,7 @@ "src.wait_sampling": "سیمپلنگ ریٹ کا انتظار", "wait.settling": "ابھی نہیں — حالیہ منٹوں کا ڈیٹا ابھی آ رہا ہے۔ ایکسپورٹر فلو ختم ہونے کے بعد رپورٹ کرتے ہیں، اس لیے آخری {n} منٹ بتدریج بھرتے ہیں۔ کچھ کرنے کی ضرورت نہیں۔", "wait.settling_one": "ابھی نہیں — حالیہ منٹوں کا ڈیٹا ابھی آ رہا ہے۔ ایکسپورٹر فلو ختم ہونے کے بعد رپورٹ کرتے ہیں، اس لیے آخری 1 منٹ بتدریج بھرتے ہیں۔ کچھ کرنے کی ضرورت نہیں۔", -"nav.names": "نام", +"nav.names": "رجسٹر", "fd.kind.fastscan": "تیز اسکین", "fd.kind.beacon": "باقاعدہ چیک اِن", "fd.kind.chain": "ہاپ چین", diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index 52de54f..231500e 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -174,7 +174,7 @@ "if.in": "Đi vào", "if.out": "Đi ra", "if.no_ctr_short": "không có bộ đếm", -"if.no_counters": "Thiết bị này không gửi bộ đếm giao diện nên không có gì để so sánh. Thêm một dòng như snmp 10.0.0.1 public trong Cài đặt → Tên để đọc qua SNMP.", +"if.no_counters": "Thiết bị này không gửi bộ đếm giao diện nên không có gì để so sánh. Thêm một dòng như snmp 10.0.0.1 public trong Sổ quản lý để đọc qua SNMP.", "if.no_flow": "Không nhận được dữ liệu flow cho giao diện này — kiểm tra việc xuất flow trên thiết bị (đã cấu hình lấy mẫu nhưng chưa xuất?)", "if.no_flow_short": "không có flow", "if.ok": "Nhất quán. Ước tính từ luồng lệch {dev} so với bộ đếm thiết bị, nằm trong sai số thống kê của lấy mẫu ({err}).", @@ -201,7 +201,7 @@ "src.title": "Nguồn", "src.sub": "Mọi thiết bị có đang gửi không, giải mã được không, tỷ lệ lấy mẫu có đúng không", "src.listeners": "Đang lắng nghe trên", -"src.names": "Tên", +"src.names": "Sổ quản lý", "src.names_sub": "Tùy chọn. Đặt tên cho mạng, thiết bị, giao diện và máy, mỗi dòng một mục.", "src.save": "Lưu", "src.saved": "Đã lưu", @@ -214,7 +214,7 @@ "st.bad": "Không nhận", "issue.silent": "Không nhận được gì trong hơn 2 phút. Kiểm tra đích export trên thiết bị và đảm bảo tường lửa cho phép UDP đi qua.", "issue.waiting_sampling": "Đã nhận template nhưng chưa có tỷ lệ lấy mẫu. {n} bản ghi đang được giữ lại và sẽ được nhân theo tỷ lệ khi nhận được, không tính là 1:1. Thiết bị thường gửi lại trong vài phút.", -"issue.assumed_unsampled": "Thiết bị này chưa khai báo tỷ lệ lấy mẫu nên các luồng của nó được tính 1:1. Nếu thiết bị có lấy mẫu, hãy cấu hình export sampler options, hoặc nhập tỷ lệ trong Cài đặt → Tên, ví dụ device {a} name sampling=1000. Nếu nó export mọi gói tin, thêm unsampled vào dòng đó.", +"issue.assumed_unsampled": "Thiết bị này chưa khai báo tỷ lệ lấy mẫu nên các luồng của nó được tính 1:1. Nếu thiết bị có lấy mẫu, hãy cấu hình export sampler options, hoặc nhập tỷ lệ trong Sổ quản lý, ví dụ device {a} name sampling=1000. Nếu nó export mọi gói tin, thêm unsampled vào dòng đó.", "issue.loss": "{p} gói export bị mất trên đường đến đây (số thứ tự bị hụt). Tổng đã được bù; chi tiết theo từng máy thì không bù được. Nếu “netstat -su” trên máy này cho thấy lỗi bộ đệm nhận (receive buffer errors) tăng, hãy tăng net.core.rmem_max; nếu không, hãy kiểm tra đường mạng.", "issue.no_template": "Dữ liệu đã đến nhưng template thì chưa. Dữ liệu được giữ lại và sẽ giải mã ngay khi template đến.", "issue.clock_skew": "Đồng hồ thiết bị lệch {s}. Thời gian đã được hiệu chỉnh theo thời điểm nhận.", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "Vòng trong: các client bận nhất; vòng ngoài: các server mỗi client sử dụng", "rg.svc_sub_v": "Vòng trong: các dịch vụ bận nhất; vòng ngoài: các server cung cấp từng dịch vụ", "rg.svc_sub_s": "Vòng trong: các server bận nhất; vòng ngoài: dịch vụ của từng server", -"geo.lines_hint": "Đặt quốc gia cho các mạng nội bộ (Cài đặt → Tên, Mạng) để thấy đường nối từ chúng đến các quốc gia chúng liên lạc.", +"geo.lines_hint": "Đặt quốc gia cho các mạng nội bộ (Sổ quản lý, Mạng) để thấy đường nối từ chúng đến các quốc gia chúng liên lạc.", "nm.country_none": "quốc gia/khu vực (tùy chọn)", "lic.free": "Phân tích lưu lượng và phát hiện vẫn hoạt động đầy đủ, và không dữ liệu nào bị xóa; những tính năng cần đăng ký được đánh dấu ngay tại chỗ của chúng.", "lic.ok": "Cấp phép cho {c}: còn {n} ngày", @@ -755,7 +755,7 @@ "src.wait_sampling": "đang chờ tỷ lệ lấy mẫu", "wait.settling": "Chưa có — những phút gần nhất vẫn đang về. Thiết bị xuất báo cáo luồng sau khi luồng kết thúc, nên {n} phút cuối sẽ dần đầy. Không cần làm gì.", "wait.settling_one": "Chưa có — những phút gần nhất vẫn đang về. Thiết bị xuất báo cáo luồng sau khi luồng kết thúc, nên 1 phút cuối sẽ dần đầy. Không cần làm gì.", -"nav.names": "Tên", +"nav.names": "Sổ quản lý", "fd.kind.fastscan": "Quét nhanh", "fd.kind.beacon": "Kết nối định kỳ", "fd.kind.chain": "Chuỗi nhảy", diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 3ce19d3..f9bfc7a 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -174,7 +174,7 @@ "if.in": "入方向", "if.out": "出方向", "if.no_ctr_short": "无计数器", -"if.no_counters": "这台设备不发送接口计数器,没有可对比的数据。在「设定 → 名称」里加一行 snmp 10.0.0.1 public,即可通过 SNMP 读取。", +"if.no_counters": "这台设备不发送接口计数器,没有可对比的数据。在「台账」里加一行 snmp 10.0.0.1 public,即可通过 SNMP 读取。", "if.no_flow": "这个接口有计数器数据,但没有收到任何 flow。请检查设备的 flow 导出(是否配置了采样但没有导出?)", "if.no_flow_short": "无 flow", "if.ok": "两边一致。流量估算和设备计数器相差 {dev},在采样的统计误差({err})以内。", @@ -201,7 +201,7 @@ "src.title": "接入", "src.sub": "每台设备有没有在发、能不能解、采样率对不对", "src.listeners": "正在监听", -"src.names": "名称", +"src.names": "台账", "src.names_sub": "可选。给网段、设备、接口和主机起名字,每行一条。", "src.save": "保存", "src.saved": "已保存", @@ -214,7 +214,7 @@ "st.bad": "没有收到", "issue.silent": "超过 2 分钟没有收到数据。检查设备上的导出地址,以及防火墙有没有放行 UDP。", "issue.waiting_sampling": "模板收到了,但采样率还没收到。{n} 条流已暂存,收到采样率后自动换算入库,不会按 1:1 算。设备一般几分钟内会重发。", -"issue.assumed_unsampled": "这台设备一直没有声明采样率,它的流按 1:1 计算。如果它开了采样,请让它导出采样器选项,或者在「设定 → 名称」里手动写上,例如 device {a} 名称 sampling=1000。如果它不采样,在那一行加 unsampled。", +"issue.assumed_unsampled": "这台设备一直没有声明采样率,它的流按 1:1 计算。如果它开了采样,请让它导出采样器选项,或者在「台账」里手动写上,例如 device {a} 名称 sampling=1000。如果它不采样,在那一行加 unsampled。", "issue.loss": "导出包在路上丢了 {p}(序号有缺口)。总量已经补偿,但每台主机的明细无法找回。如果本机 “netstat -su” 里的 receive buffer errors 在增长,请调大 net.core.rmem_max;否则请检查网络链路。", "issue.no_template": "数据在进来,但模板还没到。数据已暂存,模板一到就解码。", "issue.clock_skew": "设备时钟偏差 {s},时间已按收到的时刻校正。", @@ -610,7 +610,7 @@ "rg.hosts_sub_c": "内环:流量最大的客户端;外环:每个客户端访问的服务器", "rg.svc_sub_v": "内环:流量最大的服务;外环:提供该服务的服务器", "rg.svc_sub_s": "内环:流量最大的服务器;外环:每台服务器上的服务", -"geo.lines_hint": "在 设定 → 名称 里给内网网段设置国家/地区,地图上就会画出从它们到各个国家/地区的流向线。", +"geo.lines_hint": "在 台账 里给内网网段设置国家/地区,地图上就会画出从它们到各个国家/地区的流向线。", "nm.country_none": "国家/地区(可选)", "lic.free": "流量分析和威胁检测全部照常可用,数据也不会被删除;需要注册才能使用的功能,会在各自的位置上标明。", "lic.ok": "已授权给 {c},授权还有 {n} 天", @@ -755,7 +755,7 @@ "src.wait_sampling": "等待采样率", "wait.settling": "还没到时候——最近几分钟的数据还在陆续到达。导出器是在流结束后才上报的,所以最后 {n} 分钟会逐步补齐。无需操作。", "wait.settling_one": "还没到时候——最近几分钟的数据还在陆续到达。导出器是在流结束后才上报的,所以最后 1 分钟会逐步补齐。无需操作。", -"nav.names": "名称", +"nav.names": "台账", "fd.kind.fastscan": "高速扫描", "fd.kind.beacon": "定时回连", "fd.kind.chain": "跳板链", diff --git a/inventory.txt.example b/inventory.txt.example index 9a08ae3..4874ca1 100644 --- a/inventory.txt.example +++ b/inventory.txt.example @@ -1,5 +1,5 @@ # traffic66 inventory — copy to /inventory.txt, or edit it -# in the web UI under Sources → Names. Every line is optional. +# in the web UI under Inventory. Every line is optional. # # net networks that are yours net 10.0.0.0/8 Internal From 0ae8e301d208c855fb5bc9b0cd49a5cdb9eed55d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:48:36 +0000 Subject: [PATCH 4/8] One advertiser at a time in the sponsor strip A long banner (12:1 or wider; 2880x120 is the size to make) has the strip to itself. A small 728x90 banner is shown three times side by side, the same ad in every cell, instead of three different advertisers next to each other. The strip rotates between advertisers and keeps the height of a row of small banners, so the page under it never moves. --- internal/web/static/app.css | 11 ++--- internal/web/static/sponsor.js | 85 +++++++++++++++++----------------- 2 files changed, 47 insertions(+), 49 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 756aa14..4413f5b 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -87,18 +87,17 @@ nav.dash button[aria-current="page"] .ni{opacity:1} browser at the top of the page, above the sidebar and the content, and it scrolls away with the page rather than taking a band of every screen. - The banners in the manifest are 728x90, so a wide window is filled with - several of them side by side instead of one stretched one; the strip - decides how many fit and rotates through the rest. Its height is the - cell's aspect ratio, which is why the sidebar's own height is measured - against it (--sp-h, set by sponsor.js) instead of a fixed 100vh. */ + One advertiser at a time: a long banner alone, a small 728x90 one three + times side by side (sponsor.js decides, and sets the row height). The + sidebar's own height is measured against the strip (--sp-h) instead of + a fixed 100vh. */ .sponsor{grid-column:1/-1;background:var(--side);border-bottom:1px solid var(--side-line)} .sponsor[hidden]{display:none} .sponsor .sp-h{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3);padding:3px 12px 2px} .sponsor .sp-text{margin:0 12px 8px;border:1px dashed var(--line-2);border-radius:6px;padding:6px 8px;font-size:11.5px;line-height:1.35;color:var(--ink-3);overflow-wrap:anywhere} .sponsor .sp-text[hidden],.spc[hidden]{display:none} .spc-strip{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:1px;background:var(--side-line)} -.spc-cell{position:relative;aspect-ratio:728/90;overflow:hidden;background:var(--surface)} +.spc-cell{position:relative;overflow:hidden;background:var(--surface)} .spc-cell>a,.spc-cell>div{display:block;width:100%;height:100%} .spc-cell img{width:100%;height:100%;object-fit:cover;display:block} .spc-dots{display:flex;gap:6px;justify-content:center;padding:4px 0 5px} diff --git a/internal/web/static/sponsor.js b/internal/web/static/sponsor.js index 36b068e..1eedd9b 100644 --- a/internal/web/static/sponsor.js +++ b/internal/web/static/sponsor.js @@ -12,12 +12,15 @@ (function () { 'use strict'; var URL_ = 'https://raw.githubusercontent.com/githubflyideas/ads-sponsor/main/traffic66/sponsors.json'; - // MAX is how many banners are fetched: the strip can show three at once - // on a wide window, so four left nothing to rotate to. + // MAX is how many advertisers are fetched and rotated through. var TIMEOUT_MS = 3000, ROTATE_MS = 5500, MAX = 6; // A cell narrower than this is not worth drawing a 728x90 banner into, so // it decides how many fit across the window. var MIN_CELL = 420; + // A small banner is 728x90 (1456x180 at 2x) and shown SMALL_COPIES times + // side by side; an image LONG_RATIO times as wide as it is high or wider + // is a long banner and fills the strip alone. + var SMALL_W = 728, SMALL_H = 90, SMALL_COPIES = 3, LONG_RATIO = 12; var CACHE_KEY = 't66.sponsor', CACHE_MS = 30 * 60 * 1000; var started = false, timerId = null, roId = null; @@ -104,16 +107,23 @@ return out; } - // The strip: as many banners side by side as the window has room for, - // rotating through the rest together. One image element cannot be in two - // cells at once, so each cell gets a clone of an already-loaded image — - // the bytes are in the browser's cache, nothing is fetched twice. + // The strip shows one advertiser at a time and rotates between them. + // - a long banner (LONG_RATIO wide to 1 high or wider, 2880x120 is the + // intended size) has the strip to itself; + // - a small banner (the 728x90 kind) is shown three times side by side, + // the same ad in every cell, so the strip never mixes two advertisers. + // A narrow window has room for fewer cells (one per MIN_CELL pixels). + // The strip keeps the height of a row of small banners whichever ad is + // up, so rotating never moves the page underneath. + // + // One image element cannot be in two cells at once, so each cell gets a + // clone of an already-loaded image; nothing is fetched twice. function build(slot, slides) { var car = slot.querySelector('.spc'); car.hidden = false; var strip = document.createElement('div'); strip.className = 'spc-strip'; var dots = document.createElement('div'); dots.className = 'spc-dots'; - var cells = [], ds = [], cols = 0, off = 0, paused = false; + var ds = [], cur = 0, paused = false; function slideEl(s) { var a = document.createElement(s.link ? 'a' : 'div'); @@ -124,30 +134,25 @@ a.appendChild(img); return a; } - function show(o) { - off = ((o % slides.length) + slides.length) % slides.length; - cells.forEach(function (box, i) { - box.textContent = ''; - box.appendChild(slideEl(slides[(off + i) % slides.length])); - }); - ds.forEach(function (d, i) { d.classList.toggle('on', i === off); }); - } - // How many cells fit. Rebuilt only when the number changes, so a window - // being dragged does not rebuild the strip on every pixel. - function fit() { + function isLong(s) { return s.img.naturalWidth >= LONG_RATIO * s.img.naturalHeight; } + function draw() { + var s = slides[cur]; var w = car.clientWidth || window.innerWidth || MIN_CELL; - var k = Math.max(1, Math.min(slides.length, Math.floor(w / MIN_CELL))); - if (k === cols) return; - cols = k; + var k = Math.max(1, Math.min(SMALL_COPIES, Math.floor(w / MIN_CELL))); + var rowH = Math.round((w - (k - 1)) / k * SMALL_H / SMALL_W); + var n = isLong(s) ? 1 : k; strip.textContent = ''; - cells = []; - for (var i = 0; i < k; i++) { + strip.style.gridTemplateColumns = 'repeat(' + n + ', minmax(0, 1fr))'; + for (var i = 0; i < n; i++) { var box = document.createElement('div'); box.className = 'spc-cell'; - strip.appendChild(box); cells.push(box); + box.style.height = rowH + 'px'; + box.appendChild(slideEl(s)); + strip.appendChild(box); } - show(off); + ds.forEach(function (d, i) { d.classList.toggle('on', i === cur); }); measure(); } + function show(i) { cur = ((i % slides.length) + slides.length) % slides.length; draw(); } // The sidebar's height is the window less this strip (see app.css). function measure() { try { document.documentElement.style.setProperty('--sp-h', slot.offsetHeight + 'px'); } catch (e) {} @@ -155,31 +160,25 @@ car.textContent = ''; car.appendChild(strip); - // Dots only when there is more to see than fits: with everything on - // screen there is nothing to page to. - slides.forEach(function (s, i) { - var d = document.createElement('button'); - d.type = 'button'; d.className = 'spc-dot'; - d.setAttribute('aria-label', String(i + 1)); - d.onclick = function () { show(i); }; - dots.appendChild(d); ds.push(d); - }); - fit(); - if (slides.length > cols) { + if (slides.length > 1) { + slides.forEach(function (s, i) { + var d = document.createElement('button'); + d.type = 'button'; d.className = 'spc-dot'; + d.setAttribute('aria-label', String(i + 1)); + d.onclick = function () { show(i); }; + dots.appendChild(d); ds.push(d); + }); car.appendChild(dots); ['mouseenter', 'focusin'].forEach(function (e) { car.addEventListener(e, function () { paused = true; }); }); ['mouseleave', 'focusout'].forEach(function (e) { car.addEventListener(e, function () { paused = false; }); }); timerId = setInterval(function () { - if (!paused && !document.hidden) show(off + cols); + if (!paused && !document.hidden) show(cur + 1); }, ROTATE_MS); } slot.hidden = false; - measure(); - // The cells are fluid, so the strip's height changes with the window - // even when the number of cells does not: measure on every resize. - var onResize = function () { fit(); measure(); }; - if (window.ResizeObserver) { roId = new ResizeObserver(onResize); roId.observe(car); } - else window.addEventListener('resize', onResize); + show(0); + if (window.ResizeObserver) { roId = new ResizeObserver(draw); roId.observe(car); } + else window.addEventListener('resize', draw); } window.t66Sponsor = { From 3090852622ffdd0e1286416f9f74242a4ab0c0bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:51:32 +0000 Subject: [PATCH 5/8] 95th-percentile billing is removed The billing page, its two endpoints, the five-minute rate table they were computed from (and the pass that filled it every five minutes) and the billed mark on interface lines are gone. An inventory line that still says billed loads as before. /api/report takes its counter figures from the same counter buckets the interface page draws. --- CHANGELOG.md | 8 + cmd/traffic66/main.go | 2 +- internal/api/billing.go | 157 -------------- internal/api/billing_test.go | 217 ------------------- internal/api/handlers.go | 6 +- internal/api/report.go | 76 +++++-- internal/api/report_test.go | 7 +- internal/api/server.go | 2 - internal/enrich/billed_test.go | 64 ------ internal/enrich/enrich.go | 28 +-- internal/enrich/iface_test.go | 4 +- internal/pipeline/pipeline.go | 5 - internal/store/rates.go | 212 ------------------ internal/store/rates_test.go | 356 ------------------------------- internal/store/store.go | 12 +- internal/web/static/app.js | 140 +----------- internal/web/static/i18n/ar.json | 26 --- internal/web/static/i18n/bn.json | 26 --- internal/web/static/i18n/de.json | 26 --- internal/web/static/i18n/en.json | 26 --- internal/web/static/i18n/es.json | 26 --- internal/web/static/i18n/fr.json | 26 --- internal/web/static/i18n/hi.json | 26 --- internal/web/static/i18n/id.json | 26 --- internal/web/static/i18n/ja.json | 26 --- internal/web/static/i18n/ko.json | 26 --- internal/web/static/i18n/pt.json | 26 --- internal/web/static/i18n/ru.json | 26 --- internal/web/static/i18n/ur.json | 26 --- internal/web/static/i18n/vi.json | 26 --- internal/web/static/i18n/zh.json | 26 --- internal/web/static/index.html | 2 - 32 files changed, 74 insertions(+), 1614 deletions(-) delete mode 100644 internal/api/billing.go delete mode 100644 internal/api/billing_test.go delete mode 100644 internal/enrich/billed_test.go delete mode 100644 internal/store/rates.go delete mode 100644 internal/store/rates_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1e3f3..f78165e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ The release notes on GitHub are taken from this file: the section whose heading is the version number. +## 2.4.1 + +- 95th-percentile billing is removed: the page, `/api/billing`, `/api/billing/series`, the five-minute rate table it was computed from and the `billed` mark on interface lines (an inventory line that still carries it loads as before). The interface page shows the same counters, and `/api/report` now takes its counter figures from them. +- The evaluation mark is a step darker and a size larger. +- The names page is called Inventory again, in every language (台账 in Chinese). +- The sponsor strip shows one advertiser at a time: a long banner alone, a small 728x90 one three times side by side. +- Releases started by hand from Actions report themselves as official builds (2.4.0 said "community build"). + ## 2.4.0 - Top 66 opens on the two ends of each conversation (top clients and top servers side by side) instead of the grouped table; the table is still one click away and a shared link still lands where the sender was. diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index 7a97f59..29854d8 100644 --- a/cmd/traffic66/main.go +++ b/cmd/traffic66/main.go @@ -57,7 +57,7 @@ var version = "" // CHANGELOG.md. TestDevVersionMatchesChangelog keeps the two in step: the // default used to be a hardcoded 0.1.0-dev, and because nothing checked it // it stayed that way for two major versions. -const devVersion = "2.4.0" +const devVersion = "2.4.1" func init() { if version == "" { diff --git a/internal/api/billing.go b/internal/api/billing.go deleted file mode 100644 index 4793336..0000000 --- a/internal/api/billing.go +++ /dev/null @@ -1,157 +0,0 @@ -package api - -import ( - "errors" - "net/http" - "net/netip" - "net/url" - "strconv" - "strings" - "time" - - "github.com/githubflyideas/traffic66/internal/store" -) - -// billingSeries answers GET /api/billing/series?from&to&if=exporter/ifindex -// with the five-minute buckets one interface's bill was computed from, and -// the percentile drawn across them. The table says what is charged; this -// says what it was charged from, which is what somebody disputing a bill -// asks to see. -func (s *Server) billingSeries(w http.ResponseWriter, r *http.Request) { - v := r.URL.Query() - from, to, err := billPeriod(v) - if err != nil { - fail(w, err) - return - } - key := v.Get("if") - if err := checkIface(key); err != nil { - fail(w, err) - return - } - e, idxs, _ := strings.Cut(key, "/") - idx, _ := strconv.ParseUint(idxs, 10, 32) - pts, err := s.Store.BillSeries(from, to, e, uint32(idx)) - if err != nil { - fail(w, err) - return - } - out := map[string]any{ - "from": from.UnixMilli(), "to": to.UnixMilli(), "if": key, - "bucket_minutes": 5, "points": pts, - } - // the figure the table shows, so the line across the chart and the - // number beside it can never drift apart - if bills, err := s.Store.Bills(from, to, e); err == nil { - for _, b := range bills { - if b.IfIndex == uint32(idx) { - out["bill"] = b - out["coverage"] = b.Coverage() - break - } - } - } - writeJSON(w, http.StatusOK, out) -} - -// billPeriod reads the interval both billing endpoints take. The -// boundaries are used exactly as given: parseQuery rounds long ranges to -// the hour, which would quietly move the start of a month. -func billPeriod(v url.Values) (time.Time, time.Time, error) { - ms := func(k string) (time.Time, error) { - n, err := strconv.ParseInt(v.Get(k), 10, 64) - if err != nil { - return time.Time{}, errors.New(k + " must be a millisecond timestamp") - } - return time.UnixMilli(n).UTC(), nil - } - from, err := ms("from") - if err != nil { - return time.Time{}, time.Time{}, err - } - to, err := ms("to") - if err != nil { - return time.Time{}, time.Time{}, err - } - return from, to, nil -} - -// Billing reports the 95th percentile per interface over a period the -// caller gives as an interval. A calendar month, the last thirty days and a -// cycle that starts on the third are then the same request, and the month's -// boundaries are worked out where the user's time zone is known — in the -// browser — rather than guessed at here or pinned to a server flag. -func (s *Server) billing(w http.ResponseWriter, r *http.Request) { - v := r.URL.Query() - from, to, err := billPeriod(v) - if err != nil { - fail(w, err) - return - } - bills, err := s.Store.Bills(from, to, v.Get("exporter")) - if err != nil { - fail(w, err) - return - } - type out struct { - store.Bill - Name string `json:"name,omitempty"` - Device string `json:"device,omitempty"` - Coverage float64 `json:"coverage"` - // Not "billed": store.Bill already uses that for the figure, and an - // outer field of the same name would shadow it out of the JSON. - Marked bool `json:"marked,omitempty"` - } - // A device reports every interface it has; the two or three somebody - // pays a carrier for are the ones a bill is about. Marked interfaces - // win when there are any. When there are none the page shows - // everything and says so, because an empty page on the first visit - // teaches nobody anything. - var billed map[string]bool - if s.Inv != nil { - billed = s.Inv.Billed() - } - key := func(e string, i uint32) string { return e + "/" + strconv.FormatUint(uint64(i), 10) } - rows := make([]out, 0, len(bills)) - seen := map[string]bool{} - for _, b := range bills { - k := key(b.Exporter, b.IfIndex) - if len(billed) > 0 && !billed[k] { - continue - } - seen[k] = true - o := out{Bill: b, Coverage: b.Coverage(), Marked: billed[k]} - if a, err := netip.ParseAddr(b.Exporter); err == nil && s.Inv != nil { - o.Name = s.Inv.Iface(a, b.IfIndex).Name - o.Device = s.Inv.Device(a) - } - rows = append(rows, o) - } - // A marked interface with nothing in the period is the thing an - // operator most needs to see: the circuit they are paying for stopped - // reporting. It gets a row saying so rather than quietly vanishing. - for k := range billed { - if seen[k] { - continue - } - e, idxs, ok := strings.Cut(k, "/") - if !ok { - continue - } - i, err := strconv.ParseUint(idxs, 10, 32) - if err != nil { - continue - } - o := out{Bill: store.Bill{Exporter: e, IfIndex: uint32(i), Possible: int(to.Sub(from) / (5 * time.Minute))}, Marked: true} - if a, err := netip.ParseAddr(e); err == nil && s.Inv != nil { - o.Name = s.Inv.Iface(a, uint32(i)).Name - o.Device = s.Inv.Device(a) - } - rows = append(rows, o) - } - writeJSON(w, http.StatusOK, map[string]any{ - "from": from.UnixMilli(), "to": to.UnixMilli(), - "bucket_minutes": 5, "retention_days": s.Store.RollupRetentionDays(), - "marked": len(billed), "bills": rows, - }) -} diff --git a/internal/api/billing_test.go b/internal/api/billing_test.go deleted file mode 100644 index e5b948f..0000000 --- a/internal/api/billing_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http/httptest" - "strconv" - "testing" - "time" - - "github.com/githubflyideas/traffic66/internal/enrich" - "github.com/githubflyideas/traffic66/internal/store" -) - -func ms(t time.Time) string { return strconv.FormatInt(t.UnixMilli(), 10) } - -func billingOf(t *testing.T, s *Server, q string) map[string]any { - t.Helper() - w := httptest.NewRecorder() - s.billing(w, httptest.NewRequest("GET", "/api/billing?"+q, nil)) - if w.Code != 200 { - t.Fatalf("%s: %d %s", q, w.Code, w.Body) - } - var out map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { - t.Fatal(err) - } - return out -} - -// A month is given as an interval, and the interval is used as given. -// parseQuery rounds any range over a day to the hour, which would move the -// first midnight of a month and take the wrong samples with it; billing -// therefore reads from and to itself, and this is the test that says so. -func TestBillingKeepsTheExactPeriod(t *testing.T) { - st, err := store.Open(store.Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer st.Close() - s := &Server{Store: st} - // A month that starts at 00:00 in a zone 30 minutes off UTC: the - // boundary the browser sends is half past the hour. - from := time.Date(2026, 3, 1, 18, 30, 0, 0, time.UTC) - for i := 0; i < 288; i++ { - rate := 100e6 - if i == 0 { - rate = 900e6 // only inside the period if the boundary is kept - } - if _, err := st.DB.Exec(`INSERT INTO ifr VALUES (?, '10.0.0.1', 3, ?, 0, 10)`, - from.Add(time.Duration(i)*5*time.Minute), rate); err != nil { - t.Fatal(err) - } - } - out := billingOf(t, s, "from="+ms(from)+"&to="+ms(from.Add(24*time.Hour))) - if got := int64(out["from"].(float64)); got != from.UnixMilli() { - t.Errorf("from came back as %v, want the half-hour boundary unchanged", time.UnixMilli(got).UTC()) - } - bills := out["bills"].([]any) - if len(bills) != 1 { - t.Fatalf("%d interfaces, want 1: %v", len(bills), out) - } - b := bills[0].(map[string]any) - if b["buckets"].(float64) != 288 || b["coverage"].(float64) != 1 { - t.Errorf("buckets %v coverage %v: the period lost samples at its edge", b["buckets"], b["coverage"]) - } - if b["out_max"].(float64) != 0 || b["in_max"].(float64) != 900e6 { - t.Errorf("in_max %v out_max %v, want the first bucket inside the period", b["in_max"], b["out_max"]) - } - if b["dir"].(string) != "in" || b["billed"].(float64) != 100e6 { - t.Errorf("billed %v on %v, want 100000000 on in", b["billed"], b["dir"]) - } - if out["retention_days"].(float64) != 400 { - t.Errorf("retention_days = %v, want 400: it is what bounds how far back a bill can go", out["retention_days"]) - } -} - -func TestBillingRejectsMissingPeriod(t *testing.T) { - st, err := store.Open(store.Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer st.Close() - s := &Server{Store: st} - for _, q := range []string{"", "from=0", "to=0", "from=x&to=1", "from=2&to=1"} { - w := httptest.NewRecorder() - s.billing(w, httptest.NewRequest("GET", "/api/billing?"+q, nil)) - if w.Code == 200 { - t.Errorf("%q was accepted: %s", q, w.Body) - } - } -} - -// A switch reports dozens of interfaces and a bill is about two or three -// of them, so marked interfaces win. With none marked the page shows -// everything, because an empty page on the first visit teaches nobody -// anything — and the count of marks is reported so the page can say which -// of the two it is doing. -func TestBillingShowsTheMarkedInterfaces(t *testing.T) { - st, err := store.Open(store.Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer st.Close() - inv := enrich.NewInventory() - s := &Server{Store: st, Inv: inv} - from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - for _, idx := range []int{3, 4, 5} { - for i := 0; i < 288; i++ { - if _, err := st.DB.Exec(`INSERT INTO ifr VALUES (?, '10.0.0.1', ?, ?, 0, 10)`, - from.Add(time.Duration(i)*5*time.Minute), idx, float64(idx)*100e6); err != nil { - t.Fatal(err) - } - } - } - q := "from=" + ms(from) + "&to=" + ms(from.Add(24*time.Hour)) - - out := billingOf(t, s, q) - if n := len(out["bills"].([]any)); n != 3 || out["marked"].(float64) != 0 { - t.Fatalf("nothing marked: %d interfaces, marked=%v, want all three", n, out["marked"]) - } - - // mark one, and one that has no data at all - if err := inv.Parse("iface 10.0.0.1 4 ISP-A billed\niface 10.0.0.1 9 ISP-B billed\n"); err != nil { - t.Fatal(err) - } - out = billingOf(t, s, q) - if out["marked"].(float64) != 2 { - t.Errorf("marked = %v, want 2", out["marked"]) - } - rows := out["bills"].([]any) - got := map[float64]map[string]any{} - for _, r := range rows { - m := r.(map[string]any) - got[m["ifindex"].(float64)] = m - } - if len(rows) != 2 || got[4] == nil || got[9] == nil { - t.Fatalf("%d rows %v, want only the two marked interfaces", len(rows), got) - } - if got[4]["marked"] != true || got[4]["billed"] == nil { - t.Errorf("the marked interface with data lost its figure: %v", got[4]) - } - // the marked interface that reported nothing is still listed, saying so - if got[9]["buckets"].(float64) != 0 || got[9]["coverage"].(float64) != 0 { - t.Errorf("a marked interface with no data should be a row with nothing in it: %v", got[9]) - } - if got[9]["name"] != "ISP-B" { - t.Errorf("name = %v, want the name from the names file", got[9]["name"]) - } -} - -// The chart under the table is drawn from the same buckets the figure is -// taken from: every bucket that holds a measurement comes back, the ones -// that hold none are absent rather than zero, and the percentile the chart -// draws its line at is the one the table charges. -func TestBillingSeries(t *testing.T) { - st, err := store.Open(store.Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer st.Close() - s := &Server{Store: st} - from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - // six hours of five-minute buckets, with an hour in the middle the - // device reported nothing for - for i := 0; i < 72; i++ { - if i >= 30 && i < 42 { - continue - } - rate := 100e6 - if i == 50 { - rate = 900e6 - } - if _, err := st.DB.Exec(`INSERT INTO ifr VALUES (?, '10.0.0.1', 3, ?, ?, 10)`, - from.Add(time.Duration(i)*5*time.Minute), rate, rate/2); err != nil { - t.Fatal(err) - } - } - get := func(q string) (int, map[string]any) { - w := httptest.NewRecorder() - s.billingSeries(w, httptest.NewRequest("GET", "/api/billing/series?"+q, nil)) - var out map[string]any - json.Unmarshal(w.Body.Bytes(), &out) - return w.Code, out - } - code, out := get("from=" + ms(from) + "&to=" + ms(from.Add(6*time.Hour)) + "&if=10.0.0.1/3") - if code != 200 { - t.Fatalf("%d %v", code, out) - } - pts := out["points"].([]any) - if len(pts) != 60 { - t.Fatalf("%d buckets, want the 60 that hold a measurement and not the 12 that do not", len(pts)) - } - first := pts[0].(map[string]any) - if first["in"].(float64) != 100e6 || first["out"].(float64) != 50e6 || first["samples"].(float64) != 10 { - t.Errorf("first bucket %v", first) - } - b := out["bill"].(map[string]any) - if b["billed"].(float64) != 100e6 || b["in_max"].(float64) != 900e6 { - t.Errorf("bill %v, want the table's own figure", b) - } - if cov := out["coverage"].(float64); cov < 0.8 || cov > 0.9 { - t.Errorf("coverage %v, want the 60 buckets of 72 the period could hold", cov) - } - // the interface has to be named, and named the way the store expects - for _, q := range []string{"", "if=10.0.0.1/3", "from=" + ms(from) + "&to=" + ms(from.Add(time.Hour)), - "from=" + ms(from) + "&to=" + ms(from.Add(time.Hour)) + "&if=eth0"} { - if code, _ := get(q); code == 200 { - t.Errorf("%q was accepted", q) - } - } - // an interface nobody reported counters for answers with no buckets - // rather than an error: the page draws the empty state - code, out = get("from=" + ms(from) + "&to=" + ms(from.Add(6*time.Hour)) + "&if=10.0.0.9/1") - if code != 200 || len(out["points"].([]any)) != 0 || out["bill"] != nil { - t.Errorf("unknown interface: %d %v", code, out) - } -} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 89d518f..27355f5 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -394,7 +394,6 @@ func (s *Server) ifaces(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Tag string `json:"tag,omitempty"` Default bool `json:"default,omitempty"` - Billed bool `json:"billed,omitempty"` Device string `json:"device"` InDev float64 `json:"in_dev"` OutDev float64 `json:"out_dev"` @@ -412,7 +411,7 @@ func (s *Server) ifaces(w http.ResponseWriter, r *http.Request) { if a, err := netip.ParseAddr(f.Exporter); err == nil { it.Device = s.Inv.Device(a) ifc := s.Inv.Iface(a, f.IfIndex) - it.Name, it.Tag, it.Default, it.Billed = ifc.Name, ifc.Tag, ifc.Default, ifc.Billed + it.Name, it.Tag, it.Default = ifc.Name, ifc.Tag, ifc.Default if it.Speed == 0 { it.Speed = ifc.Speed } @@ -771,7 +770,6 @@ func (s *Server) putIface(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Tag string `json:"tag"` Default bool `json:"default"` - Billed bool `json:"billed"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&in); err != nil { fail(w, err) @@ -782,7 +780,7 @@ func (s *Server) putIface(w http.ResponseWriter, r *http.Request) { fail(w, errors.New("bad interface")) return } - if err := s.Inv.SetIface(a, in.IfIndex, in.Name, in.Tag, in.Default, in.Billed); err != nil { + if err := s.Inv.SetIface(a, in.IfIndex, in.Name, in.Tag, in.Default); err != nil { fail(w, err) return } diff --git a/internal/api/report.go b/internal/api/report.go index 48e675e..6def4a6 100644 --- a/internal/api/report.go +++ b/internal/api/report.go @@ -40,7 +40,6 @@ type reportIface struct { Device string `json:"device,omitempty"` Speed uint64 `json:"speed,omitempty"` // bits/s, 0 when unknown Source string `json:"source"` - Billed bool `json:"billed,omitempty"` InWire uint64 `json:"in_wire"` // bytes on the wire, from the flows OutWire uint64 `json:"out_wire"` @@ -57,11 +56,10 @@ type reportIface struct { UtilPeak float64 `json:"util_peak,omitempty"` Util95 float64 `json:"util95,omitempty"` - // Coverage is the share of the period that was measured, for counter - // rows: a 95th percentile taken over half the period is not wrong, but - // it is not the period either. + // Coverage is the share of the period's buckets the counters measured, + // for counter rows: a 95th percentile taken over half the period is not + // wrong, but it is not the period either. Coverage float64 `json:"coverage,omitempty"` - Samples int64 `json:"samples,omitempty"` } // reportOut is the whole report. It is a struct rather than a map so that @@ -183,18 +181,6 @@ func (s *Server) report(w http.ResponseWriter, r *http.Request) { } out.P95Bps = percentile(bps, 0.95) - // Interfaces. The device's counters are read once for the whole period - // and indexed, rather than queried per interface. - bills := map[string]store.Bill{} - if bl, err := s.Store.Bills(q.From, q.To, ""); err == nil { - for _, b := range bl { - bills[ifKey(b.Exporter, b.IfIndex)] = b - } - } - var billed map[string]bool - if s.Inv != nil { - billed = s.Inv.Billed() - } list, err := s.Store.Interfaces(q) if err != nil { fail(w, err) @@ -206,7 +192,7 @@ func (s *Server) report(w http.ResponseWriter, r *http.Request) { continue } it := reportIface{Exporter: f.Exporter, IfIndex: f.IfIndex, Speed: f.Speed, - InWire: f.InWire, OutWire: f.OutWire, Source: srcFlows, Billed: billed[k]} + InWire: f.InWire, OutWire: f.OutWire, Source: srcFlows} if a, err := netip.ParseAddr(f.Exporter); err == nil && s.Inv != nil { ifc := s.Inv.Iface(a, f.IfIndex) it.Name, it.Device = ifc.Name, s.Inv.Device(a) @@ -214,12 +200,11 @@ func (s *Server) report(w http.ResponseWriter, r *http.Request) { it.Speed = ifc.Speed } } - if b, ok := bills[k]; ok && b.Buckets > 0 { + if c := s.counterFigures(q, f); c != nil { it.Source = srcCounters - it.InAvg, it.OutAvg = b.InAvg, b.OutAvg - it.InPeak, it.OutPeak = b.InMax, b.OutMax - it.In95, it.Out95 = b.In95, b.Out95 - it.Coverage, it.Samples = b.Coverage(), b.Samples + it.InAvg, it.InPeak, it.In95 = c.in.avg, c.in.peak, c.in.p95 + it.OutAvg, it.OutPeak, it.Out95 = c.out.avg, c.out.peak, c.out.p95 + it.Coverage = c.coverage } else if span > 0 { // No counters: the flows are the only measurement there is. // They are sampled, so the row says so through Source. @@ -296,6 +281,51 @@ func checkIface(v string) error { return nil } +type figures struct{ avg, peak, p95 float64 } + +type counterRow struct { + in, out figures + coverage float64 +} + +// counterFigures is an interface's average, peak and 95th percentile from +// the device's own counters, over the same buckets the interface page +// draws them in; nil when the device reported no counters for it in the +// period. Buckets with no measurement are left out, not counted as zero. +func (s *Server) counterFigures(q store.Query, f store.IfaceInfo) *counterRow { + if !f.Counters { + return nil + } + rc, err := s.Store.Recon(q, f.Exporter, f.IfIndex) + if err != nil || !rc.HasCounters { + return nil + } + of := func(v []float64) (figures, int) { + var got []float64 + sum := 0.0 + for _, x := range v { + if x >= 0 { + got = append(got, x) + sum += x + } + } + if len(got) == 0 { + return figures{}, 0 + } + r := figures{avg: sum / float64(len(got)), p95: percentile(got, 0.95)} + for _, x := range got { + r.peak = math.Max(r.peak, x) + } + return r, len(got) + } + in, n := of(rc.InCounter) + out, _ := of(rc.OutCounter) + if n == 0 { + return nil + } + return &counterRow{in: in, out: out, coverage: float64(n) / float64(len(rc.InCounter))} +} + func ifKey(exporter string, ifindex uint32) string { return exporter + "/" + strconv.FormatUint(uint64(ifindex), 10) } diff --git a/internal/api/report_test.go b/internal/api/report_test.go index 4d096a1..fe6c2f9 100644 --- a/internal/api/report_test.go +++ b/internal/api/report_test.go @@ -54,9 +54,6 @@ func TestReport(t *testing.T) { t.Fatal(err) } } - if err := st.RollRates(to); err != nil { - t.Fatal(err) - } st.SaveFinding(store.Finding{Kind: "scan", Sev: 3, Src: "10.1.1.5", First: from.Add(10 * time.Minute), Last: from.Add(20 * time.Minute)}, 30*time.Minute) st.SaveFinding(store.Finding{Kind: "threat", Sev: 1, Src: "10.1.1.6", Dst: "203.0.113.9", First: from.Add(time.Minute), Last: to}, 30*time.Minute) @@ -116,8 +113,8 @@ func TestReport(t *testing.T) { if one.Speed != 1e9 || !near(one.Util95, 0.8, 0.05) { t.Errorf("speed %d, 95th percentile utilisation %.3f%%, want 0.8", one.Speed, one.Util95) } - if one.Coverage < 0.9 || one.Samples == 0 { - t.Errorf("coverage %.2f over %d samples", one.Coverage, one.Samples) + if one.Coverage < 0.9 { + t.Errorf("coverage %.2f", one.Coverage) } two, ok := byKey["10.0.0.1/2"] if !ok { diff --git a/internal/api/server.go b/internal/api/server.go index 9f453fe..ba897cb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -157,8 +157,6 @@ func (s *Server) Handler() http.Handler { api("GET /api/report", s.data((*Server).report)) api("GET /api/ifaces", s.ifaces) api("GET /api/recon", s.recon) - api("GET /api/billing", s.billing) - api("GET /api/billing/series", s.billingSeries) api("GET /api/sources", s.sources) adminAPI("POST /api/sources/block", s.postBlock) adminAPI("DELETE /api/sources/block", s.deleteBlock) diff --git a/internal/enrich/billed_test.go b/internal/enrich/billed_test.go deleted file mode 100644 index 5047f4e..0000000 --- a/internal/enrich/billed_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package enrich - -import ( - "net/netip" - "strings" - "testing" -) - -// A device reports every interface it has; the two or three somebody pays -// a carrier for are marked by hand, as a bare word on the iface line the -// same way default is. -func TestBilledInterfaces(t *testing.T) { - inv := NewInventory() - if err := inv.Parse(` -iface 10.0.0.1 3 GigabitEthernet0/1 speed=1000000000 tag=uplink billed -iface 10.0.0.1 4 GigabitEthernet0/2 default -iface 10.0.0.2 7 ISP-B billed -`); err != nil { - t.Fatal(err) - } - b := inv.Billed() - if len(b) != 2 || !b["10.0.0.1/3"] || !b["10.0.0.2/7"] { - t.Fatalf("billed = %v, want the two marked interfaces", b) - } - one := inv.Iface(netip.MustParseAddr("10.0.0.1"), 3) - if !one.Billed || one.Tag != "uplink" || one.Speed != 1000000000 || one.Name != "GigabitEthernet0/1" { - t.Errorf("the mark ate another field: %+v", one) - } - if inv.Iface(netip.MustParseAddr("10.0.0.1"), 4).Billed { - t.Error("an unmarked interface came back billed") - } -} - -// Marking and unmarking go through the same line, and must not lose the -// speed, the name, the tag or the default that line already carried. -func TestSetIfaceKeepsTheRestOfTheLine(t *testing.T) { - inv := NewInventory() - if err := inv.Parse("iface 10.0.0.1 3 Gi0/1 speed=1000000000 tag=uplink default\n"); err != nil { - t.Fatal(err) - } - a := netip.MustParseAddr("10.0.0.1") - if err := inv.SetIface(a, 3, "Gi0/1", "uplink", true, true); err != nil { - t.Fatal(err) - } - got := inv.Iface(a, 3) - if !got.Billed || !got.Default || got.Tag != "uplink" || got.Speed != 1000000000 { - t.Fatalf("after marking: %+v", got) - } - if n := strings.Count(inv.Text(), "iface 10.0.0.1 3"); n != 1 { - t.Fatalf("%d lines for one interface, want 1:\n%s", n, inv.Text()) - } - if err := inv.SetIface(a, 3, "Gi0/1", "uplink", true, false); err != nil { - t.Fatal(err) - } - if inv.Iface(a, 3).Billed { - t.Error("unmarking did not take") - } - if strings.Contains(inv.Text(), "billed") { - t.Errorf("the word stayed in the file:\n%s", inv.Text()) - } - if !inv.Iface(a, 3).Default || inv.Iface(a, 3).Speed != 1000000000 { - t.Errorf("unmarking lost something else: %+v", inv.Iface(a, 3)) - } -} diff --git a/internal/enrich/enrich.go b/internal/enrich/enrich.go index 9f12294..1d71ee6 100644 --- a/internal/enrich/enrich.go +++ b/internal/enrich/enrich.go @@ -52,11 +52,6 @@ type Iface struct { Speed uint64 // bits per second, 0 if unknown Tag string // a short label such as "uplink" (tag=) Default bool // the interface the pages show by default (default) - // Billed marks a circuit the 95th-percentile bill is taken over. A - // device reports every interface it has, and on a switch that is - // dozens; the two or three that somebody pays a carrier for are the - // operator's to name. It is a bare word on the line, like default. - Billed bool } // Inventory holds operator-provided names. All fields are optional. @@ -209,7 +204,8 @@ func (inv *Inventory) Parse(text string) error { continue } if w == "billed" { - it.Billed = true + // marked a circuit for the 95th-percentile bill, which + // is gone; a line written then still loads continue } words = append(words, w) @@ -279,24 +275,9 @@ func (inv *Inventory) DefaultIface() string { return "" } -// Billed is the set of interfaces a bill is taken over, keyed -// "/". Empty means none have been marked, which the -// billing page reads as "show everything" rather than "show nothing". -func (inv *Inventory) Billed() map[string]bool { - inv.mu.RLock() - defer inv.mu.RUnlock() - out := map[string]bool{} - for k, it := range inv.ifaces { - if it.Billed { - out[k] = true - } - } - return out -} - // SetIface names, tags or marks as default one interface, editing its // iface line in the names (or adding one). Only one interface is default. -func (inv *Inventory) SetIface(exporter netip.Addr, idx uint32, name, tag string, def, billed bool) error { +func (inv *Inventory) SetIface(exporter netip.Addr, idx uint32, name, tag string, def bool) error { name = strings.Join(strings.Fields(name), " ") tag = strings.Join(strings.Fields(tag), "_") if name == "" { @@ -348,9 +329,6 @@ func (inv *Inventory) SetIface(exporter netip.Addr, idx uint32, name, tag string if def { line += " default" } - if billed { - line += " billed" - } if at < 0 { out = append(out, line) } else { diff --git a/internal/enrich/iface_test.go b/internal/enrich/iface_test.go index 94797d2..2b83d34 100644 --- a/internal/enrich/iface_test.go +++ b/internal/enrich/iface_test.go @@ -13,7 +13,7 @@ func TestSetIface(t *testing.T) { } a := netip.MustParseAddr("10.0.0.1") // name and tag a new interface and make it the default - if err := inv.SetIface(a, 10, "Telecom 100G", "uplink", true, false); err != nil { + if err := inv.SetIface(a, 10, "Telecom 100G", "uplink", true); err != nil { t.Fatal(err) } if it := inv.Iface(a, 10); it.Name != "Telecom 100G" || it.Tag != "uplink" || !it.Default { @@ -26,7 +26,7 @@ func TestSetIface(t *testing.T) { t.Fatalf("default %q", inv.DefaultIface()) } // rename keeps the speed and the place of the line - if err := inv.SetIface(a, 3, "ISP #2", "", false, false); err != nil { + if err := inv.SetIface(a, 3, "ISP #2", "", false); err != nil { t.Fatal(err) } txt := inv.Text() diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index d8bb350..e1ee88a 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -181,11 +181,6 @@ func (p *Pipeline) Run(ctx context.Context) { if err := p.st.Seal(now); err != nil { p.fail(err) } - // Before Retain, which is what deletes the snapshots the - // rates are derived from. - if err := p.st.RollRates(now); err != nil { - p.fail(err) - } if err := p.st.Retain(now); err != nil { p.fail(err) } diff --git a/internal/store/rates.go b/internal/store/rates.go deleted file mode 100644 index 7379a42..0000000 --- a/internal/store/rates.go +++ /dev/null @@ -1,212 +0,0 @@ -package store - -import ( - "fmt" - "time" -) - -// Interface rates: a bill needs a month, the snapshots do not last one. -// -// ifc holds cumulative counter snapshots and is deleted with the flow -// detail at RawDays (30 by default). Thirty days cannot cover a calendar -// month, let alone "last month" asked for on the second of this one. What -// a 95th-percentile bill is made of is small, though: one row per interface -// per five minutes is 288 a day. So the snapshots are turned into rates -// while they are still here, and the rates are what is kept — for -// RollupDays (400 by default), which covers thirteen months. -// -// What the derivation drops rather than guesses: -// -// - A counter that went backwards. Device counters are often 32 bits and -// wrap; a device that reboots starts again from zero. Both look the -// same from here, so adding 2^32 to the delta would invent traffic -// every time the cause was a reboot. One interval is half a minute of a -// five-minute bucket, so dropping it costs little and claims nothing. -// - An interval longer than fifteen minutes. That is a hole in collection, -// not a measurement of it. -// - A rate above the interface's own speed. Those come from a counter -// reset the backwards test cannot see, or from two devices reporting -// under one ifIndex. The line sits a tenth over the speed so that a -// snapshot timestamp a second out does not cost the whole bucket. -// -// samples says how many intervals a bucket was made of, so a report can say -// how much of the period it is speaking for. Nothing here invents a zero: -// an interface that reported nothing has no row, which is not the same as -// an interface that reported no traffic. A report that treats the two alike -// moves the 95th percentile, and in the customer's favour, so the choice -// belongs where it can be written down next to the number. -const ( - rateBucket = 5 * time.Minute - - // How far back each pass recomputes. The pass runs every five minutes, - // so this is only reach for late snapshots and for passes that failed: - // a process that was not running wrote no snapshots either, which is - // why there is no watermark to catch up from. - rateLookback = 2 * time.Hour - - // Longer than this between two snapshots is a collection gap. - rateMaxGap = 15 * time.Minute -) - -// ifrInsert derives one span of buckets. The reach back of rateBucket on -// the read is for lag(): the first snapshot of a partition has nothing to -// subtract from, so without an earlier row the span would lose its opening -// interval. Buckets before the span are filtered out again after the join. -const ifrInsert = `INSERT INTO ifr -WITH s AS ( - SELECT ts, exporter, ifindex, speed, in_oct, out_oct, - lag(ts) OVER w AS pts, lag(in_oct) OVER w AS pin, lag(out_oct) OVER w AS pout - FROM ifc WHERE ts >= ? AND ts < ? - WINDOW w AS (PARTITION BY exporter, ifindex ORDER BY ts) -), d AS ( - SELECT time_bucket(INTERVAL 5 MINUTE, ts) AS bucket, exporter, ifindex, speed, - epoch(ts) - epoch(pts) AS dt, - CAST(in_oct AS HUGEINT) - CAST(pin AS HUGEINT) AS din, - CAST(out_oct AS HUGEINT) - CAST(pout AS HUGEINT) AS dout - FROM s WHERE pts IS NOT NULL -) -SELECT bucket, exporter, ifindex, - 8 * CAST(sum(din) AS DOUBLE) / sum(dt), 8 * CAST(sum(dout) AS DOUBLE) / sum(dt), count(*) -FROM d -WHERE dt > 0 AND dt <= ? AND din >= 0 AND dout >= 0 - AND (speed = 0 OR 8 * CAST(greatest(din, dout) AS DOUBLE) <= CAST(speed AS DOUBLE) * dt * 1.1) - AND bucket >= ? -GROUP BY bucket, exporter, ifindex` - -// RollRates turns counter snapshots into five-minute rate buckets. It is -// safe to run again over a span it has already done: the span is deleted -// first, so a second pass replaces its own work rather than doubling it. -func (s *Store) RollRates(now time.Time) error { - to := now.UTC().Truncate(rateBucket) // the bucket in progress is not finished - from := to.Add(-rateLookback) - tx, err := s.DB.Begin() - if err != nil { - return fmt.Errorf("rates: %w", err) - } - defer tx.Rollback() - if _, err := tx.Exec(`DELETE FROM ifr WHERE ts >= ? AND ts < ?`, from, to); err != nil { - return fmt.Errorf("rates: clear %s..%s: %w", from.Format(time.RFC3339), to.Format(time.RFC3339), err) - } - if _, err := tx.Exec(ifrInsert, from.Add(-rateBucket), to, rateMaxGap.Seconds(), from); err != nil { - return fmt.Errorf("rates: derive: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("rates: %w", err) - } - return nil -} - -// Bill is what one interface would be charged for over a period, and -// enough of the working to argue with. -type Bill struct { - Exporter string `json:"exporter"` - IfIndex uint32 `json:"ifindex"` - In95 float64 `json:"in95"` - Out95 float64 `json:"out95"` - Billed float64 `json:"billed"` // the greater of the two - Dir string `json:"dir"` // which of them it is - InMax float64 `json:"in_max"` - OutMax float64 `json:"out_max"` - InAvg float64 `json:"in_avg"` - OutAvg float64 `json:"out_avg"` - Buckets int `json:"buckets"` // five-minute buckets that hold a measurement - Possible int `json:"possible"` // buckets the period could have held - Samples int64 `json:"samples"` // counter intervals behind those buckets -} - -// Coverage is the share of the period that was actually measured. A bill -// taken over half a month is not wrong, but it is not a month either, and -// the report says so rather than leaving the reader to assume. -func (b Bill) Coverage() float64 { - if b.Possible <= 0 { - return 0 - } - return float64(b.Buckets) / float64(b.Possible) -} - -// Bills computes the 95th percentile per interface over [from, to). -// -// The number is the usual one: sort the five-minute samples, discard the -// top five per cent, charge the highest that is left. quantile_disc returns -// a sample that was actually measured rather than an interpolation between -// two, which is what makes the figure arguable against the device's own -// counters. In and out are percentiled separately and the greater is -// charged, which is how carriers word it; the percentile of the larger of -// each pair would be a different, higher number, and not the common term. -// -// Buckets with no measurement are absent, not zero. Counting them as zero -// would push the percentile down, in the paying customer's favour, which is -// exactly why it cannot be done quietly: Coverage is here so the report can -// show what the number rests on. -func (s *Store) Bills(from, to time.Time, exporter string) ([]Bill, error) { - if !to.After(from) { - return nil, fmt.Errorf("bills: %s is not before %s", from.Format(time.RFC3339), to.Format(time.RFC3339)) - } - q := `SELECT exporter, ifindex, - quantile_disc(in_bps, 0.95), quantile_disc(out_bps, 0.95), - max(in_bps), max(out_bps), avg(in_bps), avg(out_bps), count(*), sum(samples) - FROM ifr WHERE ts >= ? AND ts < ?` - args := []any{from.UTC(), to.UTC()} - if exporter != "" { - q += ` AND exporter = ?` - args = append(args, exporter) - } - q += ` GROUP BY exporter, ifindex ORDER BY greatest(quantile_disc(in_bps, 0.95), quantile_disc(out_bps, 0.95)) DESC` - rows, err := s.DB.Query(q, args...) - if err != nil { - return nil, fmt.Errorf("bills: %w", err) - } - defer rows.Close() - possible := int(to.Sub(from) / rateBucket) - var out []Bill - for rows.Next() { - b := Bill{Possible: possible} - if err := rows.Scan(&b.Exporter, &b.IfIndex, &b.In95, &b.Out95, &b.InMax, &b.OutMax, &b.InAvg, &b.OutAvg, &b.Buckets, &b.Samples); err != nil { - return nil, fmt.Errorf("bills: %w", err) - } - b.Billed, b.Dir = b.In95, "in" - if b.Out95 > b.In95 { - b.Billed, b.Dir = b.Out95, "out" - } - out = append(out, b) - } - return out, rows.Err() -} - -// BillPoint is one five-minute bucket of an interface's own counters. -type BillPoint struct { - TS time.Time `json:"ts"` - In float64 `json:"in"` // bits/s - Out float64 `json:"out"` // bits/s - Samples int `json:"samples"` -} - -// BillSeries returns the buckets one interface's bill was computed from, -// oldest first. It is the working behind the figure: the same rows Bills -// takes its percentile of, so a customer who disputes a bill can be shown -// the line it was drawn across rather than asked to take the number. -// -// Buckets with no measurement are absent here as they are there, rather -// than being sent as zero: a chart that draws them flat at the bottom -// claims the circuit was idle when nothing was collected. -func (s *Store) BillSeries(from, to time.Time, exporter string, ifindex uint32) ([]BillPoint, error) { - if !to.After(from) { - return nil, fmt.Errorf("bill series: %s is not before %s", from.Format(time.RFC3339), to.Format(time.RFC3339)) - } - rows, err := s.DB.Query(`SELECT ts, in_bps, out_bps, samples FROM ifr - WHERE ts >= ? AND ts < ? AND exporter = ? AND ifindex = ? ORDER BY ts`, - from.UTC(), to.UTC(), exporter, ifindex) - if err != nil { - return nil, fmt.Errorf("bill series: %w", err) - } - defer rows.Close() - out := []BillPoint{} - for rows.Next() { - var p BillPoint - if err := rows.Scan(&p.TS, &p.In, &p.Out, &p.Samples); err != nil { - return nil, fmt.Errorf("bill series: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} diff --git a/internal/store/rates_test.go b/internal/store/rates_test.go deleted file mode 100644 index 930ed64..0000000 --- a/internal/store/rates_test.go +++ /dev/null @@ -1,356 +0,0 @@ -package store - -import ( - "math" - "testing" - "time" -) - -type bucket struct { - ts time.Time - in, out float64 - samples int -} - -func rollRates(t *testing.T, snaps func(add func(ts time.Time, ifindex uint32, speed, in, out uint64)), now time.Time) map[int64]bucket { - t.Helper() - s, err := Open(Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - snaps(func(ts time.Time, ifindex uint32, speed, in, out uint64) { - if err := s.AppendCounter(ts, "10.0.0.1", ifindex, speed, in, out, 0, 0); err != nil { - t.Fatal(err) - } - }) - if err := s.RollRates(now); err != nil { - t.Fatal(err) - } - rows, err := s.DB.Query(`SELECT ts, in_bps, out_bps, samples FROM ifr ORDER BY ts`) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - got := map[int64]bucket{} - for rows.Next() { - var b bucket - if err := rows.Scan(&b.ts, &b.in, &b.out, &b.samples); err != nil { - t.Fatal(err) - } - got[b.ts.Unix()] = b - } - if err := rows.Err(); err != nil { - t.Fatal(err) - } - return got -} - -// A counter climbing at a steady rate must come back as that rate, and the -// bucket it lands in must be the five-minute boundary below its snapshots. -func TestRatesFromSteadyCounter(t *testing.T) { - now := time.Date(2026, 3, 1, 12, 10, 0, 0, time.UTC) - start := now.Add(-10 * time.Minute) // 12:00 - const perSec = 1_000_000 // bytes - got := rollRates(t, func(add func(time.Time, uint32, uint64, uint64, uint64)) { - for i := 0; i < 11; i++ { // 12:00:00 .. 12:05:00, every 30s - ts := start.Add(time.Duration(i) * 30 * time.Second) - oct := uint64(i) * 30 * perSec - add(ts, 7, 10_000_000_000, oct, oct/2) - } - }, now) - if len(got) != 2 { - t.Fatalf("want the 12:00 and 12:05 buckets, got %d: %v", len(got), got) - } - first, ok := got[start.Unix()] - if !ok { - t.Fatalf("no bucket at %s: %v", start, got) - } - if want := float64(perSec * 8); math.Abs(first.in-want) > 1 { - t.Errorf("in_bps = %v, want %v", first.in, want) - } - if want := float64(perSec * 8 / 2); math.Abs(first.out-want) > 1 { - t.Errorf("out_bps = %v, want %v", first.out, want) - } - // Ten of the eleven snapshots open an interval; nine of those land in - // the first bucket, the one ending at 12:05:00 lands in the second. - if first.samples != 9 { - t.Errorf("samples = %d, want 9", first.samples) - } -} - -// A counter that goes backwards is a wrap or a reboot and cannot be told -// apart from here, so the interval it opens is dropped rather than guessed -// at. The bucket survives on its remaining intervals. -func TestRatesDropCounterReset(t *testing.T) { - now := time.Date(2026, 3, 1, 12, 5, 0, 0, time.UTC) - start := now.Add(-5 * time.Minute) - const perSec = 1_000_000 - got := rollRates(t, func(add func(time.Time, uint32, uint64, uint64, uint64)) { - for i := 0; i < 10; i++ { - ts := start.Add(time.Duration(i) * 30 * time.Second) - oct := uint64(i) * 30 * perSec - if i >= 6 { - oct -= 6 * 30 * perSec // the device restarted at 12:03 - } - add(ts, 7, 10_000_000_000, oct, oct) - } - }, now) - b, ok := got[start.Unix()] - if !ok { - t.Fatalf("the bucket was thrown away whole: %v", got) - } - // Nine intervals, and the one crossing the reset is not one of them. - if b.samples != 8 { - t.Errorf("samples = %d, want 8 (nine intervals less the reset)", b.samples) - } - if want := float64(perSec * 8); math.Abs(b.in-want) > 1 { - t.Errorf("in_bps = %v, want %v: the reset moved the rate", b.in, want) - } -} - -// A rate above the interface speed is a counter reset the backwards test -// cannot see. It is dropped for the same reason. -func TestRatesDropAboveSpeed(t *testing.T) { - now := time.Date(2026, 3, 1, 12, 5, 0, 0, time.UTC) - start := now.Add(-5 * time.Minute) - got := rollRates(t, func(add func(time.Time, uint32, uint64, uint64, uint64)) { - add(start, 7, 1_000_000_000, 0, 0) - // 1 Gb/s for thirty seconds is 3.75 GB, which is the line exactly. - add(start.Add(30*time.Second), 7, 1_000_000_000, 30*125_000_000, 0) - add(start.Add(60*time.Second), 7, 1_000_000_000, 1<<42, 0) // far over: dropped - }, now) - b, ok := got[start.Unix()] - if !ok { - t.Fatalf("no bucket: %v", got) - } - if b.samples != 1 { - t.Errorf("samples = %d, want 1", b.samples) - } - if want := 1e9; math.Abs(b.in-want) > 1 { - t.Errorf("in_bps = %v, want %v", b.in, want) - } -} - -// Nothing invents a zero: an interface with a gap in collection has no -// bucket there, rather than a bucket saying it carried no traffic. -func TestRatesLeaveGapsEmpty(t *testing.T) { - now := time.Date(2026, 3, 1, 12, 30, 0, 0, time.UTC) - start := now.Add(-30 * time.Minute) - got := rollRates(t, func(add func(time.Time, uint32, uint64, uint64, uint64)) { - add(start, 7, 0, 0, 0) - add(start.Add(30*time.Second), 7, 0, 30_000_000, 0) - // Twenty minutes later the collector is back. The interval across - // the hole is longer than rateMaxGap and is not a measurement. - add(start.Add(25*time.Minute), 7, 0, 60_000_000, 0) - add(start.Add(25*time.Minute+30*time.Second), 7, 0, 90_000_000, 0) - }, now) - if len(got) != 2 { - t.Fatalf("want two buckets with eighteen empty between them, got %d: %v", len(got), got) - } - if _, ok := got[start.Add(10*time.Minute).Unix()]; ok { - t.Error("a bucket inside the gap was filled in") - } - // The bucket the collector came back in rests on its own interval - // alone. Folding the twenty-minute hole into it would spread half an - // hour of counting over one five-minute bucket. - back, ok := got[start.Add(25*time.Minute).Unix()] - if !ok { - t.Fatalf("no bucket where collection resumed: %v", got) - } - if back.samples != 1 { - t.Errorf("samples = %d, want 1: the gap was counted as a measurement", back.samples) - } - if want := 8e6; math.Abs(back.in-want) > 1 { - t.Errorf("in_bps = %v, want %v", back.in, want) - } -} - -// Running the pass again over a span it has already written replaces its -// own work instead of doubling it. -func TestRatesRollTwice(t *testing.T) { - now := time.Date(2026, 3, 1, 12, 5, 0, 0, time.UTC) - start := now.Add(-5 * time.Minute) - s, err := Open(Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - for i := 0; i < 10; i++ { - oct := uint64(i) * 30 * 1_000_000 - if err := s.AppendCounter(start.Add(time.Duration(i)*30*time.Second), "10.0.0.1", 7, 0, oct, oct, 0, 0); err != nil { - t.Fatal(err) - } - } - var first, second float64 - for _, p := range []*float64{&first, &second} { - if err := s.RollRates(now); err != nil { - t.Fatal(err) - } - var n int - if err := s.DB.QueryRow(`SELECT count(*), coalesce(sum(in_bps), 0) FROM ifr`).Scan(&n, p); err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("%d rows after a pass, want 1", n) - } - } - if first != second { - t.Errorf("the second pass changed the rate: %v then %v", first, second) - } -} - -func putRate(t *testing.T, s *Store, ts time.Time, ifindex uint32, in, out float64, samples int) { - t.Helper() - if _, err := s.DB.Exec(`INSERT INTO ifr VALUES (?, '10.0.0.1', ?, ?, ?, ?)`, ts.UTC(), ifindex, in, out, samples); err != nil { - t.Fatal(err) - } -} - -// The billed figure is the sample left standing after the top five per cent -// are discarded — position ceil(0.95n) counting up. A full month of -// five-minute buckets is 8640, and the 8208th is what the bill says. This -// test exists because the whole product claim rests on that being the -// number a carrier would arrive at independently. -func TestBillIsTheSampleAfterDiscardingTopFivePercent(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - const n = 8640 // thirty days of five-minute buckets - for i := 0; i < n; i++ { - // 1 Mb/s per step, so the sample's rank is readable in the answer - putRate(t, s, from.Add(time.Duration(i)*5*time.Minute), 1, float64(i+1)*1e6, 0, 10) - } - bills, err := s.Bills(from, from.Add(n*5*time.Minute), "") - if err != nil { - t.Fatal(err) - } - if len(bills) != 1 { - t.Fatalf("%d interfaces, want 1", len(bills)) - } - b := bills[0] - if want := 8208 * 1e6; b.In95 != want { - t.Errorf("in95 = %v, want %v (the 8208th of 8640 samples)", b.In95, want) - } - if b.Billed != b.In95 || b.Dir != "in" { - t.Errorf("billed %v on %q, want the inbound percentile", b.Billed, b.Dir) - } - if b.Buckets != n || b.Possible != n || b.Coverage() != 1 { - t.Errorf("coverage %v from %d of %d buckets, want the whole period", b.Coverage(), b.Buckets, b.Possible) - } - if b.Samples != n*10 { - t.Errorf("samples = %d, want %d", b.Samples, n*10) - } -} - -// In and out are percentiled separately and the greater is charged, which -// is how a carrier words it. Charging the percentile of the larger of each -// pair would be a different and higher number. -func TestBillChargesTheGreaterDirection(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - // Outbound is quiet except for one hour, when it is far above inbound. - // Its 95th percentile stays below the steady inbound one all the same. - for i := 0; i < 288; i++ { - out := 1e6 - if i < 12 { - out = 900e6 - } - putRate(t, s, from.Add(time.Duration(i)*5*time.Minute), 1, 100e6, out, 10) - } - bills, err := s.Bills(from, from.Add(24*time.Hour), "") - if err != nil { - t.Fatal(err) - } - b := bills[0] - if b.Dir != "in" || b.Billed != 100e6 { - t.Errorf("billed %v on %q, want 100000000 on in: the outbound spike is inside the discarded five per cent", b.Billed, b.Dir) - } - if b.OutMax != 900e6 { - t.Errorf("out_max = %v, want the spike to still be visible as a peak", b.OutMax) - } -} - -// A period only half measured still gives a number, and the number is -// taken over what was measured. Coverage is what stops that being quietly -// misread as a month. -func TestBillCoverageShowsWhatIsMissing(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - for i := 0; i < 144; i++ { // the first half of one day - putRate(t, s, from.Add(time.Duration(i)*5*time.Minute), 1, 500e6, 0, 10) - } - bills, err := s.Bills(from, from.Add(24*time.Hour), "") - if err != nil { - t.Fatal(err) - } - b := bills[0] - if b.Billed != 500e6 { - t.Errorf("billed = %v, want 500000000: the percentile is over what was measured", b.Billed) - } - if b.Buckets != 144 || b.Possible != 288 || b.Coverage() != 0.5 { - t.Errorf("coverage %v from %d of %d, want half the day", b.Coverage(), b.Buckets, b.Possible) - } -} - -// The period is given as an interval, so a calendar month, a thirty-day -// span and a cycle starting on the third are all the same call. Nothing in -// here knows about months, which is why no time zone is baked in: the -// caller hands over the boundaries it means. -func TestBillTakesAnyInterval(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - base := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - for i := 0; i < 288*10; i++ { - rate := 10e6 - if i >= 288*2 && i < 288*3 { // the third of the month is busy - rate = 800e6 - } - putRate(t, s, base.Add(time.Duration(i)*5*time.Minute), 1, rate, 0, 10) - } - cycle, err := s.Bills(base.AddDate(0, 0, 2), base.AddDate(0, 0, 3), "") - if err != nil { - t.Fatal(err) - } - if cycle[0].Billed != 800e6 { - t.Errorf("the third alone billed %v, want 800000000", cycle[0].Billed) - } - whole, err := s.Bills(base, base.AddDate(0, 0, 10), "") - if err != nil { - t.Fatal(err) - } - // One busy day in ten is 10% of the samples, so the percentile does not - // reach it: the top 5% are discarded, the rest of that day is not. - if whole[0].Billed != 800e6 { - t.Errorf("the ten days billed %v, want 800000000", whole[0].Billed) - } - if whole[0].Possible != 2880 { - t.Errorf("possible = %d, want 2880", whole[0].Possible) - } -} - -func TestBillRejectsBackwardsPeriod(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - now := time.Now() - if _, err := s.Bills(now, now.Add(-time.Hour), ""); err == nil { - t.Error("Bills accepted a period that ends before it starts") - } -} diff --git a/internal/store/store.go b/internal/store/store.go index b90fd05..3952bdd 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -78,12 +78,6 @@ var schema = []string{ `CREATE TABLE IF NOT EXISTS r_host (ts TIMESTAMP, ip VARCHAR, role UTINYINT, internal BOOLEAN, bytes UBIGINT, wire UBIGINT, pkts UBIGINT, flows UBIGINT)`, `CREATE TABLE IF NOT EXISTS r_dim (ts TIMESTAMP, dim VARCHAR, val VARCHAR, bytes UBIGINT, wire UBIGINT, pkts UBIGINT, flows UBIGINT)`, `CREATE TABLE IF NOT EXISTS ifc (ts TIMESTAMP, exporter VARCHAR, ifindex UINTEGER, speed UBIGINT, in_oct UBIGINT, out_oct UBIGINT, in_pkts UBIGINT, out_pkts UBIGINT)`, - // Five-minute rates derived from ifc, kept for RollupDays so that a - // bill can look back a month. No primary key: RollRates deletes the - // span it is about to write, which is what keeps it unique, and an - // index over four hundred days of buckets would cost more than it - // buys here. - `CREATE TABLE IF NOT EXISTS ifr (ts TIMESTAMP, exporter VARCHAR, ifindex UINTEGER, in_bps DOUBLE, out_bps DOUBLE, samples UINTEGER)`, `CREATE TABLE IF NOT EXISTS meta (k VARCHAR PRIMARY KEY, v VARCHAR)`, // findings of the detection rules, and what the rules have seen before `CREATE SEQUENCE IF NOT EXISTS finding_seq`, @@ -408,7 +402,7 @@ func (s *Store) Retain(now time.Time) error { rows += n return nil } - for _, t := range []string{"r_ts", "r_host", "r_dim", "ifr"} { + for _, t := range []string{"r_ts", "r_host", "r_dim"} { if err := del(t, rollCut); err != nil { return err } @@ -526,7 +520,3 @@ func TotalMemory() uint64 { return totalMemory() } // RetentionDays is how many days of flow detail are kept. func (s *Store) RetentionDays() int { return s.opt.RawDays } - -// RollupRetentionDays is how many days of rollups and interface rates are -// kept. It is what bounds how far back a bill can be taken. -func (s *Store) RollupRetentionDays() int { return s.opt.RollupDays } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index aa4df75..355ab72 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -315,7 +315,7 @@ async function resolveNames() { } // ------------------------------------------------------------ state & API -const VIEWS = ['help', 'account', 'overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'racks', 'devices', 'cleanup', 'sandbox', 'ifaces', 'billing', 'names', 'sources', 'detail']; +const VIEWS = ['help', 'account', 'overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'racks', 'devices', 'cleanup', 'sandbox', 'ifaces', 'names', 'sources', 'detail']; const RANGES = ['15m', '1h', '6h', '24h', '7d', '30d']; // How often a page redraws itself, by range. It follows the width of the @@ -423,8 +423,6 @@ async function renderIfsel() { } async function api(path, extra = {}, filters = state.f) { if (inSB() && (path === 'ifaces' || path === 'recon')) return {ifaces: []}; - // a capture file holds no device counters, so there is no bill in it - if (inSB() && path.startsWith('billing')) return {bills: [], points: []}; const p = new URLSearchParams(inSB() && SB_DATA.has(path) ? {ds: 'sb', ...sbRange(), ...extra} : extra.range ? extra : state.r === 'custom' ? {from: state.cf, to: state.ct, ...extra} : {range: state.r, ...extra}); // Which clock the day and six-hour columns are cut on. Without it they // were cut on UTC, so a day in Tokyo arrived split across two of them @@ -2467,140 +2465,6 @@ views.ifaces = async (el) => { }; // ------------------------------------------------------ 95th percentile -// The bill a carrier would send: sort the five-minute samples of a period, -// discard the top five per cent, charge the highest that is left. The -// period is worked out here rather than on the server, because this is -// where the user's time zone is known — a calendar month means the -// customer's midnights, not UTC's. -// last month by default, this month while last month has no counter -// samples yet (a new installation) -let billSel = {p: 'last', from: '', to: '', auto: true}; -const billPeriod = () => { - const now = new Date(), mStart = off => new Date(now.getFullYear(), now.getMonth() + off, 1); - if (billSel.p === 'this') return [mStart(0), now]; - if (billSel.p === 'last') return [mStart(-1), mStart(0)]; - if (billSel.p === '30d') return [new Date(now - 30 * 864e5), now]; - // Both dates the user typed are days they mean to include, so the end is - // the midnight after the one they picked. - const f = new Date(billSel.from + 'T00:00'), t = new Date(billSel.to + 'T00:00'); - if (isNaN(f) || isNaN(t) || t < f) return [mStart(-1), mStart(0)]; - return [f, new Date(t.getTime() + 864e5)]; -}; -// billGrid lays the buckets out on the period's own five-minute grid. A -// bucket that holds no measurement stays empty (null) rather than zero: -// the circuit was not idle, nothing was collected, and the chart says the -// same thing the figure does by leaving a gap. -// -// A month is nearly nine thousand buckets, more than a chart is wide, so -// above ~600 they are grouped and the highest of each group kept. Taking -// the highest rather than the average keeps a spike a spike; the dashed -// line is the figure the server worked out from every bucket, so grouping -// never moves it. -function billGrid(d, from, to) { - const step = (d.bucket_minutes || 5) * 60000; - const n = Math.max(1, Math.round((to - from) / step)); - const vin = new Array(n).fill(null), vout = new Array(n).fill(null); - (d.points || []).forEach(p => { - const i = Math.floor((new Date(p.ts).getTime() - from) / step); - if (i >= 0 && i < n) { vin[i] = p.in; vout[i] = p.out; } - }); - const g = Math.max(1, Math.ceil(n / 600)); - if (g === 1) return {times: vin.map((_, i) => from + i * step), in: vin, out: vout}; - const times = [], gin = [], gout = []; - for (let i = 0; i < n; i += g) { - const top = a => { let v = null; for (let j = i; j < Math.min(n, i + g); j++) if (a[j] != null && (v == null || a[j] > v)) v = a[j]; return v; }; - times.push(from + i * step); gin.push(top(vin)); gout.push(top(vout)); - } - return {times, in: gin, out: gout}; -} -views.billing = async (el) => { - const [from, to] = billPeriod(); - const [d, ifs] = await Promise.all([ - api('billing', {from: +from, to: +to}).catch(e => ({error: e.message || String(e)})), - api('ifaces').catch(() => ({ifaces: []})), - ]); - const rows = d.bills || []; - if (billSel.auto) { - billSel = {...billSel, auto: false}; - if (billSel.p === 'last' && !d.error && !rows.some(b => b.buckets)) { billSel = {...billSel, p: 'this'}; return views.billing(el); } - } - const ikey = f => f.exporter + '/' + f.ifindex; - // A device reports every interface it has. Which of them somebody pays a - // carrier for is not something that can be worked out from the traffic, - // so it is marked by hand and kept on the interface's line in the names - // file, beside its name and its speed. - const cand = (ifs.ifaces || []).filter(f => f.ifindex && !f.billed); - const markIf = async (f, billed) => { - const res = await fetch('/api/iface', {method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({exporter: f.exporter, ifindex: f.ifindex, name: f.name || '', tag: f.tag || '', default: !!f.default, billed})}); - if (!res.ok) { const j = await res.json().catch(() => ({})); toast(j.error || res.statusText); return; } - ifList = null; render(); - }; - const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; - const day = x => new Date(x).toLocaleDateString(LANG); - const iso = x => new Date(x.getTime() - x.getTimezoneOffset() * 60000).toISOString().slice(0, 10); - const opt = (v, k) => ``; - const picker = `
- - – - ${esc(day(from))} – ${esc(day(to.getTime() - 1))} · ${esc(zone)} - ${admOnly(cand.length ? `` : '')}
-

${esc(d.marked ? t('bill.marked', {n: nf(d.marked)}) : t('bill.all_shown'))}

`; - const body = d.error ? `
${esc(d.error)}
` - : !rows.length ? `
${esc(t('bill.empty'))}
` - : ` - ${rows.map(b => { - const nm = b.name || t('bill.ifindex', {n: b.ifindex}), dev = b.device || b.exporter; - const cov = b.coverage, low = cov < 0.95; - const head = ``; - if (!b.buckets) return `${head} - `; - return ` - - - - - - `; - }).join('')}
${t('bill.iface')}${t('bill.billed')}${t('if.in')} 95${t('if.out')} 95${t('bill.peak')}${t('bill.avg')}${t('bill.coverage')}
${esc(nm)}
${esc(dev)} · ${b.ifindex}
${esc(t('bill.nodata'))}${admOnly(``)}
${esc(nm)}
${esc(dev)} · ${b.ifindex}
${esc(fmtBps(b.billed))}
${esc(t(b.dir === 'out' ? 'if.out' : 'if.in'))}
${esc(fmtBps(b.in95))}${esc(fmtBps(b.out95))}${esc(fmtBps(Math.max(b.in_max, b.out_max)))}${esc(fmtBps(Math.max(b.in_avg, b.out_avg)))}${esc(nf(cov * 100, 1))}%
${esc(nf(b.buckets))}/${esc(nf(b.possible))}
${b.marked ? admOnly(``) : ''}
-

${esc(t('bill.gap_note'))}

`; - // The line the figure was taken across. The table says what is charged; - // somebody who disputes a bill asks to see this. Interfaces that - // reported nothing have no chart rather than an empty frame. - const charted = rows.filter(b => b.buckets).slice(0, 6); - const chTitle = b => (b.name || t('bill.ifindex', {n: b.ifindex})) + ' · ' + (b.device || b.exporter); - el.innerHTML = `
${panel('c12', t('bill.title'), t('bill.sub'), picker + body)} - ${charted.map((b, i) => panel('c12', esc(chTitle(b)), esc(t('bill.chart_sub')), - `
-
${esc(t('if.in'))}${esc(t('if.out'))} - ${esc(t('bill.p95'))}${esc(fmtBps(b.billed))}
`)).join('')} - ${panel('c12', t('bill.how'), '', `

${esc(t('bill.how_note'))}
${esc(t('bill.retention', {n: nf(d.retention_days || 0)}))}

`)}
`; - charted.forEach(async (b, i) => { - const key = b.exporter + '/' + b.ifindex; - const sd = await api('billing/series', {from: +from, to: +to, if: key}).catch(() => null); - const el2 = document.getElementById('billCh' + i); - if (!sd || !el2) return; - const g = billGrid(sd, +from, +to); - tsChart(el2, {times: g.times, lines: [ - {name: t('if.in'), color: IF_IN, data: g.in, fill: true}, - {name: t('if.out'), color: IF_OUT, data: g.out, fill: true}, - {name: t('bill.p95'), color: 'var(--accent)', data: g.times.map(() => b.billed), dash: true, keep: true}, - ]}); - }); - const sel = $('#billP'); - sel.onchange = () => { billSel = {...billSel, p: sel.value}; render(); }; - const f = $('#billF'), tt = $('#billT'); - if (f) f.onchange = tt.onchange = () => { billSel = {p: 'custom', from: f.value, to: tt.value}; render(); }; - const add = $('#billAdd'); - if (add) add.onchange = () => { const f = (ifs.ifaces || []).find(x => ikey(x) === add.value); if (f) markIf(f, true); }; - el.querySelectorAll('[data-unbill]').forEach(b => b.onclick = () => { - const k = b.dataset.unbill, i = k.lastIndexOf('/'); - const f = (ifs.ifaces || []).find(x => ikey(x) === k) || {exporter: k.slice(0, i), ifindex: +k.slice(i + 1)}; - markIf(f, false); - }); -}; - views.sources = async (el) => { const [d, gd] = await Promise.all([api('sources'), api('geo').catch(() => ({sources: []}))]); const blocked = d.blocked || []; @@ -2911,7 +2775,7 @@ views.detail = async (el) => { // ------------------------------------------------------------ shell // Settings, data cleanup, offline analysis and live flow records have no use // for the time range, refresh and copy link. -const NOBAR_VIEWS = new Set(['help', 'sources', 'cleanup', 'sandbox', 'account', 'racks', 'devices', 'billing', 'names']); +const NOBAR_VIEWS = new Set(['help', 'sources', 'cleanup', 'sandbox', 'account', 'racks', 'devices', 'names']); function renderBar() { const off = NOBAR_VIEWS.has(state.v) || (state.v === 'records' && state.rl); if (off) $('#range').hidden = true; diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index f54851c..f9093f3 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "أول تسجيل دخول: اسم المستخدم وكلمة المرور على وحدة التحكّم، طُبعا عند بدء traffic66.", "login.hint_demo": "تجريبي: المستخدم {u}، كلمة المرور {p}", -"nav.billing": "الفوترة بالمئين 95", -"bill.title": "الفوترة بالمئين 95", -"bill.sub": "ما سيحسبه المشغّل على كل واجهة", -"bill.period": "الفترة", -"bill.this_month": "هذا الشهر", -"bill.last_month": "الشهر الماضي", -"bill.last30": "آخر 30 يوماً", -"bill.custom": "فترة مخصصة", -"bill.iface": "الواجهة", -"bill.ifindex": "الواجهة {n}", -"bill.billed": "المحسوب", -"bill.peak": "الذروة", -"bill.avg": "المتوسط", -"bill.coverage": "التغطية", -"bill.empty": "لا توجد عدادات واجهات في هذه الفترة. الرقم يأتي من العدادات التي يبلّغ عنها الجهاز، لا من الحركة المأخوذة بالمعاينة.", -"bill.p95": "المئين 95", -"bill.chart_sub": "عيّنات كل خمس دقائق؛ الخط المتقطع هو القيمة المفوترة", -"bill.gap_note": "فترات الخمس دقائق التي لا قياس فيها تُستبعد ولا تُحسب صفراً. حسابها صفراً يخفض الرقم. والتغطية تبيّن على أي جزء من الفترة يستند.", -"bill.how": "كيف يُحسب الرقم", -"bill.how_note": "يُرتَّب الوارد والصادر كلٌّ على حدة خلال الفترة، وتُستبعد أعلى خمسة بالمئة من عيّنات الخمس دقائق، ويُحسب أعلى ما بقي — الأكبر من الاتجاهين. وتُحسب الأشهر بالتوقيت المحلي لهذا المتصفّح.", -"bill.retention": "تُحفظ معدلات الواجهات {n} يوماً.", "lic.install": "التسجيل", "lic.install_sub": "ثبّت الترخيص الذي وصلك. لا يُكتب شيء قبل التحقق منه.", "lic.paste": "الصق هنا محتوى license.json", "lic.choose": "اختر ملفاً…", "lic.install_btn": "تثبيت", -"bill.marked": "عدد الواجهات المحسوبة: {n}. علّم الدوائر التي تدفع ثمنها؛ وما يبلّغ عنه الجهاز غير ذلك ليس فاتورة.", -"bill.all_shown": "لم تُعلَّم أي واجهة للفوترة، لذا تظهر كلها هنا. علّم الدوائر التي تدفع عنها للمشغّل فتصير هذه الصفحة هي الفاتورة.", -"bill.add": "إضافة واجهة…", -"bill.remove": "إزالة", -"bill.nodata": "لا شيء مُبلَّغ في هذه الفترة", "geo.lines_scale": "بين البلدان", "geo.line_pal": "ألوان الخطوط", "geo.pal_auto": "حسب السمة", diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 0152ba1..47cebf6 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "প্রথম সাইন-ইন: ব্যবহারকারী ও পাসওয়ার্ড কনসোলে আছে, traffic66 চালু হওয়ার সময় ছাপা হয়েছে।", "login.hint_demo": "ডেমো: ব্যবহারকারী {u}, পাসওয়ার্ড {p}", -"nav.billing": "৯৫তম পার্সেন্টাইল বিলিং", -"bill.title": "৯৫তম পার্সেন্টাইল বিলিং", -"bill.sub": "প্রতিটি ইন্টারফেসে অপারেটর যা নিত", -"bill.period": "সময়কাল", -"bill.this_month": "এই মাস", -"bill.last_month": "গত মাস", -"bill.last30": "গত ৩০ দিন", -"bill.custom": "নিজের সময়কাল", -"bill.iface": "ইন্টারফেস", -"bill.ifindex": "ইন্টারফেস {n}", -"bill.billed": "বিলের ভিত্তি", -"bill.peak": "সর্বোচ্চ", -"bill.avg": "গড়", -"bill.coverage": "আওতা", -"bill.empty": "এই সময়কালে কোনো ইন্টারফেস কাউন্টার নেই। সংখ্যাটি ডিভাইসের জানানো কাউন্টার থেকে আসে, নমুনা করা ট্রাফিক থেকে নয়।", -"bill.p95": "৯৫তম পার্সেন্টাইল", -"bill.chart_sub": "পাঁচ মিনিটের নমুনা; ড্যাশ করা রেখা হলো বিল করা মান", -"bill.gap_note": "মাপ নেই এমন পাঁচ মিনিটের খোপ বাদ দেওয়া হয়, শূন্য ধরা হয় না। শূন্য ধরলে সংখ্যাটি নেমে যেত। আওতা বলে সংখ্যাটি সময়কালের কতটুকুর উপর দাঁড়িয়ে।", -"bill.how": "সংখ্যাটি কীভাবে আসে", -"bill.how_note": "সময়কালে আগত ও নির্গত আলাদা করে সাজানো হয়, পাঁচ মিনিটের নমুনার উপরের পাঁচ শতাংশ ফেলে দেওয়া হয়, আর বাকিগুলোর সর্বোচ্চটি বিল হয় — দুই দিকের মধ্যে বড়টি। মাস গণনা হয় এই ব্রাউজারের সময় অঞ্চলে।", -"bill.retention": "ইন্টারফেসের হার {n} দিন রাখা হয়।", "lic.install": "নিবন্ধন", "lic.install_sub": "পাঠানো লাইসেন্সটি বসান। যাচাই না হওয়া পর্যন্ত কিছুই লেখা হয় না।", "lic.paste": "license.json-এর বিষয়বস্তু এখানে পেস্ট করুন", "lic.choose": "ফাইল বাছুন…", "lic.install_btn": "বসান", -"bill.marked": "{n}টি ইন্টারফেস বিলে আছে। যে সার্কিটগুলোর টাকা দেন কেবল সেগুলো চিহ্নিত করুন; ডিভাইস যা আরও জানায় তা বিল নয়।", -"bill.all_shown": "কোনো ইন্টারফেস বিলের জন্য চিহ্নিত নয়, তাই সবগুলো দেখানো হচ্ছে। অপারেটরকে যে সার্কিটগুলোর টাকা দেন সেগুলো চিহ্নিত করলে এই পাতাটিই বিল।", -"bill.add": "ইন্টারফেস যোগ করুন…", -"bill.remove": "সরান", -"bill.nodata": "এই সময়কালে কিছু জানানো হয়নি", "geo.lines_scale": "দেশে দেশে", "geo.line_pal": "রেখার রং", "geo.pal_auto": "থিম অনুযায়ী", diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index be02ab6..a6904c2 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "Erste Anmeldung: Benutzer und Passwort stehen auf der Konsole, ausgegeben beim Start von traffic66.", "login.hint_demo": "Demo: Benutzer {u}, Passwort {p}", -"nav.billing": "95-Perzentil-Abrechnung", -"bill.title": "95-Perzentil-Abrechnung", -"bill.sub": "Was ein Anbieter je Schnittstelle berechnen würde", -"bill.period": "Zeitraum", -"bill.this_month": "Dieser Monat", -"bill.last_month": "Letzter Monat", -"bill.last30": "Letzte 30 Tage", -"bill.custom": "Eigener Zeitraum", -"bill.iface": "Schnittstelle", -"bill.ifindex": "Schnittstelle {n}", -"bill.billed": "Berechnet", -"bill.peak": "Spitze", -"bill.avg": "Mittel", -"bill.coverage": "Abdeckung", -"bill.empty": "In diesem Zeitraum liegen keine Schnittstellenzähler vor. Die Zahl stammt aus den Zählern, die ein Gerät meldet, nicht aus abgetastetem Verkehr.", -"bill.p95": "95. Perzentil", -"bill.chart_sub": "Fünf-Minuten-Werte; die gestrichelte Linie ist der abgerechnete Wert", -"bill.gap_note": "Fünf-Minuten-Fenster ohne Messung werden weggelassen und nicht als Null gezählt. Als Null gezählt würden sie die Zahl senken. Die Abdeckung sagt, auf wie viel des Zeitraums sie beruht.", -"bill.how": "Wie die Zahl entsteht", -"bill.how_note": "Ein- und Ausgang werden über den Zeitraum je sortiert, die obersten fünf Prozent der Fünf-Minuten-Werte verworfen und der höchste verbleibende berechnet — der größere der beiden Richtungen. Monate zählen in der Zeitzone dieses Browsers.", -"bill.retention": "Schnittstellenraten werden {n} Tage aufbewahrt.", "lic.install": "Registrierung", "lic.install_sub": "Die zugesandte Lizenz einspielen. Nichts wird geschrieben, bevor sie geprüft ist.", "lic.paste": "Den Inhalt von license.json hier einfügen", "lic.choose": "Datei wählen…", "lic.install_btn": "Einspielen", -"bill.marked": "Abgerechnete Schnittstellen: {n}. Markieren Sie die Leitungen, für die Sie zahlen — alles andere, was ein Gerät meldet, ist keine Rechnung.", -"bill.all_shown": "Keine Schnittstelle ist als abgerechnet markiert, daher stehen hier alle. Markieren Sie die Leitungen, die Sie einem Anbieter bezahlen, und diese Seite wird zur Rechnung.", -"bill.add": "Schnittstelle hinzufügen…", -"bill.remove": "Entfernen", -"bill.nodata": "in diesem Zeitraum nichts gemeldet", "geo.lines_scale": "zwischen Ländern", "geo.line_pal": "Linienfarben", "geo.pal_auto": "Wie Design", diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index 3170a98..56bfcc9 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "First sign-in: the user and password are on the console, printed when traffic66 started.", "login.hint_demo": "Demo: user {u}, password {p}", -"nav.billing": "95th-percentile billing", -"bill.title": "95th-percentile billing", -"bill.sub": "What a carrier would charge for each interface", -"bill.period": "Period", -"bill.this_month": "This month", -"bill.last_month": "Last month", -"bill.last30": "Last 30 days", -"bill.custom": "Custom", -"bill.iface": "Interface", -"bill.ifindex": "Interface {n}", -"bill.billed": "Billed", -"bill.peak": "Peak", -"bill.avg": "Average", -"bill.coverage": "Coverage", -"bill.empty": "No interface counters in this period. The figure comes from the counters a device reports, not from sampled flow.", -"bill.p95": "95th percentile", -"bill.chart_sub": "Five-minute samples; the dashed line is the figure charged", -"bill.gap_note": "Five-minute buckets with no measurement are left out, not counted as zero. Counting them as zero would lower the figure. Coverage is how much of the period it rests on.", -"bill.how": "How this is worked out", -"bill.how_note": "Inbound and outbound are each sorted over the period, the top five per cent of the five-minute samples are discarded, and the highest left is charged — the greater of the two directions. Months are counted in this browser's time zone.", -"bill.retention": "Interface rates are kept for {n} days.", "lic.install": "Registration", "lic.install_sub": "Install the licence you were sent. Nothing is written until it has been checked.", "lic.paste": "Paste the contents of license.json here", "lic.choose": "Choose a file…", "lic.install_btn": "Install", -"bill.marked": "Interfaces billed: {n}. Mark the circuits you pay for; the rest of what a device reports is not a bill.", -"bill.all_shown": "No interface is marked as billed, so all of them are shown. Mark the circuits you pay a carrier for and this page becomes the bill.", -"bill.add": "Add an interface…", -"bill.remove": "Remove", -"bill.nodata": "nothing reported in this period", "geo.lines_scale": "between countries/regions", "geo.line_pal": "Line colours", "geo.pal_auto": "Follow theme", diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index c40373c..a8609ad 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "Primer inicio de sesión: el usuario y la contraseña están en la consola, impresos al arrancar traffic66.", "login.hint_demo": "Demo: usuario {u}, contraseña {p}", -"nav.billing": "Facturación al percentil 95", -"bill.title": "Facturación al percentil 95", -"bill.sub": "Lo que un operador cobraría por cada interfaz", -"bill.period": "Periodo", -"bill.this_month": "Este mes", -"bill.last_month": "Mes anterior", -"bill.last30": "Últimos 30 días", -"bill.custom": "Personalizado", -"bill.iface": "Interfaz", -"bill.ifindex": "Interfaz {n}", -"bill.billed": "Facturado", -"bill.peak": "Pico", -"bill.avg": "Media", -"bill.coverage": "Cobertura", -"bill.empty": "No hay contadores de interfaz en este periodo. La cifra sale de los contadores que informa el equipo, no del tráfico muestreado.", -"bill.p95": "Percentil 95", -"bill.chart_sub": "Muestras de cinco minutos; la línea discontinua es la cifra facturada", -"bill.gap_note": "Los tramos de cinco minutos sin medición se dejan fuera, no se cuentan como cero. Contarlos como cero bajaría la cifra. La cobertura dice sobre qué parte del periodo se sostiene.", -"bill.how": "Cómo se calcula", -"bill.how_note": "La entrada y la salida se ordenan por separado en el periodo, se descarta el cinco por ciento más alto de las muestras de cinco minutos y se cobra la más alta que queda: la mayor de las dos direcciones. Los meses se cuentan en la zona horaria de este navegador.", -"bill.retention": "Las tasas de interfaz se guardan {n} días.", "lic.install": "Registro", "lic.install_sub": "Instale la licencia que recibió. No se escribe nada hasta comprobarla.", "lic.paste": "Pegue aquí el contenido de license.json", "lic.choose": "Elegir un archivo…", "lic.install_btn": "Instalar", -"bill.marked": "Interfaces facturadas: {n}. Marque los circuitos que paga; el resto de lo que informa un equipo no es una factura.", -"bill.all_shown": "Ninguna interfaz está marcada como facturada, así que se muestran todas. Marque los circuitos que paga a un operador y esta página pasa a ser la factura.", -"bill.add": "Añadir una interfaz…", -"bill.remove": "Quitar", -"bill.nodata": "sin datos en este periodo", "geo.lines_scale": "entre países", "geo.line_pal": "Colores de líneas", "geo.pal_auto": "Según el tema", diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index c32b00b..8ade5ab 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "Première connexion : l'utilisateur et le mot de passe sont sur la console, affichés au démarrage de traffic66.", "login.hint_demo": "Démo : utilisateur {u}, mot de passe {p}", -"nav.billing": "Facturation au 95e centile", -"bill.title": "Facturation au 95e centile", -"bill.sub": "Ce qu'un opérateur facturerait pour chaque interface", -"bill.period": "Période", -"bill.this_month": "Ce mois", -"bill.last_month": "Le mois dernier", -"bill.last30": "30 derniers jours", -"bill.custom": "Personnalisée", -"bill.iface": "Interface", -"bill.ifindex": "Interface {n}", -"bill.billed": "Facturé", -"bill.peak": "Pointe", -"bill.avg": "Moyenne", -"bill.coverage": "Couverture", -"bill.empty": "Aucun compteur d'interface sur cette période. Le chiffre vient des compteurs que l'équipement déclare, pas du trafic échantillonné.", -"bill.p95": "95e centile", -"bill.chart_sub": "Échantillons de cinq minutes ; la ligne pointillée est la valeur facturée", -"bill.gap_note": "Les tranches de cinq minutes sans mesure sont écartées, et non comptées comme zéro. Les compter comme zéro abaisserait le chiffre. La couverture indique sur quelle part de la période il repose.", -"bill.how": "Comment le chiffre est obtenu", -"bill.how_note": "L'entrant et le sortant sont triés séparément sur la période, les cinq pour cent les plus hauts des échantillons de cinq minutes sont écartés, et le plus haut restant est facturé — le plus grand des deux sens. Les mois sont comptés dans le fuseau de ce navigateur.", -"bill.retention": "Les débits d'interface sont conservés {n} jours.", "lic.install": "Enregistrement", "lic.install_sub": "Installez la licence qui vous a été envoyée. Rien n'est écrit tant qu'elle n'est pas vérifiée.", "lic.paste": "Collez ici le contenu de license.json", "lic.choose": "Choisir un fichier…", "lic.install_btn": "Installer", -"bill.marked": "Interfaces facturées : {n}. Marquez les liaisons que vous payez ; le reste de ce qu'un équipement déclare n'est pas une facture.", -"bill.all_shown": "Aucune interface n'est marquée comme facturée, elles sont donc toutes affichées. Marquez les liaisons que vous payez à un opérateur et cette page devient la facture.", -"bill.add": "Ajouter une interface…", -"bill.remove": "Retirer", -"bill.nodata": "rien de déclaré sur cette période", "geo.lines_scale": "entre pays", "geo.line_pal": "Couleurs des lignes", "geo.pal_auto": "Selon le thème", diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index f5b081f..89c9c28 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "पहली साइन-इन: उपयोगकर्ता और पासवर्ड कंसोल पर हैं, जो traffic66 शुरू होने पर छपे थे।", "login.hint_demo": "डेमो: उपयोगकर्ता {u}, पासवर्ड {p}", -"nav.billing": "95वाँ पर्सेंटाइल बिलिंग", -"bill.title": "95वाँ पर्सेंटाइल बिलिंग", -"bill.sub": "हर इंटरफ़ेस पर ऑपरेटर कितना वसूलेगा", -"bill.period": "अवधि", -"bill.this_month": "इस माह", -"bill.last_month": "पिछला माह", -"bill.last30": "पिछले 30 दिन", -"bill.custom": "मनचाही अवधि", -"bill.iface": "इंटरफ़ेस", -"bill.ifindex": "इंटरफ़ेस {n}", -"bill.billed": "बिल आधार", -"bill.peak": "शिखर", -"bill.avg": "औसत", -"bill.coverage": "कवरेज", -"bill.empty": "इस अवधि में कोई इंटरफ़ेस काउंटर नहीं है। यह आंकड़ा उपकरण के बताए काउंटरों से आता है, सैंपल किए गए ट्रैफ़िक से नहीं।", -"bill.p95": "95वाँ पर्सेंटाइल", -"bill.chart_sub": "पाँच मिनट के नमूने; बिंदुदार रेखा वह दर है जिसका बिल बनता है", -"bill.gap_note": "जिन पाँच-मिनट खंडों में कोई माप नहीं है उन्हें छोड़ दिया जाता है, शून्य नहीं गिना जाता। शून्य गिनने से आंकड़ा नीचे आ जाएगा। कवरेज बताता है कि यह आंकड़ा अवधि के कितने हिस्से पर टिका है।", -"bill.how": "यह आंकड़ा कैसे निकलता है", -"bill.how_note": "अवधि में आने और जाने वाले ट्रैफ़िक को अलग-अलग क्रम में रखा जाता है, पाँच-मिनट नमूनों के ऊपरी पाँच प्रतिशत हटा दिए जाते हैं, और बचा हुआ सबसे ऊँचा मान बिल बनता है — दोनों दिशाओं में से बड़ा। महीने इसी ब्राउज़र के समय क्षेत्र में गिने जाते हैं।", -"bill.retention": "इंटरफ़ेस दरें {n} दिन रखी जाती हैं।", "lic.install": "पंजीकरण", "lic.install_sub": "आपको भेजा गया लाइसेंस स्थापित करें। जाँच पूरी होने तक कुछ भी नहीं लिखा जाता।", "lic.paste": "license.json की सामग्री यहाँ चिपकाएँ", "lic.choose": "फ़ाइल चुनें…", "lic.install_btn": "स्थापित करें", -"bill.marked": "{n} इंटरफ़ेस बिल में हैं। जिन सर्किटों का आप भुगतान करते हैं उन्हें चिह्नित करें; उपकरण जो बाकी बताता है वह बिल नहीं है।", -"bill.all_shown": "कोई इंटरफ़ेस बिल के लिए चिह्नित नहीं है, इसलिए सभी दिख रहे हैं। जिन सर्किटों का आप ऑपरेटर को भुगतान करते हैं उन्हें चिह्नित करें, तब यह पन्ना बिल बन जाएगा।", -"bill.add": "इंटरफ़ेस जोड़ें…", -"bill.remove": "हटाएँ", -"bill.nodata": "इस अवधि में कुछ नहीं बताया गया", "geo.lines_scale": "देशों के बीच", "geo.line_pal": "रेखा रंग", "geo.pal_auto": "थीम के अनुसार", diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index eda9e32..57bb9c4 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "Masuk pertama: nama pengguna dan sandi ada di konsol, tercetak saat traffic66 dijalankan.", "login.hint_demo": "Demo: pengguna {u}, kata sandi {p}", -"nav.billing": "Penagihan persentil 95", -"bill.title": "Penagihan persentil 95", -"bill.sub": "Yang akan ditagih operator untuk setiap antarmuka", -"bill.period": "Periode", -"bill.this_month": "Bulan ini", -"bill.last_month": "Bulan lalu", -"bill.last30": "30 hari terakhir", -"bill.custom": "Atur sendiri", -"bill.iface": "Antarmuka", -"bill.ifindex": "Antarmuka {n}", -"bill.billed": "Ditagih", -"bill.peak": "Puncak", -"bill.avg": "Rata-rata", -"bill.coverage": "Cakupan", -"bill.empty": "Tidak ada penghitung antarmuka pada periode ini. Angkanya berasal dari penghitung yang dilaporkan perangkat, bukan dari lalu lintas yang dicuplik.", -"bill.p95": "Persentil 95", -"bill.chart_sub": "Sampel lima menit; garis putus-putus adalah angka yang ditagih", -"bill.gap_note": "Kotak lima menit tanpa pengukuran ditinggalkan, bukan dihitung nol. Menghitungnya nol akan menurunkan angkanya. Cakupan menyatakan seberapa banyak periode yang menopang angka itu.", -"bill.how": "Cara angka ini diperoleh", -"bill.how_note": "Masuk dan keluar masing-masing diurutkan sepanjang periode, lima persen teratas dari cuplikan lima menit dibuang, dan yang tertinggi sisanya ditagih — yang lebih besar dari dua arah. Bulan dihitung menurut zona waktu peramban ini.", -"bill.retention": "Laju antarmuka disimpan {n} hari.", "lic.install": "Pendaftaran", "lic.install_sub": "Pasang lisensi yang Anda terima. Tidak ada yang ditulis sebelum diperiksa.", "lic.paste": "Tempel isi license.json di sini", "lic.choose": "Pilih berkas…", "lic.install_btn": "Pasang", -"bill.marked": "{n} antarmuka ditagih. Tandai sirkuit yang Anda bayar; sisa yang dilaporkan perangkat bukan tagihan.", -"bill.all_shown": "Belum ada antarmuka yang ditandai ditagih, jadi semuanya ditampilkan. Tandai sirkuit yang Anda bayar ke operator dan halaman ini menjadi tagihannya.", -"bill.add": "Tambah antarmuka…", -"bill.remove": "Hapus", -"bill.nodata": "tidak ada laporan pada periode ini", "geo.lines_scale": "antarnegara", "geo.line_pal": "Warna garis", "geo.pal_auto": "Ikuti tema", diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index 27d5031..e1f0c6e 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "初回サインイン:ユーザー名とパスワードは、traffic66 の起動時にコンソールへ出力されています。", "login.hint_demo": "デモ:ユーザー {u}、パスワード {p}", -"nav.billing": "95パーセンタイル課金", -"bill.title": "95パーセンタイル課金", -"bill.sub": "各インターフェースで通信事業者が請求する額", -"bill.period": "期間", -"bill.this_month": "今月", -"bill.last_month": "先月", -"bill.last30": "過去 30 日", -"bill.custom": "任意指定", -"bill.iface": "インターフェース", -"bill.ifindex": "インターフェース {n}", -"bill.billed": "請求値", -"bill.peak": "ピーク", -"bill.avg": "平均", -"bill.coverage": "カバー率", -"bill.empty": "この期間にインターフェースカウンタがありません。この数値は機器が報告するカウンタによるもので、サンプリングされたフローではありません。", -"bill.p95": "95パーセンタイル", -"bill.chart_sub": "5 分ごとのサンプル。破線が課金される値です", -"bill.gap_note": "測定のない 5 分バケットは 0 として数えず、除外します。0 として数えると数値が下がります。カバー率は、この数値が期間のどれだけに基づいているかを示します。", -"bill.how": "算出方法", -"bill.how_note": "受信と送信をそれぞれ期間内で並べ、5 分サンプルの上位 5% を捨て、残りの最大値を請求します。両方向のうち大きい方を採ります。月はこのブラウザのタイムゾーンで数えます。", -"bill.retention": "インターフェースの速度は {n} 日間保持します。", "lic.install": "ライセンス登録", "lic.install_sub": "受け取ったライセンスを導入します。検証に通るまで何も書き込みません。", "lic.paste": "license.json の内容をここに貼り付けてください", "lic.choose": "ファイルを選択…", "lic.install_btn": "導入", -"bill.marked": "課金対象のインターフェースは {n} 件です。料金を払っている回線だけを選んでください。機器が報告する他の口は請求ではありません。", -"bill.all_shown": "課金対象がまだ指定されていないため、すべて表示しています。通信事業者に支払っている回線を指定すると、このページが請求書になります。", -"bill.add": "インターフェースを追加…", -"bill.remove": "外す", -"bill.nodata": "この期間の報告なし", "geo.lines_scale": "国・地域どうし", "geo.line_pal": "線の配色", "geo.pal_auto": "テーマに合わせる", diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index bf9d4e4..369a66c 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "첫 로그인: 사용자 이름과 비밀번호는 traffic66 시작 시 콘솔에 출력되어 있습니다.", "login.hint_demo": "데모: 사용자 {u}, 비밀번호 {p}", -"nav.billing": "95 백분위 과금", -"bill.title": "95 백분위 과금", -"bill.sub": "통신사가 인터페이스별로 청구할 기준", -"bill.period": "기간", -"bill.this_month": "이번 달", -"bill.last_month": "지난달", -"bill.last30": "최근 30일", -"bill.custom": "직접 지정", -"bill.iface": "인터페이스", -"bill.ifindex": "인터페이스 {n}", -"bill.billed": "과금 값", -"bill.peak": "최고", -"bill.avg": "평균", -"bill.coverage": "커버리지", -"bill.empty": "이 기간에 인터페이스 카운터가 없습니다. 이 수치는 장비가 보고하는 카운터에서 나오며, 샘플링된 플로우가 아닙니다.", -"bill.p95": "95 백분위", -"bill.chart_sub": "5분 단위 샘플이며, 점선이 과금되는 값입니다", -"bill.gap_note": "측정값이 없는 5분 구간은 0으로 세지 않고 제외합니다. 0으로 세면 수치가 낮아집니다. 커버리지는 이 수치가 기간의 얼마에 근거하는지를 나타냅니다.", -"bill.how": "계산 방식", -"bill.how_note": "수신과 송신을 각각 기간 내에서 정렬해 5분 표본의 상위 5%를 버리고 남은 최고값을 과금합니다. 두 방향 중 더 큰 값을 씁니다. 월은 이 브라우저의 시간대로 계산합니다.", -"bill.retention": "인터페이스 속도는 {n}일 동안 보관합니다.", "lic.install": "라이선스 등록", "lic.install_sub": "받은 라이선스를 설치합니다. 검증을 통과하기 전에는 아무것도 기록하지 않습니다.", "lic.paste": "license.json의 내용을 여기에 붙여넣으세요", "lic.choose": "파일 선택…", "lic.install_btn": "설치", -"bill.marked": "과금 대상 인터페이스 {n}개. 실제로 요금을 내는 회선만 표시하세요. 장비가 보고하는 나머지는 청구 대상이 아닙니다.", -"bill.all_shown": "과금 대상이 지정되지 않아 전부 표시합니다. 통신사에 요금을 내는 회선을 지정하면 이 페이지가 청구서가 됩니다.", -"bill.add": "인터페이스 추가…", -"bill.remove": "제거", -"bill.nodata": "이 기간에 보고 없음", "geo.lines_scale": "국가 간", "geo.line_pal": "선 색상", "geo.pal_auto": "테마 따르기", diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index 8a6cf40..77ab2a6 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "Primeiro início de sessão: o utilizador e a palavra-passe estão na consola, impressos ao arrancar o traffic66.", "login.hint_demo": "Demo: usuário {u}, senha {p}", -"nav.billing": "Cobrança no percentil 95", -"bill.title": "Cobrança no percentil 95", -"bill.sub": "O que uma operadora cobraria por cada interface", -"bill.period": "Período", -"bill.this_month": "Este mês", -"bill.last_month": "Mês passado", -"bill.last30": "Últimos 30 dias", -"bill.custom": "Personalizado", -"bill.iface": "Interface", -"bill.ifindex": "Interface {n}", -"bill.billed": "Cobrado", -"bill.peak": "Pico", -"bill.avg": "Média", -"bill.coverage": "Cobertura", -"bill.empty": "Não há contadores de interface neste período. O número vem dos contadores que o equipamento relata, não do tráfego amostrado.", -"bill.p95": "Percentil 95", -"bill.chart_sub": "Amostras de cinco minutos; a linha tracejada é o valor cobrado", -"bill.gap_note": "Janelas de cinco minutos sem medição ficam de fora, não entram como zero. Contá-las como zero baixaria o número. A cobertura diz sobre quanto do período ele se apoia.", -"bill.how": "Como o número é obtido", -"bill.how_note": "Entrada e saída são ordenadas separadamente no período, os cinco por cento mais altos das amostras de cinco minutos são descartados e a mais alta restante é cobrada — a maior das duas direções. Os meses são contados no fuso deste navegador.", -"bill.retention": "As taxas de interface são guardadas por {n} dias.", "lic.install": "Registo", "lic.install_sub": "Instale a licença que recebeu. Nada é gravado antes de ser verificada.", "lic.paste": "Cole aqui o conteúdo de license.json", "lic.choose": "Escolher um ficheiro…", "lic.install_btn": "Instalar", -"bill.marked": "Interfaces cobradas: {n}. Marque os circuitos que paga; o resto do que um equipamento relata não é uma fatura.", -"bill.all_shown": "Nenhuma interface está marcada como cobrada, por isso aparecem todas. Marque os circuitos que paga a uma operadora e esta página passa a ser a fatura.", -"bill.add": "Adicionar uma interface…", -"bill.remove": "Remover", -"bill.nodata": "nada relatado neste período", "geo.lines_scale": "entre países", "geo.line_pal": "Cores das linhas", "geo.pal_auto": "Seguir o tema", diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index a4f6d37..2566548 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "Первый вход: имя пользователя и пароль — в консоли, они выведены при запуске traffic66.", "login.hint_demo": "Демо: пользователь {u}, пароль {p}", -"nav.billing": "Тарификация по 95-му процентилю", -"bill.title": "Тарификация по 95-му процентилю", -"bill.sub": "Сколько оператор выставил бы за каждый интерфейс", -"bill.period": "Период", -"bill.this_month": "Этот месяц", -"bill.last_month": "Прошлый месяц", -"bill.last30": "Последние 30 дней", -"bill.custom": "Свой период", -"bill.iface": "Интерфейс", -"bill.ifindex": "Интерфейс {n}", -"bill.billed": "К оплате", -"bill.peak": "Пик", -"bill.avg": "Среднее", -"bill.coverage": "Охват", -"bill.empty": "За этот период нет счётчиков интерфейсов. Цифра берётся из счётчиков, которые сообщает устройство, а не из выборочного трафика.", -"bill.p95": "95-й процентиль", -"bill.chart_sub": "Пятиминутные отсчёты; пунктирная линия — тарифицируемое значение", -"bill.gap_note": "Пятиминутные интервалы без измерения не учитываются, а не считаются нулём. Нули снизили бы цифру. Охват показывает, на какой части периода она держится.", -"bill.how": "Как получена цифра", -"bill.how_note": "Вход и выход сортируются за период по отдельности, верхние пять процентов пятиминутных значений отбрасываются, и к оплате идёт наибольшее из оставшихся — большее из двух направлений. Месяцы считаются в часовом поясе этого браузера.", -"bill.retention": "Скорости интерфейсов хранятся {n} дней.", "lic.install": "Регистрация", "lic.install_sub": "Установите присланную лицензию. Ничего не записывается, пока она не проверена.", "lic.paste": "Вставьте сюда содержимое license.json", "lic.choose": "Выбрать файл…", "lic.install_btn": "Установить", -"bill.marked": "Тарифицируется интерфейсов: {n}. Отметьте каналы, за которые вы платите; остальное, что сообщает устройство, — не счёт.", -"bill.all_shown": "Ни один интерфейс не отмечен как тарифицируемый, поэтому показаны все. Отметьте каналы, за которые вы платите оператору, и эта страница станет счётом.", -"bill.add": "Добавить интерфейс…", -"bill.remove": "Убрать", -"bill.nodata": "за этот период ничего не поступало", "geo.lines_scale": "между странами", "geo.line_pal": "Цвета линий", "geo.pal_auto": "Как в теме", diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index ce07ad8..4651cff 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "پہلی سائن اِن: صارف نام اور پاس ورڈ کنسول پر ہیں، جو traffic66 کے آغاز پر چھپے تھے۔", "login.hint_demo": "ڈیمو: صارف {u}، پاس ورڈ {p}", -"nav.billing": "95ویں پرسنٹائل بلنگ", -"bill.title": "95ویں پرسنٹائل بلنگ", -"bill.sub": "ہر انٹرفیس پر آپریٹر کتنا وصول کرے گا", -"bill.period": "مدت", -"bill.this_month": "یہ مہینہ", -"bill.last_month": "گزشتہ مہینہ", -"bill.last30": "گزشتہ 30 دن", -"bill.custom": "اپنی مدت", -"bill.iface": "انٹرفیس", -"bill.ifindex": "انٹرفیس {n}", -"bill.billed": "بل کی بنیاد", -"bill.peak": "بلند ترین", -"bill.avg": "اوسط", -"bill.coverage": "احاطہ", -"bill.empty": "اس مدت میں کوئی انٹرفیس کاؤنٹر نہیں۔ یہ عدد اُن کاؤنٹرز سے آتا ہے جو ڈیوائس خود بتاتی ہے، نمونے والے ٹریفک سے نہیں۔", -"bill.p95": "95واں پرسنٹائل", -"bill.chart_sub": "پانچ منٹ کے نمونے؛ ڈیش لائن وہ قدر ہے جس کا بل بنتا ہے", -"bill.gap_note": "جن پانچ منٹ کے خانوں میں کوئی پیمائش نہیں، اُنہیں چھوڑ دیا جاتا ہے، صفر نہیں گنا جاتا۔ صفر گننے سے عدد نیچے آ جاتا۔ احاطہ بتاتا ہے کہ یہ عدد مدت کے کتنے حصے پر قائم ہے۔", -"bill.how": "یہ عدد کیسے نکلتا ہے", -"bill.how_note": "مدت کے دوران آنے اور جانے والے ٹریفک کو الگ الگ ترتیب دیا جاتا ہے، پانچ منٹ کے نمونوں کے اوپر والے پانچ فیصد نکال دیے جاتے ہیں، اور بچا ہوا سب سے بلند عدد بل بنتا ہے — دونوں سمتوں میں سے بڑا۔ مہینے اسی براؤزر کے ٹائم زون میں گنے جاتے ہیں۔", -"bill.retention": "انٹرفیس کی شرحیں {n} دن رکھی جاتی ہیں۔", "lic.install": "رجسٹریشن", "lic.install_sub": "بھیجا گیا لائسنس نصب کریں۔ جانچ مکمل ہونے تک کچھ نہیں لکھا جاتا۔", "lic.paste": "license.json کا مواد یہاں چسپاں کریں", "lic.choose": "فائل منتخب کریں…", "lic.install_btn": "نصب کریں", -"bill.marked": "{n} انٹرفیس بل میں ہیں۔ صرف وہ سرکٹ نشان زد کریں جن کی آپ ادائیگی کرتے ہیں؛ ڈیوائس جو باقی بتاتی ہے وہ بل نہیں۔", -"bill.all_shown": "کوئی انٹرفیس بل کے لیے نشان زد نہیں، اس لیے سب دکھائے جا رہے ہیں۔ جن سرکٹس کی آپ آپریٹر کو ادائیگی کرتے ہیں انہیں نشان زد کریں، یہ صفحہ بل بن جائے گا۔", -"bill.add": "انٹرفیس شامل کریں…", -"bill.remove": "ہٹائیں", -"bill.nodata": "اس مدت میں کچھ رپورٹ نہیں ہوا", "geo.lines_scale": "ممالک کے درمیان", "geo.line_pal": "لائن کے رنگ", "geo.pal_auto": "تھیم کے مطابق", diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index 231500e..1591243 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "Đăng nhập lần đầu: tên người dùng và mật khẩu ở trên console, được in khi traffic66 khởi động.", "login.hint_demo": "Demo: người dùng {u}, mật khẩu {p}", -"nav.billing": "Tính phí phân vị 95", -"bill.title": "Tính phí phân vị 95", -"bill.sub": "Mức nhà mạng sẽ tính cho từng cổng", -"bill.period": "Kỳ", -"bill.this_month": "Tháng này", -"bill.last_month": "Tháng trước", -"bill.last30": "30 ngày gần nhất", -"bill.custom": "Tự chọn", -"bill.iface": "Cổng", -"bill.ifindex": "Cổng {n}", -"bill.billed": "Mức tính phí", -"bill.peak": "Đỉnh", -"bill.avg": "Trung bình", -"bill.coverage": "Độ phủ", -"bill.empty": "Kỳ này không có bộ đếm cổng. Con số lấy từ bộ đếm thiết bị tự báo, không phải từ lưu lượng lấy mẫu.", -"bill.p95": "Phân vị 95", -"bill.chart_sub": "Mẫu năm phút; đường nét đứt là giá trị được tính phí", -"bill.gap_note": "Những khoảng năm phút không có số đo thì bị bỏ ra, không tính là 0. Tính là 0 sẽ kéo con số xuống. Độ phủ cho biết con số dựa trên bao nhiêu phần của kỳ.", -"bill.how": "Con số được tính thế nào", -"bill.how_note": "Chiều vào và chiều ra được xếp riêng trong kỳ, bỏ 5% mẫu năm phút cao nhất, rồi lấy mẫu cao nhất còn lại — chọn chiều lớn hơn. Tháng được tính theo múi giờ của trình duyệt này.", -"bill.retention": "Tốc độ cổng được lưu {n} ngày.", "lic.install": "Đăng ký", "lic.install_sub": "Cài giấy phép bạn nhận được. Không ghi gì cho tới khi đã kiểm tra xong.", "lic.paste": "Dán nội dung license.json vào đây", "lic.choose": "Chọn tệp…", "lic.install_btn": "Cài", -"bill.marked": "Có {n} cổng được tính phí. Hãy đánh dấu những đường bạn thực sự trả tiền; phần còn lại thiết bị báo lên không phải hóa đơn.", -"bill.all_shown": "Chưa đánh dấu cổng nào để tính phí nên đang hiện tất cả. Đánh dấu những đường bạn trả tiền cho nhà mạng, trang này sẽ thành hóa đơn.", -"bill.add": "Thêm cổng…", -"bill.remove": "Bỏ", -"bill.nodata": "kỳ này không có báo cáo", "geo.lines_scale": "giữa các nước", "geo.line_pal": "Màu đường", "geo.pal_auto": "Theo giao diện", diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index f9bfc7a..99b9852 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -704,37 +704,11 @@ "acct.ldap_base": "Base DN", "login.hint": "首次登录:用户名和密码在控制台上,traffic66 启动时已打印。", "login.hint_demo": "演示:用户 {u},密码 {p}", -"nav.billing": "九五计费", -"bill.title": "九五计费", -"bill.sub": "按接口算出运营商会收多少", -"bill.period": "周期", -"bill.this_month": "本月", -"bill.last_month": "上月", -"bill.last30": "最近 30 天", -"bill.custom": "自定义", -"bill.iface": "接口", -"bill.ifindex": "接口 {n}", -"bill.billed": "计费值", -"bill.peak": "峰值", -"bill.avg": "均值", -"bill.coverage": "覆盖率", -"bill.empty": "这个周期里没有接口计数器数据。这个数字来自设备自己上报的计数器,不是抽样流量。", -"bill.p95": "95 百分位", -"bill.chart_sub": "五分钟采样;虚线是计费值", -"bill.gap_note": "没有测到的 5 分钟桶是不计入,而不是当作 0。当作 0 会把数字拉低。覆盖率说明这个数字建立在周期的多少比例上。", -"bill.how": "这个数是怎么算出来的", -"bill.how_note": "进、出两个方向各自在周期内排序,丢掉最高的 5% 的五分钟采样,取剩下最高的那个——两个方向取较大者。月份按本浏览器所在时区计算。", -"bill.retention": "接口速率保留 {n} 天。", "lic.install": "注册", "lic.install_sub": "安装收到的授权文件。检查通过之前不会写入任何内容。", "lic.paste": "把 license.json 的内容粘贴到这里", "lic.choose": "选择文件…", "lic.install_btn": "安装", -"bill.marked": "已标记 {n} 个计费接口。只标你真正付钱的线路——设备上报的其余接口不是账单。", -"bill.all_shown": "还没有标记任何计费接口,所以这里列出了全部。把你向运营商付费的线路标出来,这一页就是账单。", -"bill.add": "添加接口…", -"bill.remove": "移除", -"bill.nodata": "这个周期内没有上报", "geo.lines_scale": "国家/地区之间", "geo.line_pal": "连线配色", "geo.pal_auto": "跟随主题", diff --git a/internal/web/static/index.html b/internal/web/static/index.html index c032390..269d9d3 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -23,7 +23,6 @@ -
@@ -78,7 +77,6 @@

-
From 830302d8eb72d80e834fc88792e41fa228442c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:57:43 +0000 Subject: [PATCH 6/8] Rooms and racks is a picture of where flows come from, and free The page was an asset ledger: sites, racks drawn by the unit, device pages, ports, cabling, circuits, networks, paste and CSV import, behind a licence. What it is for is narrower: to see which room, rack, device and port a flow came through, and which carrier and circuit that port goes out to. That is what it shows now, as rooms holding racks holding devices, each port drawn out to its carrier and circuit ID. Devices and ports come from the flows. The room and rack go on the device line of the inventory file and the carrier and circuit on the interface line, typed in once from the page; renaming an interface elsewhere keeps them. The ledger package, its database file and its API are removed, and the page needs no licence. --- CHANGELOG.md | 1 + cmd/traffic66/main.go | 13 - docs/MANUAL.md | 34 +- internal/api/admin.go | 47 +++ internal/api/adminonly_test.go | 22 +- internal/api/assetquota_test.go | 129 ------- internal/api/assets.go | 489 --------------------------- internal/api/assets_test.go | 258 -------------- internal/api/notice_test.go | 37 --- internal/api/racks.go | 183 ++++++++++ internal/api/racks_test.go | 83 +++++ internal/api/server.go | 11 +- internal/assets/assets.go | 164 --------- internal/assets/assets_test.go | 262 --------------- internal/assets/crud.go | 554 ------------------------------ internal/assets/csv.go | 374 --------------------- internal/assets/csv_test.go | 169 ---------- internal/assets/paste.go | 510 ---------------------------- internal/assets/paste_test.go | 204 ------------ internal/assets/record.go | 190 ----------- internal/assets/sort.go | 27 -- internal/assets/subnets.go | 102 ------ internal/enrich/enrich.go | 192 +++++++++-- internal/enrich/iface_test.go | 45 +++ internal/license/license.go | 5 +- internal/web/static/app.css | 56 ++-- internal/web/static/app.js | 555 +++++-------------------------- internal/web/static/i18n/ar.json | 129 ++----- internal/web/static/i18n/bn.json | 129 ++----- internal/web/static/i18n/de.json | 129 ++----- internal/web/static/i18n/en.json | 129 ++----- internal/web/static/i18n/es.json | 129 ++----- internal/web/static/i18n/fr.json | 129 ++----- internal/web/static/i18n/hi.json | 129 ++----- internal/web/static/i18n/id.json | 129 ++----- internal/web/static/i18n/ja.json | 129 ++----- internal/web/static/i18n/ko.json | 129 ++----- internal/web/static/i18n/pt.json | 129 ++----- internal/web/static/i18n/ru.json | 129 ++----- internal/web/static/i18n/ur.json | 129 ++----- internal/web/static/i18n/vi.json | 129 ++----- internal/web/static/i18n/zh.json | 129 ++----- internal/web/static/index.html | 1 - inventory.txt.example | 11 +- 44 files changed, 981 insertions(+), 5682 deletions(-) create mode 100644 internal/api/admin.go delete mode 100644 internal/api/assetquota_test.go delete mode 100644 internal/api/assets.go delete mode 100644 internal/api/assets_test.go create mode 100644 internal/api/racks.go create mode 100644 internal/api/racks_test.go delete mode 100644 internal/assets/assets.go delete mode 100644 internal/assets/assets_test.go delete mode 100644 internal/assets/crud.go delete mode 100644 internal/assets/csv.go delete mode 100644 internal/assets/csv_test.go delete mode 100644 internal/assets/paste.go delete mode 100644 internal/assets/paste_test.go delete mode 100644 internal/assets/record.go delete mode 100644 internal/assets/sort.go delete mode 100644 internal/assets/subnets.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f78165e..55b149e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ heading is the version number. - 95th-percentile billing is removed: the page, `/api/billing`, `/api/billing/series`, the five-minute rate table it was computed from and the `billed` mark on interface lines (an inventory line that still carries it loads as before). The interface page shows the same counters, and `/api/report` now takes its counter figures from them. - The evaluation mark is a step darker and a size larger. +- Rooms and racks is a picture instead of an asset ledger, and free: every device that sends flows, in its room and rack, with each port and the carrier and circuit ID it goes out to. Devices and ports come from the flows; the room, rack, carrier and circuit are typed in once and kept in `inventory.txt` (`room=`, `rack=` on device lines, `isp=`, `circuit=` on interface lines). The ledger it replaces is removed — device pages, ports, cabling, circuits, networks, paste and CSV import — and its `assets.duckdb` is no longer opened (it can be deleted). - The names page is called Inventory again, in every language (台账 in Chinese). - The sponsor strip shows one advertiser at a time: a long banner alone, a small 728x90 one three times side by side. - Releases started by hand from Actions report themselves as official builds (2.4.0 said "community build"). diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index 29854d8..1f1508e 100644 --- a/cmd/traffic66/main.go +++ b/cmd/traffic66/main.go @@ -27,7 +27,6 @@ import ( "unicode/utf8" "github.com/githubflyideas/traffic66/internal/api" - "github.com/githubflyideas/traffic66/internal/assets" "github.com/githubflyideas/traffic66/internal/auth" "github.com/githubflyideas/traffic66/internal/capture" "github.com/githubflyideas/traffic66/internal/collector" @@ -522,18 +521,6 @@ func serve(args []string, demo bool) { if lic != nil { go lic.Loop(ctx.Done(), 4*time.Hour) } - // The asset ledger opens whatever the licence says, so that installing - // one later needs no restart and nothing already written down is out of - // reach. Who may read and change it is decided per request. Analysing - // capture files has no installation to keep a ledger for. - if offline == nil { - if al, err := assets.Open(f.data); err != nil { - log.Printf("asset ledger: %v", err) - } else { - srv.Assets = al - defer al.Close() - } - } if offline != nil { srv.SB = sandbox.NewWith(filepath.Join(f.data, "sandbox"), inv, asn, thr, sandbox.LocalLimits, 0.25) srv.Offline = true diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 2d3c0fa..c0b9b8f 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -505,29 +505,17 @@ they are the same records. The hourly and daily summaries record traffic by application, host and dimension, never by interface, so they keep their totals. -### Asset ledger (registered) - -**Sites & racks**, **Devices & ports** and **IP addresses** record where the -equipment stands, what it is cabled to, and which address is whose. It is a -ledger, not a discovery tool: nothing in it is polled or guessed, so when it -and the network disagree it still says what was meant to be there. Racks are -drawn by the unit; a device cannot be placed where another already stands. -Addresses are stored canonically, so "already assigned" is a reliable answer, -and a report names what does not add up — an address in no recorded network, -one MAC on several addresses. - -Entry is by hand, by paste or by spreadsheet. **Paste** reads what the device -already prints — `show lldp neighbors`, `show cdp neighbors`, -`show interface status`, `show ip arp`, `show mac address-table` — with -nothing installed on the device and no community string or password stored -anywhere; paste the ARP table before the MAC address table and together they -say which port a host is plugged into. **CSV** exports and imports every -kind; the `id` column ties a row back to its record, so a file that came from -here updates what it came from instead of doubling it. - -Everyone signed in reads these pages; the administrator writes them, and can -hide a record from everyone else. The ledger keeps its own `assets.duckdb` -beside the flow data, so deleting traffic never touches it. +### Rooms and racks + +A picture of where the flows come from: for every device that sends flows, +the room and rack it stands in, and for each of its ports the carrier and +circuit ID it goes out to. Devices and ports appear by themselves from the +flows; click ✎ to type in the room, rack, carrier and circuit. It is filled +in once and kept in `inventory.txt` beside the names +(`device 192.0.2.1 Core router room=Room_1 rack=A03`, +`iface 192.0.2.1 3 Uplink isp=China_Telecom circuit=SH-12345`; a space is +written as `_`). Nothing is looked up in it. Everyone signed in sees it; the +administrator edits it. ### Licence diff --git a/internal/api/admin.go b/internal/api/admin.go new file mode 100644 index 0000000..2234bce --- /dev/null +++ b/internal/api/admin.go @@ -0,0 +1,47 @@ +package api + +import "net/http" + +// isAdmin reports whether the signed-in user manages the others. With no +// password file there is no administrator, so nothing may be written by +// anyone. +func (s *Server) isAdmin(r *http.Request) bool { + // This machine's own token counts: the TUI and the local subcommands + // carry it, and whoever can read it from the data directory could + // have changed the files there directly. + if local(r) { + return true + } + if s.Accounts == nil { + return false + } + u := userOf(r) + return u != "" && u == s.Accounts.Admin() +} + +// adminOnly guards h with requireAdmin. +func (s *Server) adminOnly(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.requireAdmin(w, r) { + h(w, r) + } + } +} + +// requireAdmin answers 403 unless the caller may change the installation +// itself, and is the gate behind every call that deletes stored data or +// reconfigures the installation for everybody. +// +// It is deliberately not admin(): that one asks for a password file it +// can write and answers 409 "fixed" without one, which is right when the +// call is about the account list and wrong here. An installation started +// with -password, or without a data directory, still has an +// administrator — it simply cannot add users — so gating these calls on +// admin() would lock such an installation out of its own settings. +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if s.isAdmin(r) { + return true + } + writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_admin"}) + return false +} diff --git a/internal/api/adminonly_test.go b/internal/api/adminonly_test.go index c186c41..257e287 100644 --- a/internal/api/adminonly_test.go +++ b/internal/api/adminonly_test.go @@ -19,19 +19,15 @@ import ( // that writes has to go through adminAPI, and TestEveryWriteIsDecided // fails when a new route is added without that decision being made. var openWrites = map[string]string{ - "POST /api/login": "signing in is how you stop being anonymous", - "POST /api/logout": "ending one's own session", - "POST /api/password": "one's own password; the old one is asked for again", - "POST /api/users": "admin()'s own gate, which also refuses a fixed password file", - "DELETE /api/users": "same", - "POST /api/resolve": "reverse DNS for the names on screen; stores nothing", - "POST /api/findings": "marking an alert handled is the everyday work, not administration", - "POST /api/sandbox/files": "offline analysis: uploading a capture to look at", - "POST /api/sandbox/active": "choosing which uploaded capture to look at", - "POST /api/assets/csv": "ledgerAdmin: the licence gate and the admin check together", - "POST /api/assets/paste": "same", - "POST /api/assets/{kind}": "same", - "DELETE /api/assets/{kind}": "same", + "POST /api/login": "signing in is how you stop being anonymous", + "POST /api/logout": "ending one's own session", + "POST /api/password": "one's own password; the old one is asked for again", + "POST /api/users": "admin()'s own gate, which also refuses a fixed password file", + "DELETE /api/users": "same", + "POST /api/resolve": "reverse DNS for the names on screen; stores nothing", + "POST /api/findings": "marking an alert handled is the everyday work, not administration", + "POST /api/sandbox/files": "offline analysis: uploading a capture to look at", + "POST /api/sandbox/active": "choosing which uploaded capture to look at", } // TestEveryWriteIsDecided reads the route table and fails on a writing diff --git a/internal/api/assetquota_test.go b/internal/api/assetquota_test.go deleted file mode 100644 index 4161922..0000000 --- a/internal/api/assetquota_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/githubflyideas/traffic66/internal/license" -) - -// importServer is assetServer plus the two batch routes, which its mux -// does not carry. -func importServer(t *testing.T, devices int) (*Server, func(path, body string) *httptest.ResponseRecorder) { - t.Helper() - s, _ := assetServer(t, []string{license.FeatDCIM}, 0, 0, devices) - mux := http.NewServeMux() - mux.HandleFunc("POST /api/assets/csv", s.postAssetCSV) - mux.HandleFunc("POST /api/assets/paste", s.postPaste) - call := func(path, body string) *httptest.ResponseRecorder { - r := httptest.NewRequest("POST", path, strings.NewReader(body)) - r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, "admin")) - w := httptest.NewRecorder() - mux.ServeHTTP(w, r) - return w - } - return s, call -} - -// deviceCSV is n new devices: an empty id column means "add". -func deviceCSV(from, n int) string { - var b strings.Builder - b.WriteString("id,name\n") - for i := 0; i < n; i++ { - fmt.Fprintf(&b, ",dev%d\n", from+i) - } - return b.String() -} - -func importCSV(call func(path, body string) *httptest.ResponseRecorder, csv string, apply bool) *httptest.ResponseRecorder { - body, _ := json.Marshal(map[string]any{"kind": "device", "text": csv, "apply": apply}) - return call("/api/assets/csv", string(body)) -} - -// TestCSVImportObeysTheQuota. A spreadsheet used to be the way round -// every number the licence sells: postAssetCSV had no quota check at all, -// so a licence for five devices took an import of five hundred. -func TestCSVImportObeysTheQuota(t *testing.T) { - s, call := importServer(t, 5) - devs := func() int { return s.Assets.Counts().Devices } - - // a preview writes nothing, so it is never refused - if w := importCSV(call, deviceCSV(1, 500), false); w.Code != http.StatusOK { - t.Fatalf("preview of 500: got %d %s, want 200", w.Code, w.Body) - } - if n := devs(); n != 0 { - t.Fatalf("a preview wrote %d devices", n) - } - - // applying more than the licence holds is refused, and writes nothing - w := importCSV(call, deviceCSV(1, 10), true) - if w.Code != http.StatusForbidden { - t.Errorf("importing 10 against a licence for 5: got %d %s, want 403", w.Code, w.Body) - } - if !strings.Contains(w.Body.String(), "quota") { - t.Errorf("expected a quota refusal, got %s", w.Body) - } - if n := devs(); n != 0 { - t.Fatalf("a refused import wrote %d devices; it must write none", n) - } - - // exactly the licence's worth goes in - if w := importCSV(call, deviceCSV(1, 5), true); w.Code != http.StatusOK { - t.Fatalf("importing 5 against a licence for 5: got %d %s", w.Code, w.Body) - } - if n := devs(); n != 5 { - t.Fatalf("after importing 5 the ledger holds %d", n) - } - - // and then nothing more - if w := importCSV(call, deviceCSV(6, 1), true); w.Code != http.StatusForbidden { - t.Errorf("one more at the limit: got %d %s, want 403", w.Code, w.Body) - } - if n := devs(); n != 5 { - t.Errorf("the ledger holds %d after a refused import", n) - } -} - -// TestBatchCannotOvershootTheQuota is the off-by-a-batch the first -// version of the paste check had: it asked whether the ledger was already -// at its limit, which is false at four of five, so a batch of three went -// in and left seven. -func TestBatchCannotOvershootTheQuota(t *testing.T) { - s, call := importServer(t, 5) - if w := importCSV(call, deviceCSV(1, 4), true); w.Code != http.StatusOK { - t.Fatalf("filling to 4: got %d %s", w.Code, w.Body) - } - if n := s.Assets.Counts().Devices; n != 4 { - t.Fatalf("expected 4 devices, got %d", n) - } - // 4 + 3 > 5: refused as a batch, not one at a time - if w := importCSV(call, deviceCSV(5, 3), true); w.Code != http.StatusForbidden { - t.Errorf("3 more at 4 of 5: got %d %s, want 403", w.Code, w.Body) - } - if n := s.Assets.Counts().Devices; n != 4 { - t.Errorf("the ledger overshot to %d; the licence holds 5 and it was at 4", n) - } - // the one that does fit still goes in - if w := importCSV(call, deviceCSV(5, 1), true); w.Code != http.StatusOK { - t.Errorf("the 5th device: got %d %s, want 200", w.Code, w.Body) - } - if n := s.Assets.Counts().Devices; n != 5 { - t.Errorf("expected 5 devices, got %d", n) - } -} - -// An unlimited licence (0) is not a limit of zero. -func TestQuotaZeroIsUnlimited(t *testing.T) { - s, call := importServer(t, 0) - if w := importCSV(call, deviceCSV(1, 50), true); w.Code != http.StatusOK { - t.Fatalf("50 devices on an unlimited licence: got %d %s", w.Code, w.Body) - } - if n := s.Assets.Counts().Devices; n != 50 { - t.Errorf("expected 50 devices, got %d", n) - } -} diff --git a/internal/api/assets.go b/internal/api/assets.go deleted file mode 100644 index 8ce53fd..0000000 --- a/internal/api/assets.go +++ /dev/null @@ -1,489 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - - "github.com/githubflyideas/traffic66/internal/assets" - "github.com/githubflyideas/traffic66/internal/license" -) - -// The asset ledger. Reading it is open to everyone signed in, minus what -// an administrator has hidden; writing it is the administrator's, because -// a ledger everyone can edit is not a record of anything. -// -// Two separate checks guard every call and neither stands in for the -// other: the licence decides whether the ledger exists at all on this -// installation, and the account decides who may change it. A licence that -// cannot be read grants nothing, and losing one never widens what a -// non-administrator can see. - -// ledger answers the request and returns nil when the ledger is not -// available: no licence for it, or the installation has none open. -func (s *Server) ledger(w http.ResponseWriter) *assets.Store { - if !s.allowed(w, license.FeatDCIM) { - return nil - } - if s.Assets == nil { - fail(w, errors.New("the asset ledger is not open")) - return nil - } - return s.Assets -} - -// isAdmin reports whether the signed-in user manages the others. With no -// password file there is no administrator, so nothing is hidden from and -// nothing may be written by anyone — the ledger is read-only rather than -// open to all. -func (s *Server) isAdmin(r *http.Request) bool { - // This machine's own token counts: the TUI and the local subcommands - // carry it, and whoever can read it from the data directory could - // have changed the files there directly. - if local(r) { - return true - } - if s.Accounts == nil { - return false - } - u := userOf(r) - return u != "" && u == s.Accounts.Admin() -} - -// requireAdmin answers 403 unless the caller may change the installation -// itself, and is the gate behind every call that deletes stored data or -// reconfigures the installation for everybody. -// -// It is deliberately not admin(): that one asks for a password file it -// can write and answers 409 "fixed" without one, which is right when the -// call is about the account list and wrong here. An installation started -// with -password, or without a data directory, still has an -// administrator — it simply cannot add users — so gating these calls on -// admin() would lock such an installation out of its own settings. -// adminOnly guards h with requireAdmin. -func (s *Server) adminOnly(h http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if s.requireAdmin(w, r) { - h(w, r) - } - } -} - -func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { - if s.isAdmin(r) { - return true - } - writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_admin"}) - return false -} - -// ledgerAdmin is ledger plus the administrator check, for the writes. -func (s *Server) ledgerAdmin(w http.ResponseWriter, r *http.Request) *assets.Store { - a := s.ledger(w) - if a == nil { - return nil - } - if !s.isAdmin(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_admin"}) - return nil - } - return a -} - -// getAssets returns the whole ledger. It is small by design — a few -// thousand rows at the quotas we sell — so the pages get it in one call -// and do their own joining, rather than asking per rack. -func (s *Server) getAssets(w http.ResponseWriter, r *http.Request) { - a := s.ledger(w) - if a == nil { - return - } - admin := s.isAdmin(r) - sites, err := a.Sites() - if err != nil { - fail(w, err) - return - } - racks, err := a.Racks(0) - if err != nil { - fail(w, err) - return - } - devices, err := a.Devices(assets.DeviceQuery{}) - if err != nil { - fail(w, err) - return - } - ports, err := a.Ports(0) - if err != nil { - fail(w, err) - return - } - links, err := a.Links() - if err != nil { - fail(w, err) - return - } - circuits, err := a.Circuits() - if err != nil { - fail(w, err) - return - } - if !admin { - sites = visible(sites, func(v assets.Site) bool { return v.Hidden }) - racks = visible(racks, func(v assets.Rack) bool { return v.Hidden }) - devices = visible(devices, func(v assets.Device) bool { return v.Hidden }) - circuits = visible(circuits, func(v assets.Circuit) bool { return v.Hidden }) - ports = ownedBy(ports, devices) - links = cabled(links, ports) - } - subnets, err := a.Subnets() - if err != nil { - fail(w, err) - return - } - if !admin { - subnets = visible(subnets, func(v assets.Subnet) bool { return v.Hidden }) - } - writeJSON(w, http.StatusOK, map[string]any{ - "sites": sites, "racks": racks, "devices": devices, "ports": ports, - "links": links, "circuits": circuits, "subnets": subnets, - "counts": a.Counts(), "quota": s.assetQuota(), "admin": admin, - // the height a new rack is offered with, so the page and the - // ledger cannot drift apart on it - "default_units": assets.DefaultUnits, - }) -} - -// putAsset saves one record of the kind named in the path. -func (s *Server) putAsset(w http.ResponseWriter, r *http.Request) { - a := s.ledgerAdmin(w, r) - if a == nil { - return - } - kind := r.PathValue("kind") - b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<16)) - if err != nil { - fail(w, errors.New("bad request")) - return - } - // the quota is read off the body before it is decoded, because only a - // new record (no id) adds to the count - var head struct { - ID int64 `json:"id"` - } - json.Unmarshal(b, &head) - if err := s.quotaRoom(a, kind, head.ID); err != nil { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "quota", "detail": err.Error()}) - return - } - var out any - switch kind { - case "site": - var v assets.Site - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SaveSite(v) - } - case "rack": - var v assets.Rack - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SaveRack(v) - } - case "device": - var v assets.Device - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SaveDevice(v) - } - case "port": - var v assets.Port - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SavePort(v) - } - case "link": - var v assets.Link - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SaveLink(v) - } - case "circuit": - var v assets.Circuit - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SaveCircuit(v) - } - case "subnet": - var v assets.Subnet - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SaveSubnet(v) - } - default: - fail(w, fmt.Errorf("%q is not a kind of asset", kind)) - return - } - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, out) -} - -// deleteAsset removes one record. -func (s *Server) deleteAsset(w http.ResponseWriter, r *http.Request) { - a := s.ledgerAdmin(w, r) - if a == nil { - return - } - id := idParam(r.URL.Query().Get("id")) - if id == 0 { - fail(w, errors.New("id missing")) - return - } - var err error - switch kind := r.PathValue("kind"); kind { - case "site": - err = a.DeleteSite(id) - case "rack": - err = a.DeleteRack(id) - case "device": - err = a.DeleteDevice(id) - case "port": - err = a.DeletePort(id) - case "link": - err = a.DeleteLink(id) - case "circuit": - err = a.DeleteCircuit(id) - case "subnet": - err = a.DeleteSubnet(id) - default: - fail(w, fmt.Errorf("%q is not a kind of asset", kind)) - return - } - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]string{"ok": "deleted"}) -} - -// postPaste reads a pasted command output. Without "apply" nothing is -// written and the plan only says what it would do, so an operator sees -// what a paste of the wrong buffer would have done before it does it. -func (s *Server) postPaste(w http.ResponseWriter, r *http.Request) { - a := s.ledgerAdmin(w, r) - if a == nil { - return - } - var in struct { - Text string `json:"text"` - DeviceID int64 `json:"device_id"` - Apply bool `json:"apply"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil { - fail(w, errors.New("bad request")) - return - } - plan, err := a.Paste(assets.PasteOptions{Text: in.Text, DeviceID: in.DeviceID}) - if err != nil { - fail(w, err) - return - } - if in.Apply { - // A neighbour paste adds devices, so it is held to the same quota. - // It asks for room for as many as the preview above found: asking - // only whether the ledger was already full let a paste of fifty - // through at forty-nine of fifty, and a paste that adds none is - // still never refused. - if err := s.quotaForPlan(a, plan); err != nil { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "quota", "detail": err.Error()}) - return - } - if plan, err = a.Paste(assets.PasteOptions{Text: in.Text, DeviceID: in.DeviceID, Apply: true}); err != nil { - fail(w, err) - return - } - } - writeJSON(w, http.StatusOK, plan) -} - -// getAssetCSV hands the ledger back as a spreadsheet, because an asset -// list that cannot leave the tool is a hostage. -// -// It is the administrator's, like the other whole-table operations. The -// pages filter hidden records one by one on the way out; a file of whole -// tables cannot be filtered that way without quietly handing someone a -// shorter ledger than the one they asked for, and a ledger that is quietly -// short is worse than one they cannot have. -func (s *Server) getAssetCSV(w http.ResponseWriter, r *http.Request) { - a := s.ledgerAdmin(w, r) - if a == nil { - return - } - kind := r.URL.Query().Get("kind") - var buf bytes.Buffer - // An unregistered installation's file says so in leading comment lines; - // the import below skips them, so the file still round-trips. - buf.WriteString(commentLines(s.notice(), "# ")) - if err := a.ExportCSV(&buf, kind); err != nil { - fail(w, err) - return - } - w.Header().Set("Content-Type", "text/csv; charset=utf-8") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"traffic66-%s.csv\"", kind)) - w.Header().Set("Cache-Control", "no-store") - w.Write(buf.Bytes()) -} - -// postAssetCSV reads a spreadsheet back in. Like a paste, it previews by -// default: "apply" is what writes. -func (s *Server) postAssetCSV(w http.ResponseWriter, r *http.Request) { - a := s.ledgerAdmin(w, r) - if a == nil { - return - } - var in struct { - Kind string `json:"kind"` - Text string `json:"text"` - Apply bool `json:"apply"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&in); err != nil { - fail(w, errors.New("bad request")) - return - } - // Preview first, always: the preview is what the quota is decided on, - // so an import that does not fit is refused before it has written - // half of itself. This path had no quota check at all, which made a - // spreadsheet the way round every number the licence sells. - plan, err := a.ImportCSV(strings.NewReader(in.Text), in.Kind, false) - if err != nil { - fail(w, err) - return - } - if in.Apply { - if err := s.quotaForPlan(a, plan); err != nil { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "quota", "detail": err.Error()}) - return - } - if plan, err = a.ImportCSV(strings.NewReader(in.Text), in.Kind, true); err != nil { - fail(w, err) - return - } - } - writeJSON(w, http.StatusOK, plan) -} - -// assetQuota is how many of each kind this licence allows; 0 means no -// limit. It is reported so the page can say "41 of 50" before the save -// that would fail. -func (s *Server) assetQuota() map[string]int { - if s.License == nil { - return map[string]int{"sites": license.NotAvailable, "racks": license.NotAvailable, "devices": license.NotAvailable} - } - st := s.License.State() - return map[string]int{"sites": st.SiteLimit(), "racks": st.RackLimit(), "devices": st.DeviceLimit()} -} - -// quotaRoom refuses a new record that would take the ledger past what the -// licence allows. Editing an existing record (a non-zero id) is never -// refused: a quota that stopped an operator correcting a typo would be a -// trap, and the count does not change. -func (s *Server) quotaRoom(a *assets.Store, kind string, id int64) error { - if id != 0 { - return nil - } - return s.quotaFor(a, kind, 1) -} - -// quotaFor refuses n new records of a kind when the ledger has not got -// room for all of them. -// -// Asking for the number rather than "are we at the limit yet" is the -// whole point: an import arrives as a batch, and a check that only -// refuses a ledger already at its limit lets a batch of fifty through at -// forty-nine of fifty. It is told what the batch would add and answers -// for the batch. -func (s *Server) quotaFor(a *assets.Store, kind string, n int) error { - if n <= 0 { - return nil - } - var limit, have int - c := a.Counts() - q := s.assetQuota() - switch kind { - case "site": - limit, have = q["sites"], c.Sites - case "rack": - limit, have = q["racks"], c.Racks - case "device": - limit, have = q["devices"], c.Devices - default: - return nil // ports, links, circuits, subnets and addresses are not counted - } - if limit <= 0 || have+n <= limit { // 0: no limit - return nil - } - if n == 1 { - return fmt.Errorf("this licence records %d %ss, and the ledger holds %d", limit, kind, have) - } - return fmt.Errorf("this licence records %d %ss: the ledger holds %d and this would add %d", limit, kind, have, n) -} - -// quotaForPlan refuses an import whose additions would not fit. The plan -// comes from a run that wrote nothing, so this is decided before anything -// is saved rather than discovered half way through. -func (s *Server) quotaForPlan(a *assets.Store, plan assets.Plan) error { - adds := map[string]int{} - for _, c := range plan.Changes { - if c.Action == "add" { - adds[c.Kind]++ - } - } - for _, kind := range []string{"site", "rack", "device"} { - if err := s.quotaFor(a, kind, adds[kind]); err != nil { - return err - } - } - return nil -} - -// visible drops the records an administrator has hidden. -func visible[T any](in []T, hidden func(T) bool) []T { - out := make([]T, 0, len(in)) - for _, v := range in { - if !hidden(v) { - out = append(out, v) - } - } - return out -} - -// ownedBy keeps the ports of the devices that are left, so that hiding a -// device hides its ports with it. -func ownedBy(ports []assets.Port, devices []assets.Device) []assets.Port { - seen := make(map[int64]bool, len(devices)) - for _, d := range devices { - seen[d.ID] = true - } - return visible(ports, func(p assets.Port) bool { return !seen[p.DeviceID] }) -} - -// cabled keeps the links whose both ends are still visible. -func cabled(links []assets.Link, ports []assets.Port) []assets.Link { - seen := make(map[int64]bool, len(ports)) - for _, p := range ports { - seen[p.ID] = true - } - return visible(links, func(l assets.Link) bool { return !seen[l.APort] || !seen[l.BPort] }) -} - -// idParam reads a record identifier; anything unreadable is 0, which -// every query treats as "not given". -func idParam(s string) int64 { - n, err := strconv.ParseInt(s, 10, 64) - if err != nil || n < 0 { - return 0 - } - return n -} diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go deleted file mode 100644 index aff310a..0000000 --- a/internal/api/assets_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package api - -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/githubflyideas/traffic66/internal/assets" - "github.com/githubflyideas/traffic66/internal/auth" - "github.com/githubflyideas/traffic66/internal/license" -) - -// licensed writes a licence into dir granting the given features and the -// given quotas, signed by a key only this test knows, and returns a -// checker that trusts that key. It never touches the release keyring, so -// the test says nothing about which keys we actually ship. -func licensed(t *testing.T, dir string, feats []string, sites, racks, devices int) *license.Checker { - t.Helper() - pub, priv, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatal(err) - } - f := license.File{ - KeyID: license.KeyID(pub), Customer: "Test", Product: "traffic66", - License: license.TypePerpetual, Tier: license.TierStandard, - MaxUsers: 10, MaxSites: sites, MaxRacks: racks, MaxDevices: devices, - Features: license.CanonFeatures(feats), - } - f.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, license.Message(f))) - b, err := json.Marshal(f) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, license.FileName), b, 0o600); err != nil { - t.Fatal(err) - } - c, err := license.Open(dir) - if err != nil { - t.Fatal(err) - } - c.Ring = license.Keyring{Keys: []license.Key{{ID: license.KeyID(pub), Pub: pub}}} - c.Check(time.Now()) - return c -} - -// assetServer is a signed-in server with the ledger open. -func assetServer(t *testing.T, feats []string, sites, racks, devices int) (*Server, func(method, path, body, user string) *httptest.ResponseRecorder) { - t.Helper() - dir := t.TempDir() - if err := auth.Set(dir, "admin", "s3cret-pass"); err != nil { - t.Fatal(err) - } - if err := auth.Set(dir, "viewer", "s3cret-pass"); err != nil { - t.Fatal(err) - } - fc := auth.NewFileChecker(dir, nil) - al, err := assets.Open(dir) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { al.Close() }) - s := &Server{Accounts: fc, DataDir: dir, Assets: al, sessions: map[string]session{}} - if feats != nil { - s.License = licensed(t, dir, feats, sites, racks, devices) - } - mux := http.NewServeMux() - // the handlers under test, without the session middleware: the user is - // put on the request directly so the test is about the ledger's own - // two gates, not about signing in - mux.HandleFunc("GET /api/assets", s.getAssets) - mux.HandleFunc("POST /api/assets/{kind}", s.putAsset) - mux.HandleFunc("DELETE /api/assets/{kind}", s.deleteAsset) - call := func(method, path, body, user string) *httptest.ResponseRecorder { - r := httptest.NewRequest(method, path, strings.NewReader(body)) - r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, user)) - w := httptest.NewRecorder() - mux.ServeHTTP(w, r) - return w - } - return s, call -} - -// Without a licence granting it, the ledger is not there: every call is -// refused, and refused the same way whether the caller is the -// administrator or not. -func TestAssetsNeedLicence(t *testing.T) { - _, call := assetServer(t, nil, 0, 0, 0) - for _, c := range []struct{ method, path, body string }{ - {"GET", "/api/assets", ""}, - {"POST", "/api/assets/site", `{"name":"A"}`}, - {"DELETE", "/api/assets/site?id=1", ""}, - } { - for _, u := range []string{"admin", "viewer"} { - w := call(c.method, c.path, c.body, u) - if w.Code != http.StatusForbidden { - t.Fatalf("%s %s as %s: %d %s", c.method, c.path, u, w.Code, w.Body) - } - if !strings.Contains(w.Body.String(), "not_registered") { - t.Fatalf("%s %s: %s", c.method, c.path, w.Body) - } - } - } - // a licence granting something else is no licence for this one - _, call = assetServer(t, []string{license.FeatSyslog}, 0, 0, 0) - if w := call("GET", "/api/assets", "", "admin"); w.Code != http.StatusForbidden { - t.Fatalf("with another feature only: %d %s", w.Code, w.Body) - } -} - -// With the licence, everyone signed in reads the ledger and only the -// administrator writes it. -func TestAssetsAdminWrites(t *testing.T) { - _, call := assetServer(t, []string{license.FeatDCIM}, 0, 0, 0) - if w := call("GET", "/api/assets", "", "viewer"); w.Code != 200 { - t.Fatalf("viewer reading: %d %s", w.Code, w.Body) - } - if w := call("POST", "/api/assets/site", `{"name":"Site A"}`, "viewer"); w.Code != http.StatusForbidden { - t.Fatalf("viewer writing: %d %s", w.Code, w.Body) - } - w := call("POST", "/api/assets/site", `{"name":"Site A"}`, "admin") - if w.Code != 200 { - t.Fatalf("admin writing: %d %s", w.Code, w.Body) - } - var site assets.Site - json.Unmarshal(w.Body.Bytes(), &site) - if site.ID == 0 { - t.Fatalf("no id came back: %s", w.Body) - } - if w := call("DELETE", fmt.Sprintf("/api/assets/site?id=%d", site.ID), "", "viewer"); w.Code != http.StatusForbidden { - t.Fatalf("viewer deleting: %d %s", w.Code, w.Body) - } - if w := call("DELETE", fmt.Sprintf("/api/assets/site?id=%d", site.ID), "", "admin"); w.Code != 200 { - t.Fatalf("admin deleting: %d %s", w.Code, w.Body) - } -} - -// Hidden records are the administrator's: nobody else sees them, nor the -// ports and cables that hang off them. -func TestAssetsHidden(t *testing.T) { - s, call := assetServer(t, []string{license.FeatDCIM}, 0, 0, 0) - open, err := s.Assets.SaveDevice(assets.Device{Name: "open"}) - if err != nil { - t.Fatal(err) - } - secret, err := s.Assets.SaveDevice(assets.Device{Name: "secret", Hidden: true}) - if err != nil { - t.Fatal(err) - } - p1, _ := s.Assets.SavePort(assets.Port{DeviceID: open.ID, Name: "a"}) - p2, _ := s.Assets.SavePort(assets.Port{DeviceID: secret.ID, Name: "b"}) - if _, err := s.Assets.SaveLink(assets.Link{APort: p1.ID, BPort: p2.ID}); err != nil { - t.Fatal(err) - } - read := func(user string) (devices []assets.Device, ports []assets.Port, links []assets.Link) { - w := call("GET", "/api/assets", "", user) - if w.Code != 200 { - t.Fatalf("%s: %d %s", user, w.Code, w.Body) - } - var out struct { - Devices []assets.Device `json:"devices"` - Ports []assets.Port `json:"ports"` - Links []assets.Link `json:"links"` - } - if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { - t.Fatal(err) - } - return out.Devices, out.Ports, out.Links - } - if d, p, l := read("admin"); len(d) != 2 || len(p) != 2 || len(l) != 1 { - t.Fatalf("admin sees %d devices, %d ports, %d links", len(d), len(p), len(l)) - } - d, p, l := read("viewer") - if len(d) != 1 || d[0].Name != "open" { - t.Fatalf("viewer sees devices %+v", d) - } - if len(p) != 1 || p[0].ID != p1.ID { - t.Fatalf("viewer sees ports %+v", p) - } - if len(l) != 0 { - t.Fatalf("viewer sees a cable into a hidden device: %+v", l) - } -} - -// The quotas stop a new record, never an edit of one that is already -// there: a quota that blocked a correction would be a trap. -func TestAssetsQuota(t *testing.T) { - _, call := assetServer(t, []string{license.FeatDCIM}, 1, 0, 2) - w := call("POST", "/api/assets/site", `{"name":"Site A"}`, "admin") - if w.Code != 200 { - t.Fatalf("first site: %d %s", w.Code, w.Body) - } - var site assets.Site - json.Unmarshal(w.Body.Bytes(), &site) - if w := call("POST", "/api/assets/site", `{"name":"Site B"}`, "admin"); w.Code != http.StatusForbidden { - t.Fatalf("second site past a quota of 1: %d %s", w.Code, w.Body) - } else if !strings.Contains(w.Body.String(), "quota") { - t.Fatalf("second site: %s", w.Body) - } - // the one that exists can still be corrected - if w := call("POST", "/api/assets/site", fmt.Sprintf(`{"id":%d,"name":"Site A2"}`, site.ID), "admin"); w.Code != 200 { - t.Fatalf("editing inside the quota: %d %s", w.Code, w.Body) - } - // racks are not limited here (0), devices are - if w := call("POST", "/api/assets/rack", fmt.Sprintf(`{"site_id":%d,"name":"R1","units":42}`, site.ID), "admin"); w.Code != 200 { - t.Fatalf("rack with no limit: %d %s", w.Code, w.Body) - } - for i, want := range []int{200, 200, http.StatusForbidden} { - w := call("POST", "/api/assets/device", fmt.Sprintf(`{"name":"d%d"}`, i), "admin") - if w.Code != want { - t.Fatalf("device %d: %d %s", i, w.Code, w.Body) - } - } -} - -// A network is written against a device and comes back with the ledger, -// which is all that is left of what used to be a page of its own. -func TestAssetsSubnets(t *testing.T) { - _, call := assetServer(t, []string{license.FeatDCIM}, 0, 0, 0) - w := call("POST", "/api/assets/device", `{"name":"sw1"}`, "admin") - if w.Code != 200 { - t.Fatalf("device: %d %s", w.Code, w.Body) - } - var dev assets.Device - json.Unmarshal(w.Body.Bytes(), &dev) - if w := call("POST", "/api/assets/subnet", fmt.Sprintf(`{"prefix":"10.9.9.0/29","gateway":"10.9.9.1","device_id":%d}`, dev.ID), "admin"); w.Code != 200 { - t.Fatalf("subnet: %d %s", w.Code, w.Body) - } - if w := call("POST", "/api/assets/subnet", `{"prefix":"10.9.9.0/29","device_id":999}`, "admin"); w.Code == 200 { - t.Fatal("a network was written against a device that does not exist") - } - w = call("GET", "/api/assets", "", "viewer") - if w.Code != 200 { - t.Fatalf("assets: %d %s", w.Code, w.Body) - } - var out struct { - Subnets []assets.Subnet `json:"subnets"` - } - json.Unmarshal(w.Body.Bytes(), &out) - if len(out.Subnets) != 1 || out.Subnets[0].DeviceID != dev.ID || out.Subnets[0].Prefix != "10.9.9.0/29" { - t.Fatalf("subnets: %s", w.Body) - } - // the address assignment this page used to do is gone, not hidden - if w := call("POST", "/api/assets/addr", `{"ip":"10.9.9.2","name":"printer"}`, "admin"); w.Code == 200 { - t.Fatal("an address was assigned") - } - if w := call("POST", "/api/assets/subnet", `{"prefix":"not a network"}`, "admin"); w.Code == 200 { - t.Fatal("rubbish was accepted as a network") - } -} diff --git a/internal/api/notice_test.go b/internal/api/notice_test.go index 32738e5..bb03660 100644 --- a/internal/api/notice_test.go +++ b/internal/api/notice_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "net/http" "net/http/httptest" @@ -140,39 +139,3 @@ func TestNoticeWording(t *testing.T) { t.Error("the notice spans lines; it is used in single-line places too") } } - -// The asset CSV is the one file the API hands out, and it needs no notice -// because an unregistered installation cannot reach it at all: the ledger -// is a registered-only feature. This records that, so that whoever opens -// the gate knows the file leaves unmarked until they mark it. -func TestAssetCSVIsRegisteredOnly(t *testing.T) { - s, _ := assetServer(t, nil, 0, 0, 0) - r := httptest.NewRequest("GET", "/api/assets/csv?kind=devices", nil) - r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, "admin")) - w := httptest.NewRecorder() - s.getAssetCSV(w, r) - if w.Code != 403 { - t.Fatalf("an unregistered installation got %d from the asset CSV, want 403; "+ - "if the gate has gone, the file now needs the notice on it", w.Code) - } -} - -// The asset CSV carries the notice as "# " comment lines when unregistered -// (defence in depth: today the route is registered-only) and none when -// registered. -func TestAssetCSVNotice(t *testing.T) { - s, _ := assetServer(t, []string{license.FeatDCIM}, 0, 0, 0) - r := httptest.NewRequest("GET", "/api/assets/csv?kind=site", nil) - r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, "admin")) - w := httptest.NewRecorder() - s.getAssetCSV(w, r) - if w.Code != 200 { - t.Fatalf("registered export: %d %s", w.Code, w.Body) - } - if strings.HasPrefix(w.Body.String(), "#") { - t.Errorf("a registered installation marked its CSV: %q", w.Body.String()) - } - if got := commentLines((&Server{}).notice(), "# "); !strings.HasPrefix(got, "# ") || !strings.Contains(got, "not registered") { - t.Errorf("unregistered CSV prefix = %q", got) - } -} diff --git a/internal/api/racks.go b/internal/api/racks.go new file mode 100644 index 0000000..8f155b2 --- /dev/null +++ b/internal/api/racks.go @@ -0,0 +1,183 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/netip" + "sort" + "strconv" + "strings" + "time" + + "github.com/githubflyideas/traffic66/internal/store" +) + +// The rooms-and-racks page is a picture, not an asset ledger: for every +// device that sends flows it shows the room and rack it stands in, and for +// its interfaces the carrier and circuit they go out to, so that whoever +// looks at a flow can see which room, rack, device, port and line it came +// through. It is written down once, in the inventory file beside the +// names, and nothing is looked up in it. + +type rackIface struct { + IfIndex uint32 `json:"ifindex"` + Name string `json:"name,omitempty"` + Speed uint64 `json:"speed,omitempty"` + ISP string `json:"isp,omitempty"` + Circuit string `json:"circuit,omitempty"` + Wire uint64 `json:"wire"` // bytes in the last day, busiest first +} + +type rackDevice struct { + Exporter string `json:"exporter"` + Name string `json:"name,omitempty"` + Room string `json:"room,omitempty"` + Rack string `json:"rack,omitempty"` + Seen bool `json:"seen"` // sent flows recently + Ifaces []rackIface `json:"ifaces"` +} + +// getRacks lists the devices that send flows, and those the inventory +// names, with their interfaces: the ones flows were sampled on in the last +// day, and any other that has a carrier written down. +func (s *Server) getRacks(w http.ResponseWriter, r *http.Request) { + devs := map[string]*rackDevice{} + dev := func(exp string) *rackDevice { + d, ok := devs[exp] + if !ok { + d = &rackDevice{Exporter: exp, Ifaces: []rackIface{}} + devs[exp] = d + } + return d + } + if s.Col != nil { + for _, si := range s.Col.Sources() { + dev(si.Exporter).Seen = true + } + } + for _, a := range s.Inv.Devices() { + dev(a.String()) + } + have := map[string]bool{} + if s.Store != nil { + now := time.Now().UTC() + list, err := s.Store.Interfaces(store.Query{From: now.Add(-24 * time.Hour), To: now}) + if err != nil { + fail(w, err) + return + } + for _, f := range list { + if f.Peer || f.IfIndex == 0 { + continue + } + d := dev(f.Exporter) + d.Ifaces = append(d.Ifaces, rackIface{IfIndex: f.IfIndex, Speed: f.Speed, Wire: f.InWire + f.OutWire}) + have[ifKey(f.Exporter, f.IfIndex)] = true + } + } + for k := range s.Inv.Circuits() { + if have[k] { + continue + } + exp, idx, _ := strings.Cut(k, "/") + n, err := strconv.ParseUint(idx, 10, 32) + if err != nil { + continue + } + d := dev(exp) + d.Ifaces = append(d.Ifaces, rackIface{IfIndex: uint32(n)}) + } + out := make([]rackDevice, 0, len(devs)) + for _, d := range devs { + if a, err := netip.ParseAddr(d.Exporter); err == nil { + d.Name = s.Inv.Device(a) + pl := s.Inv.PlaceOf(a) + d.Room, d.Rack = pl.Room, pl.Rack + for i := range d.Ifaces { + it := &d.Ifaces[i] + ifc := s.Inv.Iface(a, it.IfIndex) + it.Name, it.ISP, it.Circuit = ifc.Name, ifc.ISP, ifc.Circuit + if it.Speed == 0 { + it.Speed = ifc.Speed + } + } + } + sort.SliceStable(d.Ifaces, func(i, j int) bool { + if (d.Ifaces[i].ISP != "") != (d.Ifaces[j].ISP != "") { + return d.Ifaces[i].ISP != "" + } + if d.Ifaces[i].Wire != d.Ifaces[j].Wire { + return d.Ifaces[i].Wire > d.Ifaces[j].Wire + } + return d.Ifaces[i].IfIndex < d.Ifaces[j].IfIndex + }) + out = append(out, *d) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Room != b.Room { + return a.Room < b.Room + } + if a.Rack != b.Rack { + return a.Rack < b.Rack + } + return a.Exporter < b.Exporter + }) + writeJSON(w, http.StatusOK, map[string]any{"devices": out, "admin": s.isAdmin(r)}) +} + +// putRackDevice names a device and says which room and rack it is in. +func (s *Server) putRackDevice(w http.ResponseWriter, r *http.Request) { + var in struct { + Exporter, Name, Room, Rack string + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + fail(w, errors.New("bad request")) + return + } + a, err := netip.ParseAddr(in.Exporter) + if err != nil { + fail(w, errors.New("bad exporter address")) + return + } + if err := s.Inv.SetDevice(a, clip(in.Name, 80), clip(in.Room, 60), clip(in.Rack, 60)); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// putRackIface names an interface and says which carrier and circuit it +// goes out to. +func (s *Server) putRackIface(w http.ResponseWriter, r *http.Request) { + var in struct { + Exporter string + IfIndex uint32 + Name string + ISP, Circuit string + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + fail(w, errors.New("bad request")) + return + } + a, err := netip.ParseAddr(in.Exporter) + if err != nil || in.IfIndex == 0 { + fail(w, errors.New("bad interface")) + return + } + if err := s.Inv.SetCircuit(a, in.IfIndex, clip(in.Name, 80), clip(in.ISP, 60), clip(in.Circuit, 80)); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// clip trims a value typed into a form to a sane length. +func clip(s string, n int) string { + s = strings.TrimSpace(s) + if r := []rune(s); len(r) > n { + s = string(r[:n]) + } + return s +} diff --git a/internal/api/racks_test.go b/internal/api/racks_test.go new file mode 100644 index 0000000..6498b7f --- /dev/null +++ b/internal/api/racks_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/githubflyideas/traffic66/internal/enrich" + "github.com/githubflyideas/traffic66/internal/store" +) + +// The page lists every device that sent flows, with the interfaces flows +// were sampled on, and what was written down about where they are. +func TestRacks(t *testing.T) { + st, err := store.Open(store.Options{Dir: t.TempDir(), Threads: 1}) + if err != nil { + t.Fatal(err) + } + defer st.Close() + ts := time.Now().UTC().Add(-time.Hour) + if err := st.AppendRows([]store.Row{ + {TS: ts, Exporter: "10.0.0.1", InIf: 3, OutIf: 0, Wire: 900, Bytes: 900, Pkts: 1, Flows: 1}, + {TS: ts, Exporter: "10.0.0.1", InIf: 7, OutIf: 0, Wire: 100, Bytes: 100, Pkts: 1, Flows: 1}, + }); err != nil { + t.Fatal(err) + } + inv := enrich.NewInventory() + inv.Save("device 10.0.0.9 Spare switch\niface 10.0.0.9 2 Backup line isp=Other_Carrier\n") + s := &Server{Store: st, Inv: inv} + + put := func(handler string, body string) int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/racks/"+handler, strings.NewReader(body)) + if handler == "device" { + s.putRackDevice(w, r) + } else { + s.putRackIface(w, r) + } + return w.Code + } + if c := put("device", `{"exporter":"10.0.0.1","name":"Core router","room":"Room 1","rack":"A03"}`); c != 200 { + t.Fatal(c) + } + if c := put("iface", `{"exporter":"10.0.0.1","ifindex":3,"name":"Uplink","isp":"China Telecom","circuit":"SH-12345"}`); c != 200 { + t.Fatal(c) + } + if c := put("iface", `{"exporter":"not an address","ifindex":3}`); c != 400 { + t.Fatalf("a bad exporter was accepted: %d", c) + } + + w := httptest.NewRecorder() + s.getRacks(w, httptest.NewRequest("GET", "/api/racks", nil)) + var out struct { + Devices []rackDevice `json:"devices"` + } + json.Unmarshal(w.Body.Bytes(), &out) + if len(out.Devices) != 2 { + t.Fatalf("devices %+v", out.Devices) + } + core := out.Devices[1] + if core.Room != "Room 1" { + core = out.Devices[0] + } + if core.Exporter != "10.0.0.1" || core.Name != "Core router" || core.Rack != "A03" { + t.Fatalf("core %+v", core) + } + if len(core.Ifaces) == 0 || core.Ifaces[0].IfIndex != 3 || core.Ifaces[0].ISP != "China Telecom" || core.Ifaces[0].Circuit != "SH-12345" { + t.Fatalf("core interfaces %+v", core.Ifaces) + } + // a device that sent nothing still shows when the inventory names it, + // with the interface that has a carrier written down + var spare rackDevice + for _, d := range out.Devices { + if d.Exporter == "10.0.0.9" { + spare = d + } + } + if spare.Name != "Spare switch" || len(spare.Ifaces) != 1 || spare.Ifaces[0].ISP != "Other Carrier" { + t.Fatalf("spare %+v", spare) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index ba897cb..5b0da53 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -18,7 +18,6 @@ import ( "sync" "time" - "github.com/githubflyideas/traffic66/internal/assets" "github.com/githubflyideas/traffic66/internal/auth" "github.com/githubflyideas/traffic66/internal/collector" "github.com/githubflyideas/traffic66/internal/detect" @@ -43,7 +42,6 @@ type Server struct { SB *sandbox.Sandbox // offline analysis of capture files Offline bool // started on capture files (traffic66 file.pcap): no live data License *license.Checker // trial or licence state for the page footer - Assets *assets.Store // the asset ledger; nil when it could not be opened // AutoLogin is a one-time token: /auto?t= signs the browser in once. AutoLogin string Static fs.FS @@ -164,14 +162,11 @@ func (s *Server) Handler() http.Handler { api("GET /api/inventory", s.getInventory) adminAPI("POST /api/inventory", s.putInventory) adminAPI("POST /api/iface", s.putIface) + api("GET /api/racks", s.getRacks) + adminAPI("POST /api/racks/device", s.putRackDevice) + adminAPI("POST /api/racks/iface", s.putRackIface) api("GET /api/findings", s.data((*Server).findings)) api("POST /api/findings", s.data((*Server).setFindings)) - api("GET /api/assets", s.getAssets) - api("GET /api/assets/csv", s.getAssetCSV) - api("POST /api/assets/csv", s.postAssetCSV) - api("POST /api/assets/paste", s.postPaste) - api("POST /api/assets/{kind}", s.putAsset) - api("DELETE /api/assets/{kind}", s.deleteAsset) api("GET /api/cleanup", s.getCleanup) adminAPI("POST /api/cleanup", s.postCleanup) api("GET /api/cleanup/iface", s.getCleanupIface) diff --git a/internal/assets/assets.go b/internal/assets/assets.go deleted file mode 100644 index d77e115..0000000 --- a/internal/assets/assets.go +++ /dev/null @@ -1,164 +0,0 @@ -// Package assets is the asset ledger: sites, racks, devices, ports and the -// links between them, the circuits that reach them, and the networks -// written down against a device. -// -// It is deliberately a ledger, not a discovery tool. Nothing here is -// polled or guessed: an operator writes it down, or pastes it in from a -// switch's own output. What traffic66 sees on the wire is matched against -// it, never merged into it, so the ledger stays the record of what was -// meant to be there — which is the only thing it is useful for when the -// two disagree. -// -// It keeps its own database file. The flow store is append-only, huge and -// memory-limited, and deleting traffic data is a routine operation; the -// ledger is small, hand-written and must survive every one of those. -package assets - -import ( - "context" - "database/sql" - "database/sql/driver" - "fmt" - "net/url" - "path/filepath" - - duckdb "github.com/duckdb/duckdb-go/v2" -) - -// File is the ledger's database file inside the data directory. -const File = "assets.duckdb" - -// Store is the asset ledger. -type Store struct { - conn *duckdb.Connector - DB *sql.DB -} - -// schema is created on every open. Columns are added by later versions -// with ALTER TABLE in migrate, never by rewriting this list, so that an -// older ledger opens unchanged. -var schema = []string{ - `CREATE SEQUENCE IF NOT EXISTS asset_seq`, - - `CREATE TABLE IF NOT EXISTS sites (id BIGINT PRIMARY KEY, name VARCHAR, address VARCHAR, - note VARCHAR, hidden BOOLEAN, created TIMESTAMP, updated TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS racks (id BIGINT PRIMARY KEY, site_id BIGINT, name VARCHAR, - units INTEGER, note VARCHAR, hidden BOOLEAN, created TIMESTAMP, updated TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS devices (id BIGINT PRIMARY KEY, rack_id BIGINT, name VARCHAR, - role VARCHAR, vendor VARCHAR, model VARCHAR, serial VARCHAR, mgmt_ip VARCHAR, - pos_u INTEGER, height_u INTEGER, tags VARCHAR, note VARCHAR, hidden BOOLEAN, - created TIMESTAMP, updated TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS ports (id BIGINT PRIMARY KEY, device_id BIGINT, name VARCHAR, - ifindex UINTEGER, speed UBIGINT, kind VARCHAR, note VARCHAR, - created TIMESTAMP, updated TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS links (id BIGINT PRIMARY KEY, a_port BIGINT, b_port BIGINT, - note VARCHAR, created TIMESTAMP, updated TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS circuits (id BIGINT PRIMARY KEY, name VARCHAR, carrier VARCHAR, - kind VARCHAR, bandwidth VARCHAR, site_id BIGINT, port_id BIGINT, contract VARCHAR, - starts VARCHAR, ends VARCHAR, tags VARCHAR, note VARCHAR, hidden BOOLEAN, - created TIMESTAMP, updated TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS subnets (id BIGINT PRIMARY KEY, prefix VARCHAR, name VARCHAR, - vlan INTEGER, site_id BIGINT, gateway VARCHAR, tags VARCHAR, note VARCHAR, hidden BOOLEAN, - created TIMESTAMP, updated TIMESTAMP)`, - // Addresses were assigned here once, from a page of their own. Both - // are gone: it was a second place to keep what the system that hands - // out addresses already knows. The table is still created and left - // alone, because a ledger that was filled in before this must not lose - // what somebody typed into it. - `CREATE TABLE IF NOT EXISTS addrs (id BIGINT PRIMARY KEY, subnet_id BIGINT, ip VARCHAR, - name VARCHAR, kind VARCHAR, device_id BIGINT, port_id BIGINT, mac VARCHAR, - tags VARCHAR, note VARCHAR, hidden BOOLEAN, created TIMESTAMP, updated TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS meta (k VARCHAR PRIMARY KEY, v VARCHAR)`, - - `CREATE UNIQUE INDEX IF NOT EXISTS subnets_prefix ON subnets (prefix)`, - `CREATE INDEX IF NOT EXISTS ports_device ON ports (device_id)`, - `CREATE INDEX IF NOT EXISTS devices_rack ON devices (rack_id)`, -} - -// Open creates or opens the ledger in dir. -func Open(dir string) (*Store, error) { - init := func(ex driver.ExecerContext) error { - // The ledger is tiny; it must not take memory from the flow store. - for _, q := range []string{"SET memory_limit='128MB'", "SET threads=1"} { - if _, err := ex.ExecContext(context.Background(), q, nil); err != nil { - return fmt.Errorf("asset ledger setup failed (%s): %w", q, err) - } - } - return nil - } - dsn := filepath.Join(dir, File) + "?" + url.Values{ - "autoinstall_known_extensions": {"false"}, - "autoload_known_extensions": {"false"}, - "home_directory": {dir}, - }.Encode() - c, err := duckdb.NewConnector(dsn, init) - if err != nil { - return nil, fmt.Errorf("asset ledger: %w", err) - } - s := &Store{conn: c, DB: sql.OpenDB(c)} - // one writer: the ledger is written by people, never in bulk, and a - // single connection keeps the sequence and the conflict checks simple - s.DB.SetMaxOpenConns(1) - for _, q := range schema { - if _, err := s.DB.Exec(q); err != nil { - s.Close() - return nil, fmt.Errorf("asset ledger: %s: %w", q, err) - } - } - // Columns added after a release: a ledger written by an older one is - // opened as it is and grown here, never rewritten. - for _, q := range []string{ - `ALTER TABLE subnets ADD COLUMN IF NOT EXISTS device_id BIGINT DEFAULT 0`, - } { - if _, err := s.DB.Exec(q); err != nil { - s.Close() - return nil, fmt.Errorf("asset ledger: %s: %w", q, err) - } - } - return s, nil -} - -// Close releases the database. -func (s *Store) Close() error { - if s.DB != nil { - s.DB.Close() - } - if s.conn != nil { - return s.conn.Close() - } - return nil -} - -// next takes the next identifier. -func (s *Store) next() (int64, error) { - var id int64 - err := s.DB.QueryRow(`SELECT nextval('asset_seq')`).Scan(&id) - return id, err -} - -// Counts is how much of each kind the ledger holds, for the quota checks -// and the page header. -type Counts struct { - Sites int `json:"sites"` - Racks int `json:"racks"` - Devices int `json:"devices"` - Ports int `json:"ports"` - Circuits int `json:"circuits"` - Subnets int `json:"subnets"` -} - -// Counts reports the ledger's size. Hidden records are counted: they are -// kept, so they take up quota. -func (s *Store) Counts() Counts { - var c Counts - for _, p := range []struct { - table string - into *int - }{ - {"sites", &c.Sites}, {"racks", &c.Racks}, {"devices", &c.Devices}, - {"ports", &c.Ports}, {"circuits", &c.Circuits}, {"subnets", &c.Subnets}, - } { - s.DB.QueryRow(`SELECT count(*) FROM ` + p.table).Scan(p.into) - } - return c -} diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go deleted file mode 100644 index 68e70e1..0000000 --- a/internal/assets/assets_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package assets - -import ( - "errors" - "testing" -) - -func open(t *testing.T) *Store { - t.Helper() - s, err := Open(t.TempDir()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { s.Close() }) - return s -} - -// A rack holds devices at the unit they start at, and refuses a device -// that would stand where another already does or hang out of the rack. -func TestRackPlacement(t *testing.T) { - s := open(t) - site, err := s.SaveSite(Site{Name: "Site A"}) - if err != nil { - t.Fatal(err) - } - rack, err := s.SaveRack(Rack{SiteID: site.ID, Name: "R1", Units: 12}) - if err != nil { - t.Fatal(err) - } - if _, err := s.SaveDevice(Device{RackID: rack.ID, Name: "sw1", PosU: 5, HeightU: 2}); err != nil { - t.Fatal(err) - } - for _, c := range []struct { - why string - d Device - reject bool - }{ - {"on top of sw1", Device{RackID: rack.ID, Name: "sw2", PosU: 6, HeightU: 1}, true}, - {"overlapping sw1 from below", Device{RackID: rack.ID, Name: "sw3", PosU: 4, HeightU: 3}, true}, - {"out of the rack", Device{RackID: rack.ID, Name: "sw4", PosU: 12, HeightU: 2}, true}, - {"below sw1", Device{RackID: rack.ID, Name: "sw5", PosU: 3, HeightU: 2}, false}, - {"racked but unplaced", Device{RackID: rack.ID, Name: "sw6"}, false}, - {"not racked", Device{Name: "spare"}, false}, - } { - _, err := s.SaveDevice(c.d) - if c.reject && err == nil { - t.Fatalf("%s: accepted", c.why) - } - if !c.reject && err != nil { - t.Fatalf("%s: %v", c.why, err) - } - } - // the rack cannot shrink below what stands in it - rack.Units = 4 - if _, err := s.SaveRack(rack); err == nil { - t.Fatal("the rack shrank under a device") - } -} - -// A rack with no height given gets the common one, and a rack taller -// than anything real is refused. -func TestRackDefaultHeight(t *testing.T) { - s := open(t) - site, _ := s.SaveSite(Site{Name: "Site A"}) - r, err := s.SaveRack(Rack{SiteID: site.ID, Name: "R1"}) - if err != nil { - t.Fatal(err) - } - if r.Units != DefaultUnits { - t.Fatalf("a rack with no height given is %dU, want %dU", r.Units, DefaultUnits) - } - // and a height that was asked for is kept - r2, err := s.SaveRack(Rack{SiteID: site.ID, Name: "R2", Units: 12}) - if err != nil { - t.Fatal(err) - } - if r2.Units != 12 { - t.Fatalf("a 12U rack is %dU", r2.Units) - } - if _, err := s.SaveRack(Rack{SiteID: site.ID, Name: "R3", Units: MaxUnits + 1}); err == nil { - t.Fatal("a rack taller than MaxUnits was accepted") - } -} - -// Deleting a rack keeps the equipment that was in it; deleting a site is -// refused while anything points at it. -func TestDeleteKeepsEquipment(t *testing.T) { - s := open(t) - site, _ := s.SaveSite(Site{Name: "Site A"}) - rack, _ := s.SaveRack(Rack{SiteID: site.ID, Name: "R1", Units: 42}) - dev, err := s.SaveDevice(Device{RackID: rack.ID, Name: "sw1", PosU: 1}) - if err != nil { - t.Fatal(err) - } - var inUse InUse - if err := s.DeleteSite(site.ID); !errors.As(err, &inUse) { - t.Fatalf("deleting a site with a rack: %v", err) - } - if err := s.DeleteRack(rack.ID); err != nil { - t.Fatal(err) - } - ds, err := s.Devices(DeviceQuery{}) - if err != nil { - t.Fatal(err) - } - if len(ds) != 1 || ds[0].ID != dev.ID { - t.Fatalf("the device went with the rack: %+v", ds) - } - if ds[0].RackID != 0 || ds[0].PosU != 0 { - t.Fatalf("the device still claims a place: %+v", ds[0]) - } - if err := s.DeleteSite(site.ID); err != nil { - t.Fatal(err) - } -} - -// Deleting a device takes its ports and their cables, and lets go of the -// addresses without freeing them. -func TestDeleteDevice(t *testing.T) { - s := open(t) - d1, _ := s.SaveDevice(Device{Name: "sw1"}) - d2, _ := s.SaveDevice(Device{Name: "sw2"}) - p1, err := s.SavePort(Port{DeviceID: d1.ID, Name: "Gi0/1", IfIndex: 1}) - if err != nil { - t.Fatal(err) - } - p2, _ := s.SavePort(Port{DeviceID: d2.ID, Name: "Gi0/1", IfIndex: 1}) - if _, err := s.SaveLink(Link{APort: p1.ID, BPort: p2.ID}); err != nil { - t.Fatal(err) - } - if _, err := s.SaveSubnet(Subnet{Prefix: "10.0.0.0/24", DeviceID: d1.ID, Name: "mgmt"}); err != nil { - t.Fatal(err) - } - if err := s.DeleteDevice(d1.ID); err != nil { - t.Fatal(err) - } - if ls, _ := s.Links(); len(ls) != 0 { - t.Fatalf("the cable outlived the device: %+v", ls) - } - if ps, _ := s.Ports(0); len(ps) != 1 || ps[0].ID != p2.ID { - t.Fatalf("ports left: %+v", ps) - } - // the networks written against it are a note about the device, and - // they go when it does - if ns, _ := s.Subnets(); len(ns) != 0 { - t.Fatalf("the network outlived the device: %+v", ns) - } -} - -// A port carries one cable: a second one replaces the first. -func TestOneCablePerPort(t *testing.T) { - s := open(t) - d, _ := s.SaveDevice(Device{Name: "sw1"}) - var ps []Port - for _, n := range []string{"a", "b", "c"} { - p, err := s.SavePort(Port{DeviceID: d.ID, Name: n}) - if err != nil { - t.Fatal(err) - } - ps = append(ps, p) - } - if _, err := s.SaveLink(Link{APort: ps[0].ID, BPort: ps[1].ID}); err != nil { - t.Fatal(err) - } - if _, err := s.SaveLink(Link{APort: ps[0].ID, BPort: ps[2].ID}); err != nil { - t.Fatal(err) - } - ls, _ := s.Links() - if len(ls) != 1 || ls[0].BPort != ps[2].ID { - t.Fatalf("links: %+v", ls) - } - if _, err := s.SaveLink(Link{APort: ps[0].ID, BPort: ps[0].ID}); err == nil { - t.Fatal("a port was cabled to itself") - } - // a device may not have two ports of the same name - if _, err := s.SavePort(Port{DeviceID: d.ID, Name: "A"}); err == nil { - t.Fatal("a duplicate port name was accepted") - } -} - -// A network is stored with its host bits cleared, its gateway must be -// inside it, and the same network cannot be recorded twice. -func TestSubnetCanonical(t *testing.T) { - s := open(t) - dev, err := s.SaveDevice(Device{Name: "sw1"}) - if err != nil { - t.Fatal(err) - } - sn, err := s.SaveSubnet(Subnet{Prefix: "192.168.5.37/24", Gateway: "192.168.5.1", DeviceID: dev.ID}) - if err != nil { - t.Fatal(err) - } - if sn.Prefix != "192.168.5.0/24" { - t.Fatalf("prefix stored as %q", sn.Prefix) - } - if _, err := s.SaveSubnet(Subnet{Prefix: "192.168.5.0/24"}); err == nil { - t.Fatal("the same network was recorded twice") - } - if _, err := s.SaveSubnet(Subnet{Prefix: "10.0.0.0/8", Gateway: "192.168.5.1"}); err == nil { - t.Fatal("a gateway outside the network was accepted") - } - if _, err := s.SaveSubnet(Subnet{Prefix: "not a network"}); err == nil { - t.Fatal("rubbish was accepted as a network") - } - if _, err := s.SaveSubnet(Subnet{Prefix: "172.16.0.0/16", DeviceID: 999}); err == nil { - t.Fatal("a network was written against a device that does not exist") - } - if sn.DeviceID != dev.ID { - t.Fatalf("the network is not on the device: %+v", sn) - } -} - -// Tags are one tag however they are typed, and filter. -func TestTags(t *testing.T) { - s := open(t) - d, err := s.SaveDevice(Device{Name: "sw1", Tags: []string{" Core ", "core", "PROD,edge"}}) - if err != nil { - t.Fatal(err) - } - if len(d.Tags) != 3 || d.Tags[0] != "core" || d.Tags[1] != "edge" || d.Tags[2] != "prod" { - t.Fatalf("tags: %v", d.Tags) - } - if _, err := s.SaveDevice(Device{Name: "sw2", Tags: []string{"edge"}}); err != nil { - t.Fatal(err) - } - got, err := s.Devices(DeviceQuery{Tag: "CORE"}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || got[0].Name != "sw1" { - t.Fatalf("filtered by tag: %+v", got) - } -} - -// Editing something that is gone says so instead of succeeding quietly. -func TestMissing(t *testing.T) { - s := open(t) - if _, err := s.SaveSite(Site{ID: 999, Name: "ghost"}); !errors.Is(err, ErrNotFound) { - t.Fatalf("editing a missing site: %v", err) - } - if err := s.DeleteDevice(999); !errors.Is(err, ErrNotFound) { - t.Fatalf("deleting a missing device: %v", err) - } - if _, err := s.SaveDevice(Device{Name: "x", RackID: 999}); err == nil { - t.Fatal("a device went into a rack that does not exist") - } -} - -// MAC addresses are read in every form a switch prints them, and anything -// else is refused rather than stored as a fact. -func TestCanonMAC(t *testing.T) { - for _, in := range []string{"aa:bb:cc:dd:ee:ff", "AA-BB-CC-DD-EE-FF", "aabb.ccdd.eeff", "AABBCCDDEEFF"} { - if got := CanonMAC(in); got != "aa:bb:cc:dd:ee:ff" { - t.Fatalf("%q read as %q", in, got) - } - } - for _, in := range []string{"", "aa:bb:cc:dd:ee", "zz:bb:cc:dd:ee:ff", "10.0.0.1", "aabbccddeeff00"} { - if got := CanonMAC(in); got != "" { - t.Fatalf("%q read as %q", in, got) - } - } -} diff --git a/internal/assets/crud.go b/internal/assets/crud.go deleted file mode 100644 index ca287c5..0000000 --- a/internal/assets/crud.go +++ /dev/null @@ -1,554 +0,0 @@ -package assets - -import ( - "database/sql" - "errors" - "fmt" - "strings" -) - -// MaxUnits is the tallest rack the ledger will record. Racks taller than -// this are a typo, and the rack diagram has to draw whatever is stored. -const MaxUnits = 60 - -// DefaultUnits is the height a rack gets when none is given. Most -// cabinets in a room are the same height and 46U is the common one; any -// other height is a number away. -const DefaultUnits = 46 - -// ErrNotFound is returned when an identifier names nothing. -var ErrNotFound = errors.New("no such record") - -// InUse says a record cannot be deleted because others point at it, and -// names them, so the message can say what to do first. -type InUse struct{ What string } - -func (e InUse) Error() string { return "still in use by " + e.What } - -// ---------------------------------------------------------------- sites - -// SaveSite inserts a site (ID 0) or updates one. -func (s *Store) SaveSite(v Site) (Site, error) { - v.Name = trim(v.Name, 120) - v.Address, v.Note = trim(v.Address, 300), trim(v.Note, 2000) - if v.Name == "" { - return v, errors.New("the site needs a name") - } - if v.ID == 0 { - id, err := s.next() - if err != nil { - return v, err - } - v.ID = id - _, err = s.DB.Exec(`INSERT INTO sites VALUES (?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.Name, v.Address, v.Note, v.Hidden, now(), now()) - return v, err - } - r, err := s.DB.Exec(`UPDATE sites SET name = ?, address = ?, note = ?, hidden = ?, updated = ? WHERE id = ?`, - v.Name, v.Address, v.Note, v.Hidden, now(), v.ID) - return v, affected(r, err) -} - -// Sites lists sites by name. -func (s *Store) Sites() ([]Site, error) { - rows, err := s.DB.Query(`SELECT id, name, address, note, hidden FROM sites ORDER BY name`) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Site{} - for rows.Next() { - var v Site - if err := rows.Scan(&v.ID, &v.Name, &v.Address, &v.Note, &v.Hidden); err != nil { - return nil, err - } - out = append(out, v) - } - return out, rows.Err() -} - -// DeleteSite removes a site that nothing points at. -func (s *Store) DeleteSite(id int64) error { - for _, p := range []struct{ table, what string }{ - {"racks", "racks"}, {"subnets", "subnets"}, {"circuits", "circuits"}, - } { - var n int - s.DB.QueryRow(`SELECT count(*) FROM `+p.table+` WHERE site_id = ?`, id).Scan(&n) - if n > 0 { - return InUse{fmt.Sprintf("%d %s", n, p.what)} - } - } - r, err := s.DB.Exec(`DELETE FROM sites WHERE id = ?`, id) - return affected(r, err) -} - -// ---------------------------------------------------------------- racks - -// SaveRack inserts or updates a rack. -func (s *Store) SaveRack(v Rack) (Rack, error) { - v.Name, v.Note = trim(v.Name, 120), trim(v.Note, 2000) - if v.Name == "" { - return v, errors.New("the rack needs a name") - } - if v.Units <= 0 { - v.Units = DefaultUnits - } - if v.Units > MaxUnits { - return v, fmt.Errorf("a rack of %d units is more than the %d this records", v.Units, MaxUnits) - } - if err := s.exists("sites", v.SiteID, "site"); err != nil { - return v, err - } - // a rack cannot shrink below the devices already placed in it - if v.ID != 0 { - var top sql.NullInt64 - s.DB.QueryRow(`SELECT max(pos_u + greatest(height_u, 1) - 1) FROM devices WHERE rack_id = ? AND pos_u > 0`, v.ID).Scan(&top) - if top.Valid && int(top.Int64) > v.Units { - return v, fmt.Errorf("a device reaches unit %d; empty it before making the rack %d units", top.Int64, v.Units) - } - } - if v.ID == 0 { - id, err := s.next() - if err != nil { - return v, err - } - v.ID = id - _, err = s.DB.Exec(`INSERT INTO racks VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.SiteID, v.Name, v.Units, v.Note, v.Hidden, now(), now()) - return v, err - } - r, err := s.DB.Exec(`UPDATE racks SET site_id = ?, name = ?, units = ?, note = ?, hidden = ?, updated = ? WHERE id = ?`, - v.SiteID, v.Name, v.Units, v.Note, v.Hidden, now(), v.ID) - return v, affected(r, err) -} - -// Racks lists racks, of one site when siteID is not 0. -func (s *Store) Racks(siteID int64) ([]Rack, error) { - q := `SELECT id, site_id, name, units, note, hidden FROM racks` - var args []any - if siteID != 0 { - q += ` WHERE site_id = ?` - args = append(args, siteID) - } - rows, err := s.DB.Query(q+` ORDER BY name`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Rack{} - for rows.Next() { - var v Rack - if err := rows.Scan(&v.ID, &v.SiteID, &v.Name, &v.Units, &v.Note, &v.Hidden); err != nil { - return nil, err - } - out = append(out, v) - } - return out, rows.Err() -} - -// DeleteRack removes a rack. The devices in it are kept and unracked: -// equipment outlives the cabinet it stood in, and deleting a rack must -// never quietly delete the record of what was in it. -func (s *Store) DeleteRack(id int64) error { - if _, err := s.DB.Exec(`UPDATE devices SET rack_id = 0, pos_u = 0, updated = ? WHERE rack_id = ?`, now(), id); err != nil { - return err - } - r, err := s.DB.Exec(`DELETE FROM racks WHERE id = ?`, id) - return affected(r, err) -} - -// -------------------------------------------------------------- devices - -// SaveDevice inserts or updates a device, checking that it fits where it -// is placed and that nothing else is already there. -func (s *Store) SaveDevice(v Device) (Device, error) { - v.Name = trim(v.Name, 120) - v.Role, v.Vendor, v.Model = trim(v.Role, 60), trim(v.Vendor, 60), trim(v.Model, 60) - v.Serial, v.Note = trim(v.Serial, 80), trim(v.Note, 2000) - if v.Name == "" { - return v, errors.New("the device needs a name") - } - if v.MgmtIP != "" { - ip, err := canonIP(v.MgmtIP) - if err != nil { - return v, err - } - v.MgmtIP = ip - } - if v.HeightU <= 0 { - v.HeightU = 1 - } - if err := s.exists("racks", v.RackID, "rack"); err != nil { - return v, err - } - if v.RackID == 0 { - v.PosU = 0 // a position means nothing without a rack - } - if err := s.fits(v); err != nil { - return v, err - } - tags := tagString(v.Tags) - v.Tags = tagList(tags) - if v.ID == 0 { - id, err := s.next() - if err != nil { - return v, err - } - v.ID = id - _, err = s.DB.Exec(`INSERT INTO devices VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.RackID, v.Name, v.Role, v.Vendor, v.Model, v.Serial, v.MgmtIP, - v.PosU, v.HeightU, tags, v.Note, v.Hidden, now(), now()) - return v, err - } - r, err := s.DB.Exec(`UPDATE devices SET rack_id = ?, name = ?, role = ?, vendor = ?, model = ?, - serial = ?, mgmt_ip = ?, pos_u = ?, height_u = ?, tags = ?, note = ?, hidden = ?, updated = ? WHERE id = ?`, - v.RackID, v.Name, v.Role, v.Vendor, v.Model, v.Serial, v.MgmtIP, - v.PosU, v.HeightU, tags, v.Note, v.Hidden, now(), v.ID) - return v, affected(r, err) -} - -// fits checks a device's place in its rack: inside the rack, and not on -// top of another device. -func (s *Store) fits(v Device) error { - if v.RackID == 0 || v.PosU == 0 { - return nil - } - var units int - if err := s.DB.QueryRow(`SELECT units FROM racks WHERE id = ?`, v.RackID).Scan(&units); err != nil { - return ErrNotFound - } - if v.PosU < 1 || v.PosU+v.HeightU-1 > units { - return fmt.Errorf("unit %d to %d is outside a rack of %d units", v.PosU, v.PosU+v.HeightU-1, units) - } - var name string - err := s.DB.QueryRow(`SELECT name FROM devices WHERE rack_id = ? AND id <> ? AND pos_u > 0 - AND pos_u <= ? AND pos_u + greatest(height_u, 1) - 1 >= ? LIMIT 1`, - v.RackID, v.ID, v.PosU+v.HeightU-1, v.PosU).Scan(&name) - if err == nil { - return fmt.Errorf("%s is already in units %d to %d", name, v.PosU, v.PosU+v.HeightU-1) - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - return nil -} - -// DeviceQuery selects devices. -type DeviceQuery struct { - RackID int64 - SiteID int64 - Tag string - Search string -} - -// Devices lists devices, newest placement first by rack then unit. -func (s *Store) Devices(q DeviceQuery) ([]Device, error) { - conds, args := []string{"true"}, []any{} - if q.RackID != 0 { - conds, args = append(conds, "d.rack_id = ?"), append(args, q.RackID) - } - if q.SiteID != 0 { - conds, args = append(conds, "d.rack_id IN (SELECT id FROM racks WHERE site_id = ?)"), append(args, q.SiteID) - } - if t := strings.ToLower(strings.TrimSpace(q.Tag)); t != "" { - conds, args = append(conds, "list_contains(str_split(d.tags, ','), ?)"), append(args, t) - } - if t := strings.TrimSpace(q.Search); t != "" { - conds = append(conds, `(d.name ILIKE ? OR d.model ILIKE ? OR d.serial ILIKE ? OR d.mgmt_ip ILIKE ?)`) - like := "%" + t + "%" - args = append(args, like, like, like, like) - } - rows, err := s.DB.Query(`SELECT d.id, d.rack_id, d.name, d.role, d.vendor, d.model, d.serial, - d.mgmt_ip, d.pos_u, d.height_u, d.tags, d.note, d.hidden FROM devices d - WHERE `+strings.Join(conds, " AND ")+` ORDER BY d.name`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Device{} - for rows.Next() { - var v Device - var tags string - if err := rows.Scan(&v.ID, &v.RackID, &v.Name, &v.Role, &v.Vendor, &v.Model, &v.Serial, - &v.MgmtIP, &v.PosU, &v.HeightU, &tags, &v.Note, &v.Hidden); err != nil { - return nil, err - } - v.Tags = tagList(tags) - out = append(out, v) - } - return out, rows.Err() -} - -// DeleteDevice removes a device with its ports and their links. Addresses -// assigned to it are kept and unassigned: an address stays allocated when -// the box behind it is replaced, which is exactly when it must not be -// quietly freed for someone else. -func (s *Store) DeleteDevice(id int64) error { - if _, err := s.DB.Exec(`DELETE FROM links WHERE a_port IN (SELECT id FROM ports WHERE device_id = ?) - OR b_port IN (SELECT id FROM ports WHERE device_id = ?)`, id, id); err != nil { - return err - } - // The networks written against a device are a note about that device, - // so they go when it does. (Cables and ports go too; a circuit stays, - // because it was bought from a carrier and outlives the box it lands - // on, and only loses the port it landed on.) - if _, err := s.DB.Exec(`DELETE FROM subnets WHERE device_id = ?`, id); err != nil { - return err - } - if _, err := s.DB.Exec(`UPDATE circuits SET port_id = 0, updated = ? WHERE port_id IN (SELECT id FROM ports WHERE device_id = ?)`, now(), id); err != nil { - return err - } - if _, err := s.DB.Exec(`DELETE FROM ports WHERE device_id = ?`, id); err != nil { - return err - } - r, err := s.DB.Exec(`DELETE FROM devices WHERE id = ?`, id) - return affected(r, err) -} - -// ---------------------------------------------------------------- ports - -// SavePort inserts or updates a port of a device. -func (s *Store) SavePort(v Port) (Port, error) { - v.Name, v.Kind, v.Note = trim(v.Name, 80), trim(v.Kind, 40), trim(v.Note, 500) - if v.Name == "" { - return v, errors.New("the port needs a name") - } - if v.DeviceID == 0 { - return v, errors.New("the port needs a device") - } - if err := s.exists("devices", v.DeviceID, "device"); err != nil { - return v, err - } - var other int64 - err := s.DB.QueryRow(`SELECT id FROM ports WHERE device_id = ? AND lower(name) = lower(?) AND id <> ?`, - v.DeviceID, v.Name, v.ID).Scan(&other) - if err == nil { - return v, fmt.Errorf("the device already has a port called %s", v.Name) - } - if !errors.Is(err, sql.ErrNoRows) { - return v, err - } - if v.ID == 0 { - id, err := s.next() - if err != nil { - return v, err - } - v.ID = id - _, err = s.DB.Exec(`INSERT INTO ports VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.DeviceID, v.Name, v.IfIndex, v.Speed, v.Kind, v.Note, now(), now()) - return v, err - } - r, err := s.DB.Exec(`UPDATE ports SET device_id = ?, name = ?, ifindex = ?, speed = ?, kind = ?, note = ?, updated = ? WHERE id = ?`, - v.DeviceID, v.Name, v.IfIndex, v.Speed, v.Kind, v.Note, now(), v.ID) - return v, affected(r, err) -} - -// Ports lists a device's ports, or all of them when deviceID is 0. -func (s *Store) Ports(deviceID int64) ([]Port, error) { - q := `SELECT id, device_id, name, ifindex, speed, kind, note FROM ports` - var args []any - if deviceID != 0 { - q += ` WHERE device_id = ?` - args = append(args, deviceID) - } - rows, err := s.DB.Query(q+` ORDER BY device_id, ifindex, name`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Port{} - for rows.Next() { - var v Port - if err := rows.Scan(&v.ID, &v.DeviceID, &v.Name, &v.IfIndex, &v.Speed, &v.Kind, &v.Note); err != nil { - return nil, err - } - out = append(out, v) - } - return out, rows.Err() -} - -// DeletePort removes a port with its link, and lets go of what pointed at it. -func (s *Store) DeletePort(id int64) error { - if _, err := s.DB.Exec(`DELETE FROM links WHERE a_port = ? OR b_port = ?`, id, id); err != nil { - return err - } - if _, err := s.DB.Exec(`UPDATE addrs SET port_id = 0, updated = ? WHERE port_id = ?`, now(), id); err != nil { - return err - } - if _, err := s.DB.Exec(`UPDATE circuits SET port_id = 0, updated = ? WHERE port_id = ?`, now(), id); err != nil { - return err - } - r, err := s.DB.Exec(`DELETE FROM ports WHERE id = ?`, id) - return affected(r, err) -} - -// ---------------------------------------------------------------- links - -// SaveLink records a cable between two ports. A port has one cable: the -// second one replaces the first, because that is what happened in the room. -func (s *Store) SaveLink(v Link) (Link, error) { - v.Note = trim(v.Note, 500) - if v.APort == 0 || v.BPort == 0 || v.APort == v.BPort { - return v, errors.New("a link needs two different ports") - } - for _, p := range []int64{v.APort, v.BPort} { - if err := s.exists("ports", p, "port"); err != nil { - return v, err - } - } - if _, err := s.DB.Exec(`DELETE FROM links WHERE id <> ? AND (a_port IN (?, ?) OR b_port IN (?, ?))`, - v.ID, v.APort, v.BPort, v.APort, v.BPort); err != nil { - return v, err - } - if v.ID == 0 { - id, err := s.next() - if err != nil { - return v, err - } - v.ID = id - _, err = s.DB.Exec(`INSERT INTO links VALUES (?, ?, ?, ?, ?, ?)`, v.ID, v.APort, v.BPort, v.Note, now(), now()) - return v, err - } - r, err := s.DB.Exec(`UPDATE links SET a_port = ?, b_port = ?, note = ?, updated = ? WHERE id = ?`, - v.APort, v.BPort, v.Note, now(), v.ID) - return v, affected(r, err) -} - -// Links lists every cable. -func (s *Store) Links() ([]Link, error) { - rows, err := s.DB.Query(`SELECT id, a_port, b_port, note FROM links ORDER BY id`) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Link{} - for rows.Next() { - var v Link - if err := rows.Scan(&v.ID, &v.APort, &v.BPort, &v.Note); err != nil { - return nil, err - } - out = append(out, v) - } - return out, rows.Err() -} - -// DeleteLink removes a cable. -func (s *Store) DeleteLink(id int64) error { - r, err := s.DB.Exec(`DELETE FROM links WHERE id = ?`, id) - return affected(r, err) -} - -// ------------------------------------------------------------- circuits - -// SaveCircuit inserts or updates a carrier line. -func (s *Store) SaveCircuit(v Circuit) (Circuit, error) { - v.Name, v.Carrier = trim(v.Name, 120), trim(v.Carrier, 120) - v.Kind, v.Bandwidth = trim(v.Kind, 60), trim(v.Bandwidth, 40) - v.Contract, v.Note = trim(v.Contract, 120), trim(v.Note, 2000) - if v.Name == "" { - return v, errors.New("the circuit needs a name") - } - for _, d := range []*string{&v.Starts, &v.Ends} { - if *d = trim(*d, 10); *d != "" && !isDay(*d) { - return v, fmt.Errorf("%q is not a date (expected YYYY-MM-DD)", *d) - } - } - if err := s.exists("sites", v.SiteID, "site"); err != nil { - return v, err - } - if err := s.exists("ports", v.PortID, "port"); err != nil { - return v, err - } - tags := tagString(v.Tags) - v.Tags = tagList(tags) - if v.ID == 0 { - id, err := s.next() - if err != nil { - return v, err - } - v.ID = id - _, err = s.DB.Exec(`INSERT INTO circuits VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.Name, v.Carrier, v.Kind, v.Bandwidth, v.SiteID, v.PortID, - v.Contract, v.Starts, v.Ends, tags, v.Note, v.Hidden, now(), now()) - return v, err - } - r, err := s.DB.Exec(`UPDATE circuits SET name = ?, carrier = ?, kind = ?, bandwidth = ?, site_id = ?, - port_id = ?, contract = ?, starts = ?, ends = ?, tags = ?, note = ?, hidden = ?, updated = ? WHERE id = ?`, - v.Name, v.Carrier, v.Kind, v.Bandwidth, v.SiteID, v.PortID, - v.Contract, v.Starts, v.Ends, tags, v.Note, v.Hidden, now(), v.ID) - return v, affected(r, err) -} - -// Circuits lists carrier lines by name. -func (s *Store) Circuits() ([]Circuit, error) { - rows, err := s.DB.Query(`SELECT id, name, carrier, kind, bandwidth, site_id, port_id, - contract, starts, ends, tags, note, hidden FROM circuits ORDER BY name`) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Circuit{} - for rows.Next() { - var v Circuit - var tags string - if err := rows.Scan(&v.ID, &v.Name, &v.Carrier, &v.Kind, &v.Bandwidth, &v.SiteID, &v.PortID, - &v.Contract, &v.Starts, &v.Ends, &tags, &v.Note, &v.Hidden); err != nil { - return nil, err - } - v.Tags = tagList(tags) - out = append(out, v) - } - return out, rows.Err() -} - -// DeleteCircuit removes a carrier line. -func (s *Store) DeleteCircuit(id int64) error { - r, err := s.DB.Exec(`DELETE FROM circuits WHERE id = ?`, id) - return affected(r, err) -} - -// ------------------------------------------------------------- helpers - -// exists checks an optional reference: 0 means none, anything else must be -// a record of that table. -func (s *Store) exists(table string, id int64, what string) error { - if id == 0 { - return nil - } - var n int - if err := s.DB.QueryRow(`SELECT count(*) FROM `+table+` WHERE id = ?`, id).Scan(&n); err != nil { - return err - } - if n == 0 { - return fmt.Errorf("no such %s", what) - } - return nil -} - -// affected turns an update that matched nothing into ErrNotFound, so that -// editing a record someone else deleted says so instead of succeeding. -func affected(r sql.Result, err error) error { - if err != nil { - return err - } - if n, err := r.RowsAffected(); err == nil && n == 0 { - return ErrNotFound - } - return nil -} - -// isDay reports whether s is a YYYY-MM-DD date. -func isDay(s string) bool { - if len(s) != 10 || s[4] != '-' || s[7] != '-' { - return false - } - for i, c := range s { - if i == 4 || i == 7 { - continue - } - if c < '0' || c > '9' { - return false - } - } - return true -} diff --git a/internal/assets/csv.go b/internal/assets/csv.go deleted file mode 100644 index 143fdb6..0000000 --- a/internal/assets/csv.go +++ /dev/null @@ -1,374 +0,0 @@ -package assets - -import ( - "encoding/csv" - "fmt" - "io" - "strconv" - "strings" -) - -// The ledger in and out as CSV, because an asset list that cannot leave -// the tool is a hostage, and because most ledgers start life in a -// spreadsheet somebody already keeps. -// -// Export and import are the same columns in the same order, so a file -// exported, edited and imported again updates what it came from rather -// than duplicating it: the id column is what ties a row to its record, -// and a row with an empty id is a new one. - -// csvColumns are the columns of each kind, in order. The first is always -// the id. -var csvColumns = map[string][]string{ - "site": {"id", "name", "address", "note", "hidden"}, - "rack": {"id", "name", "site", "units", "note", "hidden"}, - "device": {"id", "name", "role", "vendor", "model", "serial", "mgmt_ip", "rack", "pos_u", "height_u", "tags", "note", "hidden"}, - "port": {"id", "device", "name", "ifindex", "speed", "kind", "note"}, - "circuit": {"id", "name", "carrier", "kind", "bandwidth", "site", "port", "contract", "starts", "ends", "tags", "note", "hidden"}, - "subnet": {"id", "prefix", "name", "vlan", "device", "gateway", "tags", "note", "hidden"}, -} - -// CSVKinds is every kind that can be exported, for the API and the pages. -var CSVKinds = []string{"site", "rack", "device", "port", "circuit", "subnet"} - -// names resolves the references a CSV carries. A site, rack, device or -// port is written out by name rather than by number, because a number -// means nothing in a spreadsheet and a name is what the operator already -// knows; on the way back in, a name that is not there yet is not invented. -type names struct { - site, rack, device map[int64]string - port map[int64]string -} - -func (s *Store) names() (names, error) { - n := names{site: map[int64]string{}, rack: map[int64]string{}, device: map[int64]string{}, port: map[int64]string{}} - sites, err := s.Sites() - if err != nil { - return n, err - } - for _, v := range sites { - n.site[v.ID] = v.Name - } - racks, err := s.Racks(0) - if err != nil { - return n, err - } - for _, v := range racks { - n.rack[v.ID] = v.Name - } - devs, err := s.Devices(DeviceQuery{}) - if err != nil { - return n, err - } - for _, v := range devs { - n.device[v.ID] = v.Name - } - ports, err := s.Ports(0) - if err != nil { - return n, err - } - for _, v := range ports { - n.port[v.ID] = n.device[v.DeviceID] + " " + v.Name - } - return n, nil -} - -func b(v bool) string { - if v { - return "yes" - } - return "" -} - -func num(v int) string { - if v == 0 { - return "" - } - return strconv.Itoa(v) -} - -func id(v int64) string { - if v == 0 { - return "" - } - return strconv.FormatInt(v, 10) -} - -// safeCell keeps a spreadsheet from reading a cell as a formula (CSV -// injection): a value starting with = + - @ tab or CR gets a leading '. -// unsafeCell undoes it on import so an export round-trips unchanged. -func safeCell(v string) string { - if v != "" && strings.ContainsRune("=+-@\t\r", rune(v[0])) { - return "'" + v - } - return v -} - -func unsafeCell(v string) string { - if len(v) > 1 && v[0] == '\'' && strings.ContainsRune("=+-@\t\r", rune(v[1])) { - return v[1:] - } - return v -} - -// ExportCSV writes one kind of record. -func (s *Store) ExportCSV(w io.Writer, kind string) error { - cols, ok := csvColumns[kind] - if !ok { - return fmt.Errorf("%q is not a kind of asset", kind) - } - n, err := s.names() - if err != nil { - return err - } - c := csv.NewWriter(w) - if err := c.Write(cols); err != nil { - return err - } - var rows [][]string - switch kind { - case "site": - vs, err := s.Sites() - if err != nil { - return err - } - for _, v := range vs { - rows = append(rows, []string{id(v.ID), v.Name, v.Address, v.Note, b(v.Hidden)}) - } - case "rack": - vs, err := s.Racks(0) - if err != nil { - return err - } - for _, v := range vs { - rows = append(rows, []string{id(v.ID), v.Name, n.site[v.SiteID], num(v.Units), v.Note, b(v.Hidden)}) - } - case "device": - vs, err := s.Devices(DeviceQuery{}) - if err != nil { - return err - } - for _, v := range vs { - rows = append(rows, []string{id(v.ID), v.Name, v.Role, v.Vendor, v.Model, v.Serial, v.MgmtIP, - n.rack[v.RackID], num(v.PosU), num(v.HeightU), strings.Join(v.Tags, " "), v.Note, b(v.Hidden)}) - } - case "port": - vs, err := s.Ports(0) - if err != nil { - return err - } - for _, v := range vs { - rows = append(rows, []string{id(v.ID), n.device[v.DeviceID], v.Name, num(int(v.IfIndex)), - strconv.FormatUint(v.Speed, 10), v.Kind, v.Note}) - } - case "circuit": - vs, err := s.Circuits() - if err != nil { - return err - } - for _, v := range vs { - rows = append(rows, []string{id(v.ID), v.Name, v.Carrier, v.Kind, v.Bandwidth, n.site[v.SiteID], - n.port[v.PortID], v.Contract, v.Starts, v.Ends, strings.Join(v.Tags, " "), v.Note, b(v.Hidden)}) - } - case "subnet": - vs, err := s.Subnets() - if err != nil { - return err - } - for _, v := range vs { - rows = append(rows, []string{id(v.ID), v.Prefix, v.Name, num(v.VLAN), n.device[v.DeviceID], - v.Gateway, strings.Join(v.Tags, " "), v.Note, b(v.Hidden)}) - } - } - for _, r := range rows { - for i := range r { - r[i] = safeCell(r[i]) - } - if err := c.Write(r); err != nil { - return err - } - } - c.Flush() - return c.Error() -} - -// lookup finds a record by name for an imported reference. -func lookup(m map[int64]string, name string) int64 { - name = strings.TrimSpace(name) - if name == "" { - return 0 - } - for k, v := range m { - if strings.EqualFold(v, name) { - return k - } - } - return -1 // named, but no such record -} - -func yes(s string) bool { - switch strings.ToLower(strings.TrimSpace(s)) { - case "yes", "y", "true", "1": - return true - } - return false -} - -func intOf(s string) int { - n, _ := strconv.Atoi(strings.TrimSpace(s)) - return n -} - -// bom is the byte order mark a spreadsheet puts at the start of a file it -// saved as UTF-8; it would otherwise become part of the first column name. -const bom = "\uFEFF" - -// ImportCSV reads one kind of record. A row carrying an id updates that -// record; a row without one adds. Rows are read in order and a row that -// cannot be read stops nothing: it is reported with its line number, and -// the rest of the file still lands, because a spreadsheet with one bad -// line in it is the normal case, not an exception. -func (s *Store) ImportCSV(r io.Reader, kind string, apply bool) (Plan, error) { - cols, ok := csvColumns[kind] - if !ok { - return Plan{}, fmt.Errorf("%q is not a kind of asset", kind) - } - n, err := s.names() - if err != nil { - return Plan{}, err - } - rd := csv.NewReader(r) - rd.FieldsPerRecord = -1 - rd.TrimLeadingSpace = true - rd.Comment = '#' // the evaluation notice an unregistered export starts with - head, err := rd.Read() - if err != nil { - return Plan{}, fmt.Errorf("the file has no header row") - } - at := map[string]int{} - for i, h := range head { - at[strings.ToLower(strings.TrimSpace(strings.TrimPrefix(h, bom)))] = i - } - for _, want := range cols[1:2] { // the first real column is required - if _, ok := at[want]; !ok { - return Plan{}, fmt.Errorf("the file has no %q column; export this kind first to see the columns", want) - } - } - p := Plan{Format: "csv:" + kind, Applied: apply} - line := 1 - for { - rec, err := rd.Read() - if err == io.EOF { - break - } - line++ - if err != nil { - p.Skipped = append(p.Skipped, fmt.Sprintf("line %d: %v", line, err)) - continue - } - get := func(name string) string { - i, ok := at[name] - if !ok || i >= len(rec) { - return "" - } - return unsafeCell(strings.TrimSpace(rec[i])) - } - if strings.TrimSpace(strings.Join(rec, "")) == "" { - continue - } - rid, _ := strconv.ParseInt(get("id"), 10, 64) - act := "add" - if rid != 0 { - act = "update" - } - ref := func(m map[int64]string, col string) (int64, error) { - v := lookup(m, get(col)) - if v < 0 { - return 0, fmt.Errorf("line %d: no %s called %q", line, col, get(col)) - } - return v, nil - } - var what string - var werr error - switch kind { - case "site": - v := Site{ID: rid, Name: get("name"), Address: get("address"), Note: get("note"), Hidden: yes(get("hidden"))} - what = v.Name - if apply { - _, werr = s.SaveSite(v) - } - case "rack": - site, err := ref(n.site, "site") - if err != nil { - p.add("rack", act, get("name"), err) - continue - } - v := Rack{ID: rid, Name: get("name"), SiteID: site, Units: intOf(get("units")), Note: get("note"), Hidden: yes(get("hidden"))} - what = v.Name - if apply { - _, werr = s.SaveRack(v) - } - case "device": - rack, err := ref(n.rack, "rack") - if err != nil { - p.add("device", act, get("name"), err) - continue - } - v := Device{ID: rid, Name: get("name"), Role: get("role"), Vendor: get("vendor"), Model: get("model"), - Serial: get("serial"), MgmtIP: get("mgmt_ip"), RackID: rack, PosU: intOf(get("pos_u")), - HeightU: intOf(get("height_u")), Tags: strings.Fields(get("tags")), Note: get("note"), Hidden: yes(get("hidden"))} - what = v.Name - if apply { - _, werr = s.SaveDevice(v) - } - case "port": - dev, err := ref(n.device, "device") - if err != nil { - p.add("port", act, get("name"), err) - continue - } - sp, _ := strconv.ParseUint(get("speed"), 10, 64) - v := Port{ID: rid, DeviceID: dev, Name: get("name"), IfIndex: uint32(intOf(get("ifindex"))), - Speed: sp, Kind: get("kind"), Note: get("note")} - what = get("device") + " " + v.Name - if apply { - _, werr = s.SavePort(v) - } - case "circuit": - site, err := ref(n.site, "site") - if err != nil { - p.add("circuit", act, get("name"), err) - continue - } - port, err := ref(n.port, "port") - if err != nil { - p.add("circuit", act, get("name"), err) - continue - } - v := Circuit{ID: rid, Name: get("name"), Carrier: get("carrier"), Kind: get("kind"), Bandwidth: get("bandwidth"), - SiteID: site, PortID: port, Contract: get("contract"), Starts: get("starts"), Ends: get("ends"), - Tags: strings.Fields(get("tags")), Note: get("note"), Hidden: yes(get("hidden"))} - what = v.Name - if apply { - _, werr = s.SaveCircuit(v) - } - case "subnet": - dev, err := ref(n.device, "device") - if err != nil { - p.add("subnet", act, get("prefix"), err) - continue - } - v := Subnet{ID: rid, Prefix: get("prefix"), Name: get("name"), VLAN: intOf(get("vlan")), DeviceID: dev, - Gateway: get("gateway"), Tags: strings.Fields(get("tags")), Note: get("note"), Hidden: yes(get("hidden"))} - what = v.Prefix - if apply { - _, werr = s.SaveSubnet(v) - } - } - if werr != nil { - werr = fmt.Errorf("line %d: %v", line, werr) - } - p.add(kind, act, what, werr) - } - return p, nil -} diff --git a/internal/assets/csv_test.go b/internal/assets/csv_test.go deleted file mode 100644 index 3c65e35..0000000 --- a/internal/assets/csv_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package assets - -import ( - "strings" - "testing" -) - -// A ledger exported, edited and imported again updates what it came from -// rather than duplicating it: the id column ties a row to its record. -func TestCSVRoundTrip(t *testing.T) { - s := open(t) - site, _ := s.SaveSite(Site{Name: "Tokyo DC", Address: "Koto-ku"}) - rack, _ := s.SaveRack(Rack{SiteID: site.ID, Name: "A-01", Units: 42}) - if _, err := s.SaveDevice(Device{RackID: rack.ID, Name: "core-sw1", PosU: 5, HeightU: 2, - Vendor: "Generic", Model: "GS-48", MgmtIP: "10.0.0.1", Tags: []string{"core", "prod"}}); err != nil { - t.Fatal(err) - } - var out strings.Builder - if err := s.ExportCSV(&out, "device"); err != nil { - t.Fatal(err) - } - text := out.String() - for _, want := range []string{"core-sw1", "A-01", "10.0.0.1", "core prod", "Generic"} { - if !strings.Contains(text, want) { - t.Fatalf("the export does not mention %q:\n%s", want, text) - } - } - // edit a field and put it back - edited := strings.Replace(text, "Generic", "Acme", 1) - p, err := s.ImportCSV(strings.NewReader(edited), "device", true) - if err != nil { - t.Fatal(err) - } - if len(p.Changes) != 1 || p.Changes[0].Action != "update" || p.Changes[0].Error != "" { - t.Fatalf("import: %+v", p.Changes) - } - ds, _ := s.Devices(DeviceQuery{}) - if len(ds) != 1 { - t.Fatalf("the import duplicated the device: %+v", ds) - } - if ds[0].Vendor != "Acme" || ds[0].RackID != rack.ID || ds[0].PosU != 5 { - t.Fatalf("after the import: %+v", ds[0]) - } -} - -// A spreadsheet somebody already keeps: no ids, references by name, one -// bad line among good ones. -func TestCSVImportFromScratch(t *testing.T) { - s := open(t) - site, _ := s.SaveSite(Site{Name: "Tokyo DC"}) - if _, err := s.SaveRack(Rack{SiteID: site.ID, Name: "A-01", Units: 42}); err != nil { - t.Fatal(err) - } - text := "name,rack,pos_u,height_u,model,tags\n" + - "core-sw1,A-01,5,2,GS-48,core prod\n" + - "edge-sw1,Nowhere,1,1,GS-24,edge\n" + // no such rack - "spare-sw,,0,1,GS-24,\n" // not racked at all, which is fine - p, err := s.ImportCSV(strings.NewReader(text), "device", true) - if err != nil { - t.Fatal(err) - } - var errs int - for _, c := range p.Changes { - if c.Error != "" { - errs++ - if !strings.Contains(c.Error, "line 3") { - t.Fatalf("the failing line is not named: %q", c.Error) - } - } - } - if errs != 1 { - t.Fatalf("errors %d: %+v", errs, p.Changes) - } - ds, _ := s.Devices(DeviceQuery{}) - if len(ds) != 2 { // the good lines landed; the bad one did not - t.Fatalf("devices: %+v", ds) - } -} - -// A preview writes nothing. -func TestCSVPreview(t *testing.T) { - s := open(t) - p, err := s.ImportCSV(strings.NewReader("name,address\nTokyo DC,Koto-ku\n"), "site", false) - if err != nil { - t.Fatal(err) - } - if len(p.Changes) != 1 || p.Changes[0].Action != "add" { - t.Fatalf("%+v", p.Changes) - } - if ss, _ := s.Sites(); len(ss) != 0 { - t.Fatalf("a preview wrote %d sites", len(ss)) - } -} - -// A file that is not this kind of export says so instead of filling the -// ledger with nonsense. -func TestCSVRefuses(t *testing.T) { - s := open(t) - if _, err := s.ImportCSV(strings.NewReader("a,b\n1,2\n"), "device", true); err == nil { - t.Fatal("a file with no name column was accepted") - } - if _, err := s.ImportCSV(strings.NewReader(""), "device", true); err == nil { - t.Fatal("an empty file was accepted") - } - if _, err := s.ImportCSV(strings.NewReader("name\nx\n"), "nonsense", true); err == nil { - t.Fatal("an unknown kind was accepted") - } -} - -// Every kind exports with a header, so the pages can offer them all and -// an operator can see the columns before filling one in. -func TestCSVEveryKind(t *testing.T) { - s := open(t) - for _, k := range CSVKinds { - var out strings.Builder - if err := s.ExportCSV(&out, k); err != nil { - t.Fatalf("%s: %v", k, err) - } - head := strings.SplitN(out.String(), "\n", 2)[0] - if !strings.HasPrefix(head, "id,") { - t.Fatalf("%s header %q", k, head) - } - } -} - -// A cell that starts like a formula is exported with a leading ' so a -// spreadsheet shows it as text, and the import takes the ' off again. -func TestCSVFormulaInjection(t *testing.T) { - s := open(t) - if _, err := s.SaveSite(Site{Name: "=HYPERLINK(\"http://x\")", Address: "+81", Note: "@SUM(1)"}); err != nil { - t.Fatal(err) - } - var out strings.Builder - if err := s.ExportCSV(&out, "site"); err != nil { - t.Fatal(err) - } - text := out.String() - for _, want := range []string{`"'=HYPERLINK(""http://x"")"`, "'+81", "'@SUM(1)"} { - if !strings.Contains(text, want) { - t.Fatalf("the export does not neutralise %q:\n%s", want, text) - } - } - if _, err := s.ImportCSV(strings.NewReader(text), "site", true); err != nil { - t.Fatal(err) - } - vs, _ := s.Sites() - if len(vs) != 1 || vs[0].Name != "=HYPERLINK(\"http://x\")" || vs[0].Address != "+81" || vs[0].Note != "@SUM(1)" { - t.Fatalf("after the round trip: %+v", vs) - } - for in, want := range map[string]string{"-1": "'-1", "\tx": "'\tx", "\rx": "'\rx", "a=b": "a=b", "": ""} { - if got := safeCell(in); got != want { - t.Errorf("safeCell(%q) = %q, want %q", in, got, want) - } - } -} - -// The evaluation notice is written as leading "# " lines; the import skips -// them, so a marked file can still be put back. -func TestCSVImportSkipsNotice(t *testing.T) { - s := open(t) - text := "# traffic66 EVALUATION - not registered\nname,address\nTokyo DC,Koto-ku\n" - p, err := s.ImportCSV(strings.NewReader(text), "site", true) - if err != nil { - t.Fatal(err) - } - if len(p.Changes) != 1 || p.Changes[0].Error != "" { - t.Fatalf("import: %+v", p.Changes) - } -} diff --git a/internal/assets/paste.go b/internal/assets/paste.go deleted file mode 100644 index c43fe98..0000000 --- a/internal/assets/paste.go +++ /dev/null @@ -1,510 +0,0 @@ -package assets - -import ( - "fmt" - "strconv" - "strings" -) - -// Importing a switch's own output. An operator runs the command they -// already run, pastes what it printed, and the ledger reads it: there is -// nothing to install on the device, no SNMP community to hand out and no -// credentials to store anywhere. -// -// These outputs are column-formatted, and the columns are what makes them -// readable: a field can hold spaces ("Gig 0/1", "a-full"), so splitting on -// whitespace loses them. The header line is therefore found first and its -// column offsets are used to cut every row. A line that does not fit is -// reported back rather than guessed at — the whole point of a ledger is -// that nothing in it was invented. - -// Paste formats. -const ( - FormatNeighbors = "neighbors" // show lldp neighbors, show cdp neighbors - FormatIfStatus = "ifstatus" // show interface status -) - -// show ip arp and show mac address-table were read here too, to fill in -// which address sat behind which MAC and which port it was plugged into. -// Both went with the address assignment they fed: the ledger no longer -// keeps addresses, so there is nothing for those tables to go into. - -// Change is one thing an import would do, or did. -type Change struct { - Kind string `json:"kind"` // device, port, link - Action string `json:"action"` // add, update, same - What string `json:"what"` // for the preview, in the operator's terms - Error string `json:"error,omitempty"` -} - -// Plan is what a paste would do. It is produced without writing anything, -// shown, and only then applied — so the operator sees what a paste of the -// wrong buffer would have done before it does it. -type Plan struct { - Format string `json:"format"` - Changes []Change `json:"changes"` - Skipped []string `json:"skipped,omitempty"` // lines that were not understood - Applied bool `json:"applied"` -} - -// columns are the fields of a header line: where each begins, and the -// name this understands it as ("" for a column it does not know). Every -// field is kept, known or not, because a column nobody recognises is -// still what ends the one before it — which is exactly what goes wrong -// when only the known ones are remembered. -type columns struct { - at []int - names []string -} - -// cut returns the value of one column of a row. The last column runs to -// the end of the line, and a value that overran its column (a long -// interface name does) keeps its tail up to the next gap, because the -// header says where fields begin, not where they must stop. -func (c columns) cut(line, name string) string { - i := -1 - for k, n := range c.names { - if n == name { - i = k - break - } - } - if i < 0 || c.at[i] >= len(line) { - return "" - } - end := len(line) - if i+1 < len(c.at) && c.at[i+1] < end { - end = c.at[i+1] - } - v := line[c.at[i]:end] - // a value only overran when the cut falls inside a word; a value that - // simply fills its column, as a right-aligned one does, has not - if end < len(line) && line[end] != ' ' && len(v) > 0 && v[len(v)-1] != ' ' { - rest := line[end:] - if j := strings.Index(rest, " "); j >= 0 { - v += rest[:j] - } else { - v += rest - } - } - return strings.TrimSpace(v) -} - -// headerAliases maps the column names this understands to the spellings -// the common command outputs use, lowercased. -var headerAliases = map[string][]string{ - "device": {"device id", "chassis id", "system name", "neighbor", "neighbour", "device"}, - "local": {"local intf", "local intrfce", "local interface", "local port", "local"}, - "remote": {"port id", "neighbor port", "neighbour port", "remote port", "port descr"}, - "port": {"ports", "port", "interface", "intf"}, - "name": {"name", "description", "descr"}, - "speed": {"speed"}, - "type": {"type"}, - "vlan": {"vlan"}, -} - -// headerFields cuts a line into its columns: a run of two or more spaces -// separates them, so that "Hardware Addr" and "Age (min)" stay whole. -func headerFields(line string) (at []int, text []string) { - i := 0 - for i < len(line) { - for i < len(line) && line[i] == ' ' { - i++ - } - if i >= len(line) { - break - } - j := i - for j < len(line) { - if line[j] == ' ' && (j+1 >= len(line) || line[j+1] == ' ') { - break - } - j++ - } - at = append(at, i) - text = append(text, strings.TrimSpace(line[i:j])) - i = j - } - return at, text -} - -// nameOf matches one header field against the aliases, whole. -func nameOf(field string) string { - f := strings.ToLower(strings.TrimRight(field, ":")) - for name, aliases := range headerAliases { - for _, a := range aliases { - if f == a { - return name - } - } - } - return "" -} - -// nameOfLoose also accepts a field that begins with an alias, for a -// header that carries a unit or a qualifier after the name. -func nameOfLoose(field string) string { - if n := nameOf(field); n != "" { - return n - } - f := strings.ToLower(field) - best, bestLen := "", 0 - for name, aliases := range headerAliases { - for _, a := range aliases { - if len(a) > bestLen && strings.HasPrefix(f, a+" ") { - best, bestLen = name, len(a) - } - } - } - return best -} - -// splitField decides whether a header field holding single spaces is one -// column or several. The outputs give no way to tell from the spacing -// alone — "Hardware Addr" is one column and "Speed Type" is two — so a -// field that is not itself a column name is split only when at least two -// of its words are column names. -func splitField(at int, field string) (ats []int, names []string) { - parts := strings.Split(field, " ") - if len(parts) < 2 { - return nil, nil - } - var offs []int - var ns []string - known, pos := 0, at - for _, w := range parts { - n := nameOf(w) - if n != "" { - known++ - } - offs = append(offs, pos) - ns = append(ns, n) - pos += len(w) + 1 - } - if known < 2 { - return nil, nil - } - return offs, ns -} - -// readHeader reads a header line. A column name that appears twice is -// kept only the first time, so a second "Type" cannot take the first's -// place. -func readHeader(line string) (columns, bool) { - at, text := headerFields(line) - if len(at) < 2 { - return columns{}, false - } - var c columns - seen := map[string]bool{} - known := 0 - put := func(at int, n string) { - if n != "" && seen[n] { - n = "" - } - if n != "" { - seen[n] = true - known++ - } - c.at = append(c.at, at) - c.names = append(c.names, n) - } - for i, f := range text { - // a field that is itself a column name is never split: "Mac - // Address" is one column, however many of its words look like one - if n := nameOf(f); n != "" { - put(at[i], n) - continue - } - if offs, ns := splitField(at[i], f); offs != nil { - for k := range offs { - put(offs[k], ns[k]) - } - continue - } - put(at[i], nameOfLoose(f)) - } - return c, known >= 2 -} - -// has reports whether the table carries all of these columns. -func (c columns) has(names ...string) bool { - for _, n := range names { - found := false - for _, h := range c.names { - if h == n { - found = true - } - } - if !found { - return false - } - } - return true -} - -// guessFormat names the table from the columns it carries. -func guessFormat(c columns) string { - switch { - case c.has("device", "local", "remote"): - return FormatNeighbors - case c.has("port", "speed"), c.has("port", "vlan"), c.has("port", "name"): - return FormatIfStatus - } - return "" -} - -// table finds the header of the first table in the text and returns it -// with the rows under it. -func table(text string) (columns, []string, bool) { - lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") - for i, l := range lines { - c, ok := readHeader(l) - if !ok || guessFormat(c) == "" { - continue - } - var rows []string - for _, r := range lines[i+1:] { - if strings.TrimSpace(r) == "" { - continue - } - if strings.HasPrefix(strings.TrimSpace(r), "---") || strings.HasPrefix(strings.TrimSpace(r), "===") { - continue - } - rows = append(rows, strings.TrimRight(r, " \t")) - } - return c, rows, true - } - return columns{}, nil, false -} - -// samePort reports whether two interface names are the same port written -// two ways: "Gi0/1", "Gig 0/1" and "GigabitEthernet0/1" all name one port, -// because the vendors abbreviate their own names in their own output. -func samePort(a, b string) bool { - na, ta := splitIface(a) - nb, tb := splitIface(b) - if ta != tb || ta == "" { - return strings.EqualFold(strings.ReplaceAll(a, " ", ""), strings.ReplaceAll(b, " ", "")) - } - return strings.HasPrefix(na, nb) || strings.HasPrefix(nb, na) -} - -// splitIface cuts an interface name into its alphabetic prefix, -// lowercased and without spaces, and its numeric tail ("Gig 0/1" -> -// "gig", "0/1"). -func splitIface(s string) (name, tail string) { - s = strings.TrimSpace(s) - i := 0 - for i < len(s) && ((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') || s[i] == ' ' || s[i] == '-') { - i++ - } - return strings.ToLower(strings.ReplaceAll(s[:i], " ", "")), strings.ReplaceAll(s[i:], " ", "") -} - -// parseSpeed reads the speed column of an interface table: "1000", -// "a-1000" (auto-negotiated), "10G", "a-10G". Anything else is 0, because -// a speed the ledger made up is worse than no speed. -func parseSpeed(s string) uint64 { - s = strings.ToLower(strings.TrimSpace(s)) - s = strings.TrimPrefix(s, "a-") - mult := uint64(1_000_000) // the column is megabits unless it says otherwise - switch { - case strings.HasSuffix(s, "gb/s"), strings.HasSuffix(s, "g"): - s, mult = strings.TrimSuffix(strings.TrimSuffix(s, "gb/s"), "g"), 1_000_000_000 - case strings.HasSuffix(s, "mb/s"), strings.HasSuffix(s, "m"): - s = strings.TrimSuffix(strings.TrimSuffix(s, "mb/s"), "m") - } - n, err := strconv.ParseFloat(strings.TrimSpace(s), 64) - if err != nil || n <= 0 { - return 0 - } - return uint64(n * float64(mult)) -} - -// PasteOptions say what a paste is about. -type PasteOptions struct { - Text string - DeviceID int64 // the device the command was run on; needed by all but ARP - Apply bool -} - -// Paste reads a pasted command output. With Apply false nothing is -// written and the plan is only described. -func (s *Store) Paste(o PasteOptions) (Plan, error) { - c, rows, ok := table(o.Text) - if !ok { - return Plan{}, fmt.Errorf("this does not look like the output of show lldp/cdp neighbors or show interface status") - } - p := Plan{Format: guessFormat(c), Applied: o.Apply} - var err error - switch p.Format { - case FormatNeighbors: - err = s.pasteNeighbors(&p, c, rows, o) - case FormatIfStatus: - err = s.pasteIfStatus(&p, c, rows, o) - } - return p, err -} - -// needDevice checks that the operator said which device the output came -// from. The command does not say so itself, and guessing would hang -// someone else's ports off the wrong box. -func (s *Store) needDevice(id int64) (Device, error) { - if id == 0 { - return Device{}, fmt.Errorf("say which device this was run on") - } - ds, err := s.Devices(DeviceQuery{}) - if err != nil { - return Device{}, err - } - for _, d := range ds { - if d.ID == id { - return d, nil - } - } - return Device{}, ErrNotFound -} - -// port finds a device's port by name, however the vendor spelled it, and -// adds it when add is set. -func (s *Store) port(deviceID int64, name string, add bool, p *Plan) (Port, bool, error) { - ps, err := s.Ports(deviceID) - if err != nil { - return Port{}, false, err - } - for _, x := range ps { - if samePort(x.Name, name) { - return x, false, nil - } - } - if !add { - return Port{}, false, nil - } - np := Port{DeviceID: deviceID, Name: name} - if p.Applied { - np, err = s.SavePort(np) - if err != nil { - return Port{}, false, err - } - } - return np, true, nil -} - -// device finds a device by name, or adds one. -func (s *Store) device(name string, p *Plan) (Device, bool, error) { - ds, err := s.Devices(DeviceQuery{}) - if err != nil { - return Device{}, false, err - } - // a neighbour often reports its fully qualified name - short := strings.SplitN(name, ".", 2)[0] - for _, d := range ds { - if strings.EqualFold(d.Name, name) || strings.EqualFold(d.Name, short) { - return d, false, nil - } - } - nd := Device{Name: name} - if p.Applied { - nd, err = s.SaveDevice(nd) - if err != nil { - return Device{}, false, err - } - } - return nd, true, nil -} - -func (p *Plan) add(kind, action, what string, err error) { - c := Change{Kind: kind, Action: action, What: what} - if err != nil { - c.Action, c.Error = "error", err.Error() - } - p.Changes = append(p.Changes, c) -} - -// pasteNeighbors reads show lldp/cdp neighbors: the local port, the -// device at the other end and its port. It records the neighbour and the -// cable, which is the part nobody keeps up to date by hand. -func (s *Store) pasteNeighbors(p *Plan, c columns, rows []string, o PasteOptions) error { - me, err := s.needDevice(o.DeviceID) - if err != nil { - return err - } - for _, r := range rows { - local, peer, rport := c.cut(r, "local"), c.cut(r, "device"), c.cut(r, "remote") - if local == "" || peer == "" || rport == "" { - p.Skipped = append(p.Skipped, strings.TrimSpace(r)) - continue - } - lp, added, err := s.port(me.ID, local, true, p) - if err != nil { - p.add("port", "add", me.Name+" "+local, err) - continue - } - if added { - p.add("port", "add", me.Name+" "+local, nil) - } - pd, added, err := s.device(peer, p) - if err != nil { - p.add("device", "add", peer, err) - continue - } - if added { - p.add("device", "add", peer, nil) - } - pp, added, err := s.port(pd.ID, rport, true, p) - if err != nil { - p.add("port", "add", peer+" "+rport, err) - continue - } - if added { - p.add("port", "add", peer+" "+rport, nil) - } - what := fmt.Sprintf("%s %s — %s %s", me.Name, local, peer, rport) - if p.Applied { - if _, err := s.SaveLink(Link{APort: lp.ID, BPort: pp.ID}); err != nil { - p.add("link", "add", what, err) - continue - } - } - p.add("link", "add", what, nil) - } - return nil -} - -// pasteIfStatus reads show interface status: the device's own ports, with -// the description and the speed it is running at. -func (s *Store) pasteIfStatus(p *Plan, c columns, rows []string, o PasteOptions) error { - me, err := s.needDevice(o.DeviceID) - if err != nil { - return err - } - for _, r := range rows { - name := c.cut(r, "port") - if name == "" { - p.Skipped = append(p.Skipped, strings.TrimSpace(r)) - continue - } - cur, added, err := s.port(me.ID, name, true, p) - if err != nil { - p.add("port", "add", me.Name+" "+name, err) - continue - } - cur.Note, cur.Kind = c.cut(r, "name"), c.cut(r, "type") - if sp := parseSpeed(c.cut(r, "speed")); sp > 0 { - cur.Speed = sp - } - act := "update" - if added { - act = "add" - } - if p.Applied { - if _, err := s.SavePort(cur); err != nil { - p.add("port", act, me.Name+" "+name, err) - continue - } - } - p.add("port", act, me.Name+" "+name, nil) - } - return nil -} diff --git a/internal/assets/paste_test.go b/internal/assets/paste_test.go deleted file mode 100644 index 17d1cf8..0000000 --- a/internal/assets/paste_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package assets - -import ( - "testing" -) - -// counts summarises a plan for the assertions below. -func counts(p Plan) map[string]int { - m := map[string]int{} - for _, c := range p.Changes { - m[c.Kind+"/"+c.Action]++ - if c.Error != "" { - m["errors"]++ - } - } - m["skipped"] = len(p.Skipped) - return m -} - -// show lldp neighbors: the local port, the neighbour and its port. The -// neighbour and the cable are recorded; a previewed paste writes nothing. -func TestPasteLLDP(t *testing.T) { - s := open(t) - me, err := s.SaveDevice(Device{Name: "core-sw1"}) - if err != nil { - t.Fatal(err) - } - text := `core-sw1# show lldp neighbors - -Capability codes: - (R) Router, (B) Bridge, (T) Telephone - -Device ID Local Intf Hold-time Capability Port ID -edge-sw1.lan Gi0/1 120 B,R Gi1/0/24 -fw-01 Gi0/2 120 R ge-0/0/3 - -Total entries displayed: 2 -` - p, err := s.Paste(PasteOptions{Text: text, DeviceID: me.ID}) - if err != nil { - t.Fatal(err) - } - if p.Format != FormatNeighbors { - t.Fatalf("read as %q", p.Format) - } - c := counts(p) - if c["link/add"] != 2 || c["device/add"] != 2 || c["port/add"] != 4 || c["errors"] != 0 { - t.Fatalf("preview: %v\n%+v", c, p.Changes) - } - if n, _ := s.Devices(DeviceQuery{}); len(n) != 1 { - t.Fatalf("a previewed paste wrote %d devices", len(n)) - } - - p, err = s.Paste(PasteOptions{Text: text, DeviceID: me.ID, Apply: true}) - if err != nil { - t.Fatal(err) - } - if c := counts(p); c["errors"] != 0 { - t.Fatalf("applied: %v\n%+v", c, p.Changes) - } - ds, _ := s.Devices(DeviceQuery{}) - if len(ds) != 3 { - t.Fatalf("devices: %+v", ds) - } - ls, _ := s.Links() - if len(ls) != 2 { - t.Fatalf("links: %+v", ls) - } - // running it again changes nothing that matters: the same ports and - // the same cables are found rather than doubled - if _, err := s.Paste(PasteOptions{Text: text, DeviceID: me.ID, Apply: true}); err != nil { - t.Fatal(err) - } - ds, _ = s.Devices(DeviceQuery{}) - ls, _ = s.Links() - ps, _ := s.Ports(0) - if len(ds) != 3 || len(ls) != 2 || len(ps) != 4 { - t.Fatalf("after a second paste: %d devices, %d links, %d ports", len(ds), len(ls), len(ps)) - } -} - -// show cdp neighbors spells its columns differently and puts a space -// inside the interface name, which is why the columns are read rather -// than the whitespace. -func TestPasteCDP(t *testing.T) { - s := open(t) - me, _ := s.SaveDevice(Device{Name: "core-sw1"}) - text := `Device ID Local Intrfce Holdtme Capability Platform Port ID -edge-sw1 Gig 0/1 150 S I WS-C2960 Gig 1/0/24 -` - p, err := s.Paste(PasteOptions{Text: text, DeviceID: me.ID, Apply: true}) - if err != nil { - t.Fatal(err) - } - if c := counts(p); c["link/add"] != 1 || c["errors"] != 0 || c["skipped"] != 0 { - t.Fatalf("%v\n%+v\nskipped %v", c, p.Changes, p.Skipped) - } - ps, _ := s.Ports(me.ID) - if len(ps) != 1 || ps[0].Name != "Gig 0/1" { - t.Fatalf("ports: %+v", ps) - } - // and the same port written short is recognised as the same one - short := `Device ID Local Intrfce Holdtme Capability Platform Port ID -other-sw Gi0/1 150 S I WS-C2960 Gi1/0/1 -` - if _, err := s.Paste(PasteOptions{Text: short, DeviceID: me.ID, Apply: true}); err != nil { - t.Fatal(err) - } - if ps, _ := s.Ports(me.ID); len(ps) != 1 { - t.Fatalf("the same port was recorded twice: %+v", ps) - } -} - -// show interface status fills in the descriptions and speeds of a -// device's own ports. -func TestPasteIfStatus(t *testing.T) { - s := open(t) - me, _ := s.SaveDevice(Device{Name: "core-sw1"}) - // as the switch prints it: Duplex and Speed right-aligned in their - // columns, Type left-aligned, one space between Speed and Type - text := `Port Name Status Vlan Duplex Speed Type -Gi0/1 uplink to isp connected trunk a-full a-1000 10/100/1000BaseTX -Gi0/2 notconnect 10 auto auto 10/100/1000BaseTX -Te1/1 storage connected 20 full 10G SFP-10GBase-SR -` - p, err := s.Paste(PasteOptions{Text: text, DeviceID: me.ID, Apply: true}) - if err != nil { - t.Fatal(err) - } - if p.Format != FormatIfStatus { - t.Fatalf("read as %q", p.Format) - } - if c := counts(p); c["port/add"] != 3 || c["errors"] != 0 { - t.Fatalf("%v\n%+v", c, p.Changes) - } - ps, _ := s.Ports(me.ID) - by := map[string]Port{} - for _, x := range ps { - by[x.Name] = x - } - if by["Gi0/1"].Speed != 1_000_000_000 { - t.Fatalf("Gi0/1 speed %d", by["Gi0/1"].Speed) - } - if by["Te1/1"].Speed != 10_000_000_000 { - t.Fatalf("Te1/1 speed %d", by["Te1/1"].Speed) - } - if by["Gi0/2"].Speed != 0 { // "auto" is not a speed; nothing is invented - t.Fatalf("Gi0/2 speed %d", by["Gi0/2"].Speed) - } - if by["Gi0/1"].Note != "uplink to isp" { - t.Fatalf("Gi0/1 note %q", by["Gi0/1"].Note) - } -} - -// A paste that is not one of these outputs says so, and one that needs to -// know which device it came from refuses rather than guessing. -func TestPasteRefuses(t *testing.T) { - s := open(t) - if _, err := s.Paste(PasteOptions{Text: "hello\nworld\n"}); err == nil { - t.Fatal("rubbish was accepted") - } - text := `Device ID Local Intf Hold-time Capability Port ID -edge-sw1 Gi0/1 120 B,R Gi1/0/24 -` - if _, err := s.Paste(PasteOptions{Text: text}); err == nil { - t.Fatal("neighbours were imported without saying which device they were seen from") - } - if _, err := s.Paste(PasteOptions{Text: text, DeviceID: 999}); err == nil { - t.Fatal("a device that does not exist was accepted") - } -} - -// Interface names are compared as the vendors write them. -func TestSamePort(t *testing.T) { - for _, c := range []struct { - a, b string - want bool - }{ - {"Gi0/1", "Gig 0/1", true}, - {"Gi0/1", "GigabitEthernet0/1", true}, - {"gi0/1", "Gi0/1", true}, - {"Gi0/1", "Gi0/2", false}, - {"Gi0/1", "Te0/1", false}, - {"ge-0/0/3", "ge-0/0/3", true}, - {"Eth1/1", "Ethernet1/1", true}, - } { - if got := samePort(c.a, c.b); got != c.want { - t.Fatalf("samePort(%q, %q) = %v", c.a, c.b, got) - } - } -} - -// Speeds are read as the interface tables print them, and anything that -// is not a speed stays unknown. -func TestParseSpeed(t *testing.T) { - for in, want := range map[string]uint64{ - "1000": 1_000_000_000, "a-1000": 1_000_000_000, "10G": 10_000_000_000, - "a-10G": 10_000_000_000, "100": 100_000_000, "auto": 0, "": 0, "unknown": 0, - } { - if got := parseSpeed(in); got != want { - t.Fatalf("parseSpeed(%q) = %d, want %d", in, got, want) - } - } -} diff --git a/internal/assets/record.go b/internal/assets/record.go deleted file mode 100644 index 8a598eb..0000000 --- a/internal/assets/record.go +++ /dev/null @@ -1,190 +0,0 @@ -package assets - -import ( - "fmt" - "net/netip" - "sort" - "strings" - "time" -) - -// Site is a building or data centre. -type Site struct { - ID int64 `json:"id"` - Name string `json:"name"` - Address string `json:"address,omitempty"` - Note string `json:"note,omitempty"` - Hidden bool `json:"hidden,omitempty"` -} - -// Rack is a cabinet in a site. Units is its height in rack units; devices -// are placed by the unit they start at. -type Rack struct { - ID int64 `json:"id"` - SiteID int64 `json:"site_id"` - Name string `json:"name"` - Units int `json:"units"` - Note string `json:"note,omitempty"` - Hidden bool `json:"hidden,omitempty"` -} - -// Device is one piece of equipment. RackID 0 means it is not racked, and -// PosU 0 means it is in a rack but its position is not recorded — both are -// ordinary states in a ledger people fill in over time. -type Device struct { - ID int64 `json:"id"` - RackID int64 `json:"rack_id,omitempty"` - Name string `json:"name"` - Role string `json:"role,omitempty"` - Vendor string `json:"vendor,omitempty"` - Model string `json:"model,omitempty"` - Serial string `json:"serial,omitempty"` - MgmtIP string `json:"mgmt_ip,omitempty"` - PosU int `json:"pos_u,omitempty"` - HeightU int `json:"height_u,omitempty"` - Tags []string `json:"tags,omitempty"` - Note string `json:"note,omitempty"` - Hidden bool `json:"hidden,omitempty"` -} - -// Port is a physical port of a device. IfIndex, when known, is what ties -// the ledger to the flow data: it is the same number the exporter puts in -// its records. -type Port struct { - ID int64 `json:"id"` - DeviceID int64 `json:"device_id"` - Name string `json:"name"` - IfIndex uint32 `json:"ifindex,omitempty"` - Speed uint64 `json:"speed,omitempty"` - Kind string `json:"kind,omitempty"` - Note string `json:"note,omitempty"` -} - -// Link is a cable between two ports. -type Link struct { - ID int64 `json:"id"` - APort int64 `json:"a_port"` - BPort int64 `json:"b_port"` - Note string `json:"note,omitempty"` -} - -// Circuit is a line bought from a carrier, landing on a port. -type Circuit struct { - ID int64 `json:"id"` - Name string `json:"name"` - Carrier string `json:"carrier,omitempty"` - Kind string `json:"kind,omitempty"` - Bandwidth string `json:"bandwidth,omitempty"` - SiteID int64 `json:"site_id,omitempty"` - PortID int64 `json:"port_id,omitempty"` - Contract string `json:"contract,omitempty"` - Starts string `json:"starts,omitempty"` // YYYY-MM-DD - Ends string `json:"ends,omitempty"` - Tags []string `json:"tags,omitempty"` - Note string `json:"note,omitempty"` - Hidden bool `json:"hidden,omitempty"` -} - -// Subnet is a network a device carries, written down beside it. It is a -// note, not a source of truth for anything: nothing is looked up in it. -type Subnet struct { - ID int64 `json:"id"` - DeviceID int64 `json:"device_id,omitempty"` - Prefix string `json:"prefix"` - Name string `json:"name,omitempty"` - VLAN int `json:"vlan,omitempty"` - Gateway string `json:"gateway,omitempty"` - Tags []string `json:"tags,omitempty"` - Note string `json:"note,omitempty"` - Hidden bool `json:"hidden,omitempty"` -} - -// CanonTags trims, lowercases, de-duplicates and sorts tags, so that the -// same tag typed three ways is one tag. -func CanonTags(in []string) []string { - seen := map[string]bool{} - out := []string{} - for _, t := range in { - for _, p := range strings.Split(t, ",") { - p = strings.ToLower(strings.TrimSpace(p)) - if p == "" || seen[p] || len(out) >= 24 { - continue - } - seen[p] = true - out = append(out, p) - } - } - sort.Strings(out) - return out -} - -func tagString(in []string) string { return strings.Join(CanonTags(in), ",") } - -func tagList(s string) []string { - if s == "" { - return nil - } - return CanonTags(strings.Split(s, ",")) -} - -// trim cuts a field to a sane length. The ledger is typed in by hand; a -// field longer than this is a paste accident, not a name. -func trim(s string, max int) string { - s = strings.TrimSpace(s) - if len(s) > max { - s = strings.TrimSpace(s[:max]) - } - return s -} - -// CanonMAC reads a MAC address in any of the forms switches print it -// (aa:bb:cc:dd:ee:ff, aa-bb-cc-dd-ee-ff, aabb.ccdd.eeff, aabbccddeeff) and -// returns the colon form. It returns "" when the text is not a MAC, so a -// mistyped one is dropped rather than stored as a fact. -func CanonMAC(s string) string { - var hex []rune - for _, r := range strings.ToLower(strings.TrimSpace(s)) { - switch { - case r >= '0' && r <= '9', r >= 'a' && r <= 'f': - hex = append(hex, r) - case r == ':' || r == '-' || r == '.' || r == ' ': - default: - return "" - } - } - if len(hex) != 12 { - return "" - } - var b strings.Builder - for i := 0; i < 12; i += 2 { - if i > 0 { - b.WriteByte(':') - } - b.WriteRune(hex[i]) - b.WriteRune(hex[i+1]) - } - return b.String() -} - -// canonIP returns the address in its canonical form, or an error. Storing -// it canonically is what makes "already assigned" a reliable answer: the -// same address written two ways must be one row. -func canonIP(s string) (string, error) { - a, err := netip.ParseAddr(strings.TrimSpace(s)) - if err != nil { - return "", fmt.Errorf("%q is not an IP address", s) - } - return a.Unmap().String(), nil -} - -// canonPrefix returns the network in its canonical form, with the host -// bits cleared, so that 10.0.0.7/24 and 10.0.0.0/24 are the same subnet. -func canonPrefix(s string) (netip.Prefix, error) { - p, err := netip.ParsePrefix(strings.TrimSpace(s)) - if err != nil { - return p, fmt.Errorf("%q is not a network (expected something like 10.0.0.0/24)", s) - } - return p.Masked(), nil -} - -func now() time.Time { return time.Now().UTC() } diff --git a/internal/assets/sort.go b/internal/assets/sort.go deleted file mode 100644 index e7fb5d6..0000000 --- a/internal/assets/sort.go +++ /dev/null @@ -1,27 +0,0 @@ -package assets - -import ( - "net/netip" - "sort" -) - -// sortSubnets puts the narrowest network first, so that looking up which -// network an address belongs to finds the most specific one, and the list -// reads from the smallest to the largest. -func sortSubnets(v []Subnet) { - key := func(s Subnet) (netip.Prefix, bool) { - p, err := netip.ParsePrefix(s.Prefix) - return p, err == nil - } - sort.SliceStable(v, func(i, j int) bool { - a, aok := key(v[i]) - b, bok := key(v[j]) - if !aok || !bok { - return aok - } - if a.Bits() != b.Bits() { - return a.Bits() > b.Bits() - } - return a.Addr().Less(b.Addr()) - }) -} diff --git a/internal/assets/subnets.go b/internal/assets/subnets.go deleted file mode 100644 index ebadaae..0000000 --- a/internal/assets/subnets.go +++ /dev/null @@ -1,102 +0,0 @@ -package assets - -import ( - "database/sql" - "errors" - "fmt" - "net/netip" -) - -// The networks written down against a device. traffic66 does not assign -// addresses and does not look anything up here: a network recorded on a -// switch is a note about what that switch carries, kept beside its ports, -// and nothing on any chart or page is decided from it. The address -// assignment this ledger used to do, and the page that went with it, were -// removed: they were a second place to keep what belongs in whatever -// system already hands out addresses. - -// SaveSubnet inserts or updates a network. -func (s *Store) SaveSubnet(v Subnet) (Subnet, error) { - v.Name, v.Note = trim(v.Name, 120), trim(v.Note, 2000) - p, err := canonPrefix(v.Prefix) - if err != nil { - return v, err - } - v.Prefix = p.String() - if v.VLAN < 0 || v.VLAN > 4094 { - return v, fmt.Errorf("%d is not a VLAN id (1 to 4094)", v.VLAN) - } - if v.Gateway != "" { - g, err := canonIP(v.Gateway) - if err != nil { - return v, err - } - if a, _ := netip.ParseAddr(g); !p.Contains(a) { - return v, fmt.Errorf("the gateway %s is not inside %s", g, v.Prefix) - } - v.Gateway = g - } - if err := s.exists("devices", v.DeviceID, "device"); err != nil { - return v, err - } - var other int64 - err = s.DB.QueryRow(`SELECT id FROM subnets WHERE prefix = ? AND id <> ?`, v.Prefix, v.ID).Scan(&other) - if err == nil { - return v, fmt.Errorf("%s is already recorded", v.Prefix) - } - if !errors.Is(err, sql.ErrNoRows) { - return v, err - } - tags := tagString(v.Tags) - v.Tags = tagList(tags) - if v.ID == 0 { - id, err := s.next() - if err != nil { - return v, err - } - v.ID = id - if _, err := s.DB.Exec(`INSERT INTO subnets (id, prefix, name, vlan, site_id, gateway, tags, note, hidden, created, updated, device_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.Prefix, v.Name, v.VLAN, int64(0), v.Gateway, tags, v.Note, v.Hidden, now(), now(), v.DeviceID); err != nil { - return v, err - } - } else { - r, err := s.DB.Exec(`UPDATE subnets SET prefix = ?, name = ?, vlan = ?, device_id = ?, gateway = ?, - tags = ?, note = ?, hidden = ?, updated = ? WHERE id = ?`, - v.Prefix, v.Name, v.VLAN, v.DeviceID, v.Gateway, tags, v.Note, v.Hidden, now(), v.ID) - if err := affected(r, err); err != nil { - return v, err - } - } - return v, nil -} - -// Subnets lists networks, narrowest first so that a more specific network -// is offered before the one that contains it. -func (s *Store) Subnets() ([]Subnet, error) { - rows, err := s.DB.Query(`SELECT id, prefix, name, vlan, device_id, gateway, tags, note, hidden FROM subnets`) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Subnet{} - for rows.Next() { - var v Subnet - var tags string - if err := rows.Scan(&v.ID, &v.Prefix, &v.Name, &v.VLAN, &v.DeviceID, &v.Gateway, &tags, &v.Note, &v.Hidden); err != nil { - return nil, err - } - v.Tags = tagList(tags) - out = append(out, v) - } - if err := rows.Err(); err != nil { - return nil, err - } - sortSubnets(out) - return out, nil -} - -// DeleteSubnet removes a network from the ledger. -func (s *Store) DeleteSubnet(id int64) error { - return affected(s.DB.Exec(`DELETE FROM subnets WHERE id = ?`, id)) -} diff --git a/internal/enrich/enrich.go b/internal/enrich/enrich.go index 1d71ee6..e50da6c 100644 --- a/internal/enrich/enrich.go +++ b/internal/enrich/enrich.go @@ -52,13 +52,32 @@ type Iface struct { Speed uint64 // bits per second, 0 if unknown Tag string // a short label such as "uplink" (tag=) Default bool // the interface the pages show by default (default) + ISP string // the carrier this interface goes out to (isp=) + Circuit string // the carrier's circuit ID for that line (circuit=) } +// Place is where a device stands: the room and the rack, as written on +// its device line (room=, rack=). It is there to be drawn, so that +// somebody can see which room, rack, device, port and circuit a flow +// came through; nothing is looked up or checked against it. +type Place struct { + Room string + Rack string +} + +// attr reads an attribute value. Values cannot hold spaces on a line that +// is split on spaces, so a space is written as "_" and read back as one. +func attr(v string) string { return strings.ReplaceAll(v, "_", " ") } + +// attrOut writes an attribute value, the other way round. +func attrOut(v string) string { return strings.Join(strings.Fields(v), "_") } + // Inventory holds operator-provided names. All fields are optional. type Inventory struct { mu sync.RWMutex networks []Network // longest prefix first devices map[netip.Addr]string + places map[netip.Addr]Place ifaces map[string]Iface // "exporter/ifindex" hosts map[netip.Addr]string unsampled map[netip.Addr]bool @@ -79,7 +98,7 @@ type SNMPTarget struct { } func NewInventory() *Inventory { - return &Inventory{devices: map[netip.Addr]string{}, ifaces: map[string]Iface{}, hosts: map[netip.Addr]string{}, unsampled: map[netip.Addr]bool{}, autoIfs: map[string]Iface{}} + return &Inventory{devices: map[netip.Addr]string{}, places: map[netip.Addr]Place{}, ifaces: map[string]Iface{}, hosts: map[netip.Addr]string{}, unsampled: map[netip.Addr]bool{}, autoIfs: map[string]Iface{}} } // LoadInventory reads the inventory file. A missing file is not an error. @@ -113,6 +132,7 @@ func LoadInventory(path string) (*Inventory, error) { func (inv *Inventory) Parse(text string) error { nets := []Network{} devs := map[netip.Addr]string{} + places := map[netip.Addr]Place{} ifs := map[string]Iface{} hosts := map[netip.Addr]string{} uns := map[netip.Addr]bool{} @@ -160,7 +180,16 @@ func (inv *Inventory) Parse(text string) error { return bad("bad address " + f[1]) } words := []string{} + var pl Place for _, w := range f[2:] { + if v, ok := strings.CutPrefix(w, "room="); ok { + pl.Room = attr(v) + continue + } + if v, ok := strings.CutPrefix(w, "rack="); ok { + pl.Rack = attr(v) + continue + } if strings.EqualFold(w, "unsampled") { uns[a] = true continue @@ -176,6 +205,9 @@ func (inv *Inventory) Parse(text string) error { words = append(words, w) } devs[a] = strings.Join(words, " ") + if pl != (Place{}) { + places[a] = pl + } case "iface": if len(f) < 4 { return bad("iface needs an address, an index and a name") @@ -203,6 +235,14 @@ func (inv *Inventory) Parse(text string) error { it.Default = true continue } + if v, ok := strings.CutPrefix(w, "isp="); ok { + it.ISP = attr(v) + continue + } + if v, ok := strings.CutPrefix(w, "circuit="); ok { + it.Circuit = v + continue + } if w == "billed" { // marked a circuit for the 95th-percentile bill, which // is gone; a line written then still loads @@ -246,6 +286,7 @@ func (inv *Inventory) Parse(text string) error { } sort.SliceStable(nets, func(i, j int) bool { return nets[i].Prefix.Bits() > nets[j].Prefix.Bits() }) inv.mu.Lock() + inv.places = places inv.networks, inv.devices, inv.ifaces, inv.hosts, inv.unsampled, inv.snmp, inv.text = nets, devs, ifs, hosts, uns, snmps, text inv.sampling = samp inv.mu.Unlock() @@ -277,20 +318,96 @@ func (inv *Inventory) DefaultIface() string { // SetIface names, tags or marks as default one interface, editing its // iface line in the names (or adding one). Only one interface is default. +// The carrier and circuit written on the line are kept. func (inv *Inventory) SetIface(exporter netip.Addr, idx uint32, name, tag string, def bool) error { + return inv.editIface(exporter, idx, func(it *Iface) { + it.Name, it.Tag, it.Default = name, tag, def + }) +} + +// SetCircuit names an interface and says which carrier and circuit it goes +// out to, keeping its tag, speed and default mark. +func (inv *Inventory) SetCircuit(exporter netip.Addr, idx uint32, name, isp, circuit string) error { + return inv.editIface(exporter, idx, func(it *Iface) { + it.Name, it.ISP, it.Circuit = name, isp, circuit + }) +} + +// editIface rewrites one iface line, in place, from what the line already +// says changed by edit. A line not there yet is added at the end. +func (inv *Inventory) editIface(exporter netip.Addr, idx uint32, edit func(*Iface)) error { + inv.mu.RLock() + it := inv.ifaces[fmt.Sprintf("%s/%d", exporter, idx)] + inv.mu.RUnlock() + edit(&it) + it.Name = strings.Join(strings.Fields(it.Name), " ") + if it.Name == "" { + it.Name = fmt.Sprintf("#%d", idx) + } + line := fmt.Sprintf("iface %s %d %s", exporter, idx, it.Name) + if it.Speed != 0 { + line += fmt.Sprintf(" speed=%d", it.Speed) + } + if t := attrOut(it.Tag); t != "" { + line += " tag=" + t + } + if v := attrOut(it.ISP); v != "" { + line += " isp=" + v + } + if v := attrOut(it.Circuit); v != "" { + line += " circuit=" + v + } + if it.Default { + line += " default" + } + idxs := strconv.FormatUint(uint64(idx), 10) + return inv.editLine(func(f []string) bool { + return len(f) >= 3 && f[0] == "iface" && f[1] == exporter.String() && f[2] == idxs + }, line, func(f []string, ln string) string { + // only one default: take it off the other interfaces + if it.Default && len(f) > 0 && f[0] == "iface" && strings.Contains(" "+ln+" ", " default ") { + return strings.TrimSpace(strings.Replace(" "+ln+" ", " default ", " ", 1)) + } + return ln + }) +} + +// SetDevice names a device and says which room and rack it stands in, +// keeping what else its device line says (sampling=, unsampled). +func (inv *Inventory) SetDevice(a netip.Addr, name, room, rack string) error { + inv.mu.RLock() + samp, uns := inv.sampling[a], inv.unsampled[a] + inv.mu.RUnlock() name = strings.Join(strings.Fields(name), " ") - tag = strings.Join(strings.Fields(tag), "_") if name == "" { - name = fmt.Sprintf("#%d", idx) + name = a.String() + } + line := fmt.Sprintf("device %s %s", a, name) + if v := attrOut(room); v != "" { + line += " room=" + v + } + if v := attrOut(rack); v != "" { + line += " rack=" + v + } + if samp != 0 { + line += fmt.Sprintf(" sampling=%d", samp) } + if uns { + line += " unsampled" + } + return inv.editLine(func(f []string) bool { + return len(f) >= 2 && f[0] == "device" && f[1] == a.String() + }, line, nil) +} + +// editLine replaces the first line match accepts with line (dropping any +// further ones) or appends it, passes every other line through other when +// it is given, and saves. +func (inv *Inventory) editLine(match func([]string) bool, line string, other func([]string, string) string) error { lines := strings.Split(strings.TrimRight(inv.Text(), "\n"), "\n") if len(lines) == 1 && lines[0] == "" { lines = nil } - isIface := func(f []string) bool { - return len(f) >= 3 && f[0] == "iface" && f[1] == exporter.String() && f[2] == strconv.FormatUint(uint64(idx), 10) - } - var speed string out := make([]string, 0, len(lines)+1) at := -1 for _, ln := range lines { @@ -298,41 +415,20 @@ func (inv *Inventory) SetIface(exporter netip.Addr, idx uint32, name, tag string if t := strings.TrimSpace(ln); !strings.HasPrefix(t, "#") { f = strings.Fields(t) } - if isIface(f) { - for _, w := range f[3:] { - if strings.HasPrefix(w, "speed=") { - speed = w - } - } + if match(f) { if at < 0 { at = len(out) - out = append(out, "") + out = append(out, line) } continue } - if def && len(f) > 0 && f[0] == "iface" { - // only one default: take it off the other interfaces - if strings.Contains(" "+ln+" ", " default ") { - ln = strings.Replace(" "+ln+" ", " default ", " ", 1) - ln = strings.TrimSpace(ln) - } + if other != nil { + ln = other(f, ln) } out = append(out, ln) } - line := fmt.Sprintf("iface %s %d %s", exporter, idx, name) - if speed != "" { - line += " " + speed - } - if tag != "" { - line += " tag=" + tag - } - if def { - line += " default" - } if at < 0 { out = append(out, line) - } else { - out[at] = line } return inv.Save(strings.Join(out, "\n") + "\n") } @@ -375,6 +471,38 @@ func (inv *Inventory) Device(a netip.Addr) string { return inv.devices[a] } +// PlaceOf is the room and rack written on a device's line. +func (inv *Inventory) PlaceOf(a netip.Addr) Place { + inv.mu.RLock() + defer inv.mu.RUnlock() + return inv.places[a] +} + +// Devices lists the devices the inventory names. +func (inv *Inventory) Devices() []netip.Addr { + inv.mu.RLock() + defer inv.mu.RUnlock() + out := make([]netip.Addr, 0, len(inv.devices)) + for a := range inv.devices { + out = append(out, a) + } + return out +} + +// Circuits lists the interfaces that have a carrier or a circuit written +// on their line, keyed "exporter/ifindex". +func (inv *Inventory) Circuits() map[string]Iface { + inv.mu.RLock() + defer inv.mu.RUnlock() + out := map[string]Iface{} + for k, it := range inv.ifaces { + if it.ISP != "" || it.Circuit != "" { + out[k] = it + } + } + return out +} + func (inv *Inventory) Iface(exporter netip.Addr, idx uint32) Iface { inv.mu.RLock() defer inv.mu.RUnlock() diff --git a/internal/enrich/iface_test.go b/internal/enrich/iface_test.go index 2b83d34..d6de2be 100644 --- a/internal/enrich/iface_test.go +++ b/internal/enrich/iface_test.go @@ -37,3 +37,48 @@ func TestSetIface(t *testing.T) { t.Fatalf("iface 3 %+v", it) } } + +// Where a device stands and which carrier an interface goes out to are +// written on their own lines, and editing one thing on a line keeps the +// rest of it. +func TestPlacesAndCircuits(t *testing.T) { + inv := NewInventory() + if err := inv.Save("device 10.0.0.1 Core sampling=1000\niface 10.0.0.1 3 Uplink speed=1000000000 tag=wan default\n"); err != nil { + t.Fatal(err) + } + a := netip.MustParseAddr("10.0.0.1") + if err := inv.SetDevice(a, "Core router", "Room 1", "A03"); err != nil { + t.Fatal(err) + } + if p := inv.PlaceOf(a); p.Room != "Room 1" || p.Rack != "A03" || inv.Device(a) != "Core router" { + t.Fatalf("place %+v name %q", p, inv.Device(a)) + } + if inv.Sampling()[a] != 1000 { + t.Fatal("the sampling rate on the device line was lost") + } + if err := inv.SetCircuit(a, 3, "Telecom uplink", "China Telecom", "SH-12345"); err != nil { + t.Fatal(err) + } + it := inv.Iface(a, 3) + if it.ISP != "China Telecom" || it.Circuit != "SH-12345" || it.Name != "Telecom uplink" { + t.Fatalf("circuit %+v", it) + } + if it.Speed != 1000000000 || it.Tag != "wan" || !it.Default { + t.Fatalf("the rest of the line was lost: %+v", it) + } + // renaming from the interface page keeps the carrier + if err := inv.SetIface(a, 3, "WAN", "wan", true); err != nil { + t.Fatal(err) + } + if it := inv.Iface(a, 3); it.ISP != "China Telecom" || it.Circuit != "SH-12345" { + t.Fatalf("renaming dropped the carrier: %+v", it) + } + if c := inv.Circuits(); len(c) != 1 { + t.Fatalf("circuits %v", c) + } + want := "device 10.0.0.1 Core router room=Room_1 rack=A03 sampling=1000\n" + + "iface 10.0.0.1 3 WAN speed=1000000000 tag=wan isp=China_Telecom circuit=SH-12345 default\n" + if got := inv.Text(); got != want { + t.Fatalf("text\n%s\nwant\n%s", got, want) + } +} diff --git a/internal/license/license.go b/internal/license/license.go index cdd2136..10b90fc 100644 --- a/internal/license/license.go +++ b/internal/license/license.go @@ -84,7 +84,10 @@ const ( // stays free; these are the registered-only parts. const ( FeatSyslog = "syslog" // syslog output, including several targets - FeatDCIM = "dcim" // rooms, racks, ports, circuits, the networks on a device + // FeatDCIM gates nothing either: the asset ledger it opened is gone, + // and the rooms-and-racks picture that replaced it is free. The name + // stays in Features so every licence already issued verifies as signed. + FeatDCIM = "dcim" // free since; granted by older licences // FeatTLS gates nothing: HTTPS is free, because the alternative is // passwords and session cookies crossing the network in the clear, and // that is not a feature to sell. The name stays, and stays in Features, diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 4413f5b..3bf1f8d 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -338,33 +338,35 @@ label.btn.disabled{opacity:.5;cursor:not-allowed!important} .nmadv{margin-top:14px} .nmadv summary{cursor:pointer;color:var(--ink-2);font-size:13px;margin-bottom:8px} @media (max-width:820px){.nmform{grid-template-columns:1fr 1fr}} -/* The asset ledger. A rack is drawn by the unit and numbered from the - bottom as racks are, so a gap on the page is a gap in the cabinet. */ -/* Racks stand on the floor, so cabinets of different heights line up at - the bottom and their tops differ, as they do in the room. */ -.rackwrap{display:flex;flex-wrap:wrap;gap:18px;align-items:flex-end} -/* One device's own page: a row of facts above its ports. */ -.dtop{display:flex;align-items:center;gap:8px;margin-bottom:12px} -.afacts{display:flex;flex-wrap:wrap;gap:8px 28px} -.afact{display:flex;flex-direction:column;gap:2px;min-width:0} -.afact .k{font-size:12px;color:var(--ink-3)} -.afact .v{font-size:13.5px;color:var(--ink)} -.rackcard{flex:0 0 auto;min-width:210px} -.rackcard .rh{display:flex;align-items:baseline;gap:8px;margin-bottom:6px;font-size:13px} -.rackcard .rh .rbtns{margin-inline-start:auto;display:flex;gap:4px} -.rack{position:relative;width:210px;border:1px solid var(--line-2);border-radius:6px;overflow:hidden;background:var(--surface-2)} -/* one unit is 14px: a full 46U cabinet then fits on a screen, which is - how a rack elevation is drawn on paper too */ -.rack .ru{height:14px;border-bottom:1px solid var(--line);box-sizing:border-box;position:relative} -.rack .ru:last-child{border-bottom:0} -.rack .ru span{position:absolute;inset-inline-start:3px;top:0;line-height:13px;font-size:9px;color:var(--ink-3);font-variant-numeric:tabular-nums} -.rdev{position:absolute;inset-inline-start:20px;inset-inline-end:3px;background:var(--accent);color:#fff;border:0;border-radius:3px; - font-size:11px;line-height:1;text-align:start;padding:0 5px;cursor:pointer;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} -.rdev:hover{filter:brightness(1.1)} -.rloose{margin-top:6px;font-size:12px;color:var(--ink-2)} -.pastebox{width:100%;box-sizing:border-box;font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;font-size:12.5px; - padding:8px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface);color:var(--ink);white-space:pre;overflow-x:auto} -.freelist{margin:0 0 10px;font-size:13px;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px} +/* Rooms and racks: a picture of where flows come from. A room holds its + racks side by side, a rack its devices, and every port of a device runs + out to the carrier and circuit it goes to. */ +.rkhint{margin:0 0 14px;font-size:13px;color:var(--ink-3)} +.rkroom{border:1px solid var(--line-2);border-radius:10px;padding:12px 14px 14px;margin-bottom:14px;background:var(--surface-2)} +.rkroom>h3{margin:0 0 10px;font-size:15px;display:flex;align-items:center;gap:8px} +.rkroom>h3 .ni{opacity:.7} +.rkroom.unplaced{border-style:dashed;background:transparent} +.rkracks{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start} +.rkrack{flex:1 1 360px;max-width:560px;border:2px solid var(--ink-3);border-radius:6px;background:var(--surface);overflow:hidden} +.rkrh{background:var(--ink-3);color:var(--surface);font-size:12.5px;font-weight:600;padding:4px 10px;letter-spacing:.02em} +.rkdev{margin:8px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface)} +.rkdh{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);font-size:13.5px} +.rkdh .mono{color:var(--ink-3)} +.rkif{display:flex;align-items:center;gap:8px;padding:5px 10px;font-size:13px;min-width:0} +.rkif+.rkif{border-top:1px dashed var(--line)} +.rkport{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rkport .muted{font-size:12px} +.rkwire{flex:1 1 24px;min-width:24px;border-top:2px dotted var(--line-2);align-self:center} +.rkisp{flex:0 0 auto;max-width:60%;padding:2px 9px;border-radius:999px;background:var(--accent);color:#fff;font-size:12.5px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.rkisp .mono{font-size:11.5px;opacity:.9;color:#fff} +.rkisp.none{background:transparent;color:var(--ink-3);border:1px dashed var(--line-2)} +.rkedit{margin-inline-start:auto;flex:none;background:none;border:0;padding:0 2px;color:var(--ink-3);cursor:pointer;font-size:13px} +.rkdh .rkedit{margin-inline-start:auto} +.rkif .rkedit{margin-inline-start:0} +.rkedit:hover{color:var(--accent)} +.rkmore{padding:2px 10px 6px;font-size:12.5px} +.rkmore summary{cursor:pointer;color:var(--ink-2)} .lnk{background:none;border:0;padding:0;color:var(--accent-ink);cursor:pointer;font:inherit;text-align:start} .lnk:hover{text-decoration:underline} .mono{font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;font-size:12.5px} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 355ab72..4ac43a6 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -315,7 +315,7 @@ async function resolveNames() { } // ------------------------------------------------------------ state & API -const VIEWS = ['help', 'account', 'overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'racks', 'devices', 'cleanup', 'sandbox', 'ifaces', 'names', 'sources', 'detail']; +const VIEWS = ['help', 'account', 'overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'racks', 'cleanup', 'sandbox', 'ifaces', 'names', 'sources', 'detail']; const RANGES = ['15m', '1h', '6h', '24h', '7d', '30d']; // How often a page redraws itself, by range. It follows the width of the @@ -1693,143 +1693,6 @@ async function ifaceCleanup(el) { // the ages offered, mirroring the server's own list const cleanupDaysUI = [120, 90, 60, 30, 7]; -// ------------------------------------------------------- the asset ledger -// Sites, racks, devices and their ports, the cables between them, and the -// networks and addresses assigned on them. It is a ledger: everything here -// was written down by someone, never discovered. Registered installations -// only — the panels are shown and explained rather than hidden, so what -// registering buys is visible, and the server refuses every call anyway. -let assetData = null; -const assetSel = {site: 0, rack: 0, device: 0}; - -async function loadAssets() { - const res = await fetch('/api/assets'); - if (res.status === 401) { showLogin(); throw new Error('login'); } - const j = await res.json().catch(() => ({})); - if (!res.ok) return assetData = {locked: j.error === 'not_registered', error: j.error || res.statusText}; - j.by = { - site: new Map((j.sites || []).map(x => [x.id, x])), - rack: new Map((j.racks || []).map(x => [x.id, x])), - device: new Map((j.devices || []).map(x => [x.id, x])), - port: new Map((j.ports || []).map(x => [x.id, x])), - }; - return assetData = j; -} - -async function assetSave(kind, rec) { - const res = await fetch('/api/assets/' + kind, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rec)}); - const j = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(j.detail || j.error || res.statusText); - return j; -} - -async function assetDelete(kind, id) { - const res = await fetch(`/api/assets/${kind}?id=${id}`, {method: 'DELETE'}); - const j = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(j.detail || j.error || res.statusText); -} - -// The locked panel every asset page shows without a licence. It says what -// the page would do, because a prospect has to see what they would buy. -function assetLocked(el, titleKey, whatKey) { - el.innerHTML = `
${panel('c12 locked', t(titleKey), t('cl.reg_only'), - `

${esc(t(whatKey))}

-

${esc(t('as.locked'))}

`)}
`; -} - -// assetPage loads the ledger and handles the two states that are not the -// page: no licence, and a failure worth showing. -async function assetPage(el, titleKey, whatKey) { - const d = await loadAssets(); - if (d.locked) { assetLocked(el, titleKey, whatKey); return null; } - if (d.error) { el.innerHTML = `
${panel('c12', t(titleKey), '', `
${esc(d.error)}
`)}
`; return null; } - return d; -} - -// ------------------------------------------------------------ the editor -// One form over one record. Fields describe what to show; saving posts the -// whole record back, so a field the form does not show is carried over -// untouched rather than cleared. -const ASSET_FIELDS = { - site: [['name', 'text', 1], ['address', 'text'], ['note', 'area'], ['hidden', 'check']], - rack: [['name', 'text', 1], ['site_id', 'site'], ['units', 'num'], ['note', 'area'], ['hidden', 'check']], - device: [['name', 'text', 1], ['role', 'text'], ['vendor', 'text'], ['model', 'text'], ['serial', 'text'], - ['mgmt_ip', 'text'], ['rack_id', 'rack'], ['pos_u', 'num'], ['height_u', 'num'], ['tags', 'tags'], - ['note', 'area'], ['hidden', 'check']], - port: [['name', 'text', 1], ['ifindex', 'num'], ['speed', 'num'], ['kind', 'text'], ['note', 'area']], - circuit: [['name', 'text', 1], ['carrier', 'text'], ['kind', 'text'], ['bandwidth', 'text'], - ['site_id', 'site'], ['port_id', 'port'], ['contract', 'text'], ['starts', 'day'], ['ends', 'day'], - ['tags', 'tags'], ['note', 'area'], ['hidden', 'check']], - subnet: [['prefix', 'text', 1], ['name', 'text'], ['vlan', 'num'], ['device_id', 'device'], - ['gateway', 'text'], ['tags', 'tags'], ['note', 'area'], ['hidden', 'check']], -}; - -function assetOptions(type, cur) { - const none = ``; - const opt = (v, l) => ``; - if (type === 'site') return none + (assetData.sites || []).map(x => opt(x.id, x.name)).join(''); - if (type === 'rack') return none + (assetData.racks || []).map(x => opt(x.id, rackLabel(x))).join(''); - if (type === 'device') return none + (assetData.devices || []).map(x => opt(x.id, x.name)).join(''); - if (type === 'port') return none + (assetData.ports || []).map(x => opt(x.id, portLabel(x))).join(''); - return ''; -} -const rackLabel = r => (r.site_id && assetData.by.site.get(r.site_id) ? assetData.by.site.get(r.site_id).name + ' · ' : '') + r.name; -const portLabel = p => (assetData.by.device.get(p.device_id)?.name || '?') + ' · ' + p.name; - -// assetEdit opens the editor for one record and resolves when it is saved -// (to the saved record) or cancelled (to null). -function assetEdit(kind, rec) { - return new Promise(resolve => { - const fields = ASSET_FIELDS[kind]; - const val = f => rec[f] ?? ''; - const row = ([f, type, req]) => { - const id = 'af_' + f, lab = ``; - if (type === 'check') return `
- ${esc(t('as.hidden_hint'))}
`; - if (type === 'area') return `
${lab}
`; - if (type === 'tags') return `
${lab}
`; - if (type === 'num') return `
${lab}
`; - if (type === 'day') return `
${lab}
`; - if (['site', 'rack', 'device', 'port'].includes(type)) return `
${lab}
`; - return `
${lab}
`; - }; - const dlg = document.createElement('dialog'); - dlg.className = 'aedit'; - dlg.innerHTML = `

${esc(t(rec.id ? 'as.edit_' + kind : 'as.add_' + kind))}

-
${fields.map(row).join('')}
-
-
-
`; - document.body.append(dlg); - const close = v => { dlg.close(); dlg.remove(); resolve(v); }; - dlg.querySelector('#afCancel').onclick = () => close(null); - dlg.addEventListener('cancel', e => { e.preventDefault(); close(null); }); - dlg.querySelector('#afSave').onclick = async () => { - const out = {...rec}; - for (const [f, type] of fields) { - const e = dlg.querySelector('#af_' + f); - if (type === 'check') out[f] = e.checked; - else if (type === 'tags') out[f] = e.value.split(',').map(s => s.trim()).filter(Boolean); - else if (type === 'num' || ['site', 'rack', 'device', 'port'].includes(type)) out[f] = +e.value || 0; - else out[f] = e.value.trim(); - } - const m = dlg.querySelector('#afMsg'); - m.textContent = ''; - try { close(await assetSave(kind, out)); } - catch (err) { m.textContent = err.message; } - }; - dlg.showModal(); - dlg.querySelector('input, select, textarea')?.focus(); - }); -} - -// assetRemove asks first and names what is going. -async function assetRemove(kind, rec, label) { - if (!confirm(t('as.confirm_delete', {x: label}))) return false; - try { await assetDelete(kind, rec.id); return true; } - catch (err) { alert(err.message); return false; } -} - // Controls only an administrator gets. The calls behind them are gated // server-side too (adminAPI, in internal/api/server.go) — this only keeps // the page from offering a button that would answer 403. Tables and @@ -1837,341 +1700,98 @@ async function assetRemove(kind, rec, label) { const adm = () => !!(me && me.admin); const admOnly = html => adm() ? html : ''; -// Buttons an administrator gets and nobody else: the ledger is read by -// everyone and written by the one who keeps it. -const adminOnly = html => assetData.admin ? html : ''; -const quotaLine = (have, limit) => limit > 0 ? `${nf(have)} / ${nf(limit)}` : nf(have); - -// ------------------------------------------------- the asset management -// One page for where the equipment is — sites, racks and the devices in -// them — and a page of its own for a device, reached by clicking it. The -// device page is not in the navigation because it is about one device: -// there is nothing to show until one has been chosen. -const ASSET_VIEWS = new Set(['racks', 'devices']); - -// goDevice opens one device's own page. -function goDevice(id) { assetSel.device = id; go('devices'); } - +// ------------------------------------------------------- rooms and racks +// A picture of where the flows come from: room, rack, device, port, and +// the carrier and circuit the port goes out to. It is written down once, +// beside the names in the inventory file, and nothing is looked up in it. +// Devices and ports come from the flows themselves; only the places and +// the carriers are typed in. +const RK_SHOWN = 6; // ports listed before the rest fold away views.racks = async (el) => { - const d = await assetPage(el, 'nav.racks', 'as.what_racks'); - if (!d) return; - const sites = d.sites || [], racks = d.racks || [], ports = d.ports || []; - if (assetSel.site && !d.by.site.has(assetSel.site)) assetSel.site = 0; - const shown = assetSel.site ? racks.filter(r => r.site_id === assetSel.site) : racks; - const devOf = r => (d.devices || []).filter(x => x.rack_id === r.id); - const siteRow = s => ` - ${s.hidden ? `${esc(t('as.f_hidden'))}` : ''} - ${esc(s.address || '')} - ${nf(racks.filter(r => r.site_id === s.id).length)} - ${adminOnly(` - `)}`; - // the devices of the chosen site, or of every site - const devices = assetSel.site ? (d.devices || []).filter(x => d.by.rack.get(x.rack_id)?.site_id === assetSel.site) : (d.devices || []); - const devRow = x => ` - ${x.hidden ? `${esc(t('as.f_hidden'))}` : ''} - ${(x.tags || []).map(tg => `${esc(tg)}`).join('')} - ${esc(x.role || '')} - ${esc([x.vendor, x.model].filter(Boolean).join(' '))} - ${x.mgmt_ip ? ipCell(x.mgmt_ip) : ''} - ${devPlace(d, x)} - ${nf(ports.filter(p => p.device_id === x.id).length)}`; - el.innerHTML = `
- ${panel('c12', t('nav.racks'), assetCounts(d), `

${esc(t('as.what_racks'))}

- - ${sites.map(siteRow).join('') || ``}
${esc(t('as.f_name'))}${esc(t('as.f_address'))}${esc(t('as.racks'))}
${esc(t('as.no_sites'))}
- ${adminOnly(`
- ${sites.length ? `` : ''}
`)}`)} - ${panel('c12', t('as.racks') + (assetSel.site ? ' · ' + esc(d.by.site.get(assetSel.site).name) : ''), '', - shown.length ? `
${shown.map(r => rackCard(r, devOf(r))).join('')}
` - : `
${esc(t('as.no_racks'))}
`)} - ${panel('c12', t('as.devices'), t('as.devices_sub'), ` - - ${devices.map(devRow).join('') || ``}
${esc(t('as.f_name'))}${esc(t('as.f_role'))}${esc(t('as.f_model'))}${esc(t('as.f_mgmt_ip'))}${esc(t('as.place'))}${esc(t('as.ports'))}
${esc(t('as.no_devices'))}
- ${adminOnly(`
`)}`)} - ${adminOnly(pastePanel(d) + csvPanel())} -
`; - el.querySelectorAll('[data-site]').forEach(b => b.onclick = () => { assetSel.site = assetSel.site === +b.dataset.site ? 0 : +b.dataset.site; render(); }); - el.querySelectorAll('[data-esite]').forEach(b => b.onclick = async () => { if (await assetEdit('site', {...d.by.site.get(+b.dataset.esite)})) render(); }); - el.querySelectorAll('[data-dsite]').forEach(b => b.onclick = async () => { - const s = d.by.site.get(+b.dataset.dsite); - if (await assetRemove('site', s, s.name)) render(); - }); - el.querySelectorAll('[data-erack]').forEach(b => b.onclick = async () => { if (await assetEdit('rack', {...d.by.rack.get(+b.dataset.erack)})) render(); }); - el.querySelectorAll('[data-drack]').forEach(b => b.onclick = async () => { - const r = d.by.rack.get(+b.dataset.drack); - if (await assetRemove('rack', r, r.name)) render(); + const res = await fetch('/api/racks'); + if (res.status === 401) { showLogin(); return; } + const d = await res.json().catch(() => ({})); + const devs = d.devices || [], can = !!d.admin; + const head = `

${esc(t('rk.hint'))}

`; + if (!devs.length) { el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), head + `
${esc(t('rk.empty'))}
`)}
`; return; } + const rooms = new Map(); + devs.forEach((x, i) => { + const room = x.room || '', rack = x.rack || ''; + if (!rooms.has(room)) rooms.set(room, new Map()); + const racks = rooms.get(room); + if (!racks.has(rack)) racks.set(rack, []); + racks.get(rack).push(i); }); - // a device in a rack and a device in the list both open its own page - el.querySelectorAll('[data-rdev]').forEach(b => b.onclick = () => goDevice(+b.dataset.rdev)); - el.querySelectorAll('[data-dev]').forEach(b => b.onclick = () => goDevice(+b.dataset.dev)); - const add = $('#asAddSite'); if (add) add.onclick = async () => { if (await assetEdit('site', {})) render(); }; - const addR = $('#asAddRack'); if (addR) addR.onclick = async () => { if (await assetEdit('rack', {site_id: assetSel.site || sites[0].id, units: d.default_units || 46})) render(); }; - const addD = $('#asAddDev'); if (addD) addD.onclick = async () => { - const made = await assetEdit('device', {height_u: 1, rack_id: 0}); - if (made) { assetData = null; goDevice(made.id); } + // rooms in name order, the devices nobody has placed yet last + const order = [...rooms.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true})); + const pencil = attrs => can ? `` : ''; + const port = (i, f) => `
+ ${esc(f.name || t('rk.ifindex', {n: f.ifindex}))} #${f.ifindex}${f.speed ? ' · ' + esc(fmtBps(f.speed)) : ''} + + ${f.isp || f.circuit + ? esc(f.isp || '') + (f.circuit ? ` ${f.isp ? '· ' : ''}${esc(f.circuit)}` : '') + : esc(t('rk.no_carrier'))} + ${pencil(`data-rki="${i}:${f.ifindex}"`)}
`; + const device = i => { + const x = devs[i], fs = x.ifaces || []; + // the ports with a carrier always show; of the rest, the busiest + const lead = fs.filter((f, k) => f.isp || f.circuit || k < RK_SHOWN), rest = fs.filter(f => !lead.includes(f)); + return `
${esc(x.name || x.exporter)}${x.name ? ` ${esc(x.exporter)}` : ''} + ${x.seen ? '' : `${esc(t('rk.quiet'))}`}${pencil(`data-rkd="${i}"`)}
+ ${lead.map(f => port(i, f)).join('') || `
${esc(t('rk.no_ifaces'))}
`} + ${rest.length ? `
${esc(t('rk.more', {n: nf(rest.length)}))}${rest.map(f => port(i, f)).join('')}
` : ''}
`; }; - wirePaste(); - wireCSV(); -}; - -const assetCounts = d => [ - t('as.c_sites', {x: quotaLine(d.counts.sites, d.quota.sites)}), - t('as.c_racks', {x: quotaLine(d.counts.racks, d.quota.racks)}), - t('as.c_devices', {x: quotaLine(d.counts.devices, d.quota.devices)}), -].join(' · '); - -// where a device stands, for the lists -function devPlace(d, x) { - const r = d.by.rack.get(x.rack_id); - if (!r) return `${esc(t('as.unracked'))}`; - return esc(r.name) + (x.pos_u ? ` U${x.pos_u}` : ''); -} - -// One rack drawn by the unit, numbered from the bottom as racks are. A -// device fills the units it occupies, so a gap on the page is a gap in the -// cabinet; a device in the rack whose unit nobody wrote down is listed -// under it rather than placed somewhere invented. -function rackCard(r, devs) { - // one rack unit in pixels; .rack .ru in app.css draws the same height - const H = 14, placed = devs.filter(x => x.pos_u > 0), loose = devs.filter(x => !x.pos_u); - const units = []; - for (let u = r.units; u >= 1; u--) units.push(`
${u}
`); - const blocks = placed.map(x => { - const h = Math.max(1, x.height_u || 1), top = (r.units - (x.pos_u + h - 1)) * H; - const title = `${x.name} · ${t('as.units_at', {u: x.pos_u, n: h})}${x.model ? ' · ' + x.model : ''}`; - return ``; - }); - const free = r.units - placed.reduce((n, x) => n + Math.max(1, x.height_u || 1), 0); - return `
${esc(r.name)} - ${esc(t('as.units_free', {n: free, t: r.units}))} - ${adminOnly(``)}
-
${units.join('')}${blocks.join('')}
- ${loose.length ? `
${esc(t('as.unplaced'))}: ${loose.map(x => ``).join(', ')}
` : ''}
`; -} - -// ------------------------------------------------------- one device -// Reached by clicking a device, never from the navigation: what it is, -// where it stands, and every port with the port at the other end of its -// cable. -views.devices = async (el) => { - const d = await assetPage(el, 'as.device', 'as.what_devices'); - if (!d) return; - const x = assetSel.device ? d.by.device.get(assetSel.device) : null; - if (!x) { go('racks'); return; } - $('#title').textContent = x.name; - document.title = x.name + ' · traffic66'; - const ports = (d.ports || []).filter(p => p.device_id === x.id); - const subnets = (d.subnets || []).filter(n => n.device_id === x.id); - const peer = new Map(); - (d.links || []).forEach(l => { peer.set(l.a_port, l.b_port); peer.set(l.b_port, l.a_port); }); - const fact = (k, v) => v ? `
${esc(t(k))}${v}
` : ''; - el.innerHTML = `
- ${panel('c12', esc(x.name), esc(x.role || ''), `
- - ${adminOnly(` - `)}
-
- ${fact('as.f_vendor', esc([x.vendor, x.model].filter(Boolean).join(' ')))} - ${fact('as.f_serial', esc(x.serial || ''))} - ${fact('as.f_mgmt_ip', x.mgmt_ip ? ipCell(x.mgmt_ip) : '')} - ${fact('as.place', devPlace(d, x))} - ${fact('as.f_height_u', x.height_u ? esc(x.height_u + 'U') : '')} - ${fact('as.f_tags', (x.tags || []).map(tg => `${esc(tg)}`).join(''))} - ${x.hidden ? fact('as.f_hidden', `${esc(t('as.f_hidden'))}`) : ''} -
- ${x.note ? `

${esc(x.note)}

` : ''}`)} - ${panel('c12', t('as.ports_of', {x: x.name}), portUse(ports, peer), portTable(d, ports, peer))} - ${panel('c12', t('as.subnets_of', {x: x.name}), t('as.subnets_sub'), subnetTable(subnets))} -
`; - $('#asBack').onclick = () => go('racks'); - const ed = $('#asEditDev'); if (ed) ed.onclick = async () => { if (await assetEdit('device', {...x})) render(); }; - const del = $('#asDelDev'); if (del) del.onclick = async () => { - if (await assetRemove('device', x, x.name)) { assetSel.device = 0; assetData = null; go('racks'); } + const roomIcon = ''; + const body = order.map(room => { + const racks = rooms.get(room), names = [...racks.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true})); + return `

${roomIcon}${esc(room || t('rk.unplaced'))}

+ ${names.map(rack => `
${esc(rack ? t('rk.rack_n', {x: rack}) : t('rk.no_rack'))}
${racks.get(rack).map(device).join('')}
`).join('')} +
`; + }).join(''); + el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), head + body)}
`; + const uniq = k => [...new Set(devs.map(x => x[k]).filter(Boolean))].sort(); + const carriers = [...new Set(devs.flatMap(x => (x.ifaces || []).map(f => f.isp)).filter(Boolean))].sort(); + const send = async (path, body) => { + const r = await fetch('/api/racks/' + path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); + if (!r.ok) { const j = await r.json().catch(() => ({})); toast(j.error || r.statusText); return; } + ifList = null; render(); }; - el.querySelectorAll('[data-eport]').forEach(b => b.onclick = async () => { if (await assetEdit('port', {...d.by.port.get(+b.dataset.eport)})) render(); }); - el.querySelectorAll('[data-dport]').forEach(b => b.onclick = async () => { - const p = d.by.port.get(+b.dataset.dport); - if (await assetRemove('port', p, portLabel(p))) render(); + el.querySelectorAll('[data-rkd]').forEach(b => b.onclick = async () => { + const x = devs[+b.dataset.rkd]; + const v = await rkForm(x.name || x.exporter, [['name', t('rk.device_name'), x.name], ['room', t('rk.room'), x.room, uniq('room')], ['rack', t('rk.rack'), x.rack, uniq('rack')]]); + if (v) send('device', {exporter: x.exporter, ...v}); }); - el.querySelectorAll('[data-cable]').forEach(b => b.onclick = () => cablePort(d, +b.dataset.cable, peer)); - const addP = $('#asAddPort'); if (addP) addP.onclick = async () => { if (await assetEdit('port', {device_id: x.id})) render(); }; - el.querySelectorAll('[data-esub]').forEach(b => b.onclick = async () => { if (await assetEdit('subnet', {...subnets.find(n => n.id === +b.dataset.esub)})) render(); }); - el.querySelectorAll('[data-dsub]').forEach(b => b.onclick = async () => { - const n = subnets.find(y => y.id === +b.dataset.dsub); - if (await assetRemove('subnet', n, n.prefix)) render(); + el.querySelectorAll('[data-rki]').forEach(b => b.onclick = async () => { + const [i, idx] = b.dataset.rki.split(':').map(Number), x = devs[i], f = (x.ifaces || []).find(y => y.ifindex === idx) || {ifindex: idx}; + const v = await rkForm((x.name || x.exporter) + ' · #' + idx, [['name', t('rk.iface_name'), f.name], ['isp', t('rk.isp'), f.isp, carriers], ['circuit', t('rk.circuit'), f.circuit]]); + if (v) send('iface', {exporter: x.exporter, ifindex: idx, ...v}); }); - const addS = $('#asAddSub'); if (addS) addS.onclick = async () => { if (await assetEdit('subnet', {device_id: x.id})) render(); }; }; -// The networks written down against a device. They are a note beside its -// ports — what this switch carries — and nothing is looked up in them: -// no chart, no name and no filter is decided from this table. -function subnetTable(ns) { - const row = n => `${esc(n.prefix)}${n.hidden ? ` ${esc(t('as.f_hidden'))}` : ''} - ${esc(n.name || '')} - ${n.vlan ? nf(n.vlan) : ''} - ${esc(n.gateway || '')} - ${(n.tags || []).map(tg => `${esc(tg)}`).join('')} - ${adminOnly(` - `)}`; - return ` - - ${ns.map(row).join('') || ``}
${esc(t('as.f_prefix'))}${esc(t('as.f_name'))}${esc(t('as.f_vlan'))}${esc(t('as.f_gateway'))}${esc(t('as.f_tags'))}
${esc(t('as.no_subnets'))}
- ${adminOnly(`
`)}`; -} - -// Port occupancy in one line: how many of the ports written down carry a -// cable. It counts the ledger, not the link state — nothing here is polled. -function portUse(ps, peer) { - if (!ps.length) return ''; - const used = ps.filter(p => peer.has(p.id)).length; - return t('as.port_use', {u: nf(used), n: nf(ps.length), p: nf(Math.round(used / ps.length * 100))}); -} - -function portTable(d, ps, peer) { - const row = p => { - const o = peer.get(p.id), op = o ? d.by.port.get(o) : null; - return `${esc(p.name)} - ${p.ifindex ? nf(p.ifindex) : ''} - ${p.speed ? fmtBps(p.speed) : ''} - ${esc(p.kind || '')} - ${op ? esc(portLabel(op)) : `${esc(t('as.port_free'))}`} - ${adminOnly(` - - `)}`; - }; - return ` - - ${ps.map(row).join('') || ``}
${esc(t('as.f_name'))}${esc(t('as.f_ifindex'))}${esc(t('as.f_speed'))}${esc(t('as.f_kind'))}${esc(t('as.cabled_to'))}
${esc(t('as.no_ports'))}
- ${adminOnly(`
`)}`; -} - -// Cabling a port: pick the other end, or pull the cable out. A port holds -// one cable, so choosing a port that already has one moves it. -function cablePort(d, portID, peer) { - const others = (d.ports || []).filter(p => p.id !== portID); - const cur = peer.get(portID) || 0; - const dlg = document.createElement('dialog'); - dlg.className = 'aedit'; - dlg.innerHTML = `

${esc(t('as.cable_title', {x: portLabel(d.by.port.get(portID))}))}

-
-
-
-
-
`; - document.body.append(dlg); - const close = () => { dlg.close(); dlg.remove(); }; - dlg.querySelector('#afCancel').onclick = close; - dlg.addEventListener('cancel', e => { e.preventDefault(); close(); }); - dlg.querySelector('#afSave').onclick = async () => { - const to = +dlg.querySelector('#afPeer').value; - const link = (d.links || []).find(l => l.a_port === portID || l.b_port === portID); - try { - if (!to) { if (link) await assetDelete('link', link.id); } - else await assetSave('link', {id: link ? link.id : 0, a_port: portID, b_port: to}); - close(); render(); - } catch (err) { dlg.querySelector('#afMsg').textContent = err.message; } - }; - dlg.showModal(); -} - -// ------------------------------------------------------- import and export -// Pasting what a switch printed. The operator runs the command they -// already run; there is nothing to install on the device, no SNMP -// community to hand out and no credentials to keep anywhere. Nothing is -// written until the plan has been shown and accepted, so a paste of the -// wrong buffer is seen rather than suffered. -function pastePanel(d) { - const opt = x => ``; - return panel('c12', t('as.paste_title'), t('as.paste_sub'), - `

${esc(t('as.paste_what'))}

-
- - -
- -
-
-
`); -} - -// The ledger as a spreadsheet, in and out. Export, edit, import: the id -// column ties each row back to its record, so a file that came from here -// updates what it came from instead of doubling it. -function csvPanel() { - const kinds = ['site', 'rack', 'device', 'port', 'circuit', 'subnet', 'addr']; - const opt = k => ``; - return panel('c12', t('as.csv_title'), t('as.csv_sub'), - `

${esc(t('as.csv_what'))}

-
- - - -
-
-
-
`); -} - -// A plan, as a table of what would happen. Errors are shown with the rest -// rather than instead of it: one bad line in a spreadsheet does not make -// the other fifty wrong. -function planTable(plan) { - if (!plan) return ''; - const rows = plan.changes || []; - if (!rows.length && !(plan.skipped || []).length) return `
${esc(t('as.plan_none'))}
`; - const row = c => `${esc(t('as.kind_of_' + c.kind) || c.kind)} - ${esc(t('as.act_' + c.action))} - ${esc(c.what)}${esc(c.error || '')}`; - const n = rows.filter(c => c.action !== 'error' && c.action !== 'same').length; - return `

${esc(t(plan.applied ? 'as.plan_done' : 'as.plan_would', {n: nf(n)}))}

- ${rows.map(row).join('')}
${esc(t('as.what'))}${esc(t('as.detail'))}
- ${(plan.skipped || []).length ? `

${esc(t('as.plan_skipped'))}: ${esc(plan.skipped.slice(0, 8).join(' | '))}

` : ''}`; -} - -// wirePaste and wireCSV are called after the devices page is drawn. -function wirePaste() { - const box = $('#asPasteText'), check = $('#asPasteCheck'), apply = $('#asPasteApply'), out = $('#asPastePlan'); - if (!box) return; - const send = async applyIt => { - const body = {text: box.value, device_id: +$('#asPasteDev').value, apply: applyIt}; - const res = await fetch('/api/assets/paste', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); - const j = await res.json().catch(() => ({})); - if (!res.ok) { out.innerHTML = `
${esc(j.detail || j.error || res.statusText)}
`; apply.disabled = true; return null; } - out.innerHTML = planTable(j); - return j; - }; - check.onclick = async () => { const j = await send(false); apply.disabled = !j || !(j.changes || []).some(c => c.action !== 'error' && c.action !== 'same'); }; - apply.onclick = async () => { if (await send(true)) { apply.disabled = true; assetData = null; } }; -} - -function wireCSV() { - const kind = $('#asCsvKind'), file = $('#asCsvFile'), check = $('#asCsvCheck'), apply = $('#asCsvApply'), out = $('#asCsvPlan'); - if (!kind) return; - let text = ''; - $('#asCsvOut').onclick = () => { location.href = '/api/assets/csv?kind=' + encodeURIComponent(kind.value); }; - file.onchange = async () => { - const f = file.files[0]; - if (!f) { text = ''; check.disabled = true; return; } - text = await f.text(); - check.disabled = false; - apply.disabled = true; - out.innerHTML = ''; - }; - const send = async applyIt => { - const res = await fetch('/api/assets/csv', {method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({kind: kind.value, text, apply: applyIt})}); - const j = await res.json().catch(() => ({})); - if (!res.ok) { out.innerHTML = `
${esc(j.detail || j.error || res.statusText)}
`; apply.disabled = true; return null; } - out.innerHTML = planTable(j); - return j; - }; - check.onclick = async () => { const j = await send(false); apply.disabled = !j || !(j.changes || []).some(c => c.action !== 'error' && c.action !== 'same'); }; - apply.onclick = async () => { if (await send(true)) { apply.disabled = true; assetData = null; } }; +// rkForm asks for a few short values and resolves to them, or to null when +// cancelled. A list offers what was typed for other devices before, so one +// room is not written three ways. +function rkForm(title, fields) { + return new Promise(resolve => { + const dlg = document.createElement('dialog'); + dlg.className = 'aedit'; + dlg.innerHTML = `

${esc(title)}

${fields.map(([k, lab, v, list]) => `
+ + ${list && list.length ? `${list.map(o => `` : ''}
`).join('')}
+
`; + document.body.appendChild(dlg); + dlg.addEventListener('close', () => { + const out = {}; + dlg.querySelectorAll('input').forEach(x => { out[x.name] = x.value.trim(); }); + const ok = dlg.returnValue === 'ok'; + dlg.remove(); + resolve(ok ? out : null); + }); + dlg.showModal(); + dlg.querySelector('input')?.focus(); + }); } -// ------------------------------------------------------------- addresses // ------------------------------------------------------------ offline analysis // Capture files are imported into a database of their own; "Analyse" shows // them on all the other pages, apart from the live data. @@ -2775,7 +2395,7 @@ views.detail = async (el) => { // ------------------------------------------------------------ shell // Settings, data cleanup, offline analysis and live flow records have no use // for the time range, refresh and copy link. -const NOBAR_VIEWS = new Set(['help', 'sources', 'cleanup', 'sandbox', 'account', 'racks', 'devices', 'names']); +const NOBAR_VIEWS = new Set(['help', 'sources', 'cleanup', 'sandbox', 'account', 'racks', 'names']); function renderBar() { const off = NOBAR_VIEWS.has(state.v) || (state.v === 'records' && state.rl); if (off) $('#range').hidden = true; @@ -2792,7 +2412,7 @@ function refreshLabel() { } // The sandbox bar replaces the time range while capture files are shown. function renderSB() { - const on = inSB() && !['ifaces', 'sources', 'racks', 'devices'].includes(state.v); + const on = inSB() && !['ifaces', 'sources', 'racks'].includes(state.v); $('#sbbar').hidden = !on; $('#range').hidden = on; $('#refresh').hidden = on; @@ -2843,11 +2463,6 @@ async function render(push) { $('#title').textContent = t('nav.' + state.v); // the detail view refines it document.title = t('nav.' + state.v) + ' · traffic66'; document.querySelectorAll('#nav button').forEach(b => b.dataset.v === state.v ? b.setAttribute('aria-current', 'page') : b.removeAttribute('aria-current')); - // the device page is reached from the rack page, so the sidebar marks - // rooms and racks while it is open - document.querySelectorAll('#nav [data-v=racks]').forEach(b => { - if (ASSET_VIEWS.has(state.v)) b.setAttribute('aria-current', 'page'); - }); renderRange(); await renderIfsel(); writeHash(false); diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index f9093f3..04700fa 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -468,115 +468,8 @@ "cl.if_confirm": "حذف كل البيانات المحفوظة لـ {i}؟ لا يمكن التراجع.", "cl.if_confirm_days": "حذف بيانات {i} الأقدم من {n} يومًا؟ لا يمكن التراجع.", "cl.if_done": "حُذف {n} سجل تدفق و{c} عيّنة عدّاد من {i}، وتحررت {s}.", -"as.f_name": "الاسم", -"as.f_address": "العنوان", -"as.f_note": "ملاحظة", -"as.f_hidden": "مخفي", -"as.f_site_id": "الموقع", -"as.f_units": "عدد الوحدات", -"as.f_role": "الدور", -"as.f_vendor": "المُصنّع", -"as.f_model": "الطراز", -"as.f_serial": "الرقم التسلسلي", -"as.f_mgmt_ip": "IP الإدارة", -"as.f_rack_id": "الخزانة", -"as.f_pos_u": "الوحدة", -"as.f_height_u": "الارتفاع (U)", -"as.f_tags": "الوسوم", -"as.f_ifindex": "ifIndex", -"as.f_speed": "السرعة", -"as.f_kind": "النوع", -"as.f_carrier": "المشغّل", -"as.f_bandwidth": "النطاق", -"as.f_port_id": "المنفذ", -"as.f_contract": "العقد", -"as.f_starts": "من", -"as.f_ends": "إلى", -"as.f_prefix": "الشبكة", -"as.f_vlan": "VLAN", -"as.f_gateway": "البوابة", -"as.f_device_id": "الجهاز", "nav.racks": "المواقع والخزانات", "nav.devices": "الأجهزة والمنافذ", -"as.device": "جهاز", -"as.devices": "الأجهزة", -"as.devices_sub": "انقر على أحدها لعرض منافذه وتوصيلاته", -"as.what_racks": "أين تقف المعدات: المواقع والخزانات وما يشغل كل وحدة. كل ما هنا مُدوَّن بأيدي الناس لا مُكتشَفًا آليًا — ولذلك حين يختلف السجل عن الشبكة يظل السجل يقول ما كان ينبغي أن يكون هناك.", -"as.what_devices": "ما هو الجهاز وبماذا يتصل: المُصنّع والطراز والرقم التسلسلي وعنوان الإدارة، وكل منفذ ومعه المنفذ في الطرف الآخر من الكابل.", -"as.locked": "يتطلب هذا نسخة مسجَّلة. كل ما دُوِّن من قبل يبقى، ويعود متاحًا فور تثبيت الترخيص.", -"as.none": "—", -"as.cancel": "إلغاء", -"as.save": "حفظ", -"as.edit": "تعديل", -"as.delete": "حذف", -"as.confirm_delete": "حذف {x}؟ لا يمكن التراجع.", -"as.add_site": "إضافة موقع", -"as.edit_site": "تعديل الموقع", -"as.add_rack": "إضافة خزانة", -"as.edit_rack": "تعديل الخزانة", -"as.add_device": "إضافة جهاز", -"as.edit_device": "تعديل الجهاز", -"as.add_port": "إضافة منفذ", -"as.edit_port": "تعديل المنفذ", -"as.add_circuit": "إضافة دائرة", -"as.edit_circuit": "تعديل الدائرة", -"as.add_subnet": "إضافة شبكة", -"as.edit_subnet": "تعديل الشبكة", -"as.hidden_hint": "لا يرى هذا السجل إلا المسؤول.", -"as.tags_hint": "core, prod, edge", -"as.racks": "الخزانات", -"as.no_sites": "لا مواقع بعد.", -"as.no_racks": "لا خزانات بعد.", -"as.no_devices": "لا أجهزة بعد.", -"as.no_ports": "لا منافذ بعد.", -"as.subnets_of": "الشبكات على {x}", -"as.subnets_sub": "تُكتب بجانب المنافذ: ما يحمله هذا الجهاز. لا يُبحث هنا عن شيء — لا رسم بياني ولا اسم ولا مرشِّح يأتي من هذا الجدول.", -"as.no_subnets": "لا شبكات بعد.", -"as.c_sites": "المواقع {x}", -"as.c_racks": "الخزانات {x}", -"as.c_devices": "الأجهزة {x}", -"as.units_at": "الوحدة {u}، ارتفاع {n}U", -"as.units_free": "{n} حرة من أصل {t} وحدة", -"as.unplaced": "داخل الخزانة، الوحدة غير مُدوَّنة", -"as.place": "المكان", -"as.unracked": "خارج الخزانة", -"as.ports": "المنافذ", -"as.ports_of": "منافذ {x}", -"as.port_use": "{u} من {n} موصولة ({p}٪)", -"as.port_free": "حر", -"as.cable": "توصيل…", -"as.recable": "إعادة توصيل…", -"as.cabled_to": "موصول بـ", -"as.cable_title": "توصيل {x}", -"as.no_cable": "بلا كابل", -"as.what": "ماذا", -"as.detail": "التفصيل", -"as.paste_title": "لصق مخرجات محوّل", -"as.paste_sub": "ما يطبعه الجهاز أصلًا", -"as.paste_what": "شغّل الأمر الذي تستخدمه أصلاً والصق ما طبعه: show lldp neighbors و show cdp neighbors و show interface status. لا يُثبَّت شيء على الجهاز ولا تُحفَظ أي سلسلة مجتمع أو كلمة مرور في أي مكان.", -"as.paste_from": "نُفِّذ على", -"as.paste_hint": "الصق المخرجات هنا", -"as.preview": "اعرض ما سيحدث", -"as.apply": "اكتب", -"as.plan_would": "ستُجرى {n} تغييرات.", -"as.plan_done": "أُجريت {n} تغييرات.", -"as.plan_none": "لا شيء لتغييره.", -"as.plan_skipped": "لم يُفهم", -"as.act_add": "إضافة", -"as.act_update": "تحديث", -"as.act_same": "دون تغيير", -"as.act_error": "مشكلة", -"as.csv_title": "جدول بيانات", -"as.csv_sub": "تصدير واستيراد بصيغة CSV", -"as.csv_what": "صدِّر نوعًا، عدِّله، ثم استورده ثانية: عمود id يربط كل صف بسجله، فملفٌ خرج من هنا يُحدِّث ما خرج منه بدل أن يضاعفه. الملف بلا عمود id يضيف، والإشارات تُطابَق بالاسم.", -"as.csv_export": "تصدير", -"as.kind_of_site": "موقع", -"as.kind_of_rack": "خزانة", -"as.kind_of_device": "جهاز", -"as.kind_of_port": "منفذ", -"as.kind_of_link": "كابل", -"as.kind_of_circuit": "دائرة", -"as.kind_of_subnet": "شبكة", "nm.k_host": "مضيف", "nm.k_net": "شبكة", "nm.k_device": "جهاز", @@ -790,5 +683,25 @@ "ob.step2": "يظهر في الإعدادات خلال ثوانٍ، وتمتلئ هذه الصفحة بعد دقيقة أو دقيقتين.", "ob.try": "أو الصق أحد هذه الأوامر، وفيها عنوان هذا الجهاز ومنافذه مسبقًا:", "ob.copy": "نسخ", -"nav.help": "المساعدة" +"nav.help": "المساعدة", +"rk.sub": "أين يقف كل جهاز يرسل التدفقات، وإلى أي مشغّل ودائرة يخرج كل منفذ", +"rk.hint": "املأه مرة واحدة. الأجهزة والمنافذ تأتي من التدفقات؛ ولا يُكتب إلا الغرفة والخزانة والمشغّل والدائرة، وتُحفظ في ملف السجل.", +"rk.empty": "لم يرسل أي جهاز تدفقات بعد.", +"rk.unplaced": "لم يُحدَّد مكانه بعد", +"rk.no_rack": "بلا خزانة", +"rk.rack_n": "الخزانة {x}", +"rk.room": "الغرفة", +"rk.rack": "الخزانة", +"rk.device_name": "اسم الجهاز", +"rk.iface_name": "اسم المنفذ", +"rk.isp": "المشغّل", +"rk.circuit": "معرّف الدائرة", +"rk.no_carrier": "بلا مشغّل", +"rk.ifindex": "المنفذ {n}", +"rk.no_ifaces": "لم يُرَ أي منفذ في اليوم الأخير", +"rk.more": "{n} منافذ أخرى", +"rk.quiet": "لا يرسل الآن", +"rk.edit": "تعديل", +"rk.save": "حفظ", +"rk.cancel": "إلغاء" } diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 47cebf6..75888ab 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -468,115 +468,8 @@ "cl.if_confirm": "{i}-এর সব সংরক্ষিত ডেটা মুছবেন? এটি ফেরানো যায় না।", "cl.if_confirm_days": "{i}-এর {n} দিনের পুরনো ডেটা মুছবেন? এটি ফেরানো যায় না।", "cl.if_done": "{i}-এর {n}টি ফ্লো রেকর্ড ও {c}টি কাউন্টার নমুনা মোছা হয়েছে, {s} খালি হয়েছে।", -"as.f_name": "নাম", -"as.f_address": "ঠিকানা", -"as.f_note": "নোট", -"as.f_hidden": "লুকানো", -"as.f_site_id": "সাইট", -"as.f_units": "ইউনিট", -"as.f_role": "ভূমিকা", -"as.f_vendor": "নির্মাতা", -"as.f_model": "মডেল", -"as.f_serial": "সিরিয়াল নম্বর", -"as.f_mgmt_ip": "ম্যানেজমেন্ট IP", -"as.f_rack_id": "র‍্যাক", -"as.f_pos_u": "ইউনিট", -"as.f_height_u": "উচ্চতা (U)", -"as.f_tags": "ট্যাগ", -"as.f_ifindex": "ifIndex", -"as.f_speed": "গতি", -"as.f_kind": "ধরন", -"as.f_carrier": "ক্যারিয়ার", -"as.f_bandwidth": "ব্যান্ডউইথ", -"as.f_port_id": "পোর্ট", -"as.f_contract": "চুক্তি", -"as.f_starts": "থেকে", -"as.f_ends": "পর্যন্ত", -"as.f_prefix": "নেটওয়ার্ক", -"as.f_vlan": "VLAN", -"as.f_gateway": "গেটওয়ে", -"as.f_device_id": "ডিভাইস", "nav.racks": "সাইট ও র‍্যাক", "nav.devices": "ডিভাইস ও পোর্ট", -"as.device": "ডিভাইস", -"as.devices": "ডিভাইস", -"as.devices_sub": "পোর্ট ও কেবলিং দেখতে যেকোনো একটিতে ক্লিক করুন", -"as.what_racks": "যন্ত্রপাতি কোথায় আছে: সাইট, ক্যাবিনেট, এবং কোন ইউনিটে কী আছে। এখানকার সবই হাতে লেখা, আবিষ্কার করা নয় — তাই এটি আর নেটওয়ার্ক যখন আলাদা কথা বলে, তখনও এটি বলে কী থাকার কথা ছিল।", -"as.what_devices": "যন্ত্রটি কী এবং কীসের সঙ্গে যুক্ত: নির্মাতা, মডেল, সিরিয়াল নম্বর, ম্যানেজমেন্ট ঠিকানা, এবং প্রতিটি পোর্ট ও তার কেবলের অন্য প্রান্তের পোর্ট।", -"as.locked": "এর জন্য নিবন্ধিত সংস্করণ দরকার। যা আগে লেখা হয়েছে তা থেকে যায়, এবং লাইসেন্স বসানোর সঙ্গে সঙ্গেই আবার ব্যবহারযোগ্য হয়।", -"as.none": "—", -"as.cancel": "বাতিল", -"as.save": "সংরক্ষণ", -"as.edit": "সম্পাদনা", -"as.delete": "মুছুন", -"as.confirm_delete": "{x} মুছবেন? এটি ফেরানো যায় না।", -"as.add_site": "সাইট যোগ করুন", -"as.edit_site": "সাইট সম্পাদনা", -"as.add_rack": "র‍্যাক যোগ করুন", -"as.edit_rack": "র‍্যাক সম্পাদনা", -"as.add_device": "ডিভাইস যোগ করুন", -"as.edit_device": "ডিভাইস সম্পাদনা", -"as.add_port": "পোর্ট যোগ করুন", -"as.edit_port": "পোর্ট সম্পাদনা", -"as.add_circuit": "সার্কিট যোগ করুন", -"as.edit_circuit": "সার্কিট সম্পাদনা", -"as.add_subnet": "নেটওয়ার্ক যোগ করুন", -"as.edit_subnet": "নেটওয়ার্ক সম্পাদনা", -"as.hidden_hint": "এই রেকর্ড কেবল প্রশাসক দেখতে পান।", -"as.tags_hint": "core, prod, edge", -"as.racks": "র‍্যাক", -"as.no_sites": "এখনও কোনও সাইট নেই।", -"as.no_racks": "এখনও কোনও র‍্যাক নেই।", -"as.no_devices": "এখনও কোনও ডিভাইস নেই।", -"as.no_ports": "এখনও কোনও পোর্ট নেই।", -"as.subnets_of": "{x}-এর নেটওয়ার্ক", -"as.subnets_sub": "পোর্টের পাশে লেখা থাকে: এই যন্ত্র কোন নেটওয়ার্ক বহন করে। এখান থেকে কিছু খোঁজা হয় না — কোনো চার্ট, নাম বা ফিল্টার এই টেবিল থেকে আসে না।", -"as.no_subnets": "এখনও কোনও নেটওয়ার্ক নেই।", -"as.c_sites": "সাইট {x}", -"as.c_racks": "র‍্যাক {x}", -"as.c_devices": "ডিভাইস {x}", -"as.units_at": "U{u}, উচ্চতা {n}U", -"as.units_free": "{t}U-এর মধ্যে {n}U খালি", -"as.unplaced": "র‍্যাকে আছে, ইউনিট লেখা নেই", -"as.place": "অবস্থান", -"as.unracked": "র‍্যাকে নেই", -"as.ports": "পোর্ট", -"as.ports_of": "{x}-এর পোর্ট", -"as.port_use": "{n}টির মধ্যে {u}টি যুক্ত ({p}%)", -"as.port_free": "খালি", -"as.cable": "কেবল যুক্ত করুন…", -"as.recable": "কেবল বদলান…", -"as.cabled_to": "যুক্ত আছে", -"as.cable_title": "{x}-এর কেবল", -"as.no_cable": "কেবল নেই", -"as.what": "কী", -"as.detail": "বিবরণ", -"as.paste_title": "সুইচ থেকে পেস্ট করুন", -"as.paste_sub": "যন্ত্র যা ইতিমধ্যেই ছাপে", -"as.paste_what": "আপনি যে কমান্ড এমনিতেই চালান সেটি চালিয়ে আউটপুট এখানে পেস্ট করুন: show lldp neighbors, show cdp neighbors, show interface status। যন্ত্রে কিছু ইনস্টল করা হয় না এবং কোনো কমিউনিটি স্ট্রিং বা পাসওয়ার্ড কোথাও রাখা হয় না।", -"as.paste_from": "কোন যন্ত্রে চালানো", -"as.paste_hint": "আউটপুট এখানে পেস্ট করুন", -"as.preview": "কী হবে দেখুন", -"as.apply": "লিখুন", -"as.plan_would": "{n}টি পরিবর্তন হবে।", -"as.plan_done": "{n}টি পরিবর্তন হয়েছে।", -"as.plan_none": "বদলানোর কিছু নেই।", -"as.plan_skipped": "বোঝা যায়নি", -"as.act_add": "যোগ", -"as.act_update": "হালনাগাদ", -"as.act_same": "অপরিবর্তিত", -"as.act_error": "সমস্যা", -"as.csv_title": "স্প্রেডশিট", -"as.csv_sub": "CSV-তে আমদানি-রপ্তানি", -"as.csv_what": "এক ধরনের রেকর্ড রপ্তানি করুন, সম্পাদনা করুন, আবার আমদানি করুন: id কলাম প্রতিটি সারিকে তার রেকর্ডের সঙ্গে বাঁধে, তাই এখান থেকে নেওয়া ফাইল দ্বিগুণ না করে হালনাগাদ করে। id কলাম না থাকলে নতুন যোগ হয়; রেফারেন্স নাম দিয়ে মেলে।", -"as.csv_export": "রপ্তানি", -"as.kind_of_site": "সাইট", -"as.kind_of_rack": "র‍্যাক", -"as.kind_of_device": "ডিভাইস", -"as.kind_of_port": "পোর্ট", -"as.kind_of_link": "কেবল", -"as.kind_of_circuit": "সার্কিট", -"as.kind_of_subnet": "নেটওয়ার্ক", "nm.k_host": "হোস্ট", "nm.k_net": "নেটওয়ার্ক", "nm.k_device": "ডিভাইস", @@ -790,5 +683,25 @@ "ob.step2": "কয়েক সেকেন্ডে এটি সেটিংসে দেখা যাবে, আর এক-দুই মিনিটে এই পাতা ভরে যাবে।", "ob.try": "অথবা এর একটি পেস্ট করুন, এই মেশিনের ঠিকানা ও পোর্ট আগেই বসানো:", "ob.copy": "কপি", -"nav.help": "সহায়তা" +"nav.help": "সহায়তা", +"rk.sub": "ফ্লো পাঠানো প্রতিটি যন্ত্র কোথায় আছে, আর প্রতিটি পোর্ট কোন ক্যারিয়ার ও সার্কিটে যায়", +"rk.hint": "একবার পূরণ করলেই হয়। যন্ত্র ও পোর্ট ফ্লো থেকে আসে; শুধু রুম, র‍্যাক, ক্যারিয়ার ও সার্কিট লিখতে হয়, যা ইনভেন্টরি ফাইলে থাকে।", +"rk.empty": "এখনও কোনো যন্ত্র ফ্লো পাঠায়নি।", +"rk.unplaced": "এখনও স্থান ঠিক হয়নি", +"rk.no_rack": "র‍্যাক নেই", +"rk.rack_n": "র‍্যাক {x}", +"rk.room": "রুম", +"rk.rack": "র‍্যাক", +"rk.device_name": "যন্ত্রের নাম", +"rk.iface_name": "পোর্টের নাম", +"rk.isp": "ক্যারিয়ার", +"rk.circuit": "সার্কিট ID", +"rk.no_carrier": "ক্যারিয়ার নেই", +"rk.ifindex": "পোর্ট {n}", +"rk.no_ifaces": "গত এক দিনে কোনো পোর্ট দেখা যায়নি", +"rk.more": "আরও {n}টি পোর্ট", +"rk.quiet": "এখন পাঠাচ্ছে না", +"rk.edit": "সম্পাদনা", +"rk.save": "সংরক্ষণ", +"rk.cancel": "বাতিল" } diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index a6904c2..2842218 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -468,115 +468,8 @@ "cl.if_confirm": "Alle gespeicherten Daten von {i} löschen? Das ist nicht umkehrbar.", "cl.if_confirm_days": "Die Daten von {i} löschen, die älter als {n} Tage sind? Das ist nicht umkehrbar.", "cl.if_done": "{n} Flow-Datensätze und {c} Zählerwerte von {i} gelöscht, {s} freigegeben.", -"as.f_name": "Name", -"as.f_address": "Adresse", -"as.f_note": "Notiz", -"as.f_hidden": "Verborgen", -"as.f_site_id": "Standort", -"as.f_units": "HE", -"as.f_role": "Rolle", -"as.f_vendor": "Hersteller", -"as.f_model": "Modell", -"as.f_serial": "Seriennummer", -"as.f_mgmt_ip": "Management-IP", -"as.f_rack_id": "Rack", -"as.f_pos_u": "HE-Position", -"as.f_height_u": "Höhe (HE)", -"as.f_tags": "Schlagwörter", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Geschwindigkeit", -"as.f_kind": "Art", -"as.f_carrier": "Anbieter", -"as.f_bandwidth": "Bandbreite", -"as.f_port_id": "Port", -"as.f_contract": "Vertrag", -"as.f_starts": "Von", -"as.f_ends": "Bis", -"as.f_prefix": "Netz", -"as.f_vlan": "VLAN", -"as.f_gateway": "Gateway", -"as.f_device_id": "Gerät", "nav.racks": "Standorte & Racks", "nav.devices": "Geräte & Ports", -"as.device": "Gerät", -"as.devices": "Geräte", -"as.devices_sub": "Für Ports und Verkabelung eines anklicken", -"as.what_racks": "Wo die Technik steht: Standorte, Schränke und was in welcher Höheneinheit sitzt. Alles hier wird aufgeschrieben, nie entdeckt — und wenn es dem Netz widerspricht, sagt das Verzeichnis immer noch, was dort stehen sollte.", -"as.what_devices": "Was die Technik ist und woran sie hängt: Hersteller, Modell, Seriennummer, Management-Adresse und jeder Port samt dem Port am anderen Kabelende.", -"as.locked": "Dafür ist eine registrierte Installation nötig. Alles bereits Erfasste bleibt und ist wieder erreichbar, sobald eine Lizenz installiert ist.", -"as.none": "—", -"as.cancel": "Abbrechen", -"as.save": "Speichern", -"as.edit": "Bearbeiten", -"as.delete": "Löschen", -"as.confirm_delete": "{x} löschen? Das ist nicht umkehrbar.", -"as.add_site": "Standort anlegen", -"as.edit_site": "Standort bearbeiten", -"as.add_rack": "Rack anlegen", -"as.edit_rack": "Rack bearbeiten", -"as.add_device": "Gerät anlegen", -"as.edit_device": "Gerät bearbeiten", -"as.add_port": "Port anlegen", -"as.edit_port": "Port bearbeiten", -"as.add_circuit": "Leitung anlegen", -"as.edit_circuit": "Leitung bearbeiten", -"as.add_subnet": "Netz anlegen", -"as.edit_subnet": "Netz bearbeiten", -"as.hidden_hint": "Diesen Eintrag sieht nur die Administration.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Racks", -"as.no_sites": "Noch keine Standorte.", -"as.no_racks": "Noch keine Racks.", -"as.no_devices": "Noch keine Geräte.", -"as.no_ports": "Noch keine Ports.", -"as.subnets_of": "Netze an {x}", -"as.subnets_sub": "Neben den Ports notiert: was dieses Gerät führt. Hier wird nichts nachgeschlagen — kein Diagramm, Name oder Filter stammt aus dieser Tabelle.", -"as.no_subnets": "Noch keine Netze.", -"as.c_sites": "Standorte {x}", -"as.c_racks": "Racks {x}", -"as.c_devices": "Geräte {x}", -"as.units_at": "HE {u}, {n} HE hoch", -"as.units_free": "{n} von {t} HE frei", -"as.unplaced": "Im Rack, Position nicht erfasst", -"as.place": "Platz", -"as.unracked": "Nicht im Rack", -"as.ports": "Ports", -"as.ports_of": "Ports von {x}", -"as.port_use": "{u} von {n} verkabelt ({p} %)", -"as.port_free": "frei", -"as.cable": "Verkabeln…", -"as.recable": "Umstecken…", -"as.cabled_to": "Verbunden mit", -"as.cable_title": "{x} verkabeln", -"as.no_cable": "Kein Kabel", -"as.what": "Was", -"as.detail": "Details", -"as.paste_title": "Von einem Switch einfügen", -"as.paste_sub": "Was das Gerät ohnehin ausgibt", -"as.paste_what": "Führen Sie den Befehl aus, den Sie ohnehin ausführen, und fügen Sie die Ausgabe ein: show lldp neighbors, show cdp neighbors, show interface status. Auf dem Gerät wird nichts installiert, und kein Community-String und kein Passwort wird irgendwo gespeichert.", -"as.paste_from": "Ausgeführt auf", -"as.paste_hint": "Ausgabe hier einfügen", -"as.preview": "Ansehen, was passieren würde", -"as.apply": "Schreiben", -"as.plan_would": "{n} Änderungen würden vorgenommen.", -"as.plan_done": "{n} Änderungen vorgenommen.", -"as.plan_none": "Nichts zu ändern.", -"as.plan_skipped": "Nicht verstanden", -"as.act_add": "neu", -"as.act_update": "ändern", -"as.act_same": "unverändert", -"as.act_error": "Problem", -"as.csv_title": "Tabelle", -"as.csv_sub": "Ein und aus als CSV", -"as.csv_what": "Eine Art exportieren, bearbeiten und wieder einlesen: die Spalte „id“ bindet jede Zeile an ihren Datensatz, sodass eine Datei von hier das aktualisiert, woraus sie kam, statt es zu verdoppeln. Eine Datei ohne id-Spalte legt neu an; Verweise gehen über den Namen.", -"as.csv_export": "Exportieren", -"as.kind_of_site": "Standort", -"as.kind_of_rack": "Rack", -"as.kind_of_device": "Gerät", -"as.kind_of_port": "Port", -"as.kind_of_link": "Kabel", -"as.kind_of_circuit": "Leitung", -"as.kind_of_subnet": "Netz", "nm.k_host": "Host", "nm.k_net": "Netz", "nm.k_device": "Gerät", @@ -790,5 +683,25 @@ "ob.step2": "Es erscheint binnen Sekunden unter Einstellungen; diese Seite füllt sich ein bis zwei Minuten später.", "ob.try": "Oder fügen Sie einen dieser Befehle ein, schon mit Adresse und Ports dieses Rechners:", "ob.copy": "Kopieren", -"nav.help": "Hilfe" +"nav.help": "Hilfe", +"rk.sub": "Wo jedes Gerät steht, das Flows sendet, und zu welchem Anbieter und welcher Leitung jeder Port führt", +"rk.hint": "Einmal ausfüllen genügt. Geräte und Ports kommen aus den Flows; eingetragen werden nur Raum, Rack, Anbieter und Leitung, gespeichert in der Inventardatei.", +"rk.empty": "Noch hat kein Gerät Flows gesendet.", +"rk.unplaced": "Noch nicht zugeordnet", +"rk.no_rack": "Ohne Rack", +"rk.rack_n": "Rack {x}", +"rk.room": "Raum", +"rk.rack": "Rack", +"rk.device_name": "Gerätename", +"rk.iface_name": "Portname", +"rk.isp": "Anbieter", +"rk.circuit": "Leitungs-ID", +"rk.no_carrier": "kein Anbieter", +"rk.ifindex": "Port {n}", +"rk.no_ifaces": "Am letzten Tag keine Ports gesehen", +"rk.more": "{n} weitere Ports", +"rk.quiet": "sendet gerade nicht", +"rk.edit": "Bearbeiten", +"rk.save": "Speichern", +"rk.cancel": "Abbrechen" } diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index 56bfcc9..61fa2d5 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -468,115 +468,8 @@ "cl.if_confirm": "Delete all stored data of {i}? This cannot be undone.", "cl.if_confirm_days": "Delete the data of {i} older than {n} days? This cannot be undone.", "cl.if_done": "Deleted {n} flow records and {c} counter samples of {i}, freed {s}.", -"as.f_name": "Name", -"as.f_address": "Address", -"as.f_note": "Note", -"as.f_hidden": "Hidden", -"as.f_site_id": "Site", -"as.f_units": "Units", -"as.f_role": "Role", -"as.f_vendor": "Vendor", -"as.f_model": "Model", -"as.f_serial": "Serial number", -"as.f_mgmt_ip": "Management IP", -"as.f_rack_id": "Rack", -"as.f_pos_u": "Unit", -"as.f_height_u": "Height (U)", -"as.f_tags": "Tags", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Speed", -"as.f_kind": "Kind", -"as.f_carrier": "Carrier", -"as.f_bandwidth": "Bandwidth", -"as.f_port_id": "Port", -"as.f_contract": "Contract", -"as.f_starts": "From", -"as.f_ends": "To", -"as.f_prefix": "Network", -"as.f_vlan": "VLAN", -"as.f_gateway": "Gateway", -"as.f_device_id": "Device", "nav.racks": "Sites & racks", "nav.devices": "Devices & ports", -"as.device": "Device", -"as.devices": "Devices", -"as.devices_sub": "Click one for its ports and cabling", -"as.what_racks": "Where the equipment stands: sites, cabinets, and what is in which unit. Everything here is written down, never discovered — so when it and the network disagree, the ledger still says what was meant to be there.", -"as.what_devices": "What the equipment is and what it is cabled to: make, model, serial number, management address, and every port with the port at the other end of its cable.", -"as.locked": "This needs a registered copy. Everything already written down stays, and becomes reachable again the moment a licence is installed.", -"as.none": "—", -"as.cancel": "Cancel", -"as.save": "Save", -"as.edit": "Edit", -"as.delete": "Delete", -"as.confirm_delete": "Delete {x}? This cannot be undone.", -"as.add_site": "Add a site", -"as.edit_site": "Edit the site", -"as.add_rack": "Add a rack", -"as.edit_rack": "Edit the rack", -"as.add_device": "Add a device", -"as.edit_device": "Edit the device", -"as.add_port": "Add a port", -"as.edit_port": "Edit the port", -"as.add_circuit": "Add a circuit", -"as.edit_circuit": "Edit the circuit", -"as.add_subnet": "Add a network", -"as.edit_subnet": "Edit the network", -"as.hidden_hint": "Only the administrator sees this record.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Racks", -"as.no_sites": "No sites yet.", -"as.no_racks": "No racks yet.", -"as.no_devices": "No devices yet.", -"as.no_ports": "No ports yet.", -"as.subnets_of": "Networks on {x}", -"as.subnets_sub": "Written down beside the ports: what this device carries. Nothing is looked up here — no chart, name or filter comes from this table.", -"as.no_subnets": "No networks yet.", -"as.c_sites": "Sites {x}", -"as.c_racks": "Racks {x}", -"as.c_devices": "Devices {x}", -"as.units_at": "U{u}, {n}U high", -"as.units_free": "{n} of {t} U free", -"as.unplaced": "In the rack, unit not recorded", -"as.place": "Place", -"as.unracked": "Not racked", -"as.ports": "Ports", -"as.ports_of": "Ports of {x}", -"as.port_use": "{u} of {n} cabled ({p}%)", -"as.port_free": "free", -"as.cable": "Cable…", -"as.recable": "Re-cable…", -"as.cabled_to": "Cabled to", -"as.cable_title": "Cable {x}", -"as.no_cable": "No cable", -"as.what": "What", -"as.detail": "Detail", -"as.paste_title": "Paste from a switch", -"as.paste_sub": "What the device already prints", -"as.paste_what": "Run the command you already run and paste what it printed: show lldp neighbors, show cdp neighbors, show interface status. Nothing is installed on the device and no community string or password is stored anywhere.", -"as.paste_from": "Run on", -"as.paste_hint": "Paste the output here", -"as.preview": "See what it would do", -"as.apply": "Write it", -"as.plan_would": "{n} changes would be made.", -"as.plan_done": "{n} changes made.", -"as.plan_none": "Nothing to change.", -"as.plan_skipped": "Not understood", -"as.act_add": "add", -"as.act_update": "update", -"as.act_same": "unchanged", -"as.act_error": "problem", -"as.csv_title": "Spreadsheet", -"as.csv_sub": "In and out as CSV", -"as.csv_what": "Export a kind, edit it, and import it again: the id column ties each row back to its record, so a file that came from here updates what it came from instead of doubling it. A file with no id column adds; references are by name.", -"as.csv_export": "Export", -"as.kind_of_site": "Site", -"as.kind_of_rack": "Rack", -"as.kind_of_device": "Device", -"as.kind_of_port": "Port", -"as.kind_of_link": "Cable", -"as.kind_of_circuit": "Circuit", -"as.kind_of_subnet": "Network", "nm.k_host": "Host", "nm.k_net": "Network", "nm.k_device": "Device", @@ -790,5 +683,25 @@ "ob.step2": "It appears under Settings within seconds, and this page fills in a minute or two later.", "ob.try": "Or paste one of these, already set to this machine's address and ports:", "ob.copy": "Copy", -"nav.help": "Help" +"nav.help": "Help", +"rk.sub": "Where each device that sends flows stands, and which carrier and circuit each port goes out to", +"rk.hint": "Fill this in once. Devices and ports come from the flows; only the room, rack, carrier and circuit are typed in, and they are kept in the inventory file.", +"rk.empty": "No device has sent flows yet.", +"rk.unplaced": "Not placed yet", +"rk.no_rack": "No rack", +"rk.rack_n": "Rack {x}", +"rk.room": "Room", +"rk.rack": "Rack", +"rk.device_name": "Device name", +"rk.iface_name": "Port name", +"rk.isp": "Carrier", +"rk.circuit": "Circuit ID", +"rk.no_carrier": "no carrier", +"rk.ifindex": "Port {n}", +"rk.no_ifaces": "No ports seen in the last day", +"rk.more": "{n} more ports", +"rk.quiet": "not sending now", +"rk.edit": "Edit", +"rk.save": "Save", +"rk.cancel": "Cancel" } diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index a8609ad..b006b05 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -468,115 +468,8 @@ "cl.if_confirm": "¿Borrar todos los datos guardados de {i}? No se puede deshacer.", "cl.if_confirm_days": "¿Borrar los datos de {i} con más de {n} días? No se puede deshacer.", "cl.if_done": "Se borraron {n} registros de flujo y {c} muestras de contadores de {i}, liberando {s}.", -"as.f_name": "Nombre", -"as.f_address": "Dirección", -"as.f_note": "Nota", -"as.f_hidden": "Oculto", -"as.f_site_id": "Sede", -"as.f_units": "Us", -"as.f_role": "Función", -"as.f_vendor": "Fabricante", -"as.f_model": "Modelo", -"as.f_serial": "Número de serie", -"as.f_mgmt_ip": "IP de gestión", -"as.f_rack_id": "Rack", -"as.f_pos_u": "Unidad", -"as.f_height_u": "Altura (U)", -"as.f_tags": "Etiquetas", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Velocidad", -"as.f_kind": "Tipo", -"as.f_carrier": "Operador", -"as.f_bandwidth": "Ancho de banda", -"as.f_port_id": "Puerto", -"as.f_contract": "Contrato", -"as.f_starts": "Desde", -"as.f_ends": "Hasta", -"as.f_prefix": "Red", -"as.f_vlan": "VLAN", -"as.f_gateway": "Puerta de enlace", -"as.f_device_id": "Equipo", "nav.racks": "Sedes y racks", "nav.devices": "Equipos y puertos", -"as.device": "Equipo", -"as.devices": "Equipos", -"as.devices_sub": "Haga clic en uno para ver sus puertos y cableado", -"as.what_racks": "Dónde está el equipamiento: sedes, armarios y qué ocupa cada unidad. Todo aquí se anota a mano, nunca se descubre; por eso, cuando el inventario y la red discrepan, el inventario sigue diciendo qué debía haber.", -"as.what_devices": "Qué es cada equipo y a qué está cableado: fabricante, modelo, número de serie, dirección de gestión, y cada puerto con el puerto del otro extremo del cable.", -"as.locked": "Esto requiere una copia registrada. Todo lo ya anotado se conserva y vuelve a estar accesible en cuanto se instala una licencia.", -"as.none": "—", -"as.cancel": "Cancelar", -"as.save": "Guardar", -"as.edit": "Editar", -"as.delete": "Borrar", -"as.confirm_delete": "¿Borrar {x}? No se puede deshacer.", -"as.add_site": "Añadir sede", -"as.edit_site": "Editar la sede", -"as.add_rack": "Añadir rack", -"as.edit_rack": "Editar el rack", -"as.add_device": "Añadir equipo", -"as.edit_device": "Editar el equipo", -"as.add_port": "Añadir puerto", -"as.edit_port": "Editar el puerto", -"as.add_circuit": "Añadir circuito", -"as.edit_circuit": "Editar el circuito", -"as.add_subnet": "Añadir red", -"as.edit_subnet": "Editar la red", -"as.hidden_hint": "Solo la administración ve este registro.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Racks", -"as.no_sites": "Aún no hay sedes.", -"as.no_racks": "Aún no hay racks.", -"as.no_devices": "Aún no hay equipos.", -"as.no_ports": "Aún no hay puertos.", -"as.subnets_of": "Redes en {x}", -"as.subnets_sub": "Anotadas junto a los puertos: lo que transporta este equipo. Aquí no se consulta nada: ningún gráfico, nombre ni filtro sale de esta tabla.", -"as.no_subnets": "Aún no hay redes.", -"as.c_sites": "Sedes {x}", -"as.c_racks": "Racks {x}", -"as.c_devices": "Equipos {x}", -"as.units_at": "U{u}, {n} U de alto", -"as.units_free": "{n} de {t} U libres", -"as.unplaced": "En el rack, unidad sin anotar", -"as.place": "Sitio", -"as.unracked": "Fuera del rack", -"as.ports": "Puertos", -"as.ports_of": "Puertos de {x}", -"as.port_use": "{u} de {n} cableados ({p} %)", -"as.port_free": "libre", -"as.cable": "Cablear…", -"as.recable": "Recablear…", -"as.cabled_to": "Cableado a", -"as.cable_title": "Cablear {x}", -"as.no_cable": "Sin cable", -"as.what": "Qué", -"as.detail": "Detalle", -"as.paste_title": "Pegar desde un switch", -"as.paste_sub": "Lo que el equipo ya imprime", -"as.paste_what": "Ejecute el comando que ya usa y pegue lo que imprimió: show lldp neighbors, show cdp neighbors, show interface status. No se instala nada en el equipo y no se guarda ninguna cadena de comunidad ni contraseña.", -"as.paste_from": "Ejecutado en", -"as.paste_hint": "Pegue aquí la salida", -"as.preview": "Ver qué haría", -"as.apply": "Escribir", -"as.plan_would": "Se harían {n} cambios.", -"as.plan_done": "Se hicieron {n} cambios.", -"as.plan_none": "Nada que cambiar.", -"as.plan_skipped": "No entendido", -"as.act_add": "añadir", -"as.act_update": "actualizar", -"as.act_same": "sin cambios", -"as.act_error": "problema", -"as.csv_title": "Hoja de cálculo", -"as.csv_sub": "Entrada y salida en CSV", -"as.csv_what": "Exporte un tipo, edítelo y vuelva a importarlo: la columna «id» une cada fila con su registro, así que un archivo salido de aquí actualiza aquello de donde salió en vez de duplicarlo. Un archivo sin columna id añade; las referencias van por nombre.", -"as.csv_export": "Exportar", -"as.kind_of_site": "Sede", -"as.kind_of_rack": "Rack", -"as.kind_of_device": "Equipo", -"as.kind_of_port": "Puerto", -"as.kind_of_link": "Cable", -"as.kind_of_circuit": "Circuito", -"as.kind_of_subnet": "Red", "nm.k_host": "Host", "nm.k_net": "Red", "nm.k_device": "Dispositivo", @@ -790,5 +683,25 @@ "ob.step2": "Aparece en Configuración en segundos, y esta página se llena uno o dos minutos después.", "ob.try": "O pegue uno de estos comandos, ya con la dirección y los puertos de esta máquina:", "ob.copy": "Copiar", -"nav.help": "Ayuda" +"nav.help": "Ayuda", +"rk.sub": "Dónde está cada equipo que envía flujos y a qué operador y circuito sale cada puerto", +"rk.hint": "Basta con rellenarlo una vez. Equipos y puertos salen de los flujos; solo se escriben sala, rack, operador y circuito, y se guardan en el archivo de inventario.", +"rk.empty": "Ningún equipo ha enviado flujos todavía.", +"rk.unplaced": "Sin ubicar", +"rk.no_rack": "Sin rack", +"rk.rack_n": "Rack {x}", +"rk.room": "Sala", +"rk.rack": "Rack", +"rk.device_name": "Nombre del equipo", +"rk.iface_name": "Nombre del puerto", +"rk.isp": "Operador", +"rk.circuit": "ID del circuito", +"rk.no_carrier": "sin operador", +"rk.ifindex": "Puerto {n}", +"rk.no_ifaces": "Sin puertos vistos en el último día", +"rk.more": "{n} puertos más", +"rk.quiet": "no envía ahora", +"rk.edit": "Editar", +"rk.save": "Guardar", +"rk.cancel": "Cancelar" } diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index 8ade5ab..31dfcdb 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -468,115 +468,8 @@ "cl.if_confirm": "Supprimer toutes les données de {i} ? Irréversible.", "cl.if_confirm_days": "Supprimer les données de {i} antérieures à {n} jours ? Irréversible.", "cl.if_done": "{n} enregistrements et {c} relevés de compteurs de {i} supprimés, {s} libérés.", -"as.f_name": "Nom", -"as.f_address": "Adresse", -"as.f_note": "Note", -"as.f_hidden": "Masqué", -"as.f_site_id": "Site", -"as.f_units": "U", -"as.f_role": "Rôle", -"as.f_vendor": "Fabricant", -"as.f_model": "Modèle", -"as.f_serial": "Numéro de série", -"as.f_mgmt_ip": "IP d'administration", -"as.f_rack_id": "Baie", -"as.f_pos_u": "Unité", -"as.f_height_u": "Hauteur (U)", -"as.f_tags": "Étiquettes", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Débit", -"as.f_kind": "Type", -"as.f_carrier": "Opérateur", -"as.f_bandwidth": "Débit souscrit", -"as.f_port_id": "Port", -"as.f_contract": "Contrat", -"as.f_starts": "Du", -"as.f_ends": "Au", -"as.f_prefix": "Réseau", -"as.f_vlan": "VLAN", -"as.f_gateway": "Passerelle", -"as.f_device_id": "Équipement", "nav.racks": "Sites et baies", "nav.devices": "Équipements et ports", -"as.device": "Équipement", -"as.devices": "Équipements", -"as.devices_sub": "Cliquez-en un pour ses ports et son câblage", -"as.what_racks": "Où se trouve le matériel : sites, baies, et ce qui occupe chaque unité. Tout ici est saisi à la main, jamais découvert — et quand l'inventaire et le réseau divergent, l'inventaire dit encore ce qui devait s'y trouver.", -"as.what_devices": "Ce qu'est le matériel et à quoi il est raccordé : marque, modèle, numéro de série, adresse d'administration, et chaque port avec le port à l'autre bout du câble.", -"as.locked": "Cela demande une version enregistrée. Tout ce qui est déjà saisi reste en place et redevient accessible dès qu'une licence est installée.", -"as.none": "—", -"as.cancel": "Annuler", -"as.save": "Enregistrer", -"as.edit": "Modifier", -"as.delete": "Supprimer", -"as.confirm_delete": "Supprimer {x} ? Irréversible.", -"as.add_site": "Ajouter un site", -"as.edit_site": "Modifier le site", -"as.add_rack": "Ajouter une baie", -"as.edit_rack": "Modifier la baie", -"as.add_device": "Ajouter un équipement", -"as.edit_device": "Modifier l'équipement", -"as.add_port": "Ajouter un port", -"as.edit_port": "Modifier le port", -"as.add_circuit": "Ajouter un lien", -"as.edit_circuit": "Modifier le lien", -"as.add_subnet": "Ajouter un réseau", -"as.edit_subnet": "Modifier le réseau", -"as.hidden_hint": "Seule l'administration voit cette fiche.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Baies", -"as.no_sites": "Aucun site pour l'instant.", -"as.no_racks": "Aucune baie pour l'instant.", -"as.no_devices": "Aucun équipement pour l'instant.", -"as.no_ports": "Aucun port pour l'instant.", -"as.subnets_of": "Réseaux sur {x}", -"as.subnets_sub": "Notés à côté des ports : ce que porte cet équipement. Rien n’est recherché ici — aucun graphique, nom ou filtre ne vient de ce tableau.", -"as.no_subnets": "Aucun réseau pour l'instant.", -"as.c_sites": "Sites {x}", -"as.c_racks": "Baies {x}", -"as.c_devices": "Équipements {x}", -"as.units_at": "U{u}, {n} U de haut", -"as.units_free": "{n} U libres sur {t}", -"as.unplaced": "Dans la baie, unité non notée", -"as.place": "Place", -"as.unracked": "Hors baie", -"as.ports": "Ports", -"as.ports_of": "Ports de {x}", -"as.port_use": "{u} sur {n} raccordés ({p} %)", -"as.port_free": "libre", -"as.cable": "Raccorder…", -"as.recable": "Rebrancher…", -"as.cabled_to": "Raccordé à", -"as.cable_title": "Raccorder {x}", -"as.no_cable": "Aucun câble", -"as.what": "Quoi", -"as.detail": "Détail", -"as.paste_title": "Coller depuis un commutateur", -"as.paste_sub": "Ce que l'équipement affiche déjà", -"as.paste_what": "Lancez la commande que vous utilisez déjà et collez sa sortie : show lldp neighbors, show cdp neighbors, show interface status. Rien n’est installé sur l’équipement et aucune chaîne de communauté ni mot de passe n’est conservé.", -"as.paste_from": "Exécuté sur", -"as.paste_hint": "Collez la sortie ici", -"as.preview": "Voir ce que cela ferait", -"as.apply": "Écrire", -"as.plan_would": "{n} modifications seraient faites.", -"as.plan_done": "{n} modifications faites.", -"as.plan_none": "Rien à modifier.", -"as.plan_skipped": "Non compris", -"as.act_add": "ajout", -"as.act_update": "mise à jour", -"as.act_same": "inchangé", -"as.act_error": "problème", -"as.csv_title": "Tableur", -"as.csv_sub": "Entrée et sortie en CSV", -"as.csv_what": "Exportez un type, modifiez-le et réimportez-le : la colonne « id » relie chaque ligne à sa fiche, donc un fichier venu d'ici met à jour ce dont il vient au lieu de le doubler. Un fichier sans colonne id ajoute ; les références se font par nom.", -"as.csv_export": "Exporter", -"as.kind_of_site": "Site", -"as.kind_of_rack": "Baie", -"as.kind_of_device": "Équipement", -"as.kind_of_port": "Port", -"as.kind_of_link": "Câble", -"as.kind_of_circuit": "Lien", -"as.kind_of_subnet": "Réseau", "nm.k_host": "Hôte", "nm.k_net": "Réseau", "nm.k_device": "Équipement", @@ -790,5 +683,25 @@ "ob.step2": "Il apparaît dans Réglages en quelques secondes, et cette page se remplit une ou deux minutes plus tard.", "ob.try": "Ou collez l'une de ces commandes, déjà réglées sur l'adresse et les ports de cette machine :", "ob.copy": "Copier", -"nav.help": "Aide" +"nav.help": "Aide", +"rk.sub": "Où se trouve chaque équipement qui envoie des flux, et vers quel opérateur et quel circuit part chaque port", +"rk.hint": "À remplir une seule fois. Équipements et ports viennent des flux ; seuls la salle, la baie, l’opérateur et le circuit sont saisis, et ils sont gardés dans le fichier d’inventaire.", +"rk.empty": "Aucun équipement n’a encore envoyé de flux.", +"rk.unplaced": "Pas encore placé", +"rk.no_rack": "Sans baie", +"rk.rack_n": "Baie {x}", +"rk.room": "Salle", +"rk.rack": "Baie", +"rk.device_name": "Nom de l’équipement", +"rk.iface_name": "Nom du port", +"rk.isp": "Opérateur", +"rk.circuit": "ID du circuit", +"rk.no_carrier": "sans opérateur", +"rk.ifindex": "Port {n}", +"rk.no_ifaces": "Aucun port vu ces dernières 24 h", +"rk.more": "{n} ports de plus", +"rk.quiet": "n’envoie pas en ce moment", +"rk.edit": "Modifier", +"rk.save": "Enregistrer", +"rk.cancel": "Annuler" } diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index 89c9c28..27eaabb 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -468,115 +468,8 @@ "cl.if_confirm": "{i} का सारा संग्रहित डेटा हटाएँ? इसे पलटा नहीं जा सकता।", "cl.if_confirm_days": "{i} का {n} दिन से पुराना डेटा हटाएँ? इसे पलटा नहीं जा सकता।", "cl.if_done": "{i} के {n} फ़्लो रिकॉर्ड और {c} काउंटर नमूने हटाए, {s} खाली हुआ।", -"as.f_name": "नाम", -"as.f_address": "पता", -"as.f_note": "टिप्पणी", -"as.f_hidden": "छिपा हुआ", -"as.f_site_id": "साइट", -"as.f_units": "यूनिट", -"as.f_role": "भूमिका", -"as.f_vendor": "विक्रेता", -"as.f_model": "मॉडल", -"as.f_serial": "सीरियल नंबर", -"as.f_mgmt_ip": "प्रबंधन IP", -"as.f_rack_id": "रैक", -"as.f_pos_u": "यूनिट", -"as.f_height_u": "ऊँचाई (U)", -"as.f_tags": "टैग", -"as.f_ifindex": "ifIndex", -"as.f_speed": "गति", -"as.f_kind": "प्रकार", -"as.f_carrier": "वाहक", -"as.f_bandwidth": "बैंडविड्थ", -"as.f_port_id": "पोर्ट", -"as.f_contract": "अनुबंध", -"as.f_starts": "से", -"as.f_ends": "तक", -"as.f_prefix": "नेटवर्क", -"as.f_vlan": "VLAN", -"as.f_gateway": "गेटवे", -"as.f_device_id": "उपकरण", "nav.racks": "साइट और रैक", "nav.devices": "उपकरण और पोर्ट", -"as.device": "उपकरण", -"as.devices": "उपकरण", -"as.devices_sub": "पोर्ट और केबलिंग देखने के लिए किसी एक पर क्लिक करें", -"as.what_racks": "उपकरण कहाँ खड़ा है: साइट, कैबिनेट, और किस यूनिट में क्या है। यहाँ सब कुछ लिखकर दर्ज किया जाता है, खोजा नहीं जाता — इसलिए जब यह और नेटवर्क अलग-अलग बताएँ, तब भी यह बताता है कि वहाँ क्या होना चाहिए था।", -"as.what_devices": "उपकरण क्या है और किससे जुड़ा है: निर्माता, मॉडल, सीरियल नंबर, प्रबंधन पता, और हर पोर्ट के साथ उसके केबल के दूसरे सिरे का पोर्ट।", -"as.locked": "इसके लिए पंजीकृत प्रति चाहिए। जो पहले से दर्ज है वह बना रहता है, और लाइसेंस लगाते ही फिर से उपलब्ध हो जाता है।", -"as.none": "—", -"as.cancel": "रद्द करें", -"as.save": "सहेजें", -"as.edit": "संपादित करें", -"as.delete": "हटाएँ", -"as.confirm_delete": "{x} हटाएँ? इसे पलटा नहीं जा सकता।", -"as.add_site": "साइट जोड़ें", -"as.edit_site": "साइट संपादित करें", -"as.add_rack": "रैक जोड़ें", -"as.edit_rack": "रैक संपादित करें", -"as.add_device": "उपकरण जोड़ें", -"as.edit_device": "उपकरण संपादित करें", -"as.add_port": "पोर्ट जोड़ें", -"as.edit_port": "पोर्ट संपादित करें", -"as.add_circuit": "सर्किट जोड़ें", -"as.edit_circuit": "सर्किट संपादित करें", -"as.add_subnet": "नेटवर्क जोड़ें", -"as.edit_subnet": "नेटवर्क संपादित करें", -"as.hidden_hint": "यह रिकॉर्ड केवल प्रशासक को दिखता है।", -"as.tags_hint": "core, prod, edge", -"as.racks": "रैक", -"as.no_sites": "अभी कोई साइट नहीं।", -"as.no_racks": "अभी कोई रैक नहीं।", -"as.no_devices": "अभी कोई उपकरण नहीं।", -"as.no_ports": "अभी कोई पोर्ट नहीं।", -"as.subnets_of": "{x} पर नेटवर्क", -"as.subnets_sub": "पोर्ट के बगल में लिखा रिकॉर्ड: यह डिवाइस कौन से नेटवर्क ले जाता है। यहाँ से कुछ नहीं खोजा जाता — कोई चार्ट, नाम या फ़िल्टर इस तालिका से नहीं आता।", -"as.no_subnets": "अभी कोई नेटवर्क नहीं।", -"as.c_sites": "साइट {x}", -"as.c_racks": "रैक {x}", -"as.c_devices": "उपकरण {x}", -"as.units_at": "U{u}, {n}U ऊँचा", -"as.units_free": "{t}U में से {n}U खाली", -"as.unplaced": "रैक में है, यूनिट दर्ज नहीं", -"as.place": "स्थान", -"as.unracked": "रैक में नहीं", -"as.ports": "पोर्ट", -"as.ports_of": "{x} के पोर्ट", -"as.port_use": "{n} में से {u} जुड़े ({p}%)", -"as.port_free": "खाली", -"as.cable": "केबल लगाएँ…", -"as.recable": "केबल बदलें…", -"as.cabled_to": "जुड़ा है", -"as.cable_title": "{x} की केबल", -"as.no_cable": "कोई केबल नहीं", -"as.what": "क्या", -"as.detail": "विवरण", -"as.paste_title": "स्विच से चिपकाएँ", -"as.paste_sub": "जो उपकरण पहले से छापता है", -"as.paste_what": "जो कमांड आप पहले से चलाते हैं उसे चलाइए और उसका आउटपुट यहाँ चिपकाइए: show lldp neighbors, show cdp neighbors, show interface status. डिवाइस पर कुछ भी इंस्टॉल नहीं होता और कोई कम्युनिटी स्ट्रिंग या पासवर्ड कहीं सेव नहीं होता।", -"as.paste_from": "किस उपकरण पर चला", -"as.paste_hint": "आउटपुट यहाँ चिपकाएँ", -"as.preview": "देखें क्या होगा", -"as.apply": "लिखें", -"as.plan_would": "{n} बदलाव किए जाएँगे।", -"as.plan_done": "{n} बदलाव किए गए।", -"as.plan_none": "बदलने को कुछ नहीं।", -"as.plan_skipped": "समझ नहीं आया", -"as.act_add": "जोड़ें", -"as.act_update": "अद्यतन", -"as.act_same": "अपरिवर्तित", -"as.act_error": "समस्या", -"as.csv_title": "स्प्रेडशीट", -"as.csv_sub": "CSV में आयात-निर्यात", -"as.csv_what": "एक प्रकार निर्यात करें, संपादित करें, फिर आयात करें: id स्तंभ हर पंक्ति को उसके रिकॉर्ड से जोड़ता है, इसलिए यहाँ से निकली फ़ाइल दोहराती नहीं, अद्यतन करती है। बिना id स्तंभ वाली फ़ाइल नया जोड़ती है; संदर्भ नाम से मिलाए जाते हैं।", -"as.csv_export": "निर्यात", -"as.kind_of_site": "साइट", -"as.kind_of_rack": "रैक", -"as.kind_of_device": "उपकरण", -"as.kind_of_port": "पोर्ट", -"as.kind_of_link": "केबल", -"as.kind_of_circuit": "सर्किट", -"as.kind_of_subnet": "नेटवर्क", "nm.k_host": "होस्ट", "nm.k_net": "नेटवर्क", "nm.k_device": "डिवाइस", @@ -790,5 +683,25 @@ "ob.step2": "यह कुछ सेकंड में सेटिंग्स में दिखेगा, और यह पेज एक-दो मिनट में भर जाएगा।", "ob.try": "या इनमें से कोई कमांड पेस्ट करें, इसमें इस मशीन का पता और पोर्ट पहले से भरे हैं:", "ob.copy": "कॉपी करें", -"nav.help": "सहायता" +"nav.help": "सहायता", +"rk.sub": "फ़्लो भेजने वाला हर डिवाइस कहाँ लगा है, और हर पोर्ट किस कैरियर और सर्किट से जुड़ा है", +"rk.hint": "इसे एक बार भरें। डिवाइस और पोर्ट फ़्लो से आते हैं; केवल कमरा, रैक, कैरियर और सर्किट लिखने होते हैं, जो इन्वेंटरी फ़ाइल में रहते हैं।", +"rk.empty": "अभी तक किसी डिवाइस ने फ़्लो नहीं भेजा है।", +"rk.unplaced": "अभी स्थान तय नहीं", +"rk.no_rack": "कोई रैक नहीं", +"rk.rack_n": "रैक {x}", +"rk.room": "कमरा", +"rk.rack": "रैक", +"rk.device_name": "डिवाइस का नाम", +"rk.iface_name": "पोर्ट का नाम", +"rk.isp": "कैरियर", +"rk.circuit": "सर्किट ID", +"rk.no_carrier": "कैरियर नहीं", +"rk.ifindex": "पोर्ट {n}", +"rk.no_ifaces": "पिछले एक दिन में कोई पोर्ट नहीं दिखा", +"rk.more": "{n} और पोर्ट", +"rk.quiet": "अभी नहीं भेज रहा", +"rk.edit": "बदलें", +"rk.save": "सहेजें", +"rk.cancel": "रद्द करें" } diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index 57bb9c4..7d58de2 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -468,115 +468,8 @@ "cl.if_confirm": "Hapus semua data tersimpan dari {i}? Tidak dapat dibatalkan.", "cl.if_confirm_days": "Hapus data {i} yang lebih tua dari {n} hari? Tidak dapat dibatalkan.", "cl.if_done": "Menghapus {n} catatan flow dan {c} sampel pencacah dari {i}, membebaskan {s}.", -"as.f_name": "Nama", -"as.f_address": "Alamat", -"as.f_note": "Catatan", -"as.f_hidden": "Disembunyikan", -"as.f_site_id": "Lokasi", -"as.f_units": "Unit", -"as.f_role": "Peran", -"as.f_vendor": "Vendor", -"as.f_model": "Model", -"as.f_serial": "Nomor seri", -"as.f_mgmt_ip": "IP manajemen", -"as.f_rack_id": "Rak", -"as.f_pos_u": "Unit", -"as.f_height_u": "Tinggi (U)", -"as.f_tags": "Tag", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Kecepatan", -"as.f_kind": "Jenis", -"as.f_carrier": "Operator", -"as.f_bandwidth": "Bandwidth", -"as.f_port_id": "Port", -"as.f_contract": "Kontrak", -"as.f_starts": "Dari", -"as.f_ends": "Sampai", -"as.f_prefix": "Jaringan", -"as.f_vlan": "VLAN", -"as.f_gateway": "Gerbang", -"as.f_device_id": "Perangkat", "nav.racks": "Lokasi & rak", "nav.devices": "Perangkat & port", -"as.device": "Perangkat", -"as.devices": "Perangkat", -"as.devices_sub": "Klik salah satu untuk melihat port dan kabelnya", -"as.what_racks": "Di mana perangkat berada: lokasi, rak, dan apa yang mengisi setiap unit. Semua di sini dicatat, bukan ditemukan otomatis — jadi ketika catatan dan jaringan berbeda, catatan tetap mengatakan apa yang seharusnya ada.", -"as.what_devices": "Perangkat itu apa dan tersambung ke mana: vendor, model, nomor seri, alamat manajemen, dan setiap port beserta port di ujung kabel satunya.", -"as.locked": "Ini memerlukan salinan terdaftar. Semua yang sudah dicatat tetap ada, dan dapat diakses lagi begitu lisensi dipasang.", -"as.none": "—", -"as.cancel": "Batal", -"as.save": "Simpan", -"as.edit": "Ubah", -"as.delete": "Hapus", -"as.confirm_delete": "Hapus {x}? Tidak dapat dibatalkan.", -"as.add_site": "Tambah lokasi", -"as.edit_site": "Ubah lokasi", -"as.add_rack": "Tambah rak", -"as.edit_rack": "Ubah rak", -"as.add_device": "Tambah perangkat", -"as.edit_device": "Ubah perangkat", -"as.add_port": "Tambah port", -"as.edit_port": "Ubah port", -"as.add_circuit": "Tambah sirkuit", -"as.edit_circuit": "Ubah sirkuit", -"as.add_subnet": "Tambah jaringan", -"as.edit_subnet": "Ubah jaringan", -"as.hidden_hint": "Hanya administrator yang melihat catatan ini.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Rak", -"as.no_sites": "Belum ada lokasi.", -"as.no_racks": "Belum ada rak.", -"as.no_devices": "Belum ada perangkat.", -"as.no_ports": "Belum ada port.", -"as.subnets_of": "Jaringan di {x}", -"as.subnets_sub": "Dicatat di samping port: jaringan yang dibawa perangkat ini. Tidak ada yang dicari di sini — grafik, nama, dan filter tidak berasal dari tabel ini.", -"as.no_subnets": "Belum ada jaringan.", -"as.c_sites": "Lokasi {x}", -"as.c_racks": "Rak {x}", -"as.c_devices": "Perangkat {x}", -"as.units_at": "U{u}, tinggi {n}U", -"as.units_free": "{n} dari {t} U kosong", -"as.unplaced": "Di rak, unit tidak dicatat", -"as.place": "Tempat", -"as.unracked": "Tidak di rak", -"as.ports": "Port", -"as.ports_of": "Port {x}", -"as.port_use": "{u} dari {n} terpasang kabel ({p}%)", -"as.port_free": "kosong", -"as.cable": "Pasang kabel…", -"as.recable": "Ganti kabel…", -"as.cabled_to": "Tersambung ke", -"as.cable_title": "Kabel {x}", -"as.no_cable": "Tanpa kabel", -"as.what": "Apa", -"as.detail": "Rincian", -"as.paste_title": "Tempel dari switch", -"as.paste_sub": "Apa yang sudah dicetak perangkat", -"as.paste_what": "Jalankan perintah yang sudah biasa Anda pakai lalu tempel keluarannya: show lldp neighbors, show cdp neighbors, show interface status. Tidak ada yang dipasang di perangkat dan tidak ada community string atau kata sandi yang disimpan.", -"as.paste_from": "Dijalankan di", -"as.paste_hint": "Tempel keluarannya di sini", -"as.preview": "Lihat apa yang akan terjadi", -"as.apply": "Tulis", -"as.plan_would": "{n} perubahan akan dibuat.", -"as.plan_done": "{n} perubahan dibuat.", -"as.plan_none": "Tidak ada yang perlu diubah.", -"as.plan_skipped": "Tidak dipahami", -"as.act_add": "tambah", -"as.act_update": "perbarui", -"as.act_same": "tidak berubah", -"as.act_error": "masalah", -"as.csv_title": "Lembar kerja", -"as.csv_sub": "Masuk dan keluar sebagai CSV", -"as.csv_what": "Ekspor satu jenis, ubah, lalu impor lagi: kolom id mengikat tiap baris ke catatannya, jadi berkas dari sini memperbarui asalnya alih-alih menggandakannya. Berkas tanpa kolom id akan menambah; rujukan dicocokkan menurut nama.", -"as.csv_export": "Ekspor", -"as.kind_of_site": "Lokasi", -"as.kind_of_rack": "Rak", -"as.kind_of_device": "Perangkat", -"as.kind_of_port": "Port", -"as.kind_of_link": "Kabel", -"as.kind_of_circuit": "Sirkuit", -"as.kind_of_subnet": "Jaringan", "nm.k_host": "Host", "nm.k_net": "Jaringan", "nm.k_device": "Perangkat", @@ -790,5 +683,25 @@ "ob.step2": "Perangkat muncul di Pengaturan dalam hitungan detik, dan halaman ini terisi satu-dua menit kemudian.", "ob.try": "Atau tempel salah satu perintah ini, sudah berisi alamat dan port mesin ini:", "ob.copy": "Salin", -"nav.help": "Bantuan" +"nav.help": "Bantuan", +"rk.sub": "Di mana setiap perangkat yang mengirim flow berada, dan ke operator serta sirkuit mana setiap port keluar", +"rk.hint": "Cukup diisi sekali. Perangkat dan port berasal dari flow; hanya ruang, rak, operator, dan sirkuit yang diketik, disimpan di berkas inventaris.", +"rk.empty": "Belum ada perangkat yang mengirim flow.", +"rk.unplaced": "Belum ditempatkan", +"rk.no_rack": "Tanpa rak", +"rk.rack_n": "Rak {x}", +"rk.room": "Ruang", +"rk.rack": "Rak", +"rk.device_name": "Nama perangkat", +"rk.iface_name": "Nama port", +"rk.isp": "Operator", +"rk.circuit": "ID sirkuit", +"rk.no_carrier": "tanpa operator", +"rk.ifindex": "Port {n}", +"rk.no_ifaces": "Tidak ada port terlihat dalam sehari terakhir", +"rk.more": "{n} port lagi", +"rk.quiet": "sedang tidak mengirim", +"rk.edit": "Ubah", +"rk.save": "Simpan", +"rk.cancel": "Batal" } diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index e1f0c6e..cc2279e 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -468,115 +468,8 @@ "cl.if_confirm": "{i} の保存データをすべて削除しますか?元に戻せません。", "cl.if_confirm_days": "{i} の {n} 日より古いデータを削除しますか?元に戻せません。", "cl.if_done": "{i} のフローレコード {n} 件とカウンター {c} 件を削除し、{s} 空きました。", -"as.f_name": "名前", -"as.f_address": "住所", -"as.f_note": "メモ", -"as.f_hidden": "非表示", -"as.f_site_id": "拠点", -"as.f_units": "U数", -"as.f_role": "役割", -"as.f_vendor": "ベンダー", -"as.f_model": "型番", -"as.f_serial": "シリアル番号", -"as.f_mgmt_ip": "管理IP", -"as.f_rack_id": "ラック", -"as.f_pos_u": "搭載U", -"as.f_height_u": "高さ (U)", -"as.f_tags": "タグ", -"as.f_ifindex": "ifIndex", -"as.f_speed": "速度", -"as.f_kind": "種別", -"as.f_carrier": "回線事業者", -"as.f_bandwidth": "帯域", -"as.f_port_id": "ポート", -"as.f_contract": "契約番号", -"as.f_starts": "開始日", -"as.f_ends": "終了日", -"as.f_prefix": "ネットワーク", -"as.f_vlan": "VLAN", -"as.f_gateway": "ゲートウェイ", -"as.f_device_id": "機器", "nav.racks": "拠点とラック", "nav.devices": "機器とポート", -"as.device": "機器", -"as.devices": "機器一覧", -"as.devices_sub": "クリックするとポートと結線が見られます", -"as.what_racks": "機器がどこにあるか:拠点、ラック、どのUに何が入っているか。ここにあるものはすべて人が記録したもので、自動検出ではありません。だからこそ実際のネットワークと食い違ったときに、本来あるべき姿を示せます。", -"as.what_devices": "機器が何であり、何とつながっているか:メーカー、型番、シリアル番号、管理アドレス、そして各ポートとケーブルの対向ポート。", -"as.locked": "この機能は登録版が必要です。すでに記録した内容はそのまま保持され、ライセンスを入れればすぐに再び利用できます。", -"as.none": "—", -"as.cancel": "取消", -"as.save": "保存", -"as.edit": "編集", -"as.delete": "削除", -"as.confirm_delete": "{x} を削除しますか?元に戻せません。", -"as.add_site": "拠点を追加", -"as.edit_site": "拠点を編集", -"as.add_rack": "ラックを追加", -"as.edit_rack": "ラックを編集", -"as.add_device": "機器を追加", -"as.edit_device": "機器を編集", -"as.add_port": "ポートを追加", -"as.edit_port": "ポートを編集", -"as.add_circuit": "回線を追加", -"as.edit_circuit": "回線を編集", -"as.add_subnet": "ネットワークを追加", -"as.edit_subnet": "ネットワークを編集", -"as.hidden_hint": "この記録は管理者だけに表示されます。", -"as.tags_hint": "core, prod, edge", -"as.racks": "ラック", -"as.no_sites": "拠点がまだありません。", -"as.no_racks": "ラックがまだありません。", -"as.no_devices": "機器がまだありません。", -"as.no_ports": "ポートがまだありません。", -"as.subnets_of": "{x} のネットワーク", -"as.subnets_sub": "ポートの横に書き留めるもの。この機器が運ぶネットワークです。ここは参照されません — グラフも名前もフィルターもこの表からは作られません。", -"as.no_subnets": "ネットワークがまだありません。", -"as.c_sites": "拠点 {x}", -"as.c_racks": "ラック {x}", -"as.c_devices": "機器 {x}", -"as.units_at": "U{u}・{n}U", -"as.units_free": "{t}U 中 {n}U 空き", -"as.unplaced": "ラック内・搭載U未記入", -"as.place": "設置位置", -"as.unracked": "ラック外", -"as.ports": "ポート", -"as.ports_of": "{x} のポート", -"as.port_use": "{n} 本中 {u} 本が接続済み({p}%)", -"as.port_free": "空き", -"as.cable": "結線…", -"as.recable": "結線変更…", -"as.cabled_to": "接続先", -"as.cable_title": "{x} の結線", -"as.no_cable": "未接続", -"as.what": "内容", -"as.detail": "詳細", -"as.paste_title": "スイッチの出力を貼り付け", -"as.paste_sub": "機器がすでに表示しているものを使う", -"as.paste_what": "普段使っているコマンドの出力をそのまま貼り付けてください: show lldp neighbors、show cdp neighbors、show interface status。機器には何もインストールせず、コミュニティ名やパスワードをどこにも保存しません。", -"as.paste_from": "実行した機器", -"as.paste_hint": "ここに出力を貼り付け", -"as.preview": "何が起きるか確認", -"as.apply": "書き込む", -"as.plan_would": "{n} 件を変更します。", -"as.plan_done": "{n} 件を変更しました。", -"as.plan_none": "変更はありません。", -"as.plan_skipped": "読み取れなかった行", -"as.act_add": "追加", -"as.act_update": "更新", -"as.act_same": "変更なし", -"as.act_error": "問題", -"as.csv_title": "表計算ファイル", -"as.csv_sub": "CSV で入出力", -"as.csv_what": "種別ごとに書き出し、編集して、また読み込めます。id 列が各行を元の記録に結び付けるので、ここから出したファイルは二重登録にならず元の記録を更新します。id 列のないファイルは新規追加になり、参照は名前で行います。", -"as.csv_export": "書き出し", -"as.kind_of_site": "拠点", -"as.kind_of_rack": "ラック", -"as.kind_of_device": "機器", -"as.kind_of_port": "ポート", -"as.kind_of_link": "ケーブル", -"as.kind_of_circuit": "回線", -"as.kind_of_subnet": "ネットワーク", "nm.k_host": "ホスト", "nm.k_net": "ネットワーク", "nm.k_device": "機器", @@ -790,5 +683,25 @@ "ob.step2": "数秒で「設定」に表示され、1〜2 分後にこのページにデータが出ます。", "ob.try": "このマシンのアドレスとポートを入れたコマンド。そのまま貼り付けられます:", "ob.copy": "コピー", -"nav.help": "ヘルプ" +"nav.help": "ヘルプ", +"rk.sub": "フローを送る各機器の設置場所と、各ポートがつながる回線事業者と回線", +"rk.hint": "一度入力すれば済みます。機器とポートはフローから自動で並び、入力するのは部屋・ラック・回線事業者・回線 ID だけで、台帳ファイルに保存されます。", +"rk.empty": "まだフローを送った機器はありません。", +"rk.unplaced": "設置場所未設定", +"rk.no_rack": "ラック未設定", +"rk.rack_n": "ラック {x}", +"rk.room": "部屋", +"rk.rack": "ラック", +"rk.device_name": "機器名", +"rk.iface_name": "ポート名", +"rk.isp": "回線事業者", +"rk.circuit": "回線 ID", +"rk.no_carrier": "事業者未入力", +"rk.ifindex": "ポート {n}", +"rk.no_ifaces": "この 1 日ポートが見えていません", +"rk.more": "ほか {n} ポート", +"rk.quiet": "現在送信なし", +"rk.edit": "編集", +"rk.save": "保存", +"rk.cancel": "キャンセル" } diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index 369a66c..01a547c 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -468,115 +468,8 @@ "cl.if_confirm": "{i}의 저장된 데이터를 모두 삭제하시겠습니까? 되돌릴 수 없습니다.", "cl.if_confirm_days": "{i}의 {n}일보다 오래된 데이터를 삭제하시겠습니까? 되돌릴 수 없습니다.", "cl.if_done": "{i}의 플로 레코드 {n}개와 카운터 표본 {c}개를 삭제하고 {s}를 확보했습니다.", -"as.f_name": "이름", -"as.f_address": "주소", -"as.f_note": "메모", -"as.f_hidden": "숨김", -"as.f_site_id": "사이트", -"as.f_units": "U 수", -"as.f_role": "역할", -"as.f_vendor": "제조사", -"as.f_model": "모델", -"as.f_serial": "일련번호", -"as.f_mgmt_ip": "관리 IP", -"as.f_rack_id": "랙", -"as.f_pos_u": "위치 U", -"as.f_height_u": "높이 (U)", -"as.f_tags": "태그", -"as.f_ifindex": "ifIndex", -"as.f_speed": "속도", -"as.f_kind": "종류", -"as.f_carrier": "통신사", -"as.f_bandwidth": "대역폭", -"as.f_port_id": "포트", -"as.f_contract": "계약 번호", -"as.f_starts": "시작일", -"as.f_ends": "종료일", -"as.f_prefix": "네트워크", -"as.f_vlan": "VLAN", -"as.f_gateway": "게이트웨이", -"as.f_device_id": "장비", "nav.racks": "사이트와 랙", "nav.devices": "장비와 포트", -"as.device": "장비", -"as.devices": "장비 목록", -"as.devices_sub": "클릭하면 포트와 결선을 볼 수 있습니다", -"as.what_racks": "장비가 어디에 있는지: 사이트, 랙, 그리고 어느 U에 무엇이 들어 있는지. 여기 있는 내용은 모두 사람이 적은 것이며 자동 탐지가 아닙니다. 그래서 실제 네트워크와 어긋났을 때도 원래 무엇이 있어야 했는지를 말해 줍니다.", -"as.what_devices": "장비가 무엇이고 어디에 연결되어 있는지: 제조사, 모델, 일련번호, 관리 주소, 그리고 각 포트와 케이블 반대쪽 포트.", -"as.locked": "등록판이 필요합니다. 이미 적어 둔 내용은 그대로 남아 있으며, 라이선스를 넣는 즉시 다시 사용할 수 있습니다.", -"as.none": "—", -"as.cancel": "취소", -"as.save": "저장", -"as.edit": "편집", -"as.delete": "삭제", -"as.confirm_delete": "{x}을(를) 삭제하시겠습니까? 되돌릴 수 없습니다.", -"as.add_site": "사이트 추가", -"as.edit_site": "사이트 편집", -"as.add_rack": "랙 추가", -"as.edit_rack": "랙 편집", -"as.add_device": "장비 추가", -"as.edit_device": "장비 편집", -"as.add_port": "포트 추가", -"as.edit_port": "포트 편집", -"as.add_circuit": "회선 추가", -"as.edit_circuit": "회선 편집", -"as.add_subnet": "네트워크 추가", -"as.edit_subnet": "네트워크 편집", -"as.hidden_hint": "이 기록은 관리자만 볼 수 있습니다.", -"as.tags_hint": "core, prod, edge", -"as.racks": "랙", -"as.no_sites": "아직 사이트가 없습니다.", -"as.no_racks": "아직 랙이 없습니다.", -"as.no_devices": "아직 장비가 없습니다.", -"as.no_ports": "아직 포트가 없습니다.", -"as.subnets_of": "{x}의 네트워크", -"as.subnets_sub": "포트 옆에 적어 두는 기록으로, 이 장비가 담당하는 네트워크입니다. 여기서는 아무것도 조회하지 않습니다 — 차트, 이름, 필터 모두 이 표에서 오지 않습니다.", -"as.no_subnets": "아직 네트워크가 없습니다.", -"as.c_sites": "사이트 {x}", -"as.c_racks": "랙 {x}", -"as.c_devices": "장비 {x}", -"as.units_at": "U{u}, 높이 {n}U", -"as.units_free": "{t}U 중 {n}U 비어 있음", -"as.unplaced": "랙에 있으나 U 미기재", -"as.place": "위치", -"as.unracked": "랙 외", -"as.ports": "포트", -"as.ports_of": "{x}의 포트", -"as.port_use": "{n}개 중 {u}개 연결됨 ({p}%)", -"as.port_free": "비어 있음", -"as.cable": "케이블 연결…", -"as.recable": "케이블 변경…", -"as.cabled_to": "연결 대상", -"as.cable_title": "{x} 케이블 연결", -"as.no_cable": "케이블 없음", -"as.what": "무엇", -"as.detail": "상세", -"as.paste_title": "스위치 출력 붙여넣기", -"as.paste_sub": "장비가 이미 출력하는 내용", -"as.paste_what": "평소 쓰던 명령의 출력을 그대로 붙여 넣으세요: show lldp neighbors, show cdp neighbors, show interface status. 장비에 아무것도 설치하지 않으며 커뮤니티 문자열이나 비밀번호도 저장하지 않습니다.", -"as.paste_from": "실행한 장비", -"as.paste_hint": "여기에 출력을 붙여 넣으세요", -"as.preview": "무엇을 할지 미리 보기", -"as.apply": "기록하기", -"as.plan_would": "{n}건을 변경합니다.", -"as.plan_done": "{n}건을 변경했습니다.", -"as.plan_none": "변경할 것이 없습니다.", -"as.plan_skipped": "읽지 못한 줄", -"as.act_add": "추가", -"as.act_update": "갱신", -"as.act_same": "변화 없음", -"as.act_error": "문제", -"as.csv_title": "스프레드시트", -"as.csv_sub": "CSV로 가져오기·내보내기", -"as.csv_what": "종류별로 내보내고, 편집하고, 다시 가져오세요. id 열이 각 행을 원래 기록에 묶어 주므로, 여기서 나간 파일은 중복이 아니라 원래 기록을 갱신합니다. id 열이 없는 파일은 새로 추가되며, 참조는 이름으로 맞춥니다.", -"as.csv_export": "내보내기", -"as.kind_of_site": "사이트", -"as.kind_of_rack": "랙", -"as.kind_of_device": "장비", -"as.kind_of_port": "포트", -"as.kind_of_link": "케이블", -"as.kind_of_circuit": "회선", -"as.kind_of_subnet": "네트워크", "nm.k_host": "호스트", "nm.k_net": "네트워크", "nm.k_device": "장비", @@ -790,5 +683,25 @@ "ob.step2": "몇 초 안에 설정에 나타나고, 1~2분 뒤 이 페이지에 데이터가 표시됩니다.", "ob.try": "이 컴퓨터의 주소와 포트가 들어간 명령입니다. 그대로 붙여 넣으세요:", "ob.copy": "복사", -"nav.help": "도움말" +"nav.help": "도움말", +"rk.sub": "플로를 보내는 각 장비의 위치와, 각 포트가 연결된 통신사와 회선", +"rk.hint": "한 번만 입력하면 됩니다. 장비와 포트는 플로에서 나오고, 입력하는 것은 전산실·랙·통신사·회선 ID뿐이며 대장 파일에 저장됩니다.", +"rk.empty": "아직 플로를 보낸 장비가 없습니다.", +"rk.unplaced": "위치 미지정", +"rk.no_rack": "랙 미지정", +"rk.rack_n": "랙 {x}", +"rk.room": "전산실", +"rk.rack": "랙", +"rk.device_name": "장비 이름", +"rk.iface_name": "포트 이름", +"rk.isp": "통신사", +"rk.circuit": "회선 ID", +"rk.no_carrier": "통신사 미입력", +"rk.ifindex": "포트 {n}", +"rk.no_ifaces": "최근 하루 동안 포트가 보이지 않음", +"rk.more": "포트 {n}개 더", +"rk.quiet": "현재 전송 없음", +"rk.edit": "수정", +"rk.save": "저장", +"rk.cancel": "취소" } diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index 77ab2a6..57df1fc 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -468,115 +468,8 @@ "cl.if_confirm": "Apagar todos os dados guardados de {i}? Não há como desfazer.", "cl.if_confirm_days": "Apagar os dados de {i} com mais de {n} dias? Não há como desfazer.", "cl.if_done": "Apagados {n} registros de fluxo e {c} amostras de contadores de {i}, liberados {s}.", -"as.f_name": "Nome", -"as.f_address": "Endereço", -"as.f_note": "Nota", -"as.f_hidden": "Oculto", -"as.f_site_id": "Site", -"as.f_units": "Us", -"as.f_role": "Função", -"as.f_vendor": "Fabricante", -"as.f_model": "Modelo", -"as.f_serial": "Número de série", -"as.f_mgmt_ip": "IP de gestão", -"as.f_rack_id": "Rack", -"as.f_pos_u": "Unidade", -"as.f_height_u": "Altura (U)", -"as.f_tags": "Etiquetas", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Velocidade", -"as.f_kind": "Tipo", -"as.f_carrier": "Operadora", -"as.f_bandwidth": "Banda", -"as.f_port_id": "Porta", -"as.f_contract": "Contrato", -"as.f_starts": "De", -"as.f_ends": "Até", -"as.f_prefix": "Rede", -"as.f_vlan": "VLAN", -"as.f_gateway": "Gateway", -"as.f_device_id": "Equipamento", "nav.racks": "Sites e racks", "nav.devices": "Equipamentos e portas", -"as.device": "Equipamento", -"as.devices": "Equipamentos", -"as.devices_sub": "Clique em um para ver suas portas e cabeamento", -"as.what_racks": "Onde o equipamento está: sites, racks e o que ocupa cada unidade. Tudo aqui é anotado, nunca descoberto — e quando o registro e a rede divergem, o registro ainda diz o que deveria estar ali.", -"as.what_devices": "O que é cada equipamento e a que está ligado: fabricante, modelo, número de série, endereço de gestão, e cada porta com a porta da outra ponta do cabo.", -"as.locked": "Isto exige uma cópia registrada. Tudo o que já foi anotado permanece e volta a ficar acessível assim que uma licença for instalada.", -"as.none": "—", -"as.cancel": "Cancelar", -"as.save": "Salvar", -"as.edit": "Editar", -"as.delete": "Apagar", -"as.confirm_delete": "Apagar {x}? Não há como desfazer.", -"as.add_site": "Adicionar site", -"as.edit_site": "Editar o site", -"as.add_rack": "Adicionar rack", -"as.edit_rack": "Editar o rack", -"as.add_device": "Adicionar equipamento", -"as.edit_device": "Editar o equipamento", -"as.add_port": "Adicionar porta", -"as.edit_port": "Editar a porta", -"as.add_circuit": "Adicionar circuito", -"as.edit_circuit": "Editar o circuito", -"as.add_subnet": "Adicionar rede", -"as.edit_subnet": "Editar a rede", -"as.hidden_hint": "Só a administração vê este registro.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Racks", -"as.no_sites": "Ainda não há sites.", -"as.no_racks": "Ainda não há racks.", -"as.no_devices": "Ainda não há equipamentos.", -"as.no_ports": "Ainda não há portas.", -"as.subnets_of": "Redes em {x}", -"as.subnets_sub": "Anotadas ao lado das portas: o que este equipamento transporta. Nada é consultado aqui — nenhum gráfico, nome ou filtro vem desta tabela.", -"as.no_subnets": "Ainda não há redes.", -"as.c_sites": "Sites {x}", -"as.c_racks": "Racks {x}", -"as.c_devices": "Equipamentos {x}", -"as.units_at": "U{u}, {n} U de altura", -"as.units_free": "{n} de {t} U livres", -"as.unplaced": "No rack, unidade não anotada", -"as.place": "Lugar", -"as.unracked": "Fora do rack", -"as.ports": "Portas", -"as.ports_of": "Portas de {x}", -"as.port_use": "{u} de {n} cabeadas ({p} %)", -"as.port_free": "livre", -"as.cable": "Cabear…", -"as.recable": "Recabear…", -"as.cabled_to": "Ligada a", -"as.cable_title": "Cabear {x}", -"as.no_cable": "Sem cabo", -"as.what": "O quê", -"as.detail": "Detalhe", -"as.paste_title": "Colar de um switch", -"as.paste_sub": "O que o equipamento já imprime", -"as.paste_what": "Execute o comando que você já usa e cole o que ele imprimiu: show lldp neighbors, show cdp neighbors, show interface status. Nada é instalado no equipamento e nenhuma community string ou senha é guardada.", -"as.paste_from": "Executado em", -"as.paste_hint": "Cole a saída aqui", -"as.preview": "Ver o que faria", -"as.apply": "Gravar", -"as.plan_would": "Seriam feitas {n} alterações.", -"as.plan_done": "{n} alterações feitas.", -"as.plan_none": "Nada a alterar.", -"as.plan_skipped": "Não compreendido", -"as.act_add": "adicionar", -"as.act_update": "atualizar", -"as.act_same": "sem mudança", -"as.act_error": "problema", -"as.csv_title": "Planilha", -"as.csv_sub": "Entrada e saída em CSV", -"as.csv_what": "Exporte um tipo, edite e importe de volta: a coluna “id” liga cada linha ao seu registro, então um arquivo vindo daqui atualiza aquilo de onde veio em vez de duplicar. Um arquivo sem coluna id adiciona; as referências são por nome.", -"as.csv_export": "Exportar", -"as.kind_of_site": "Site", -"as.kind_of_rack": "Rack", -"as.kind_of_device": "Equipamento", -"as.kind_of_port": "Porta", -"as.kind_of_link": "Cabo", -"as.kind_of_circuit": "Circuito", -"as.kind_of_subnet": "Rede", "nm.k_host": "Host", "nm.k_net": "Rede", "nm.k_device": "Dispositivo", @@ -790,5 +683,25 @@ "ob.step2": "Ele aparece em Configurações em segundos, e esta página se preenche um ou dois minutos depois.", "ob.try": "Ou cole um destes comandos, já com o endereço e as portas desta máquina:", "ob.copy": "Copiar", -"nav.help": "Ajuda" +"nav.help": "Ajuda", +"rk.sub": "Onde fica cada equipamento que envia fluxos e para qual operadora e circuito sai cada porta", +"rk.hint": "Basta preencher uma vez. Equipamentos e portas vêm dos fluxos; só sala, rack, operadora e circuito são digitados, e ficam no arquivo de inventário.", +"rk.empty": "Nenhum equipamento enviou fluxos ainda.", +"rk.unplaced": "Ainda sem local", +"rk.no_rack": "Sem rack", +"rk.rack_n": "Rack {x}", +"rk.room": "Sala", +"rk.rack": "Rack", +"rk.device_name": "Nome do equipamento", +"rk.iface_name": "Nome da porta", +"rk.isp": "Operadora", +"rk.circuit": "ID do circuito", +"rk.no_carrier": "sem operadora", +"rk.ifindex": "Porta {n}", +"rk.no_ifaces": "Nenhuma porta vista no último dia", +"rk.more": "mais {n} portas", +"rk.quiet": "não envia agora", +"rk.edit": "Editar", +"rk.save": "Salvar", +"rk.cancel": "Cancelar" } diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index 2566548..77a11f1 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -468,115 +468,8 @@ "cl.if_confirm": "Удалить все сохранённые данные {i}? Отменить нельзя.", "cl.if_confirm_days": "Удалить данные {i} старше {n} дней? Отменить нельзя.", "cl.if_done": "Удалено записей потоков: {n} и показаний счётчиков: {c} для {i}, освобождено {s}.", -"as.f_name": "Название", -"as.f_address": "Адрес", -"as.f_note": "Заметка", -"as.f_hidden": "Скрыто", -"as.f_site_id": "Площадка", -"as.f_units": "Юнитов", -"as.f_role": "Роль", -"as.f_vendor": "Производитель", -"as.f_model": "Модель", -"as.f_serial": "Серийный номер", -"as.f_mgmt_ip": "IP управления", -"as.f_rack_id": "Стойка", -"as.f_pos_u": "Юнит", -"as.f_height_u": "Высота (U)", -"as.f_tags": "Метки", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Скорость", -"as.f_kind": "Тип", -"as.f_carrier": "Оператор", -"as.f_bandwidth": "Полоса", -"as.f_port_id": "Порт", -"as.f_contract": "Договор", -"as.f_starts": "С", -"as.f_ends": "По", -"as.f_prefix": "Сеть", -"as.f_vlan": "VLAN", -"as.f_gateway": "Шлюз", -"as.f_device_id": "Устройство", "nav.racks": "Площадки и стойки", "nav.devices": "Устройства и порты", -"as.device": "Устройство", -"as.devices": "Устройства", -"as.devices_sub": "Нажмите на любое, чтобы увидеть его порты и кабели", -"as.what_racks": "Где стоит оборудование: площадки, стойки и что занимает каждый юнит. Всё здесь записывается вручную, а не обнаруживается, — поэтому, когда учёт и сеть расходятся, учёт по-прежнему говорит, как должно было быть.", -"as.what_devices": "Что это за оборудование и к чему оно подключено: производитель, модель, серийный номер, адрес управления и каждый порт вместе с портом на другом конце кабеля.", -"as.locked": "Нужна зарегистрированная версия. Всё уже записанное сохраняется и снова станет доступным, как только будет установлена лицензия.", -"as.none": "—", -"as.cancel": "Отмена", -"as.save": "Сохранить", -"as.edit": "Изменить", -"as.delete": "Удалить", -"as.confirm_delete": "Удалить {x}? Отменить нельзя.", -"as.add_site": "Добавить площадку", -"as.edit_site": "Изменить площадку", -"as.add_rack": "Добавить стойку", -"as.edit_rack": "Изменить стойку", -"as.add_device": "Добавить устройство", -"as.edit_device": "Изменить устройство", -"as.add_port": "Добавить порт", -"as.edit_port": "Изменить порт", -"as.add_circuit": "Добавить линию", -"as.edit_circuit": "Изменить линию", -"as.add_subnet": "Добавить сеть", -"as.edit_subnet": "Изменить сеть", -"as.hidden_hint": "Эту запись видит только администратор.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Стойки", -"as.no_sites": "Площадок пока нет.", -"as.no_racks": "Стоек пока нет.", -"as.no_devices": "Устройств пока нет.", -"as.no_ports": "Портов пока нет.", -"as.subnets_of": "Сети на {x}", -"as.subnets_sub": "Записаны рядом с портами: что несёт это устройство. Здесь ничего не ищется — ни график, ни имя, ни фильтр не берутся из этой таблицы.", -"as.no_subnets": "Сетей пока нет.", -"as.c_sites": "Площадки {x}", -"as.c_racks": "Стойки {x}", -"as.c_devices": "Устройства {x}", -"as.units_at": "U{u}, высота {n}U", -"as.units_free": "Свободно {n} из {t} U", -"as.unplaced": "В стойке, юнит не записан", -"as.place": "Место", -"as.unracked": "Вне стойки", -"as.ports": "Порты", -"as.ports_of": "Порты {x}", -"as.port_use": "Подключено {u} из {n} ({p} %)", -"as.port_free": "свободен", -"as.cable": "Подключить…", -"as.recable": "Переключить…", -"as.cabled_to": "Подключён к", -"as.cable_title": "Подключение {x}", -"as.no_cable": "Без кабеля", -"as.what": "Что", -"as.detail": "Подробности", -"as.paste_title": "Вставить вывод коммутатора", -"as.paste_sub": "То, что устройство и так печатает", -"as.paste_what": "Выполните команду, которой вы и так пользуетесь, и вставьте её вывод: show lldp neighbors, show cdp neighbors, show interface status. На устройство ничего не устанавливается, ни community-строка, ни пароль нигде не сохраняются.", -"as.paste_from": "Выполнено на", -"as.paste_hint": "Вставьте вывод сюда", -"as.preview": "Посмотреть, что получится", -"as.apply": "Записать", -"as.plan_would": "Будет изменений: {n}.", -"as.plan_done": "Изменений сделано: {n}.", -"as.plan_none": "Менять нечего.", -"as.plan_skipped": "Не распознано", -"as.act_add": "добавить", -"as.act_update": "обновить", -"as.act_same": "без изменений", -"as.act_error": "проблема", -"as.csv_title": "Таблица", -"as.csv_sub": "Ввод и вывод в CSV", -"as.csv_what": "Выгрузите вид, отредактируйте и загрузите обратно: столбец «id» связывает строку с записью, поэтому файл отсюда обновляет то, откуда он взялся, а не удваивает это. Файл без столбца id добавляет; ссылки — по имени.", -"as.csv_export": "Выгрузить", -"as.kind_of_site": "Площадка", -"as.kind_of_rack": "Стойка", -"as.kind_of_device": "Устройство", -"as.kind_of_port": "Порт", -"as.kind_of_link": "Кабель", -"as.kind_of_circuit": "Линия", -"as.kind_of_subnet": "Сеть", "nm.k_host": "Хост", "nm.k_net": "Сеть", "nm.k_device": "Устройство", @@ -790,5 +683,25 @@ "ob.step2": "Устройство появится в «Настройках» через несколько секунд, а эта страница заполнится через минуту-две.", "ob.try": "Или вставьте одну из команд — адрес и порты этой машины уже подставлены:", "ob.copy": "Копировать", -"nav.help": "Справка" +"nav.help": "Справка", +"rk.sub": "Где стоит каждое устройство, присылающее потоки, и к какому оператору и каналу ведёт каждый порт", +"rk.hint": "Заполняется один раз. Устройства и порты берутся из потоков; вводятся только помещение, стойка, оператор и канал, они хранятся в файле реестра.", +"rk.empty": "Ни одно устройство ещё не прислало потоков.", +"rk.unplaced": "Место не указано", +"rk.no_rack": "Без стойки", +"rk.rack_n": "Стойка {x}", +"rk.room": "Помещение", +"rk.rack": "Стойка", +"rk.device_name": "Имя устройства", +"rk.iface_name": "Имя порта", +"rk.isp": "Оператор", +"rk.circuit": "ID канала", +"rk.no_carrier": "нет оператора", +"rk.ifindex": "Порт {n}", +"rk.no_ifaces": "За последние сутки портов не видно", +"rk.more": "ещё портов: {n}", +"rk.quiet": "сейчас не присылает", +"rk.edit": "Изменить", +"rk.save": "Сохранить", +"rk.cancel": "Отмена" } diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index 4651cff..3d40ef6 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -468,115 +468,8 @@ "cl.if_confirm": "{i} کا تمام محفوظ ڈیٹا حذف کریں؟ واپس نہیں ہوسکتا۔", "cl.if_confirm_days": "{i} کا {n} دن سے پرانا ڈیٹا حذف کریں؟ واپس نہیں ہوسکتا۔", "cl.if_done": "{i} کے {n} فلو ریکارڈز اور {c} کاؤنٹر نمونے حذف ہوئے، {s} خالی ہوا۔", -"as.f_name": "نام", -"as.f_address": "پتہ", -"as.f_note": "نوٹ", -"as.f_hidden": "پوشیدہ", -"as.f_site_id": "سائٹ", -"as.f_units": "یونٹ", -"as.f_role": "کردار", -"as.f_vendor": "بنانے والا", -"as.f_model": "ماڈل", -"as.f_serial": "سیریل نمبر", -"as.f_mgmt_ip": "مینجمنٹ IP", -"as.f_rack_id": "ریک", -"as.f_pos_u": "یونٹ", -"as.f_height_u": "اونچائی (U)", -"as.f_tags": "ٹیگ", -"as.f_ifindex": "ifIndex", -"as.f_speed": "رفتار", -"as.f_kind": "قسم", -"as.f_carrier": "کیریئر", -"as.f_bandwidth": "بینڈوڈتھ", -"as.f_port_id": "پورٹ", -"as.f_contract": "معاہدہ", -"as.f_starts": "سے", -"as.f_ends": "تک", -"as.f_prefix": "نیٹ ورک", -"as.f_vlan": "VLAN", -"as.f_gateway": "گیٹ وے", -"as.f_device_id": "ڈیوائس", "nav.racks": "سائٹس اور ریک", "nav.devices": "ڈیوائسز اور پورٹس", -"as.device": "ڈیوائس", -"as.devices": "ڈیوائسز", -"as.devices_sub": "پورٹس اور کیبلنگ دیکھنے کے لیے کسی ایک پر کلک کریں", -"as.what_racks": "سامان کہاں رکھا ہے: سائٹس، کیبنٹ، اور کس یونٹ میں کیا ہے۔ یہاں کی ہر چیز لکھ کر درج کی جاتی ہے، خود سے دریافت نہیں ہوتی — اسی لیے جب یہ اور نیٹ ورک مختلف بات کہیں، تب بھی یہ بتاتا ہے کہ وہاں کیا ہونا چاہیے تھا۔", -"as.what_devices": "ڈیوائس کیا ہے اور کس سے جُڑی ہے: بنانے والا، ماڈل، سیریل نمبر، مینجمنٹ پتہ، اور ہر پورٹ کے ساتھ کیبل کے دوسرے سرے کا پورٹ۔", -"as.locked": "اس کے لیے رجسٹرڈ نسخہ درکار ہے۔ جو پہلے درج ہو چکا ہے وہ برقرار رہتا ہے، اور لائسنس لگاتے ہی دوبارہ دستیاب ہو جاتا ہے۔", -"as.none": "—", -"as.cancel": "منسوخ کریں", -"as.save": "محفوظ کریں", -"as.edit": "ترمیم", -"as.delete": "حذف کریں", -"as.confirm_delete": "{x} حذف کریں؟ واپس نہیں ہوسکتا۔", -"as.add_site": "سائٹ شامل کریں", -"as.edit_site": "سائٹ میں ترمیم", -"as.add_rack": "ریک شامل کریں", -"as.edit_rack": "ریک میں ترمیم", -"as.add_device": "ڈیوائس شامل کریں", -"as.edit_device": "ڈیوائس میں ترمیم", -"as.add_port": "پورٹ شامل کریں", -"as.edit_port": "پورٹ میں ترمیم", -"as.add_circuit": "سرکٹ شامل کریں", -"as.edit_circuit": "سرکٹ میں ترمیم", -"as.add_subnet": "نیٹ ورک شامل کریں", -"as.edit_subnet": "نیٹ ورک میں ترمیم", -"as.hidden_hint": "یہ ریکارڈ صرف منتظم کو نظر آتا ہے۔", -"as.tags_hint": "core, prod, edge", -"as.racks": "ریک", -"as.no_sites": "ابھی کوئی سائٹ نہیں۔", -"as.no_racks": "ابھی کوئی ریک نہیں۔", -"as.no_devices": "ابھی کوئی ڈیوائس نہیں۔", -"as.no_ports": "ابھی کوئی پورٹ نہیں۔", -"as.subnets_of": "{x} پر نیٹ ورکس", -"as.subnets_sub": "پورٹس کے ساتھ لکھا ریکارڈ: یہ آلہ کون سے نیٹ ورک اٹھاتا ہے۔ یہاں سے کچھ تلاش نہیں ہوتا — کوئی چارٹ، نام یا فلٹر اس جدول سے نہیں آتا۔", -"as.no_subnets": "ابھی کوئی نیٹ ورک نہیں۔", -"as.c_sites": "سائٹس {x}", -"as.c_racks": "ریک {x}", -"as.c_devices": "ڈیوائسز {x}", -"as.units_at": "U{u}، اونچائی {n}U", -"as.units_free": "{t}U میں سے {n}U خالی", -"as.unplaced": "ریک میں ہے، یونٹ درج نہیں", -"as.place": "جگہ", -"as.unracked": "ریک میں نہیں", -"as.ports": "پورٹس", -"as.ports_of": "{x} کے پورٹس", -"as.port_use": "{n} میں سے {u} جُڑے ({p}%)", -"as.port_free": "خالی", -"as.cable": "کیبل لگائیں…", -"as.recable": "کیبل بدلیں…", -"as.cabled_to": "جُڑا ہے", -"as.cable_title": "{x} کی کیبل", -"as.no_cable": "کوئی کیبل نہیں", -"as.what": "کیا", -"as.detail": "تفصیل", -"as.paste_title": "سوئچ سے پیسٹ کریں", -"as.paste_sub": "جو ڈیوائس پہلے ہی چھاپتی ہے", -"as.paste_what": "جو کمانڈ آپ پہلے ہی چلاتے ہیں وہ چلائیں اور اس کا آؤٹ پٹ یہاں پیسٹ کریں: show lldp neighbors، show cdp neighbors، show interface status۔ آلے پر کچھ انسٹال نہیں ہوتا اور نہ کوئی کمیونٹی اسٹرنگ یا پاس ورڈ کہیں محفوظ ہوتا ہے۔", -"as.paste_from": "کس ڈیوائس پر چلایا", -"as.paste_hint": "آؤٹ پٹ یہاں پیسٹ کریں", -"as.preview": "دیکھیں کیا ہوگا", -"as.apply": "لکھیں", -"as.plan_would": "{n} تبدیلیاں کی جائیں گی۔", -"as.plan_done": "{n} تبدیلیاں کی گئیں۔", -"as.plan_none": "بدلنے کو کچھ نہیں۔", -"as.plan_skipped": "سمجھ نہیں آیا", -"as.act_add": "اضافہ", -"as.act_update": "تازہ کاری", -"as.act_same": "بلا تبدیلی", -"as.act_error": "مسئلہ", -"as.csv_title": "اسپریڈ شیٹ", -"as.csv_sub": "CSV میں درآمد و برآمد", -"as.csv_what": "ایک قسم برآمد کریں، ترمیم کریں، پھر درآمد کریں: id کالم ہر سطر کو اس کے ریکارڈ سے جوڑتا ہے، اس لیے یہاں سے نکلی فائل دہراتی نہیں بلکہ اصل ریکارڈ کو تازہ کرتی ہے۔ id کالم کے بغیر فائل نیا اندراج کرتی ہے؛ حوالے نام سے ملائے جاتے ہیں۔", -"as.csv_export": "برآمد", -"as.kind_of_site": "سائٹ", -"as.kind_of_rack": "ریک", -"as.kind_of_device": "ڈیوائس", -"as.kind_of_port": "پورٹ", -"as.kind_of_link": "کیبل", -"as.kind_of_circuit": "سرکٹ", -"as.kind_of_subnet": "نیٹ ورک", "nm.k_host": "ہوسٹ", "nm.k_net": "نیٹ ورک", "nm.k_device": "ڈیوائس", @@ -790,5 +683,25 @@ "ob.step2": "یہ چند سیکنڈ میں ترتیبات میں نظر آئے گا، اور یہ صفحہ ایک دو منٹ میں بھر جائے گا۔", "ob.try": "یا ان میں سے کوئی کمانڈ چسپاں کریں، اس مشین کا پتا اور پورٹس پہلے سے درج ہیں:", "ob.copy": "کاپی", -"nav.help": "مدد" +"nav.help": "مدد", +"rk.sub": "فلو بھیجنے والا ہر آلہ کہاں لگا ہے، اور ہر پورٹ کس کیریئر اور سرکٹ سے جڑا ہے", +"rk.hint": "اسے ایک بار بھریں۔ آلات اور پورٹس فلو سے آتے ہیں؛ صرف کمرہ، ریک، کیریئر اور سرکٹ لکھنے ہوتے ہیں، جو رجسٹر فائل میں رہتے ہیں۔", +"rk.empty": "ابھی تک کسی آلے نے فلو نہیں بھیجا۔", +"rk.unplaced": "ابھی جگہ مقرر نہیں", +"rk.no_rack": "کوئی ریک نہیں", +"rk.rack_n": "ریک {x}", +"rk.room": "کمرہ", +"rk.rack": "ریک", +"rk.device_name": "آلے کا نام", +"rk.iface_name": "پورٹ کا نام", +"rk.isp": "کیریئر", +"rk.circuit": "سرکٹ ID", +"rk.no_carrier": "کیریئر نہیں", +"rk.ifindex": "پورٹ {n}", +"rk.no_ifaces": "پچھلے ایک دن میں کوئی پورٹ نظر نہیں آیا", +"rk.more": "{n} مزید پورٹس", +"rk.quiet": "ابھی نہیں بھیج رہا", +"rk.edit": "ترمیم", +"rk.save": "محفوظ کریں", +"rk.cancel": "منسوخ" } diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index 1591243..e57bba5 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -468,115 +468,8 @@ "cl.if_confirm": "Xóa toàn bộ dữ liệu đã lưu của {i}? Không thể hoàn tác.", "cl.if_confirm_days": "Xóa dữ liệu của {i} cũ hơn {n} ngày? Không thể hoàn tác.", "cl.if_done": "Đã xóa {n} bản ghi luồng và {c} mẫu bộ đếm của {i}, giải phóng {s}.", -"as.f_name": "Tên", -"as.f_address": "Địa chỉ", -"as.f_note": "Ghi chú", -"as.f_hidden": "Ẩn", -"as.f_site_id": "Địa điểm", -"as.f_units": "Số U", -"as.f_role": "Vai trò", -"as.f_vendor": "Hãng", -"as.f_model": "Model", -"as.f_serial": "Số sê-ri", -"as.f_mgmt_ip": "IP quản trị", -"as.f_rack_id": "Tủ rack", -"as.f_pos_u": "Vị trí U", -"as.f_height_u": "Chiều cao (U)", -"as.f_tags": "Thẻ", -"as.f_ifindex": "ifIndex", -"as.f_speed": "Tốc độ", -"as.f_kind": "Loại", -"as.f_carrier": "Nhà mạng", -"as.f_bandwidth": "Băng thông", -"as.f_port_id": "Cổng", -"as.f_contract": "Hợp đồng", -"as.f_starts": "Từ", -"as.f_ends": "Đến", -"as.f_prefix": "Mạng", -"as.f_vlan": "VLAN", -"as.f_gateway": "Cổng mặc định", -"as.f_device_id": "Thiết bị", "nav.racks": "Địa điểm & tủ rack", "nav.devices": "Thiết bị & cổng", -"as.device": "Thiết bị", -"as.devices": "Thiết bị", -"as.devices_sub": "Bấm vào một thiết bị để xem cổng và đi dây", -"as.what_racks": "Thiết bị đặt ở đâu: địa điểm, tủ rack, và mỗi vị trí U chứa gì. Mọi thứ ở đây đều do người ghi lại, không phải dò tìm tự động — nên khi sổ và mạng thực tế khác nhau, sổ vẫn cho biết lẽ ra phải có gì ở đó.", -"as.what_devices": "Thiết bị là gì và nối tới đâu: hãng, model, số sê-ri, địa chỉ quản trị, và từng cổng cùng cổng ở đầu dây bên kia.", -"as.locked": "Phần này cần bản đã đăng ký. Mọi thứ đã ghi vẫn còn nguyên, và dùng lại được ngay khi cài giấy phép.", -"as.none": "—", -"as.cancel": "Hủy", -"as.save": "Lưu", -"as.edit": "Sửa", -"as.delete": "Xóa", -"as.confirm_delete": "Xóa {x}? Không thể hoàn tác.", -"as.add_site": "Thêm địa điểm", -"as.edit_site": "Sửa địa điểm", -"as.add_rack": "Thêm tủ rack", -"as.edit_rack": "Sửa tủ rack", -"as.add_device": "Thêm thiết bị", -"as.edit_device": "Sửa thiết bị", -"as.add_port": "Thêm cổng", -"as.edit_port": "Sửa cổng", -"as.add_circuit": "Thêm đường truyền", -"as.edit_circuit": "Sửa đường truyền", -"as.add_subnet": "Thêm mạng", -"as.edit_subnet": "Sửa mạng", -"as.hidden_hint": "Chỉ quản trị viên thấy bản ghi này.", -"as.tags_hint": "core, prod, edge", -"as.racks": "Tủ rack", -"as.no_sites": "Chưa có địa điểm nào.", -"as.no_racks": "Chưa có tủ rack nào.", -"as.no_devices": "Chưa có thiết bị nào.", -"as.no_ports": "Chưa có cổng nào.", -"as.subnets_of": "Mạng trên {x}", -"as.subnets_sub": "Ghi cạnh các cổng: thiết bị này mang những mạng nào. Không tra cứu gì ở đây — biểu đồ, tên hay bộ lọc đều không lấy từ bảng này.", -"as.no_subnets": "Chưa có mạng nào.", -"as.c_sites": "Địa điểm {x}", -"as.c_racks": "Tủ rack {x}", -"as.c_devices": "Thiết bị {x}", -"as.units_at": "U{u}, cao {n}U", -"as.units_free": "Trống {n} trên {t} U", -"as.unplaced": "Trong tủ, chưa ghi vị trí U", -"as.place": "Vị trí", -"as.unracked": "Chưa lắp tủ", -"as.ports": "Cổng", -"as.ports_of": "Cổng của {x}", -"as.port_use": "{u} trên {n} đã đi dây ({p}%)", -"as.port_free": "trống", -"as.cable": "Đi dây…", -"as.recable": "Đổi dây…", -"as.cabled_to": "Nối tới", -"as.cable_title": "Đi dây {x}", -"as.no_cable": "Không có dây", -"as.what": "Việc gì", -"as.detail": "Chi tiết", -"as.paste_title": "Dán từ switch", -"as.paste_sub": "Thứ thiết bị vốn đã in ra", -"as.paste_what": "Chạy lệnh bạn vẫn dùng và dán kết quả vào đây: show lldp neighbors, show cdp neighbors, show interface status. Không cài gì lên thiết bị và không lưu chuỗi community hay mật khẩu ở đâu cả.", -"as.paste_from": "Chạy trên", -"as.paste_hint": "Dán kết quả vào đây", -"as.preview": "Xem trước việc sẽ làm", -"as.apply": "Ghi vào", -"as.plan_would": "Sẽ thực hiện {n} thay đổi.", -"as.plan_done": "Đã thực hiện {n} thay đổi.", -"as.plan_none": "Không có gì để thay đổi.", -"as.plan_skipped": "Không đọc được", -"as.act_add": "thêm", -"as.act_update": "cập nhật", -"as.act_same": "không đổi", -"as.act_error": "vấn đề", -"as.csv_title": "Bảng tính", -"as.csv_sub": "Xuất và nhập bằng CSV", -"as.csv_what": "Xuất một loại, sửa, rồi nhập lại: cột id gắn mỗi dòng với bản ghi của nó, nên tệp lấy từ đây sẽ cập nhật chính nó thay vì nhân đôi. Tệp không có cột id sẽ thêm mới; tham chiếu khớp theo tên.", -"as.csv_export": "Xuất", -"as.kind_of_site": "Địa điểm", -"as.kind_of_rack": "Tủ rack", -"as.kind_of_device": "Thiết bị", -"as.kind_of_port": "Cổng", -"as.kind_of_link": "Dây", -"as.kind_of_circuit": "Đường truyền", -"as.kind_of_subnet": "Mạng", "nm.k_host": "Máy", "nm.k_net": "Mạng", "nm.k_device": "Thiết bị", @@ -790,5 +683,25 @@ "ob.step2": "Thiết bị sẽ hiện trong Cài đặt sau vài giây, và trang này có dữ liệu sau một hai phút.", "ob.try": "Hoặc dán một trong các lệnh sau, đã điền sẵn địa chỉ và cổng của máy này:", "ob.copy": "Sao chép", -"nav.help": "Trợ giúp" +"nav.help": "Trợ giúp", +"rk.sub": "Mỗi thiết bị gửi luồng đặt ở đâu, và mỗi cổng đi ra nhà mạng, đường truyền nào", +"rk.hint": "Chỉ cần nhập một lần. Thiết bị và cổng lấy từ luồng; chỉ nhập phòng máy, tủ rack, nhà mạng và mã đường truyền, lưu trong tệp sổ quản lý.", +"rk.empty": "Chưa có thiết bị nào gửi luồng.", +"rk.unplaced": "Chưa xếp chỗ", +"rk.no_rack": "Chưa có tủ rack", +"rk.rack_n": "Tủ {x}", +"rk.room": "Phòng máy", +"rk.rack": "Tủ rack", +"rk.device_name": "Tên thiết bị", +"rk.iface_name": "Tên cổng", +"rk.isp": "Nhà mạng", +"rk.circuit": "Mã đường truyền", +"rk.no_carrier": "chưa có nhà mạng", +"rk.ifindex": "Cổng {n}", +"rk.no_ifaces": "Không thấy cổng nào trong ngày qua", +"rk.more": "thêm {n} cổng", +"rk.quiet": "hiện không gửi", +"rk.edit": "Sửa", +"rk.save": "Lưu", +"rk.cancel": "Hủy" } diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 99b9852..832f11c 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -468,115 +468,8 @@ "cl.if_confirm": "确定删除 {i} 的全部存储数据吗?无法撤销。", "cl.if_confirm_days": "确定删除 {i} 中早于 {n} 天的数据吗?无法撤销。", "cl.if_done": "已删除 {i} 的 {n} 条流记录和 {c} 条计数器采样,释放 {s}。", -"as.f_name": "名称", -"as.f_address": "地址", -"as.f_note": "备注", -"as.f_hidden": "隐藏", -"as.f_site_id": "机房", -"as.f_units": "U 数", -"as.f_role": "角色", -"as.f_vendor": "厂商", -"as.f_model": "型号", -"as.f_serial": "序列号", -"as.f_mgmt_ip": "管理 IP", -"as.f_rack_id": "机柜", -"as.f_pos_u": "所在 U", -"as.f_height_u": "高度 (U)", -"as.f_tags": "标签", -"as.f_ifindex": "ifIndex", -"as.f_speed": "速率", -"as.f_kind": "类型", -"as.f_carrier": "运营商", -"as.f_bandwidth": "带宽", -"as.f_port_id": "端口", -"as.f_contract": "合同号", -"as.f_starts": "开始日期", -"as.f_ends": "结束日期", -"as.f_prefix": "网段", -"as.f_vlan": "VLAN", -"as.f_gateway": "网关", -"as.f_device_id": "设备", "nav.racks": "机房与机柜", "nav.devices": "设备与端口", -"as.device": "设备", -"as.devices": "设备列表", -"as.devices_sub": "点击某台设备查看它的端口和接线", -"as.what_racks": "设备在哪里:机房、机柜,以及每个 U 位里装的是什么。这里的内容全部由人录入,而非自动发现——正因如此,当它和实际网络对不上时,台账仍然说明了本该是什么样子。", -"as.what_devices": "设备是什么、连到了哪里:厂商、型号、序列号、管理地址,以及每个端口和它另一端连接的端口。", -"as.locked": "此功能需要注册版。已经录入的内容会完整保留,装上授权后立刻可以继续使用。", -"as.none": "—", -"as.cancel": "取消", -"as.save": "保存", -"as.edit": "编辑", -"as.delete": "删除", -"as.confirm_delete": "确定删除 {x} 吗?无法撤销。", -"as.add_site": "添加机房", -"as.edit_site": "编辑机房", -"as.add_rack": "添加机柜", -"as.edit_rack": "编辑机柜", -"as.add_device": "添加设备", -"as.edit_device": "编辑设备", -"as.add_port": "添加端口", -"as.edit_port": "编辑端口", -"as.add_circuit": "添加线路", -"as.edit_circuit": "编辑线路", -"as.add_subnet": "添加网段", -"as.edit_subnet": "编辑网段", -"as.hidden_hint": "此记录只有管理员能看到。", -"as.tags_hint": "core, prod, edge", -"as.racks": "机柜", -"as.no_sites": "还没有机房。", -"as.no_racks": "还没有机柜。", -"as.no_devices": "还没有设备。", -"as.no_ports": "还没有端口。", -"as.subnets_of": "{x} 上的网段", -"as.subnets_sub": "记在端口旁边:这台设备承载的网段。这里不参与任何查询——图表、名称和筛选都不取自这张表。", -"as.no_subnets": "还没有网段。", -"as.c_sites": "机房 {x}", -"as.c_racks": "机柜 {x}", -"as.c_devices": "设备 {x}", -"as.units_at": "U{u},{n}U 高", -"as.units_free": "{t}U 中空闲 {n}U", -"as.unplaced": "在机柜内,未记录 U 位", -"as.place": "位置", -"as.unracked": "未上架", -"as.ports": "端口", -"as.ports_of": "{x} 的端口", -"as.port_use": "{n} 个端口中 {u} 个已接线({p}%)", -"as.port_free": "空闲", -"as.cable": "接线…", -"as.recable": "改接…", -"as.cabled_to": "对端", -"as.cable_title": "{x} 的接线", -"as.no_cable": "未接线", -"as.what": "问题", -"as.detail": "详情", -"as.paste_title": "从交换机粘贴", -"as.paste_sub": "直接用设备本来就会打印的内容", -"as.paste_what": "运行你平时就在用的命令,把输出粘贴进来:show lldp neighbors、show cdp neighbors、show interface status。不会在设备上安装任何东西,也不会保存任何 community 字符串或密码。", -"as.paste_from": "运行在哪台设备", -"as.paste_hint": "把输出粘贴到这里", -"as.preview": "先看看会做什么", -"as.apply": "写入", -"as.plan_would": "将变更 {n} 条。", -"as.plan_done": "已变更 {n} 条。", -"as.plan_none": "没有需要变更的内容。", -"as.plan_skipped": "未能识别的行", -"as.act_add": "新增", -"as.act_update": "更新", -"as.act_same": "未变化", -"as.act_error": "有问题", -"as.csv_title": "表格文件", -"as.csv_sub": "CSV 导入导出", -"as.csv_what": "按类别导出、编辑、再导入:id 列把每一行和原记录对应起来,所以从这里导出的文件再导入时是更新而不是重复。没有 id 列的文件会新增,引用按名称匹配。", -"as.csv_export": "导出", -"as.kind_of_site": "机房", -"as.kind_of_rack": "机柜", -"as.kind_of_device": "设备", -"as.kind_of_port": "端口", -"as.kind_of_link": "线缆", -"as.kind_of_circuit": "线路", -"as.kind_of_subnet": "网段", "nm.k_host": "主机", "nm.k_net": "网段", "nm.k_device": "设备", @@ -790,5 +683,25 @@ "ob.step2": "几秒内就会出现在「设置」里,一两分钟后本页就有数据。", "ob.try": "或者直接粘贴下面的命令,地址和端口已经填好:", "ob.copy": "复制", -"nav.help": "帮助" +"nav.help": "帮助", +"rk.sub": "每台发送流量的设备在哪个机房、哪个机柜,每个接口接的是哪家运营商、哪条线路", +"rk.hint": "录入一次即可。设备和接口来自流量本身,只需填写机房、机柜、运营商和线路 ID,保存在台账文件里。", +"rk.empty": "还没有设备发送流量。", +"rk.unplaced": "未指定机房", +"rk.no_rack": "未指定机柜", +"rk.rack_n": "机柜 {x}", +"rk.room": "机房", +"rk.rack": "机柜", +"rk.device_name": "设备名称", +"rk.iface_name": "接口名称", +"rk.isp": "运营商", +"rk.circuit": "线路 ID", +"rk.no_carrier": "未填运营商", +"rk.ifindex": "接口 {n}", +"rk.no_ifaces": "最近一天没有看到接口", +"rk.more": "另外 {n} 个接口", +"rk.quiet": "当前未发送", +"rk.edit": "修改", +"rk.save": "保存", +"rk.cancel": "取消" } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 269d9d3..fa0fb09 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -70,7 +70,6 @@

-
diff --git a/inventory.txt.example b/inventory.txt.example index 4874ca1..4977359 100644 --- a/inventory.txt.example +++ b/inventory.txt.example @@ -7,12 +7,13 @@ net 192.168.0.0/16 Office country=JP # country=XX (two letters) places a network on the world map: lines are drawn # from that country to the countries it talks to # -# device [unsampled] [sampling=N] -# (sampling=N: the device samples 1:N but does not declare it in its export) -device 192.0.2.1 Core router +# device [room=R] [rack=K] [unsampled] [sampling=N] +# (sampling=N: the device samples 1:N but does not declare it in its export; +# room= and rack= place it on the Rooms and racks page, a space written as _) +device 192.0.2.1 Core router room=Room_1 rack=A03 # -# iface [speed=] -iface 192.0.2.1 3 ISP uplink speed=1000000000 +# iface [speed=] [isp=C] [circuit=ID] +iface 192.0.2.1 3 ISP uplink speed=1000000000 isp=China_Telecom circuit=SH-12345 # # host
host 192.168.1.10 File server From e21fa933a5401a47e970c6f3fe313c56006d2b84 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:59:20 +0000 Subject: [PATCH 7/8] Threat lists are imported from the threat page A list used to need a file dropped into /threats and a restart. The threat page now lists what is loaded and, for the administrator, imports a file or a paste under a name and removes a list. An import is read on its own first, so a file with no address in it is refused rather than replacing a working list with an empty one; it is saved where the startup loader reads it and is in use at once. --- CHANGELOG.md | 1 + internal/api/server.go | 2 + internal/api/threatlists.go | 105 +++++++++++++++++++++++++++++++ internal/api/threatlists_test.go | 52 +++++++++++++++ internal/enrich/enrich.go | 19 ++++++ internal/web/static/app.css | 2 + internal/web/static/app.js | 32 ++++++++++ internal/web/static/i18n/ar.json | 16 ++++- internal/web/static/i18n/bn.json | 16 ++++- internal/web/static/i18n/de.json | 16 ++++- internal/web/static/i18n/en.json | 16 ++++- internal/web/static/i18n/es.json | 16 ++++- internal/web/static/i18n/fr.json | 16 ++++- internal/web/static/i18n/hi.json | 16 ++++- internal/web/static/i18n/id.json | 16 ++++- internal/web/static/i18n/ja.json | 16 ++++- internal/web/static/i18n/ko.json | 16 ++++- internal/web/static/i18n/pt.json | 16 ++++- internal/web/static/i18n/ru.json | 16 ++++- internal/web/static/i18n/ur.json | 16 ++++- internal/web/static/i18n/vi.json | 16 ++++- internal/web/static/i18n/zh.json | 16 ++++- 22 files changed, 423 insertions(+), 30 deletions(-) create mode 100644 internal/api/threatlists.go create mode 100644 internal/api/threatlists_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b149e..6d5d925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ heading is the version number. - 95th-percentile billing is removed: the page, `/api/billing`, `/api/billing/series`, the five-minute rate table it was computed from and the `billed` mark on interface lines (an inventory line that still carries it loads as before). The interface page shows the same counters, and `/api/report` now takes its counter figures from them. - The evaluation mark is a step darker and a size larger. +- Threat lists can be imported from the threat page — a file or a paste, one address or network per line — and removed there. An imported list is saved as `/threats/.txt` and is in use at once, without a restart; it applies to flows from then on. - Rooms and racks is a picture instead of an asset ledger, and free: every device that sends flows, in its room and rack, with each port and the carrier and circuit ID it goes out to. Devices and ports come from the flows; the room, rack, carrier and circuit are typed in once and kept in `inventory.txt` (`room=`, `rack=` on device lines, `isp=`, `circuit=` on interface lines). The ledger it replaces is removed — device pages, ports, cabling, circuits, networks, paste and CSV import — and its `assets.duckdb` is no longer opened (it can be deleted). - The names page is called Inventory again, in every language (台账 in Chinese). - The sponsor strip shows one advertiser at a time: a long banner alone, a small 728x90 one three times side by side. diff --git a/internal/api/server.go b/internal/api/server.go index 5b0da53..f61fe57 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -163,6 +163,8 @@ func (s *Server) Handler() http.Handler { adminAPI("POST /api/inventory", s.putInventory) adminAPI("POST /api/iface", s.putIface) api("GET /api/racks", s.getRacks) + adminAPI("POST /api/threats/lists", s.putThreatList) + adminAPI("DELETE /api/threats/lists", s.deleteThreatList) adminAPI("POST /api/racks/device", s.putRackDevice) adminAPI("POST /api/racks/iface", s.putRackIface) api("GET /api/findings", s.data((*Server).findings)) diff --git a/internal/api/threatlists.go b/internal/api/threatlists.go new file mode 100644 index 0000000..62991a2 --- /dev/null +++ b/internal/api/threatlists.go @@ -0,0 +1,105 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/githubflyideas/traffic66/internal/enrich" +) + +// Threat lists can be imported from the threat page as well as dropped +// into /threats by hand: a file or a paste, one address or network +// per line, kept as /threats/.txt and in use at once, without +// a restart. Flows that already arrived keep what they were matched +// against; a list applies to what comes in after it. +// +// Nothing is fetched from anywhere: the operator brings the list. That +// keeps the installation from reaching out to the internet on a schedule, +// as it does for nothing else. + +var threatName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$`) + +const maxThreatList = 32 << 20 + +func (s *Server) threatDir() string { return filepath.Join(s.DataDir, "threats") } + +// putThreatList imports or replaces one list. +func (s *Server) putThreatList(w http.ResponseWriter, r *http.Request) { + if s.Thr == nil || s.DataDir == "" { + fail(w, errors.New("threat lists cannot be saved on this installation")) + return + } + var in struct { + Name string `json:"name"` + Text string `json:"text"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxThreatList+4096)).Decode(&in); err != nil { + fail(w, errors.New("the list is too large or not readable")) + return + } + in.Name = strings.TrimSuffix(strings.TrimSpace(in.Name), ".txt") + if !threatName.MatchString(in.Name) { + fail(w, errors.New("a list name is letters, digits, dot, dash or underscore, up to 40")) + return + } + // read it on its own first: a file with no address in it is refused + // rather than replacing a working list with an empty one + n, err := enrich.NewThreats().Add(in.Name, strings.NewReader(in.Text)) + if err != nil { + fail(w, err) + return + } + if n == 0 { + fail(w, errors.New("no address or network found in the list")) + return + } + if err := os.MkdirAll(s.threatDir(), 0o755); err != nil { + fail(w, err) + return + } + path := filepath.Join(s.threatDir(), in.Name+".txt") + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(in.Text), 0o644); err != nil { + fail(w, err) + return + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + fail(w, err) + return + } + if _, err := s.Thr.Add(in.Name, strings.NewReader(in.Text)); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"name": in.Name, "entries": n, "lists": s.Thr.Lists()}) +} + +// deleteThreatList stops using one list and deletes its file. +func (s *Server) deleteThreatList(w http.ResponseWriter, r *http.Request) { + if s.Thr == nil || s.DataDir == "" { + fail(w, errors.New("threat lists cannot be changed on this installation")) + return + } + name := r.URL.Query().Get("name") + if !threatName.MatchString(name) { + fail(w, errors.New("bad list name")) + return + } + if _, ok := s.Thr.Lists()[name]; !ok { + fail(w, fmt.Errorf("no list called %s", name)) + return + } + if err := os.Remove(filepath.Join(s.threatDir(), name+".txt")); err != nil && !os.IsNotExist(err) { + fail(w, err) + return + } + s.Thr.Remove(name) + writeJSON(w, http.StatusOK, map[string]any{"lists": s.Thr.Lists()}) +} diff --git a/internal/api/threatlists_test.go b/internal/api/threatlists_test.go new file mode 100644 index 0000000..b230ab7 --- /dev/null +++ b/internal/api/threatlists_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/githubflyideas/traffic66/internal/enrich" +) + +// A list imported from the page is saved where the startup loader reads +// it, and is in use at once. +func TestThreatListImport(t *testing.T) { + dir := t.TempDir() + s := &Server{Thr: enrich.NewThreats(), DataDir: dir} + call := func(method, url, body string) int { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, url, strings.NewReader(body)) + if method == "POST" { + s.putThreatList(w, r) + } else { + s.deleteThreatList(w, r) + } + return w.Code + } + if c := call("POST", "/api/threats/lists", `{"name":"drop","text":"# Spamhaus DROP\n198.51.100.0/24 ; SBL1\n203.0.113.66\n"}`); c != 200 { + t.Fatal(c) + } + if got := s.Thr.Match(netip.MustParseAddr("198.51.100.7")); got != "drop" { + t.Fatalf("not in use: %q", got) + } + if b, err := os.ReadFile(filepath.Join(dir, "threats", "drop.txt")); err != nil || !strings.Contains(string(b), "203.0.113.66") { + t.Fatalf("not saved: %v", err) + } + for _, bad := range []string{`{"name":"../etc","text":"1.2.3.4"}`, `{"name":"x","text":"no addresses here"}`, `{"name":"","text":"1.2.3.4"}`} { + if c := call("POST", "/api/threats/lists", bad); c != 400 { + t.Errorf("%s accepted: %d", bad, c) + } + } + if c := call("DELETE", "/api/threats/lists?name=drop", ""); c != 200 { + t.Fatal(c) + } + if s.Thr.Match(netip.MustParseAddr("198.51.100.7")) != "" || len(s.Thr.Lists()) != 0 { + t.Fatal("the list is still in use") + } + if _, err := os.Stat(filepath.Join(dir, "threats", "drop.txt")); !os.IsNotExist(err) { + t.Fatal("the file is still there") + } +} diff --git a/internal/enrich/enrich.go b/internal/enrich/enrich.go index e50da6c..31692ad 100644 --- a/internal/enrich/enrich.go +++ b/internal/enrich/enrich.go @@ -931,6 +931,25 @@ func (t *Threats) Match(a netip.Addr) string { return "" } +// Remove drops one list. +func (t *Threats) Remove(name string) { + t.mu.Lock() + defer t.mu.Unlock() + for a, n := range t.exact { + if n == name { + delete(t.exact, a) + } + } + keep := t.nets[:0] + for _, x := range t.nets { + if x.Name != name { + keep = append(keep, x) + } + } + t.nets = keep + delete(t.lists, name) +} + func (t *Threats) Lists() map[string]int { t.mu.RLock() defer t.mu.RUnlock() diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 3bf1f8d..0238b4f 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -341,6 +341,8 @@ label.btn.disabled{opacity:.5;cursor:not-allowed!important} /* Rooms and racks: a picture of where flows come from. A room holds its racks side by side, a rack its devices, and every port of a device runs out to the carrier and circuit it goes to. */ +.thrimp{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px} +.thrimp input{border:1px solid var(--line-2);border-radius:6px;padding:6px 8px;font:inherit;background:var(--surface);color:var(--ink);min-width:220px} .rkhint{margin:0 0 14px;font-size:13px;color:var(--ink-3)} .rkroom{border:1px solid var(--line-2);border-radius:10px;padding:12px 14px 14px;margin-bottom:14px;background:var(--surface-2)} .rkroom>h3{margin:0 0 10px;font-size:15px;display:flex;align-items:center;gap:8px} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 4ac43a6..07ba5d0 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -1936,7 +1936,39 @@ views.threats = async (el) => { ${panel('c12', t('thr.table'), t('thr.sub'), nLists === 0 ? `
${t('thr.no_lists')}
` : ` ${rows.map(r => { const [cc, dir] = r.extra.split('|'); const out = dir === '1'; return ``; }).join('') || ``}
${t('col.list')}${t('col.client')}${t('col.server')}${t('col.country')}${t('col.direction')}${t('col.traffic')}${t('col.flows')}
${status(out ? 'bad' : 'warn', r.key)}${ipCell(r.key2)}${ipCell(r.key3)}${cc ? V('country', cc, country(cc)) : ''}${t('dir.' + dir)}${fmtBytes(r.wire)}${nf(r.flows)}
${t('thr.none')}
`)} + ${panel('c12', t('thr.manage'), t('thr.manage_sub'), ` + ${Object.keys(lists).sort().map(n => ``).join('') || ``}
${t('thr.col_name')}${t('thr.col_entries')}
${esc(n)}${nf(lists[n])}${admOnly(``)}
${esc(t('thr.no_lists'))}
+ ${admOnly(`

${esc(t('thr.import'))}

+ + +
+ +
`)}`)} `; + el.querySelectorAll('[data-thrm]').forEach(b => b.onclick = async () => { + if (!confirm(t('thr.confirm_rm', {x: b.dataset.thrm}))) return; + const r = await fetch('/api/threats/lists?name=' + encodeURIComponent(b.dataset.thrm), {method: 'DELETE'}); + if (!r.ok) { const j = await r.json().catch(() => ({})); toast(j.error || r.statusText); return; } + render(); + }); + const file = $('#thrFile'), go = $('#thrGo'); + if (!go) return; + let fromFile = ''; + file.onchange = async () => { + const f = file.files[0]; if (!f) return; + fromFile = await f.text(); + $('#thrFileName').textContent = f.name; + // a list is usually called what its file is called + if (!$('#thrName').value) $('#thrName').value = f.name.replace(/\.[^.]*$/, '').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40); + }; + go.onclick = async () => { + const msg = $('#thrMsg'), text = $('#thrText').value.trim() || fromFile; + const r = await fetch('/api/threats/lists', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: $('#thrName').value.trim(), text})}); + const j = await r.json().catch(() => ({})); + if (!r.ok) { msg.style.color = 'var(--crit)'; msg.textContent = j.error || r.statusText; return; } + toast(t('thr.imported', {n: nf(j.entries), x: j.name})); + render(); + }; }; const REC_COLS = [['time', 1], ['client', 1], ['cport', 1], ['server', 1], ['port', 1], ['proto', 1], ['app', 1], ['country', 1], ['encap', 1], ['traffic', 1], ['packets', 1], ['device', 1], diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index 04700fa..9372571 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -141,7 +141,7 @@ "thr.table": "المطابقات", "thr.sub": "الأحمر: مضيف داخلي أرسل بيانات إلى عنوان مدرج", "thr.none": "لا مطابقات في هذه الفترة", -"thr.no_lists": "لم تُحمَّل أي قائمة تهديدات. اكتب عنوانًا أو شبكة في كل سطر داخل data/threats/<الاسم>.txt ثم أعد التشغيل.", +"thr.no_lists": "لم تُحمَّل أي قائمة تهديدات بعد. استورد واحدة أدناه: ملفًا أو نصًا ملصوقًا، عنوانًا أو شبكة في كل سطر.", "rec.title": "سجلات التدفق", "rec.latest": "آخر {n}", "rec.columns": "الأعمدة", @@ -703,5 +703,17 @@ "rk.quiet": "لا يرسل الآن", "rk.edit": "تعديل", "rk.save": "حفظ", -"rk.cancel": "إلغاء" +"rk.cancel": "إلغاء", +"thr.manage": "قوائم التهديدات", +"thr.manage_sub": "عنوان أو شبكة في كل سطر؛ تنطبق القائمة على التدفقات منذ استيرادها", +"thr.col_name": "القائمة", +"thr.col_entries": "الإدخالات", +"thr.import": "استيراد قائمة", +"thr.name_ph": "اسم القائمة، مثل spamhaus-drop", +"thr.file": "اختر ملفًا…", +"thr.paste_ph": "أو الصق العناوين والشبكات هنا، واحدًا في كل سطر", +"thr.import_btn": "استيراد", +"thr.remove": "إزالة", +"thr.confirm_rm": "إزالة القائمة {x}؟", +"thr.imported": "تم استيراد {n} إدخالًا إلى {x}." } diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 75888ab..769833f 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -141,7 +141,7 @@ "thr.table": "মিল", "thr.sub": "লাল: কোনো অভ্যন্তরীণ হোস্ট তালিকাভুক্ত ঠিকানায় ডেটা পাঠিয়েছে", "thr.none": "এই সময়ে কোনো মিল নেই", -"thr.no_lists": "কোনো হুমকি তালিকা লোড হয়নি। data/threats/<নাম>.txt ফাইলে প্রতি লাইনে একটি ঠিকানা বা নেটওয়ার্ক লিখে আবার চালু করুন।", +"thr.no_lists": "এখনও কোনো হুমকি তালিকা লোড হয়নি। নিচে আমদানি করুন: ফাইল বা পেস্ট, প্রতি লাইনে একটি ঠিকানা বা নেটওয়ার্ক।", "rec.title": "ফ্লো রেকর্ড", "rec.latest": "সর্বশেষ {n}টি", "rec.columns": "কলাম", @@ -703,5 +703,17 @@ "rk.quiet": "এখন পাঠাচ্ছে না", "rk.edit": "সম্পাদনা", "rk.save": "সংরক্ষণ", -"rk.cancel": "বাতিল" +"rk.cancel": "বাতিল", +"thr.manage": "হুমকি তালিকা", +"thr.manage_sub": "প্রতি লাইনে একটি ঠিকানা বা নেটওয়ার্ক; তালিকা আমদানির পর থেকে ফ্লোতে প্রযোজ্য", +"thr.col_name": "তালিকা", +"thr.col_entries": "এন্ট্রি", +"thr.import": "তালিকা আমদানি", +"thr.name_ph": "তালিকার নাম, যেমন spamhaus-drop", +"thr.file": "ফাইল বেছে নিন…", +"thr.paste_ph": "অথবা ঠিকানা ও নেটওয়ার্ক এখানে পেস্ট করুন, প্রতি লাইনে একটি", +"thr.import_btn": "আমদানি", +"thr.remove": "সরান", +"thr.confirm_rm": "তালিকা {x} সরাবেন?", +"thr.imported": "{x}-এ {n}টি এন্ট্রি আমদানি হয়েছে।" } diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index 2842218..3064fb3 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -141,7 +141,7 @@ "thr.table": "Treffer", "thr.sub": "Rot: Ein interner Host hat Daten an eine gelistete Adresse gesendet", "thr.none": "Keine Treffer in diesem Zeitraum", -"thr.no_lists": "Keine Bedrohungslisten geladen. Legen Sie eine Adresse oder ein Netz pro Zeile in data/threats/.txt ab und starten Sie neu.", +"thr.no_lists": "Noch keine Bedrohungsliste geladen. Unten importieren: als Datei oder eingefügt, eine Adresse oder ein Netz pro Zeile.", "rec.title": "Flow-Datensätze", "rec.latest": "Neueste {n}", "rec.columns": "Spalten", @@ -703,5 +703,17 @@ "rk.quiet": "sendet gerade nicht", "rk.edit": "Bearbeiten", "rk.save": "Speichern", -"rk.cancel": "Abbrechen" +"rk.cancel": "Abbrechen", +"thr.manage": "Bedrohungslisten", +"thr.manage_sub": "Eine Adresse oder ein Netz pro Zeile; eine Liste gilt für Flows ab dem Import", +"thr.col_name": "Liste", +"thr.col_entries": "Einträge", +"thr.import": "Liste importieren", +"thr.name_ph": "Listenname, z. B. spamhaus-drop", +"thr.file": "Datei wählen…", +"thr.paste_ph": "oder Adressen und Netze hier einfügen, eine pro Zeile", +"thr.import_btn": "Importieren", +"thr.remove": "Entfernen", +"thr.confirm_rm": "Liste „{x}“ entfernen?", +"thr.imported": "{n} Einträge in „{x}“ importiert." } diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index 61fa2d5..a02d94d 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -141,7 +141,7 @@ "thr.table": "Matches", "thr.sub": "Red: an internal host sent data to a listed address", "thr.none": "No matches in this period", -"thr.no_lists": "No threat lists loaded. Put one address or network per line in data/threats/.txt and restart.", +"thr.no_lists": "No threat list is loaded yet. Import one below: a file or a paste, one address or network per line.", "rec.title": "Flow records", "rec.latest": "Latest {n}", "rec.columns": "Columns", @@ -703,5 +703,17 @@ "rk.quiet": "not sending now", "rk.edit": "Edit", "rk.save": "Save", -"rk.cancel": "Cancel" +"rk.cancel": "Cancel", +"thr.manage": "Threat lists", +"thr.manage_sub": "One address or network per line; a list applies to flows from the moment it is imported", +"thr.col_name": "List", +"thr.col_entries": "Entries", +"thr.import": "Import a list", +"thr.name_ph": "list name, e.g. spamhaus-drop", +"thr.file": "Choose file…", +"thr.paste_ph": "or paste addresses and networks here, one per line", +"thr.import_btn": "Import", +"thr.remove": "Remove", +"thr.confirm_rm": "Remove the list {x}?", +"thr.imported": "Imported {n} entries into {x}." } diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index b006b05..e3cd217 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -141,7 +141,7 @@ "thr.table": "Coincidencias", "thr.sub": "En rojo: un equipo interno envió datos a una dirección listada", "thr.none": "No hay coincidencias en este periodo", -"thr.no_lists": "No hay listas de amenazas cargadas. Escriba una dirección o red por línea en data/threats/.txt y reinicie.", +"thr.no_lists": "Aún no hay listas de amenazas. Importe una abajo: un archivo o un texto pegado, una dirección o red por línea.", "rec.title": "Registros de flujo", "rec.latest": "Últimos {n}", "rec.columns": "Columnas", @@ -703,5 +703,17 @@ "rk.quiet": "no envía ahora", "rk.edit": "Editar", "rk.save": "Guardar", -"rk.cancel": "Cancelar" +"rk.cancel": "Cancelar", +"thr.manage": "Listas de amenazas", +"thr.manage_sub": "Una dirección o red por línea; la lista se aplica a los flujos desde que se importa", +"thr.col_name": "Lista", +"thr.col_entries": "Entradas", +"thr.import": "Importar una lista", +"thr.name_ph": "nombre de la lista, p. ej. spamhaus-drop", +"thr.file": "Elegir archivo…", +"thr.paste_ph": "o pegue aquí direcciones y redes, una por línea", +"thr.import_btn": "Importar", +"thr.remove": "Quitar", +"thr.confirm_rm": "¿Quitar la lista {x}?", +"thr.imported": "{n} entradas importadas en {x}." } diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index 31dfcdb..c5c4466 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -141,7 +141,7 @@ "thr.table": "Correspondances", "thr.sub": "En rouge : un hôte interne a envoyé des données vers une adresse listée", "thr.none": "Aucune correspondance sur cette période", -"thr.no_lists": "Aucune liste de menaces chargée. Écrivez une adresse ou un réseau par ligne dans data/threats/.txt puis redémarrez.", +"thr.no_lists": "Aucune liste de menaces chargée. Importez-en une ci-dessous : un fichier ou un collage, une adresse ou un réseau par ligne.", "rec.title": "Enregistrements de flux", "rec.latest": "{n} derniers", "rec.columns": "Colonnes", @@ -703,5 +703,17 @@ "rk.quiet": "n’envoie pas en ce moment", "rk.edit": "Modifier", "rk.save": "Enregistrer", -"rk.cancel": "Annuler" +"rk.cancel": "Annuler", +"thr.manage": "Listes de menaces", +"thr.manage_sub": "Une adresse ou un réseau par ligne ; une liste s’applique aux flux dès son import", +"thr.col_name": "Liste", +"thr.col_entries": "Entrées", +"thr.import": "Importer une liste", +"thr.name_ph": "nom de la liste, p. ex. spamhaus-drop", +"thr.file": "Choisir un fichier…", +"thr.paste_ph": "ou collez ici adresses et réseaux, un par ligne", +"thr.import_btn": "Importer", +"thr.remove": "Retirer", +"thr.confirm_rm": "Retirer la liste {x} ?", +"thr.imported": "{n} entrées importées dans {x}." } diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index 27eaabb..d628518 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -141,7 +141,7 @@ "thr.table": "मिलान", "thr.sub": "लाल: किसी आंतरिक होस्ट ने सूचीबद्ध पते पर डेटा भेजा", "thr.none": "इस अवधि में कोई मिलान नहीं", -"thr.no_lists": "कोई ख़तरा सूची लोड नहीं है। data/threats/<नाम>.txt में हर पंक्ति में एक पता या नेटवर्क लिखें और फिर से शुरू करें।", +"thr.no_lists": "अभी कोई ख़तरा सूची लोड नहीं है। नीचे आयात करें: फ़ाइल या चिपकाया पाठ, हर पंक्ति में एक पता या नेटवर्क।", "rec.title": "फ़्लो रिकॉर्ड", "rec.latest": "नवीनतम {n}", "rec.columns": "कॉलम", @@ -703,5 +703,17 @@ "rk.quiet": "अभी नहीं भेज रहा", "rk.edit": "बदलें", "rk.save": "सहेजें", -"rk.cancel": "रद्द करें" +"rk.cancel": "रद्द करें", +"thr.manage": "ख़तरा सूचियाँ", +"thr.manage_sub": "हर पंक्ति में एक पता या नेटवर्क; सूची आयात होने के बाद के फ़्लो पर लागू होती है", +"thr.col_name": "सूची", +"thr.col_entries": "प्रविष्टियाँ", +"thr.import": "सूची आयात करें", +"thr.name_ph": "सूची का नाम, जैसे spamhaus-drop", +"thr.file": "फ़ाइल चुनें…", +"thr.paste_ph": "या पते और नेटवर्क यहाँ चिपकाएँ, हर पंक्ति में एक", +"thr.import_btn": "आयात करें", +"thr.remove": "हटाएँ", +"thr.confirm_rm": "सूची {x} हटाएँ?", +"thr.imported": "{x} में {n} प्रविष्टियाँ आयात हुईं।" } diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index 7d58de2..1bbe015 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -141,7 +141,7 @@ "thr.table": "Kecocokan", "thr.sub": "Merah: host internal mengirim data ke alamat terdaftar", "thr.none": "Tidak ada kecocokan pada periode ini", -"thr.no_lists": "Belum ada daftar ancaman. Tulis satu alamat atau jaringan per baris di data/threats/.txt lalu mulai ulang.", +"thr.no_lists": "Belum ada daftar ancaman. Impor di bawah: berkas atau tempelan, satu alamat atau jaringan per baris.", "rec.title": "Catatan flow", "rec.latest": "{n} terakhir", "rec.columns": "Kolom", @@ -703,5 +703,17 @@ "rk.quiet": "sedang tidak mengirim", "rk.edit": "Ubah", "rk.save": "Simpan", -"rk.cancel": "Batal" +"rk.cancel": "Batal", +"thr.manage": "Daftar ancaman", +"thr.manage_sub": "Satu alamat atau jaringan per baris; daftar berlaku untuk flow sejak diimpor", +"thr.col_name": "Daftar", +"thr.col_entries": "Entri", +"thr.import": "Impor daftar", +"thr.name_ph": "nama daftar, mis. spamhaus-drop", +"thr.file": "Pilih berkas…", +"thr.paste_ph": "atau tempel alamat dan jaringan di sini, satu per baris", +"thr.import_btn": "Impor", +"thr.remove": "Hapus", +"thr.confirm_rm": "Hapus daftar {x}?", +"thr.imported": "{n} entri diimpor ke {x}." } diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index cc2279e..0c0df1a 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -141,7 +141,7 @@ "thr.table": "一致した通信", "thr.sub": "赤:内部ホストがリスト掲載アドレスへデータを送信", "thr.none": "この期間に一致はありません", -"thr.no_lists": "脅威リストが読み込まれていません。data/threats/<名前>.txt に 1 行 1 アドレスまたはネットワークを書き、再起動してください。", +"thr.no_lists": "脅威リストはまだ読み込まれていません。下から取り込めます。ファイルか貼り付けで、1 行に 1 つのアドレスまたはネットワークです。", "rec.title": "フローレコード", "rec.latest": "最新 {n} 件", "rec.columns": "列", @@ -703,5 +703,17 @@ "rk.quiet": "現在送信なし", "rk.edit": "編集", "rk.save": "保存", -"rk.cancel": "キャンセル" +"rk.cancel": "キャンセル", +"thr.manage": "脅威リスト", +"thr.manage_sub": "1 行に 1 つのアドレスまたはネットワーク。取り込んだ時点以降のフローに適用されます", +"thr.col_name": "リスト", +"thr.col_entries": "件数", +"thr.import": "リストを取り込む", +"thr.name_ph": "リスト名(例: spamhaus-drop)", +"thr.file": "ファイルを選択…", +"thr.paste_ph": "またはここにアドレスやネットワークを 1 行に 1 つずつ貼り付け", +"thr.import_btn": "取り込む", +"thr.remove": "削除", +"thr.confirm_rm": "リスト「{x}」を削除しますか?", +"thr.imported": "{n} 件を「{x}」に取り込みました。" } diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index 01a547c..f6ec24d 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -141,7 +141,7 @@ "thr.table": "일치 항목", "thr.sub": "빨간색: 내부 호스트가 목록의 주소로 데이터를 보냄", "thr.none": "이 기간에는 일치 항목이 없습니다", -"thr.no_lists": "불러온 위협 목록이 없습니다. data/threats/.txt에 한 줄에 하나씩 주소나 네트워크를 적고 다시 시작하세요.", +"thr.no_lists": "아직 불러온 위협 목록이 없습니다. 아래에서 가져오세요. 파일이나 붙여 넣기로, 한 줄에 주소나 네트워크 하나씩입니다.", "rec.title": "플로 레코드", "rec.latest": "최근 {n}건", "rec.columns": "열", @@ -703,5 +703,17 @@ "rk.quiet": "현재 전송 없음", "rk.edit": "수정", "rk.save": "저장", -"rk.cancel": "취소" +"rk.cancel": "취소", +"thr.manage": "위협 목록", +"thr.manage_sub": "한 줄에 주소나 네트워크 하나씩. 가져온 시점 이후의 플로에 적용됩니다", +"thr.col_name": "목록", +"thr.col_entries": "항목", +"thr.import": "목록 가져오기", +"thr.name_ph": "목록 이름, 예: spamhaus-drop", +"thr.file": "파일 선택…", +"thr.paste_ph": "또는 주소와 네트워크를 한 줄에 하나씩 여기에 붙여 넣기", +"thr.import_btn": "가져오기", +"thr.remove": "삭제", +"thr.confirm_rm": "목록 \"{x}\"을(를) 삭제할까요?", +"thr.imported": "\"{x}\"에 {n}개 항목을 가져왔습니다." } diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index 57df1fc..4133229 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -141,7 +141,7 @@ "thr.table": "Ocorrências", "thr.sub": "Em vermelho: um host interno enviou dados para um endereço listado", "thr.none": "Nenhuma ocorrência neste período", -"thr.no_lists": "Nenhuma lista de ameaças carregada. Escreva um endereço ou rede por linha em data/threats/.txt e reinicie.", +"thr.no_lists": "Nenhuma lista de ameaças carregada. Importe uma abaixo: um arquivo ou texto colado, um endereço ou rede por linha.", "rec.title": "Registros de fluxo", "rec.latest": "Últimos {n}", "rec.columns": "Colunas", @@ -703,5 +703,17 @@ "rk.quiet": "não envia agora", "rk.edit": "Editar", "rk.save": "Salvar", -"rk.cancel": "Cancelar" +"rk.cancel": "Cancelar", +"thr.manage": "Listas de ameaças", +"thr.manage_sub": "Um endereço ou rede por linha; a lista vale para os fluxos a partir da importação", +"thr.col_name": "Lista", +"thr.col_entries": "Entradas", +"thr.import": "Importar uma lista", +"thr.name_ph": "nome da lista, ex.: spamhaus-drop", +"thr.file": "Escolher arquivo…", +"thr.paste_ph": "ou cole aqui endereços e redes, um por linha", +"thr.import_btn": "Importar", +"thr.remove": "Remover", +"thr.confirm_rm": "Remover a lista {x}?", +"thr.imported": "{n} entradas importadas em {x}." } diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index 77a11f1..69a6741 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -141,7 +141,7 @@ "thr.table": "Совпадения", "thr.sub": "Красным: внутренний узел отправлял данные на адрес из списка", "thr.none": "Совпадений за этот период нет", -"thr.no_lists": "Списки угроз не загружены. Запишите по одному адресу или сети в строке в data/threats/<имя>.txt и перезапустите.", +"thr.no_lists": "Списки угроз ещё не загружены. Импортируйте ниже: файл или вставленный текст, один адрес или сеть на строку.", "rec.title": "Записи потоков", "rec.latest": "Последние {n}", "rec.columns": "Столбцы", @@ -703,5 +703,17 @@ "rk.quiet": "сейчас не присылает", "rk.edit": "Изменить", "rk.save": "Сохранить", -"rk.cancel": "Отмена" +"rk.cancel": "Отмена", +"thr.manage": "Списки угроз", +"thr.manage_sub": "Один адрес или сеть на строку; список применяется к потокам с момента импорта", +"thr.col_name": "Список", +"thr.col_entries": "Записей", +"thr.import": "Импорт списка", +"thr.name_ph": "имя списка, напр. spamhaus-drop", +"thr.file": "Выбрать файл…", +"thr.paste_ph": "или вставьте сюда адреса и сети, по одному в строке", +"thr.import_btn": "Импортировать", +"thr.remove": "Удалить", +"thr.confirm_rm": "Удалить список «{x}»?", +"thr.imported": "Импортировано записей: {n} в «{x}»." } diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index 3d40ef6..829f172 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -141,7 +141,7 @@ "thr.table": "مماثلتیں", "thr.sub": "سرخ: کسی اندرونی ہوسٹ نے فہرست شدہ پتے کو ڈیٹا بھیجا", "thr.none": "اس مدت میں کوئی مماثلت نہیں", -"thr.no_lists": "کوئی خطرے کی فہرست لوڈ نہیں۔ data/threats/.txt میں ہر سطر پر ایک پتہ یا نیٹ ورک لکھیں اور دوبارہ شروع کریں۔", +"thr.no_lists": "ابھی کوئی خطرے کی فہرست لوڈ نہیں۔ نیچے درآمد کریں: فائل یا پیسٹ، ہر سطر میں ایک پتہ یا نیٹ ورک۔", "rec.title": "فلو ریکارڈ", "rec.latest": "تازہ ترین {n}", "rec.columns": "کالم", @@ -703,5 +703,17 @@ "rk.quiet": "ابھی نہیں بھیج رہا", "rk.edit": "ترمیم", "rk.save": "محفوظ کریں", -"rk.cancel": "منسوخ" +"rk.cancel": "منسوخ", +"thr.manage": "خطرے کی فہرستیں", +"thr.manage_sub": "ہر سطر میں ایک پتہ یا نیٹ ورک؛ فہرست درآمد کے بعد کے فلو پر لاگو ہوتی ہے", +"thr.col_name": "فہرست", +"thr.col_entries": "اندراجات", +"thr.import": "فہرست درآمد کریں", +"thr.name_ph": "فہرست کا نام، مثلاً spamhaus-drop", +"thr.file": "فائل منتخب کریں…", +"thr.paste_ph": "یا پتے اور نیٹ ورک یہاں پیسٹ کریں، ہر سطر میں ایک", +"thr.import_btn": "درآمد", +"thr.remove": "ہٹائیں", +"thr.confirm_rm": "فہرست {x} ہٹائیں؟", +"thr.imported": "{x} میں {n} اندراجات درآمد ہو گئے۔" } diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index e57bba5..6fd1e44 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -141,7 +141,7 @@ "thr.table": "Kết quả khớp", "thr.sub": "Đỏ: một máy nội bộ đã gửi dữ liệu đến địa chỉ trong danh sách", "thr.none": "Không có kết quả khớp trong khoảng này", -"thr.no_lists": "Chưa nạp danh sách đe dọa nào. Ghi mỗi dòng một địa chỉ hoặc một mạng vào data/threats/.txt rồi khởi động lại.", +"thr.no_lists": "Chưa nạp danh sách mối đe dọa nào. Nhập bên dưới: một tệp hoặc dán trực tiếp, mỗi dòng một địa chỉ hoặc dải mạng.", "rec.title": "Bản ghi luồng", "rec.latest": "{n} mới nhất", "rec.columns": "Cột", @@ -703,5 +703,17 @@ "rk.quiet": "hiện không gửi", "rk.edit": "Sửa", "rk.save": "Lưu", -"rk.cancel": "Hủy" +"rk.cancel": "Hủy", +"thr.manage": "Danh sách mối đe dọa", +"thr.manage_sub": "Mỗi dòng một địa chỉ hoặc dải mạng; danh sách áp dụng cho luồng kể từ lúc nhập", +"thr.col_name": "Danh sách", +"thr.col_entries": "Mục", +"thr.import": "Nhập danh sách", +"thr.name_ph": "tên danh sách, ví dụ spamhaus-drop", +"thr.file": "Chọn tệp…", +"thr.paste_ph": "hoặc dán địa chỉ và dải mạng vào đây, mỗi dòng một mục", +"thr.import_btn": "Nhập", +"thr.remove": "Xóa", +"thr.confirm_rm": "Xóa danh sách {x}?", +"thr.imported": "Đã nhập {n} mục vào {x}." } diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 832f11c..02c5004 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -141,7 +141,7 @@ "thr.table": "命中记录", "thr.sub": "红色:内网主机向名单地址发送了数据", "thr.none": "这段时间没有命中", -"thr.no_lists": "还没有加载威胁名单。在 data/threats/<名字>.txt 里每行写一个地址或网段,然后重启。", +"thr.no_lists": "还没有加载威胁名单。在下方导入:选一个文件或直接粘贴,每行一个地址或网段。", "rec.title": "流记录", "rec.latest": "最近 {n} 条", "rec.columns": "列", @@ -703,5 +703,17 @@ "rk.quiet": "当前未发送", "rk.edit": "修改", "rk.save": "保存", -"rk.cancel": "取消" +"rk.cancel": "取消", +"thr.manage": "威胁名单", +"thr.manage_sub": "每行一个地址或网段;名单导入后对之后的流量生效", +"thr.col_name": "名单", +"thr.col_entries": "条目", +"thr.import": "导入名单", +"thr.name_ph": "名单名称,例如 spamhaus-drop", +"thr.file": "选择文件…", +"thr.paste_ph": "或把地址和网段粘贴到这里,每行一个", +"thr.import_btn": "导入", +"thr.remove": "删除", +"thr.confirm_rm": "删除名单“{x}”?", +"thr.imported": "已导入 {n} 条到“{x}”。" } From 87c40bba2ad0fcbda8e43b02ef61eef02094cca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:03:47 +0000 Subject: [PATCH 8/8] Overview inbound and outbound follow the bytes, not the connection The toggle filtered on who opened the connection. A network whose hosts only connect outwards has no connection opened from outside, so Inbound was empty and said its data was still arriving, while most of its traffic is the replies it downloads. It now filters on which way the bytes went: from outside to an internal host, or the other way. --- CHANGELOG.md | 1 + internal/store/bytesdir_test.go | 45 +++++++++++++++++++++++++++++++++ internal/store/query.go | 17 +++++++++++++ internal/web/static/app.js | 7 +++-- 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 internal/store/bytesdir_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5d925..e4d316c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ heading is the version number. ## 2.4.1 +- Overview: Inbound and Outbound now mean which way the bytes went — what arrived at your hosts, what they sent — instead of who opened the connection. On a network whose hosts only connect outwards, Inbound used to be empty (and said the data was still arriving), although most of its traffic is the replies it downloads. - 95th-percentile billing is removed: the page, `/api/billing`, `/api/billing/series`, the five-minute rate table it was computed from and the `billed` mark on interface lines (an inventory line that still carries it loads as before). The interface page shows the same counters, and `/api/report` now takes its counter figures from them. - The evaluation mark is a step darker and a size larger. - Threat lists can be imported from the threat page — a file or a paste, one address or network per line — and removed there. An imported list is saved as `/threats/.txt` and is in use at once, without a restart; it applies to flows from then on. diff --git a/internal/store/bytesdir_test.go b/internal/store/bytesdir_test.go new file mode 100644 index 0000000..b86054a --- /dev/null +++ b/internal/store/bytesdir_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "testing" + "time" +) + +// An office that only opens connections outwards still has inbound +// traffic: the replies it downloads. The byte-direction filter counts +// them; the connection-direction filter (dir=inbound) rightly does not. +func TestBytesDirection(t *testing.T) { + s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ts := time.Now().UTC().Add(-10 * time.Minute) + pc, web := "10.1.1.5", "203.0.113.9" + base := Row{TS: ts, Client: pc, Server: web, Cli4: 0x0A010105, Srv4: 0xCB007109, Dir: DirOutbound, CliInt: true, Pkts: 1, Flows: 1, Exporter: "10.0.0.1"} + req, resp := base, base + req.Src, req.Dst, req.Wire, req.Bytes = pc, web, 1000, 1000 // the request, out + resp.Src, resp.Dst, resp.Wire, resp.Bytes = web, pc, 9000, 9000 // the download, in + if err := s.AppendRows([]Row{req, resp}); err != nil { + t.Fatal(err) + } + q := func(f ...Filter) uint64 { + tot, err := s.Totals(Query{From: ts.Add(-time.Minute), To: ts.Add(time.Minute), Filters: f}) + if err != nil { + t.Fatal(err) + } + return tot.Wire + } + if got := q(Filter{Field: "bytes", Value: "in"}); got != 9000 { + t.Errorf("bytes in = %d, want the 9000 downloaded", got) + } + if got := q(Filter{Field: "bytes", Value: "out"}); got != 1000 { + t.Errorf("bytes out = %d, want the 1000 sent", got) + } + if got := q(Filter{Field: "dir", Value: "inbound"}); got != 0 { + t.Errorf("dir=inbound = %d: no connection was opened from outside", got) + } + if _, err := s.Totals(Query{From: ts, To: ts.Add(time.Minute), Filters: []Filter{{Field: "bytes", Value: "sideways"}}}); err == nil { + t.Error("a bad byte direction was accepted") + } +} diff --git a/internal/store/query.go b/internal/store/query.go index 4c5cd11..50466f9 100644 --- a/internal/store/query.go +++ b/internal/store/query.go @@ -186,6 +186,23 @@ func (q Query) where() (string, []any, error) { } c = "dir = ?" args = append(args, n) + case "bytes": + // Which way the bytes went, from your networks' point of view: + // "in" is what arrived at an internal host from outside, "out" + // what an internal host sent outside. It is not dir, which says + // who opened the connection: on a network whose hosts only open + // connections outwards, the replies they download are inbound + // bytes on outbound connections, and dir=inbound is empty. + srcInt := "(CASE WHEN src = client THEN cli_int ELSE srv_int END)" + dstInt := "(CASE WHEN dst = client THEN cli_int ELSE srv_int END)" + switch v { + case "in": + c = "(NOT " + srcInt + " AND " + dstInt + ")" + case "out": + c = "(" + srcInt + " AND NOT " + dstInt + ")" + default: + return "", nil, fmt.Errorf("bad byte direction %q", v) + } case "encap": c = "encap = ?" n, err := strconv.Atoi(v) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 07ba5d0..a3e9f37 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -936,9 +936,12 @@ views.help = async (el) => { el.querySelectorAll('a[data-hlang]').forEach(a => a.onclick = e => { e.preventDefault(); helpLang = a.dataset.hlang; render(); window.scrollTo(0, 0); }); }; views.overview = async (el) => { - // total, or only inbound or only outbound traffic (direction from your networks' point of view) + // total, or only the bytes that came in or only those that went out, + // from your networks' point of view — which way the bytes went, not who + // opened the connection: a network whose hosts only connect outwards + // still downloads, and those replies are its inbound traffic const od = ['inbound', 'outbound'].includes(state.od) ? state.od : ''; - const [d, ifs, fd] = await Promise.all([api('overview', {}, od ? [...state.f, {f: 'dir', v: od, neg: false}] : state.f), inSB() ? null : loadIfaces(), api('findings', {limit: 3}).catch(() => null)]); + const [d, ifs, fd] = await Promise.all([api('overview', {}, od ? [...state.f, {f: 'bytes', v: od === 'inbound' ? 'in' : 'out', neg: false}] : state.f), inSB() ? null : loadIfaces(), api('findings', {limit: 3}).catch(() => null)]); // the interface chart: the interface picked at the top, else the default, else the busiest const ifl = ifs?.ifaces || [], ikey = f => f.exporter + '/' + f.ifindex; const ifk = state.ifv && state.ifv !== 'all' ? state.ifv : (ifs?.default || ((ifl.find(f => !f.peer) || ifl[0]) ? ikey(ifl.find(f => !f.peer) || ifl[0]) : ''));