diff --git a/CHANGELOG.md b/CHANGELOG.md index d36e8cf..9c1e3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ The release notes on GitHub are taken from this file: the section whose heading is the version number. +## 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. +- The sponsor banner of an unregistered installation is a strip across the top of the browser instead of a column in the sidebar: the 728x90 banners are shown side by side, as many as the window has room for, and the rest rotate in together. It scrolls away with the page and is left out of print. +- The traffic66 server no longer downloads the sponsor banner. Only the viewer's browser fetches it from GitHub; a browser that cannot reach GitHub shows the built-in text. `/api/sponsor` and the copy in `/sponsor` are gone (an existing folder can be deleted). +- The evaluation mark is on every page — the overview, the sources, the account and the help page were left out before — and the word repeats about half as often again, in the same faint ink and the same size. The login screen is not marked. +- Every page that shows incoming data refreshes itself, on a beat that follows the range: 20 s on 15 minutes, 30 s on an hour, a minute on 6 and 24 hours, five minutes on 7 and 30 days. The traffic, Top 66, flow paths, geo and interface pages used to stand still on a 6-hour or 24-hour range until something was clicked, which is what "the chart lags behind the data" looked like. The top bar says the beat the range actually uses. +- The source and destination network charts count traffic whose AS the database could not place, as "unknown", the way the AS table beside them already did. Leaving it out emptied whole buckets on short ranges and drew the chart as blocks with gaps between them. +- 95th-percentile billing shows the line its figure was taken across: one chart per billed interface, inbound and outbound over the period, with a dashed line at the figure charged. `GET /api/billing/series?from&to&if` returns the same five-minute buckets. Buckets the device reported nothing for stay empty, as they are left out of the percentile. +- Geo map: the three line widths are 1.5 / 3 / 4.5 px (were 1.5 / 4 / 8). The widest line drowned the five shades that carry the same figure. +- A licence says who it is made out to — a person or an organisation — and the sidebar, the footer and the licence page say it beside the name. The signed message gains a third version; licences issued before it still verify and show "kind not stated" rather than a guess. +- The asset ledger's address assignment is removed: the IP page, the addresses, the utilisation bars, the free-address suggestions, the conflict report, their CSV columns and the two paste formats that only fed them (`show ip arp`, `show mac address-table`). What is left is a note beside a device's ports: the networks it carries, added on the device's own page. Nothing is looked up in them. Sites and racks, and names, now stand on their own in the sidebar instead of under an asset-management group. A ledger filled in before this keeps its address rows; nothing reads them. +- `GET /api/report?from&to&if` answers the numbers behind a period report: traffic and flow totals, average, peak and 95th percentile of the whole period, a row per interface, the top hosts, applications and countries, and how many findings the detection rules raised, by severity. `if=exporter/ifindex` narrows the whole report to one interface (its totals, its top lists, its row). +- Each interface row says which measurement it rests on: `counters` where the device's own interface counters were read (what a carrier bills against — average, peak and 95th percentile per direction, with the share of the period that was measured), `flows` where there are none and the sampled flows are the only measurement there is. Utilisation against the interface speed is given only where that speed is known, rather than as a percentage of a guess. +- The peak and the percentile leave out the bucket that is still filling, so a report that reaches up to now no longer takes its peak from a half-counted minute. +- `TestAttackFound` no longer fails now and then: the test builds its traffic from a fixed hour of the day, so the 1:4096 sampling of the demo attack draws the same records on every run and on every platform (the port scan reaches detection as 18 distinct ports against a threshold of 5, instead of coming within one port of it at 15:00). + ## 2.3.9 - NetFlow / IPFIX loss compensation starts much sooner: a session is compensated once 100 records have arrived and the factor is recomputed every 5 seconds (was 30 seconds), so totals in the first minute after start are no longer low when the exporter or the kernel drops packets. With 37% loss the compensated total at 30 s rose from 0.62 to 0.98 of the real total. diff --git a/README.md b/README.md index e34a580..1a743b2 100644 --- a/README.md +++ b/README.md @@ -67,4 +67,4 @@ macOS: run `xattr -dr com.apple.quarantine .` in the folder first. PowerShell: u Web UI: **Help** in the menu. GitHub: [MANUAL.md](docs/MANUAL.md) Free for evaluation and for organizations under 100 people ([licence](LICENSE.md)). -Unregistered builds show a small sponsor banner, loaded by your browser from GitHub or cached by the server from GitHub (no data sent); registering removes it. +Unregistered builds show a sponsor strip, loaded only by your browser from GitHub (the server never fetches it, and no data is sent); registering removes it. diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index 42a5eab..7a97f59 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.3.9" +const devVersion = "2.4.0" func init() { if version == "" { @@ -576,9 +576,6 @@ func serve(args []string, demo bool) { log.Printf("your browser will warn about it once; check that fingerprint, or add %s to your trusted certificates", info.CertPath) } } - if offline == nil { - go srv.RunSponsor(ctx) // sponsor banner fallback; skipped while registered - } hs := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} go hs.Serve(ln) log.Printf("web UI: %s://%s", scheme, displayAddr(ln.Addr())) diff --git a/docs/MANUAL.ar.md b/docs/MANUAL.ar.md index 0873592..e80a52d 100644 --- a/docs/MANUAL.ar.md +++ b/docs/MANUAL.ar.md @@ -490,7 +490,7 @@ root، وWindows إلى [Npcap](https://npcap.com). تأتي التدفقات ا `license.json` الذي تتلقاه في دليل البيانات. للتواصل: . -تعرض التثبيتات غير المسجلة لافتة رعاية صغيرة في الشريط الجانبي الأيسر. يحمّلها متصفحك من GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))، أو إن تعذّر الوصول إليه فمن نسخة نزّلها خادم traffic66 من هناك ويحفظها في `/sponsor`؛ ولا يرسل traffic66 أي بيانات عنك أو عن التثبيت. التسجيل يزيلها. +تعرض التثبيتات غير المسجلة شريط رعاية أعلى الصفحة. يحمّله متصفحك وحده من GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))؛ ولا يتصل خادم traffic66 بـ GitHub لأجله أبدًا ولا يحتفظ بنسخة منه. والمتصفح الذي لا يصل إلى GitHub يعرض بدلًا منه نصًا قصيرًا مدمجًا. لا تُرسل أي بيانات عنك أو عن التثبيت. ويزول بالتسجيل. diff --git a/docs/MANUAL.bn.md b/docs/MANUAL.bn.md index c657a1f..6515cf2 100644 --- a/docs/MANUAL.bn.md +++ b/docs/MANUAL.bn.md @@ -499,7 +499,7 @@ available (ইংরেজি পাঠ বাধ্যতামূলক): ম পাবেন তা data directory-তে রাখুন। যোগাযোগ: । -নিবন্ধিত নয় এমন ইনস্টলেশনে বাম সাইডবারে একটি ছোট স্পনসর ব্যানার দেখা যায়। আপনার ব্রাউজার এটি GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) থেকে লোড করে, আর GitHub-এ পৌঁছাতে না পারলে traffic66 সার্ভার সেখান থেকে ডাউনলোড করে `/sponsor`-এ রাখা কপি থেকে; traffic66 আপনার বা ইনস্টলেশনের কোনো তথ্য পাঠায় না। নিবন্ধন করলে এটি সরে যায়। +নিবন্ধিত নয় এমন ইনস্টলেশনে পাতার উপরে একটি স্পনসর ফিতা দেখা যায়। শুধু আপনার ব্রাউজার এটি GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) থেকে লোড করে; traffic66 সার্ভার এর জন্য কখনো GitHub-এ যোগাযোগ করে না এবং কোনো কপি রাখে না। যে ব্রাউজার GitHub-এ পৌঁছাতে পারে না, সেখানে এর বদলে একটি ছোট অন্তর্নির্মিত লেখা দেখা যায়। আপনার বা ইনস্টলেশনের কোনো তথ্য পাঠানো হয় না। নিবন্ধন করলে এটি সরে যায়। diff --git a/docs/MANUAL.de.md b/docs/MANUAL.de.md index fd7a249..2413691 100644 --- a/docs/MANUAL.de.md +++ b/docs/MANUAL.de.md @@ -516,7 +516,7 @@ Installationsnummer; senden Sie sie an den Autor und legen Sie die zurückgeschickte `license.json` in das Datenverzeichnis. Kontakt: . -Nicht registrierte Installationen zeigen in der linken Seitenleiste ein kleines Sponsorenbanner. Ihr Browser lädt es von GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) oder, wenn GitHub nicht erreichbar ist, aus einer Kopie, die der traffic66-Server von dort geladen hat und in `/sponsor` aufbewahrt; traffic66 sendet keinerlei Daten über Sie oder die Installation. Mit der Registrierung verschwindet es. +Nicht registrierte Installationen zeigen oben auf der Seite einen Sponsorenstreifen. Nur Ihr Browser lädt ihn von GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)); der traffic66-Server kontaktiert GitHub dafür nie und hält keine Kopie. Ein Browser, der GitHub nicht erreicht, zeigt stattdessen einen kurzen eingebauten Text. Es werden keine Daten über Sie oder die Installation gesendet. Mit der Registrierung verschwindet er. diff --git a/docs/MANUAL.es.md b/docs/MANUAL.es.md index b6dfc53..7c16106 100644 --- a/docs/MANUAL.es.md +++ b/docs/MANUAL.es.md @@ -512,7 +512,7 @@ pie de cada página muestra el número de instalación de 8 dígitos; envíelo al autor y ponga el `license.json` que reciba en el directorio de datos. Contacto: . -Las instalaciones no registradas muestran un pequeño banner de patrocinio en la barra lateral izquierda. Tu navegador lo carga desde GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) o, si no puede alcanzarlo, desde una copia que el servidor traffic66 descargó de allí y guarda en `/sponsor`; traffic66 no envía ningún dato sobre ti ni sobre la instalación. Al registrarse desaparece. +Las instalaciones no registradas muestran una franja de patrocinio en la parte superior de la página. Solo tu navegador la carga, desde GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)); el servidor traffic66 nunca contacta con GitHub para ello ni guarda una copia. Un navegador que no alcanza GitHub muestra en su lugar un breve texto incorporado. No se envía ningún dato sobre ti ni sobre la instalación. Al registrarte desaparece. diff --git a/docs/MANUAL.fr.md b/docs/MANUAL.fr.md index 14882db..741c692 100644 --- a/docs/MANUAL.fr.md +++ b/docs/MANUAL.fr.md @@ -522,7 +522,7 @@ d'installation à 8 chiffres ; envoyez-le à l'auteur et placez le `license.json` reçu en retour dans le répertoire de données. Contact : . -Les installations non enregistrées affichent une petite bannière de sponsor dans la barre latérale gauche. Votre navigateur la charge depuis GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) ou, s'il ne peut pas l'atteindre, depuis une copie que le serveur traffic66 y a téléchargée et garde dans `/sponsor` ; traffic66 n'envoie aucune donnée sur vous ni sur l'installation. L'enregistrement la supprime. +Les installations non enregistrées affichent un bandeau de sponsor en haut de la page. Seul votre navigateur le charge, depuis GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) ; le serveur traffic66 ne contacte jamais GitHub pour cela et n'en garde aucune copie. Un navigateur qui ne peut pas atteindre GitHub affiche à la place un court texte intégré. Aucune donnée sur vous ni sur l'installation n'est envoyée. L'enregistrement le fait disparaître. diff --git a/docs/MANUAL.hi.md b/docs/MANUAL.hi.md index 86cbc75..79954f2 100644 --- a/docs/MANUAL.hi.md +++ b/docs/MANUAL.hi.md @@ -499,7 +499,7 @@ installation number दिखता है; उसे लेखक को भे मिले उसे data directory में रखें। संपर्क: । -अपंजीकृत इंस्टॉलेशन में बाईं साइडबार में एक छोटा प्रायोजक बैनर दिखता है। आपका ब्राउज़र इसे GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) से लोड करता है, या GitHub तक न पहुँच पाने पर उस प्रति से जिसे traffic66 सर्वर ने वहीं से डाउनलोड करके `/sponsor` में रखा है; traffic66 आपके या इंस्टॉलेशन के बारे में कोई डेटा नहीं भेजता। पंजीकरण करने पर यह हट जाता है। +अपंजीकृत इंस्टॉलेशन में पेज के ऊपर एक प्रायोजक पट्टी दिखती है। इसे केवल आपका ब्राउज़र GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) से लोड करता है; traffic66 सर्वर इसके लिए कभी GitHub से संपर्क नहीं करता और कोई प्रति नहीं रखता। जो ब्राउज़र GitHub तक नहीं पहुँच पाता, वह इसकी जगह एक छोटा अंतर्निहित पाठ दिखाता है। आपके या इंस्टॉलेशन के बारे में कोई डेटा नहीं भेजा जाता। पंजीकरण करने पर यह हट जाती है। diff --git a/docs/MANUAL.id.md b/docs/MANUAL.id.md index 65289fb..c67b3be 100644 --- a/docs/MANUAL.id.md +++ b/docs/MANUAL.id.md @@ -508,7 +508,7 @@ setiap halaman menampilkan nomor instalasi 8 digit; kirim nomor itu kepada pembuatnya, lalu letakkan `license.json` yang Anda terima di direktori data. Kontak: . -Instalasi yang belum terdaftar menampilkan banner sponsor kecil di bilah sisi kiri. Browser Anda memuatnya dari GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), atau bila GitHub tidak terjangkau, dari salinan yang diunduh server traffic66 dari sana dan disimpan di `/sponsor`; traffic66 tidak mengirim data apa pun tentang Anda atau instalasi. Mendaftar akan menghapusnya. +Instalasi yang belum terdaftar menampilkan strip sponsor di bagian atas halaman. Hanya browser Anda yang memuatnya, dari GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)); server traffic66 tidak pernah menghubungi GitHub untuk itu dan tidak menyimpan salinan. Browser yang tidak dapat menjangkau GitHub menampilkan teks bawaan singkat sebagai gantinya. Tidak ada data tentang Anda atau instalasi yang dikirim. Mendaftar akan menghapusnya. diff --git a/docs/MANUAL.ja.md b/docs/MANUAL.ja.md index 2f6f27d..8b60643 100644 --- a/docs/MANUAL.ja.md +++ b/docs/MANUAL.ja.md @@ -412,7 +412,7 @@ Linux では root か `setcap cap_net_raw,cap_net_admin+ep`、macOS では root [PolyForm Noncommercial License 1.0.0](../LICENSE.md) と [Traffic66 Additional Use Grant](../ADDITIONAL-USE-GRANT.md)(英語版が正本)のもとでのソースアベイラブルです。評価用途と 100 人未満の組織は無料。それより大きな組織は本番利用 30 日後に登録が必要です。販売、他者向けのホスティング、競合製品には商用ライセンスが必要です。機能が止まることは一切ありません。各ページの下部に 8 桁のインストール番号が表示されるので、それを作者に送り、返ってきた `license.json` をデータディレクトリに置いてください。連絡先:。 -未登録のインストールでは、左サイドバーに小さなスポンサーバナーが表示されます。ブラウザーが GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))から読み込みます。GitHub に届かない場合は、traffic66 サーバーがそこからダウンロードして `/sponsor` に保存したコピーを使います。traffic66 はあなたやインストールに関するデータを一切送信しません。登録すると表示されなくなります。 +未登録のインストールでは、ページ上部にスポンサー帯が表示されます。読み込むのはブラウザーだけで、GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))から取得します。traffic66 サーバーはそのために GitHub へ接続せず、コピーも保持しません。GitHub に届かないブラウザーでは、代わりに短い組み込みテキストが表示されます。あなたやインストールに関するデータは送信されません。登録すると表示されなくなります。 diff --git a/docs/MANUAL.ko.md b/docs/MANUAL.ko.md index 8847b54..a434ecb 100644 --- a/docs/MANUAL.ko.md +++ b/docs/MANUAL.ko.md @@ -412,7 +412,7 @@ Linux는 root 또는 `setcap cap_net_raw,cap_net_admin+ep`, macOS는 root, Windo [PolyForm Noncommercial License 1.0.0](../LICENSE.md)과 [Traffic66 Additional Use Grant](../ADDITIONAL-USE-GRANT.md)(영문이 구속력을 가짐)에 따른 소스 공개: 평가 용도와 100명 미만 조직은 무료이고, 더 큰 조직은 운영 사용 30일 뒤 등록합니다. 판매, 타인을 위한 호스팅, 경쟁 제품에는 상용 라이선스가 필요합니다. 꺼지는 기능은 전혀 없습니다. 모든 페이지 하단에 8자리 설치 번호가 표시되니 작성자에게 보내고, 돌려받은 `license.json`을 데이터 디렉터리에 넣으십시오. 연락처: . -등록되지 않은 설치에서는 왼쪽 사이드바에 작은 후원 배너가 표시됩니다. 브라우저가 GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))에서 불러오고, GitHub에 닿지 않으면 traffic66 서버가 그곳에서 내려받아 `/sponsor`에 보관한 사본을 사용합니다. traffic66은 사용자나 설치에 관한 어떤 데이터도 보내지 않습니다. 등록하면 사라집니다. +등록되지 않은 설치에서는 페이지 상단에 후원 배너 띠가 표시됩니다. 브라우저만 GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))에서 불러오며, traffic66 서버는 이를 위해 GitHub에 접속하지 않고 사본도 보관하지 않습니다. GitHub에 닿지 않는 브라우저에서는 대신 짧은 내장 문구가 표시됩니다. 사용자나 설치에 관한 데이터는 전송되지 않습니다. 등록하면 사라집니다. diff --git a/docs/MANUAL.md b/docs/MANUAL.md index bfe224d..b4b7007 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -540,7 +540,7 @@ of each page shows the 8-digit installation number; send it to the author, and put the `license.json` you get back in the data directory. Contact: . -Unregistered installations show a small sponsor banner in the left sidebar. Your browser loads it from GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), or, when it cannot reach GitHub, from a copy the traffic66 server downloaded from there and keeps in `/sponsor`; traffic66 sends no data about you or the installation. Registering removes it. +Unregistered installations show a sponsor strip across the top of the page. Only your browser loads it, from GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)); the traffic66 server never contacts GitHub for it and keeps no copy. A browser that cannot reach GitHub shows a short built-in text instead. No data about you or the installation is sent. Registering removes it. ## 11. Security, sizing, troubleshooting diff --git a/docs/MANUAL.pt.md b/docs/MANUAL.pt.md index 0bdf6b8..2af870d 100644 --- a/docs/MANUAL.pt.md +++ b/docs/MANUAL.pt.md @@ -507,7 +507,7 @@ mostra o número de instalação de 8 dígitos; envie-o ao autor e coloque o `license.json` que receber de volta no diretório de dados. Contato: . -Instalações não registradas mostram um pequeno banner de patrocínio na barra lateral esquerda. O seu navegador o carrega do GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) ou, se não conseguir alcançá-lo, de uma cópia que o servidor traffic66 baixou de lá e guarda em `/sponsor`; o traffic66 não envia nenhum dado sobre você ou a instalação. O registro o remove. +Instalações não registradas mostram uma faixa de patrocínio no topo da página. Só o seu navegador a carrega, do GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)); o servidor traffic66 nunca contata o GitHub para isso nem guarda cópia. Um navegador que não alcança o GitHub mostra em vez disso um breve texto embutido. Nenhum dado sobre você ou a instalação é enviado. O registro a remove. diff --git a/docs/MANUAL.ru.md b/docs/MANUAL.ru.md index 494dcf6..1c6e19a 100644 --- a/docs/MANUAL.ru.md +++ b/docs/MANUAL.ru.md @@ -507,7 +507,7 @@ traffic66 и скопируйте каталог; обновление: заме отправьте его автору и положите полученный `license.json` в каталог данных. Контакт: . -В незарегистрированных установках в левой боковой панели показывается небольшой спонсорский баннер. Браузер загружает его с GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), а если GitHub недоступен — из копии, которую сервер traffic66 скачал оттуда и хранит в `/sponsor`; traffic66 не отправляет никаких данных о вас или об установке. После регистрации баннер исчезает. +В незарегистрированных установках вверху страницы показывается спонсорская полоса. Её загружает только ваш браузер, с GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)); сервер traffic66 ради неё никогда не обращается к GitHub и не хранит копию. Браузер, которому GitHub недоступен, показывает вместо неё короткий встроенный текст. Никаких данных о вас или установке не отправляется. После регистрации полоса исчезает. diff --git a/docs/MANUAL.ur.md b/docs/MANUAL.ur.md index b37719e..1abc850 100644 --- a/docs/MANUAL.ur.md +++ b/docs/MANUAL.ur.md @@ -500,7 +500,7 @@ available (انگریزی متن لازم ہے): جانچ کے لیے اور 100 مصنف کو بھیجیں، اور جو `license.json` واپس ملے اسے data directory میں رکھیں۔ رابطہ: ۔ -غیر رجسٹرڈ انسٹالیشنز میں بائیں سائیڈبار میں ایک چھوٹا سپانسر بینر دکھائی دیتا ہے۔ آپ کا براؤزر اسے GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) سے لوڈ کرتا ہے، یا GitHub تک رسائی نہ ہو تو اس نقل سے جو traffic66 سرور نے وہیں سے ڈاؤن لوڈ کر کے `/sponsor` میں رکھی ہے؛ traffic66 آپ یا انسٹالیشن کے بارے میں کوئی ڈیٹا نہیں بھیجتا۔ رجسٹر کرنے پر یہ ہٹ جاتا ہے۔ +غیر رجسٹرڈ انسٹالیشنز میں صفحے کے اوپر ایک سپانسر پٹی دکھائی دیتی ہے۔ اسے صرف آپ کا براؤزر GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)) سے لوڈ کرتا ہے؛ traffic66 سرور اس کے لیے کبھی GitHub سے رابطہ نہیں کرتا اور کوئی نقل نہیں رکھتا۔ جو براؤزر GitHub تک نہ پہنچ سکے وہ اس کی جگہ ایک مختصر اندرونی متن دکھاتا ہے۔ آپ یا انسٹالیشن کے بارے میں کوئی ڈیٹا نہیں بھیجا جاتا۔ رجسٹر کرنے پر یہ ہٹ جاتی ہے۔ diff --git a/docs/MANUAL.vi.md b/docs/MANUAL.vi.md index 19b05b5..3a8ce41 100644 --- a/docs/MANUAL.vi.md +++ b/docs/MANUAL.vi.md @@ -501,7 +501,7 @@ có tính năng nào bị khóa. Cuối mỗi trang hiển thị mã cài đặt này cho tác giả và đặt file `license.json` nhận được vào thư mục dữ liệu. Liên hệ: . -Bản cài đặt chưa đăng ký hiển thị một banner tài trợ nhỏ ở thanh bên trái. Trình duyệt của bạn tải nó từ GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)), hoặc khi không truy cập được GitHub thì từ bản sao mà máy chủ traffic66 đã tải về và lưu trong `/sponsor`; traffic66 không gửi bất kỳ dữ liệu nào về bạn hay bản cài đặt. Đăng ký sẽ gỡ bỏ nó. +Bản cài đặt chưa đăng ký hiển thị một dải tài trợ ở đầu trang. Chỉ trình duyệt của bạn tải nó, từ GitHub ([ads-sponsor](https://github.com/githubflyideas/ads-sponsor)); máy chủ traffic66 không bao giờ kết nối GitHub cho việc này và không lưu bản sao. Trình duyệt không truy cập được GitHub sẽ hiển thị một đoạn chữ ngắn có sẵn thay thế. Không có dữ liệu nào về bạn hay bản cài đặt được gửi đi. Đăng ký sẽ gỡ nó. diff --git a/docs/MANUAL.zh.md b/docs/MANUAL.zh.md index 26a6d65..83fe75e 100644 --- a/docs/MANUAL.zh.md +++ b/docs/MANUAL.zh.md @@ -412,7 +412,7 @@ Linux 需要 root 或 `setcap cap_net_raw,cap_net_admin+ep`,macOS 需要 root 以 [PolyForm Noncommercial License 1.0.0](../LICENSE.md) 和 [Traffic66 Additional Use Grant](../ADDITIONAL-USE-GRANT.md) 公开源码(以英文文本为准):评估免费,少于 100 人的组织免费;更大的组织在生产使用 30 天后需注册;销售、为他人托管或提供竞争性产品需要商业许可。任何功能都不会被关闭。每个页面底部显示 8 位安装编号;把它发给作者,并把收到的 `license.json` 放进数据目录。联系方式:。 -未注册的安装会在左侧栏显示一个小的赞助横幅。它由你的浏览器从 GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))加载;浏览器访问不了 GitHub 时,改用 traffic66 服务器从那里下载并缓存在 `/sponsor` 的副本。traffic66 不会发送任何关于你或本安装的数据。注册后横幅即消失。 +未注册的安装会在页面顶部显示一条赞助横幅。它只由你的浏览器从 GitHub([ads-sponsor](https://github.com/githubflyideas/ads-sponsor))加载;traffic66 服务器不会为此访问 GitHub,也不保存副本。浏览器访问不了 GitHub 时,显示一段内置的简短文字。不会发送任何关于你或本安装的数据。注册后横幅即消失。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6217830..e794bbd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -5,9 +5,20 @@ Tick an item in the same pull request that completes it. Releases are separate: merged work reaches users with the next release. +## Asked for since (done) +- [x] Top 66 opens on the two ends of each conversation +- [x] Sponsor banner as a strip across the top, built from the 728x90 banners +- [x] The evaluation mark on every page, a little denser +- [x] Live pages refresh themselves on a beat that follows the range +- [x] Network charts count traffic whose AS is unknown, so short ranges are not drawn as blocks +- [x] 95th-percentile billing draws the line its figure was taken across +- [x] Narrower top end for the map's line widths +- [x] A licence says whether it is made out to a person or an organisation +- [x] The ledger's addresses go; a device carries the networks written against it + ## Reports (target 7 Nov) -- [ ] Make `TestAttackFound` deterministic (it fails now and then with sampled scans) -- [ ] `GET /api/report?from&to&if`: totals, average, peak and 95th percentile per interface, utilisation against interface speed, top hosts, applications and countries, findings by severity; with tests +- [x] Make `TestAttackFound` deterministic (it fails now and then with sampled scans) +- [x] `GET /api/report?from&to&if`: totals, average, peak and 95th percentile per interface, utilisation against interface speed, top hosts, applications and countries, findings by severity; with tests - [ ] Report page: printable layout (print CSS), period and interface picker, in all 15 languages - [ ] Report: CSV export of the same tables - [ ] Report: comparison with the previous period (change in %, new top hosts) diff --git a/internal/api/assets.go b/internal/api/assets.go index 8e08182..8ce53fd 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -142,9 +142,17 @@ func (s *Server) getAssets(w http.ResponseWriter, r *http.Request) { 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, + "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 @@ -152,68 +160,6 @@ func (s *Server) getAssets(w http.ResponseWriter, r *http.Request) { }) } -// getIPAM returns the networks with how full they are, and what does not -// add up in the assignments. -func (s *Server) getIPAM(w http.ResponseWriter, r *http.Request) { - a := s.ledger(w) - if a == nil { - return - } - use, err := a.Use() - if err != nil { - fail(w, err) - return - } - if !s.isAdmin(r) { - use = visible(use, func(v assets.SubnetUse) bool { return v.Hidden }) - } - conflicts, err := a.Conflicts() - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"subnets": use, "conflicts": conflicts}) -} - -// getAddrs lists assignments, filtered by network, device, tag or text. -func (s *Server) getAddrs(w http.ResponseWriter, r *http.Request) { - a := s.ledger(w) - if a == nil { - return - } - q := r.URL.Query() - list, err := a.Addrs(assets.AddrQuery{ - SubnetID: idParam(q.Get("subnet")), - DeviceID: idParam(q.Get("device")), - Tag: q.Get("tag"), - Search: q.Get("q"), - Limit: limitParam(r, 5000), - }) - if err != nil { - fail(w, err) - return - } - if !s.isAdmin(r) { - list = visible(list, func(v assets.Addr) bool { return v.Hidden }) - } - writeJSON(w, http.StatusOK, map[string]any{"addrs": list}) -} - -// getFreeAddrs suggests addresses nothing is assigned to. -func (s *Server) getFreeAddrs(w http.ResponseWriter, r *http.Request) { - a := s.ledger(w) - if a == nil { - return - } - n, _ := strconv.Atoi(r.URL.Query().Get("n")) - free, err := a.FreeAddrs(idParam(r.URL.Query().Get("subnet")), n) - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"free": free}) -} - // 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) @@ -273,11 +219,6 @@ func (s *Server) putAsset(w http.ResponseWriter, r *http.Request) { if err = json.Unmarshal(b, &v); err == nil { out, err = a.SaveSubnet(v) } - case "addr": - var v assets.Addr - if err = json.Unmarshal(b, &v); err == nil { - out, err = a.SaveAddr(v) - } default: fail(w, fmt.Errorf("%q is not a kind of asset", kind)) return @@ -316,8 +257,6 @@ func (s *Server) deleteAsset(w http.ResponseWriter, r *http.Request) { err = a.DeleteCircuit(id) case "subnet": err = a.DeleteSubnet(id) - case "addr": - err = a.DeleteAddr(id) default: fail(w, fmt.Errorf("%q is not a kind of asset", kind)) return diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 004bcf7..aff310a 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -77,9 +77,6 @@ func assetServer(t *testing.T, feats []string, sites, racks, devices int) (*Serv // 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("GET /api/assets/ipam", s.getIPAM) - mux.HandleFunc("GET /api/assets/addrs", s.getAddrs) - mux.HandleFunc("GET /api/assets/free", s.getFreeAddrs) 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 { @@ -99,7 +96,6 @@ func TestAssetsNeedLicence(t *testing.T) { _, call := assetServer(t, nil, 0, 0, 0) for _, c := range []struct{ method, path, body string }{ {"GET", "/api/assets", ""}, - {"GET", "/api/assets/ipam", ""}, {"POST", "/api/assets/site", `{"name":"A"}`}, {"DELETE", "/api/assets/site?id=1", ""}, } { @@ -225,38 +221,36 @@ func TestAssetsQuota(t *testing.T) { } } -// The IPAM pages answer with what the ledger holds. -func TestAssetsIPAM(t *testing.T) { +// 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) - if w := call("POST", "/api/assets/subnet", `{"prefix":"10.9.9.0/29","gateway":"10.9.9.1"}`, "admin"); w.Code != 200 { + 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) } - var sn assets.Subnet - w := call("GET", "/api/assets/ipam", "", "viewer") + 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("ipam: %d %s", w.Code, w.Body) + t.Fatalf("assets: %d %s", w.Code, w.Body) } var out struct { - Subnets []assets.SubnetUse `json:"subnets"` + Subnets []assets.Subnet `json:"subnets"` } json.Unmarshal(w.Body.Bytes(), &out) - if len(out.Subnets) != 1 || out.Subnets[0].Hosts != 6 { - t.Fatalf("ipam: %s", w.Body) - } - sn = out.Subnets[0].Subnet - if w := call("POST", "/api/assets/addr", `{"ip":"10.9.9.2","name":"printer"}`, "admin"); w.Code != 200 { - t.Fatalf("addr: %d %s", w.Code, w.Body) - } - if w := call("POST", "/api/assets/addr", `{"ip":"10.9.9.2","name":"someone else"}`, "admin"); w.Code == 200 { - t.Fatal("the address was assigned twice") - } - w = call("GET", fmt.Sprintf("/api/assets/free?subnet=%d&n=3", sn.ID), "", "viewer") - if w.Code != 200 || !strings.Contains(w.Body.String(), `"10.9.9.3"`) || strings.Contains(w.Body.String(), `"10.9.9.2"`) { - t.Fatalf("free: %d %s", w.Code, w.Body) + 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) } - w = call("GET", fmt.Sprintf("/api/assets/addrs?subnet=%d", sn.ID), "", "viewer") - if !strings.Contains(w.Body.String(), "printer") { - t.Fatalf("addrs: %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/billing.go b/internal/api/billing.go index 5e58828..4793336 100644 --- a/internal/api/billing.go +++ b/internal/api/billing.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "net/netip" + "net/url" "strconv" "strings" "time" @@ -11,13 +12,52 @@ import ( "github.com/githubflyideas/traffic66/internal/store" ) -// 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) { +// 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 { @@ -27,16 +67,27 @@ func (s *Server) billing(w http.ResponseWriter, r *http.Request) { } from, err := ms("from") if err != nil { - fail(w, err) - return + 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 } - // The boundaries are used exactly as given. parseQuery rounds long - // ranges to the hour, which would quietly move the start of a month. bills, err := s.Store.Bills(from, to, v.Get("exporter")) if err != nil { fail(w, err) diff --git a/internal/api/billing_test.go b/internal/api/billing_test.go index fb4a956..e5b948f 100644 --- a/internal/api/billing_test.go +++ b/internal/api/billing_test.go @@ -147,3 +147,71 @@ func TestBillingShowsTheMarkedInterfaces(t *testing.T) { 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/report.go b/internal/api/report.go new file mode 100644 index 0000000..48e675e --- /dev/null +++ b/internal/api/report.go @@ -0,0 +1,301 @@ +package api + +import ( + "fmt" + "math" + "net/http" + "net/netip" + "sort" + "strconv" + "strings" + "time" + + "github.com/githubflyideas/traffic66/internal/store" +) + +// The report is the one page somebody prints and hands to a customer or a +// manager, so every number on it has to say where it came from. Two +// measurements of the same interface exist here and they do not agree: +// +// - the device's own interface counters, read over SNMP into five-minute +// rate buckets. They are what a carrier bills against and they count +// every byte, sampled or not. +// - the flows, which are sampled and therefore an estimate, but are the +// only measurement for an interface no device reports counters for. +// +// Each interface row says which of the two it is (Source), rather than +// mixing them into one number nobody can argue with. Utilisation is only +// given where the interface speed is known: a percentage of an unknown +// capacity is not a figure, it is a guess with a unit. +const ( + srcCounters = "counters" // the device's interface counters + srcFlows = "flows" // sampled flow records +) + +// reportIface is one interface's figures over the period. +type reportIface struct { + Exporter string `json:"exporter"` + IfIndex uint32 `json:"ifindex"` + Name string `json:"name,omitempty"` + 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"` + + InAvg float64 `json:"in_avg"` // bits/s + OutAvg float64 `json:"out_avg"` + InPeak float64 `json:"in_peak"` + OutPeak float64 `json:"out_peak"` + In95 float64 `json:"in95"` + Out95 float64 `json:"out95"` + + // Util* are per cent of Speed, omitted when the speed is unknown. + UtilAvg float64 `json:"util_avg,omitempty"` + 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 float64 `json:"coverage,omitempty"` + Samples int64 `json:"samples,omitempty"` +} + +// reportOut is the whole report. It is a struct rather than a map so that +// the printable page and the CSV export read the same field names. +type reportOut struct { + From int64 `json:"from"` + To int64 `json:"to"` + SpanS int64 `json:"span_s"` + Generated int64 `json:"generated"` + Iface string `json:"if,omitempty"` + + Totals store.Totals `json:"totals"` + AvgBps float64 `json:"avg_bps"` + PeakBps float64 `json:"peak_bps"` + PeakAt int64 `json:"peak_at,omitempty"` + P95Bps float64 `json:"p95_bps"` + // Step is the width of the buckets the peak and the percentile above + // were taken over, in seconds. A peak over a one-minute bucket and a + // peak over an hour are different claims. + Step int64 `json:"step"` + + Ifaces []reportIface `json:"ifaces"` + + TopHosts []store.TopRow `json:"top_hosts"` + TopApps []store.TopRow `json:"top_apps"` + TopCountries []store.TopRow `json:"top_countries"` + + Findings map[string]int `json:"findings"` + Top []store.Finding `json:"top_findings,omitempty"` + + Notice string `json:"notice,omitempty"` +} + +// percentile returns the 95th percentile of v by nearest rank, which is +// the sample a device's own counters could be checked against rather than +// an interpolation between two of them (see store.Bills). +func percentile(v []float64, p float64) float64 { + if len(v) == 0 { + return 0 + } + s := append([]float64(nil), v...) + sort.Float64s(s) + i := int(math.Ceil(p*float64(len(s)))) - 1 + if i < 0 { + i = 0 + } + if i >= len(s) { + i = len(s) - 1 + } + return s[i] +} + +// totalSeries sums every application's series into one line of bits/s and +// drops the bucket that is still filling: the last minute of a live range +// is half counted, and a report that takes its peak or its percentile from +// that minute understates both. +func (s *Server) totalSeries(q store.Query) (step int64, times []int64, bps []float64, err error) { + se, err := s.Store.SeriesByApp(q, 1<<30) + if err != nil { + return 0, nil, nil, err + } + for i, t := range se.Times { + if se.SettledTo > 0 && t >= se.SettledTo { + break + } + sum := 0.0 + for _, v := range se.Values { + sum += v[i] + } + times, bps = append(times, t), append(bps, sum) + } + return se.Step, times, bps, nil +} + +// report answers GET /api/report?from&to&if — the numbers behind the +// printable report: traffic over the period, what each interface carried +// and how full it was, what the top of the period was made of, and what +// the detection rules found while it ran. +func (s *Server) report(w http.ResponseWriter, r *http.Request) { + q, err := parseQuery(r) + if err != nil { + fail(w, err) + return + } + only := r.URL.Query().Get("if") + if only != "" { + // One interface: every flow figure below is then this interface's, + // including the top hosts and applications, which is what somebody + // reporting on one circuit asked for. + if err := checkIface(only); err != nil { + fail(w, err) + return + } + q.Filters = append(q.Filters, store.Filter{Field: "iface", Value: only}) + } + span := q.To.Sub(q.From) + out := reportOut{ + From: q.From.UnixMilli(), To: q.To.UnixMilli(), SpanS: int64(span.Seconds()), + Generated: time.Now().UTC().UnixMilli(), Iface: only, Notice: s.notice(), + Findings: map[string]int{"high": 0, "medium": 0, "low": 0}, + } + if out.Totals, err = s.Store.Totals(q); err != nil { + fail(w, err) + return + } + if span > 0 { + out.AvgBps = float64(out.Totals.Wire) * 8 / span.Seconds() + } + step, times, bps, err := s.totalSeries(q) + if err != nil { + fail(w, err) + return + } + out.Step = step + for i, v := range bps { + if v > out.PeakBps { + out.PeakBps, out.PeakAt = v, times[i] + } + } + 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) + return + } + for _, f := range list { + k := ifKey(f.Exporter, f.IfIndex) + if only != "" && k != only { + continue + } + it := reportIface{Exporter: f.Exporter, IfIndex: f.IfIndex, Speed: f.Speed, + InWire: f.InWire, OutWire: f.OutWire, Source: srcFlows, Billed: billed[k]} + 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) + if it.Speed == 0 { + it.Speed = ifc.Speed + } + } + if b, ok := bills[k]; ok && b.Buckets > 0 { + 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 + } else if span > 0 { + // No counters: the flows are the only measurement there is. + // They are sampled, so the row says so through Source. + it.InAvg = float64(f.InWire) * 8 / span.Seconds() + it.OutAvg = float64(f.OutWire) * 8 / span.Seconds() + qi := q + if only == "" { + qi.Filters = append(append([]store.Filter{}, q.Filters...), store.Filter{Field: "iface", Value: k}) + } + if _, _, b, err := s.totalSeries(qi); err == nil { + for _, v := range b { + it.InPeak = math.Max(it.InPeak, v) + } + it.In95 = percentile(b, 0.95) + // One series per interface covers both directions; it is + // reported as the inbound figure with the outbound left + // empty rather than counted twice. + } + } + if it.Speed > 0 { + pc := func(v float64) float64 { return v / float64(it.Speed) * 100 } + it.UtilAvg = pc(math.Max(it.InAvg, it.OutAvg)) + it.UtilPeak = pc(math.Max(it.InPeak, it.OutPeak)) + it.Util95 = pc(math.Max(it.In95, it.Out95)) + } + out.Ifaces = append(out.Ifaces, it) + if len(out.Ifaces) >= 50 { + break + } + } + + top := func(dim string) []store.TopRow { + rows, err := s.Store.TopN(q, dim, 10) + if err != nil { + return nil + } + return rows + } + out.TopHosts, out.TopApps, out.TopCountries = top("client"), top("app"), top("country") + + fs, err := s.Store.Findings(store.FindingQuery{From: q.From, To: q.To, Status: "all", Limit: 1000}) + if err != nil { + fail(w, err) + return + } + name := map[int]string{3: "high", 2: "medium", 1: "low"} + for _, f := range fs { + if n, ok := name[f.Sev]; ok { + out.Findings[n]++ + } + } + if len(fs) > 20 { + fs = fs[:20] + } + out.Top = fs + writeJSON(w, http.StatusOK, out) +} + +// checkIface refuses an interface that is not written as the store's +// filter expects it (exporter/ifindex), here rather than inside a query, +// so the caller is told what is wrong with the request instead of being +// handed a database error. +func checkIface(v string) error { + e, idx, ok := strings.Cut(v, "/") + if !ok || e == "" { + return fmt.Errorf("if must be written exporter/ifindex, not %q", v) + } + if _, err := netip.ParseAddr(e); err != nil { + return fmt.Errorf("if: %q is not an exporter address", e) + } + if _, err := strconv.ParseUint(idx, 10, 32); err != nil { + return fmt.Errorf("if: %q is not an interface number", idx) + } + return nil +} + +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 new file mode 100644 index 0000000..4d096a1 --- /dev/null +++ b/internal/api/report_test.go @@ -0,0 +1,195 @@ +package api + +import ( + "encoding/json" + "fmt" + "math" + "net/http/httptest" + "testing" + "time" + + "github.com/githubflyideas/traffic66/internal/store" +) + +// An hour of traffic over two interfaces: one the device reports counters +// for, one it does not. The report has to speak for both, and say which +// measurement each row rests on. +func TestReport(t *testing.T) { + st, err := store.Open(store.Options{Dir: t.TempDir(), Threads: 1}) + if err != nil { + t.Fatal(err) + } + defer st.Close() + to := time.Now().UTC().Truncate(5 * time.Minute) + from := to.Add(-time.Hour) + + // flows: the counted interface carries twice what the other one does, + // and one minute in the middle carries ten times its usual minute so + // that the peak and the percentile cannot be the same number. + const perMin = 60_000_000 // wire bytes a minute on iface 1 + var rows []store.Row + for i := 0; i < 60; i++ { + ts := from.Add(time.Duration(i)*time.Minute + 30*time.Second) + w := uint64(perMin) + if i == 30 { + w *= 10 + } + rows = append(rows, + store.Row{TS: ts, Exporter: "10.0.0.1", InIf: 1, OutIf: 9, Wire: w, Bytes: w, Pkts: w / 1000, Flows: 1, + Client: "10.1.1.5", Server: "203.0.113.9", Cli4: ip4(10, 1, 1, 5), Srv4: ip4(203, 0, 113, 9), + App: "https", SvcPort: 443, Proto: 6, Dir: store.DirOutbound, PeerCC: "US", CliInt: true}, + store.Row{TS: ts, Exporter: "10.0.0.1", InIf: 2, OutIf: 9, Wire: w / 2, Bytes: w / 2, Pkts: w / 2000, Flows: 1, + Client: "10.1.1.6", Server: "198.51.100.7", Cli4: ip4(10, 1, 1, 6), Srv4: ip4(198, 51, 100, 7), + App: "dns", SvcPort: 53, Proto: 17, Dir: store.DirOutbound, PeerCC: "NL", CliInt: true}) + } + if err := st.AppendRows(rows); err != nil { + t.Fatal(err) + } + // counters for iface 1 only: a steady megabyte a second, so 8 Mbit/s + // against a gigabit interface — 0.8% full. + for i := 0; i <= 120; i++ { // every 30 s across the hour + ts := from.Add(time.Duration(i) * 30 * time.Second) + oct := uint64(i) * 30 * 1_000_000 + if err := st.AppendCounter(ts, "10.0.0.1", 1, 1e9, oct, oct/2, 0, 0); err != nil { + 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) + + s := &Server{Store: st} + get := func(extra string) (int, reportOut) { + w := httptest.NewRecorder() + url := fmt.Sprintf("/api/report?from=%d&to=%d%s", from.UnixMilli(), to.UnixMilli(), extra) + s.report(w, httptest.NewRequest("GET", url, nil)) + var out reportOut + json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out + } + code, out := get("") + if code != 200 { + t.Fatalf("%d", code) + } + if out.From != from.UnixMilli() || out.To != to.UnixMilli() || out.SpanS != 3600 { + t.Errorf("period %d..%d span %d", out.From, out.To, out.SpanS) + } + // 60 minutes on iface 1 plus half as much on iface 2, with one minute + // at ten times the rate on each + wantWire := uint64(perMin*69) + uint64(perMin*69)/2 + if out.Totals.Wire != wantWire { + t.Errorf("wire %d, want %d", out.Totals.Wire, wantWire) + } + if want := float64(wantWire) * 8 / 3600; !near(out.AvgBps, want, 0.001) { + t.Errorf("avg %.0f bits/s, want %.0f", out.AvgBps, want) + } + if out.Step != 60 { + t.Errorf("step %d s, want per-minute buckets", out.Step) + } + // the busy minute is the peak; the percentile is not dragged up to it + if !near(out.PeakBps, float64(perMin)*10*1.5*8/60, 0.02) { + t.Errorf("peak %.0f bits/s", out.PeakBps) + } + if out.P95Bps >= out.PeakBps || out.P95Bps <= out.AvgBps/2 { + t.Errorf("p95 %.0f against peak %.0f and average %.0f", out.P95Bps, out.PeakBps, out.AvgBps) + } + if out.PeakAt < out.From || out.PeakAt >= out.To { + t.Errorf("peak at %d, outside the period", out.PeakAt) + } + + byKey := map[string]reportIface{} + for _, f := range out.Ifaces { + byKey[ifKey(f.Exporter, f.IfIndex)] = f + } + one, ok := byKey["10.0.0.1/1"] + if !ok { + t.Fatalf("no row for the counted interface: %+v", out.Ifaces) + } + if one.Source != srcCounters { + t.Errorf("counted interface reported from %s", one.Source) + } + if !near(one.InAvg, 8e6, 0.02) || !near(one.In95, 8e6, 0.02) { + t.Errorf("counters: avg %.0f, p95 %.0f bits/s, want 8M", one.InAvg, one.In95) + } + 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) + } + two, ok := byKey["10.0.0.1/2"] + if !ok { + t.Fatalf("no row for the interface without counters: %+v", out.Ifaces) + } + if two.Source != srcFlows { + t.Errorf("interface without counters reported from %s", two.Source) + } + if want := float64(perMin*69) / 2 * 8 / 3600; !near(two.InAvg, want, 0.001) { + t.Errorf("flows: avg %.0f bits/s, want %.0f", two.InAvg, want) + } + if two.In95 == 0 || two.InPeak <= two.In95 { + t.Errorf("flows: p95 %.0f bits/s is not below the peak %.0f", two.In95, two.InPeak) + } + // nothing says how fast that interface is, so no utilisation is given + // rather than a percentage of a guess + if two.Speed != 0 || two.Util95 != 0 || two.UtilPeak != 0 { + t.Errorf("utilisation without a known speed: %+v", two) + } + + if len(out.TopHosts) == 0 || out.TopHosts[0].Key != "10.1.1.5" { + t.Errorf("top hosts %+v", out.TopHosts) + } + if len(out.TopApps) == 0 || out.TopApps[0].Key != "https" { + t.Errorf("top applications %+v", out.TopApps) + } + if len(out.TopCountries) == 0 || out.TopCountries[0].Key != "US" { + t.Errorf("top countries %+v", out.TopCountries) + } + if out.Findings["high"] != 1 || out.Findings["low"] != 1 || out.Findings["medium"] != 0 { + t.Errorf("findings %v", out.Findings) + } + if len(out.Top) != 2 || out.Top[0].Kind != "scan" { + t.Errorf("top findings %+v", out.Top) + } + // an unregistered installation marks its own report + if out.Notice == "" { + t.Error("no evaluation notice on an unregistered installation") + } + + // one interface: the whole report is then about that interface + code, only := get("&if=10.0.0.1/2") + if code != 200 { + t.Fatalf("%d", code) + } + if only.Iface != "10.0.0.1/2" || len(only.Ifaces) != 1 || only.Ifaces[0].IfIndex != 2 { + t.Fatalf("if=: %+v", only.Ifaces) + } + if only.Totals.Wire != uint64(perMin*69)/2 { + t.Errorf("one interface's wire %d", only.Totals.Wire) + } + if len(only.TopApps) != 1 || only.TopApps[0].Key != "dns" { + t.Errorf("one interface's applications %+v", only.TopApps) + } + + for _, bad := range []string{"&if=eth0", "&if=10.0.0.1/eth0", "&if=hostname/1"} { + if code, _ := get(bad); code != 400 { + t.Errorf("%s: %d, want 400", bad, code) + } + } + w := httptest.NewRecorder() + s.report(w, httptest.NewRequest("GET", "/api/report?from=zzz&to=1", nil)) + if w.Code != 400 { + t.Errorf("bad period accepted: %d", w.Code) + } +} + +func near(got, want, tol float64) bool { + if want == 0 { + return got == 0 + } + return math.Abs(got-want)/math.Abs(want) <= tol +} + +func ip4(a, b, c, d uint32) uint32 { return a<<24 | b<<16 | c<<8 | d } diff --git a/internal/api/server.go b/internal/api/server.go index 37ccc3f..9f453fe 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -154,9 +154,11 @@ func (s *Server) Handler() http.Handler { api("GET /api/threats", s.data((*Server).threats)) api("GET /api/rings", s.data((*Server).rings)) api("GET /api/geolines", s.data((*Server).geoLines)) + 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) @@ -167,9 +169,6 @@ func (s *Server) Handler() http.Handler { 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/ipam", s.getIPAM) - api("GET /api/assets/addrs", s.getAddrs) - api("GET /api/assets/free", s.getFreeAddrs) api("GET /api/assets/csv", s.getAssetCSV) api("POST /api/assets/csv", s.postAssetCSV) api("POST /api/assets/paste", s.postPaste) @@ -184,8 +183,6 @@ func (s *Server) Handler() http.Handler { adminAPI("DELETE /api/sandbox/files", s.deleteSandbox) api("POST /api/sandbox/active", s.putSandboxActive) adminAPI("POST /api/license", s.putLicense) - api("GET /api/sponsor", s.getSponsor) - api("GET /api/sponsor/img/{name}", s.getSponsorImg) adminAPI("POST /api/logo", s.putLogo) adminAPI("DELETE /api/logo", s.deleteLogo) api("GET /api/geo", s.getGeo) diff --git a/internal/api/sponsor.go b/internal/api/sponsor.go deleted file mode 100644 index 9be88e8..0000000 --- a/internal/api/sponsor.go +++ /dev/null @@ -1,272 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -// The sponsor banner of unregistered installations. The browser first loads -// it straight from GitHub; when it cannot (intranet, proxy, blocked), the -// page asks this server, which keeps the last set it managed to download in -// /sponsor and serves that. The download is a plain GET with no data -// about the user or the installation, and is skipped while registered. - -var ( - sponsorBase = "https://raw.githubusercontent.com/githubflyideas/ads-sponsor/main/traffic66/" - sponsorEvery = 6 * time.Hour - sponsorTimeout = 10 * time.Second - sponsorClient = &http.Client{Timeout: sponsorTimeout} - sponsorImgTypes = map[string]string{"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"} - sponsorHTTPS = true // images over https only; tests use plain http - sponsorName = regexp.MustCompile(`^[0-3]\.(png|jpg|webp|gif)$`) -) - -const ( - sponsorMaxSlides = 4 - sponsorMaxImage = 150 << 10 - sponsorMaxManifest = 64 << 10 -) - -// SponsorSlide is one slide as kept on disk and served to the page. -type SponsorSlide struct { - ID string `json:"id,omitempty"` - Image string `json:"image"` - Link string `json:"link,omitempty"` - Alt string `json:"alt"` - Title string `json:"title,omitempty"` -} - -type sponsorManifest struct { - Version int `json:"version"` - Updated string `json:"updated,omitempty"` - Slides []SponsorSlide `json:"slides"` -} - -func clip(s string, n int) string { - s = strings.TrimSpace(s) - if len(s) > n { - s = s[:n] - } - return s -} - -func httpLink(v string, base *url.URL, httpsOnly bool) string { - u, err := url.Parse(strings.TrimSpace(v)) - if err != nil || v == "" { - return "" - } - if base != nil { - u = base.ResolveReference(u) - } - if u.Scheme == "https" || (!httpsOnly && u.Scheme == "http") { - if u.Host != "" { - return u.String() - } - } - return "" -} - -// validSponsor checks a downloaded manifest: at most four slides, https -// images, http or https links (others dropped, the slide not clickable). -func validSponsor(b []byte, base string) ([]SponsorSlide, error) { - var m sponsorManifest - if err := json.Unmarshal(b, &m); err != nil { - return nil, fmt.Errorf("sponsor: manifest: %w", err) - } - bu, _ := url.Parse(base) - var out []SponsorSlide - for _, s := range m.Slides { - if len(out) == sponsorMaxSlides { - break - } - img := httpLink(s.Image, bu, sponsorHTTPS) - if img == "" { - continue - } - out = append(out, SponsorSlide{ID: clip(s.ID, 60), Image: img, Link: httpLink(s.Link, nil, false), - Alt: clip(s.Alt, 120), Title: clip(s.Title, 80)}) - } - if len(out) == 0 { - return nil, errors.New("sponsor: no usable slides") - } - return out, nil -} - -// sponsorImage checks one downloaded image and returns its file extension. -func sponsorImage(b []byte) (string, error) { - if len(b) == 0 || len(b) > sponsorMaxImage { - return "", fmt.Errorf("sponsor: image of %d bytes", len(b)) - } - ext, ok := sponsorImgTypes[http.DetectContentType(b)] - if !ok { - return "", fmt.Errorf("sponsor: image type %s", http.DetectContentType(b)) - } - return ext, nil -} - -func sponsorGet(ctx context.Context, u string, max int64, ua string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", ua) - resp, err := sponsorClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s: %s", u, resp.Status) - } - b, err := io.ReadAll(io.LimitReader(resp.Body, max+1)) - if err != nil { - return nil, err - } - if int64(len(b)) > max { - return nil, fmt.Errorf("%s: larger than %d bytes", u, max) - } - return b, nil -} - -// refreshSponsor downloads the manifest and its images into dir. The set on -// disk is replaced only by a complete new one with at least one image, so -// the last good set stays when GitHub cannot be reached. -func refreshSponsor(ctx context.Context, dir, ua string) error { - b, err := sponsorGet(ctx, sponsorBase+"sponsors.json", sponsorMaxManifest, ua) - if err != nil { - return err - } - slides, err := validSponsor(b, sponsorBase+"sponsors.json") - if err != nil { - return err - } - tmp := dir + ".new" - os.RemoveAll(tmp) - if err := os.MkdirAll(tmp, 0o755); err != nil { - return err - } - defer os.RemoveAll(tmp) - var keep []SponsorSlide - for _, s := range slides { - ib, err := sponsorGet(ctx, s.Image, sponsorMaxImage, ua) - if err != nil { - continue - } - ext, err := sponsorImage(ib) - if err != nil { - continue - } - name := fmt.Sprintf("%d%s", len(keep), ext) - if err := os.WriteFile(filepath.Join(tmp, name), ib, 0o644); err != nil { - return err - } - s.Image = name - keep = append(keep, s) - } - if len(keep) == 0 { - return errors.New("sponsor: no image could be downloaded") - } - mb, _ := json.Marshal(sponsorManifest{Version: 1, Updated: time.Now().UTC().Format(time.RFC3339), Slides: keep}) - if err := os.WriteFile(filepath.Join(tmp, "sponsors.json"), mb, 0o644); err != nil { - return err - } - old := dir + ".old" - os.RemoveAll(old) - if _, err := os.Stat(dir); err == nil { - if err := os.Rename(dir, old); err != nil { - return err - } - } - if err := os.Rename(tmp, dir); err != nil { - os.Rename(old, dir) - return err - } - os.RemoveAll(old) - return nil -} - -func (s *Server) sponsorDir() string { return filepath.Join(s.DataDir, "sponsor") } - -func (s *Server) registered() bool { return s.License != nil && s.License.State().Registered() } - -// RunSponsor keeps the sponsor cache fresh: at start and every six hours, -// and only while the installation is not registered. -func (s *Server) RunSponsor(ctx context.Context) { - if s.DataDir == "" { - return - } - ua := "traffic66/" + s.Version - for { - if !s.registered() { - c, cancel := context.WithTimeout(ctx, time.Minute) - if err := refreshSponsor(c, s.sponsorDir(), ua); err != nil { - log.Printf("sponsor: %v (keeping the cached banner, if any)", err) - } - cancel() - } - select { - case <-ctx.Done(): - return - case <-time.After(sponsorEvery): - } - } -} - -// getSponsor serves the cached manifest with the images pointing here. -func (s *Server) getSponsor(w http.ResponseWriter, r *http.Request) { - if s.registered() || s.DataDir == "" { - http.NotFound(w, r) - return - } - b, err := os.ReadFile(filepath.Join(s.sponsorDir(), "sponsors.json")) - if err != nil { - http.NotFound(w, r) - return - } - var m sponsorManifest - if json.Unmarshal(b, &m) != nil { - http.NotFound(w, r) - return - } - var out []SponsorSlide - for _, sl := range m.Slides { - if sponsorName.MatchString(sl.Image) && len(out) < sponsorMaxSlides { - sl.Image = "/api/sponsor/img/" + sl.Image - out = append(out, sl) - } - } - writeJSON(w, http.StatusOK, map[string]any{"slides": out}) -} - -func (s *Server) getSponsorImg(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if s.registered() || s.DataDir == "" || !sponsorName.MatchString(name) { - http.NotFound(w, r) - return - } - b, err := os.ReadFile(filepath.Join(s.sponsorDir(), name)) - if err != nil { - http.NotFound(w, r) - return - } - ext, err := sponsorImage(b) - if err != nil || ext != filepath.Ext(name) { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", http.DetectContentType(b)) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Cache-Control", "private, max-age=3600") - w.Write(b) -} diff --git a/internal/api/sponsor_test.go b/internal/api/sponsor_test.go deleted file mode 100644 index 48e4dfc..0000000 --- a/internal/api/sponsor_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "image" - "image/png" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" -) - -func tinyPNG(t *testing.T) []byte { - var b bytes.Buffer - if err := png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 1))); err != nil { - t.Fatal(err) - } - return b.Bytes() -} - -func TestValidSponsor(t *testing.T) { - base := "https://example.com/t/sponsors.json" - in := `{"slides":[ - {"image":"a.png","link":"https://a.example/?ref=1","alt":"A"}, - {"image":"http://insecure/b.png","link":"https://b","alt":"B"}, - {"image":"c.png","link":"mailto:x@y","alt":"C"}, - {"image":"d.png","link":"javascript:alert(1)","alt":"D"}, - {"image":"e.png","alt":"E"}, - {"image":"f.png","alt":"F"}]}` - s, err := validSponsor([]byte(in), base) - if err != nil { - t.Fatal(err) - } - if len(s) != 4 { - t.Fatalf("want 4 slides, got %d", len(s)) - } - if s[0].Image != "https://example.com/t/a.png" || s[0].Link != "https://a.example/?ref=1" { - t.Fatalf("slide 0: %+v", s[0]) - } - if s[1].Alt != "C" || s[1].Link != "" || s[2].Link != "" { - t.Fatalf("bad links kept: %+v", s) - } - if _, err := validSponsor([]byte(`{"slides":[]}`), base); err == nil { - t.Fatal("empty manifest accepted") - } - if _, err := validSponsor([]byte(`nope`), base); err == nil { - t.Fatal("bad json accepted") - } -} - -func TestSponsorImage(t *testing.T) { - if ext, err := sponsorImage(tinyPNG(t)); err != nil || ext != ".png" { - t.Fatalf("png: %q %v", ext, err) - } - if _, err := sponsorImage([]byte("")); err == nil { - t.Fatal("svg accepted") - } - big := append(tinyPNG(t), make([]byte, sponsorMaxImage)...) - if _, err := sponsorImage(big); err == nil { - t.Fatal("oversized image accepted") - } -} - -func TestSponsorCacheFallback(t *testing.T) { - pic := tinyPNG(t) - up := true - var ua string - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ua = r.UserAgent() - if !up { - http.Error(w, "down", 503) - return - } - switch r.URL.Path { - case "/t/sponsors.json": - w.Write([]byte(`{"slides":[{"image":"a.png","link":"https://a.example/","alt":"A"},{"image":"missing.png","alt":"M"}]}`)) - case "/t/a.png": - w.Write(pic) - default: - http.NotFound(w, r) - } - })) - defer ts.Close() - old := sponsorBase - sponsorBase = ts.URL + "/t/" - sponsorHTTPS = false - defer func() { sponsorBase, sponsorHTTPS = old, true }() - - data := t.TempDir() - s := &Server{DataDir: data, Version: "9.9.9"} - dir := s.sponsorDir() - if err := refreshSponsor(context.Background(), dir, "traffic66/9.9.9"); err != nil { - t.Fatal(err) - } - if ua != "traffic66/9.9.9" { - t.Fatalf("user agent %q", ua) - } - // GitHub goes away: the last good set stays - up = false - if err := refreshSponsor(context.Background(), dir, "x"); err == nil { - t.Fatal("refresh should fail while offline") - } - w := httptest.NewRecorder() - s.getSponsor(w, httptest.NewRequest("GET", "/api/sponsor", nil)) - var got struct{ Slides []SponsorSlide } - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || len(got.Slides) != 1 { - t.Fatalf("cached manifest: %s %v", w.Body, err) - } - if got.Slides[0].Image != "/api/sponsor/img/0.png" || got.Slides[0].Link != "https://a.example/" { - t.Fatalf("slide: %+v", got.Slides[0]) - } - r := httptest.NewRequest("GET", "/api/sponsor/img/0.png", nil) - r.SetPathValue("name", "0.png") - w = httptest.NewRecorder() - s.getSponsorImg(w, r) - if w.Code != 200 || !bytes.Equal(w.Body.Bytes(), pic) || w.Header().Get("Content-Type") != "image/png" { - t.Fatalf("image: %d %q", w.Code, w.Header().Get("Content-Type")) - } - for _, bad := range []string{"../license.json", "sponsors.json", "9.png"} { - r := httptest.NewRequest("GET", "/x", nil) - r.SetPathValue("name", bad) - w := httptest.NewRecorder() - s.getSponsorImg(w, r) - if w.Code != 404 { - t.Fatalf("%s served: %d", bad, w.Code) - } - } - // nothing cached at all: 404, the page shows its built-in text - os.RemoveAll(dir) - w = httptest.NewRecorder() - s.getSponsor(w, httptest.NewRequest("GET", "/api/sponsor", nil)) - if w.Code != 404 { - t.Fatalf("empty cache: %d", w.Code) - } - if _, err := os.Stat(filepath.Join(data, "sponsor.new")); !os.IsNotExist(err) { - t.Fatal("temporary directory left behind") - } -} diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 860874a..d77e115 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -1,6 +1,6 @@ // Package assets is the asset ledger: sites, racks, devices, ports and the -// links between them, the circuits that reach them, and the subnets and -// addresses assigned on them. +// 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 @@ -60,14 +60,16 @@ var schema = []string{ `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)`, - // one address may be written down once; the rest is left to the - // conflict report, which explains rather than refuses - `CREATE UNIQUE INDEX IF NOT EXISTS addrs_ip ON addrs (ip)`, `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)`, @@ -103,6 +105,16 @@ func Open(dir string) (*Store, error) { 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 } @@ -133,7 +145,6 @@ type Counts struct { Ports int `json:"ports"` Circuits int `json:"circuits"` Subnets int `json:"subnets"` - Addrs int `json:"addrs"` } // Counts reports the ledger's size. Hidden records are counted: they are @@ -146,7 +157,6 @@ func (s *Store) Counts() Counts { }{ {"sites", &c.Sites}, {"racks", &c.Racks}, {"devices", &c.Devices}, {"ports", &c.Ports}, {"circuits", &c.Circuits}, {"subnets", &c.Subnets}, - {"addrs", &c.Addrs}, } { s.DB.QueryRow(`SELECT count(*) FROM ` + p.table).Scan(p.into) } diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index b696422..68e70e1 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -128,7 +128,7 @@ func TestDeleteDevice(t *testing.T) { if _, err := s.SaveLink(Link{APort: p1.ID, BPort: p2.ID}); err != nil { t.Fatal(err) } - if _, err := s.SaveAddr(Addr{IP: "10.0.0.5", DeviceID: d1.ID, PortID: p1.ID, Name: "mgmt"}); err != nil { + 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 { @@ -140,12 +140,10 @@ func TestDeleteDevice(t *testing.T) { if ps, _ := s.Ports(0); len(ps) != 1 || ps[0].ID != p2.ID { t.Fatalf("ports left: %+v", ps) } - as, _ := s.Addrs(AddrQuery{}) - if len(as) != 1 { - t.Fatalf("the address went with the device: %+v", as) - } - if as[0].DeviceID != 0 || as[0].PortID != 0 { - t.Fatalf("the address still points at the device: %+v", as[0]) + // 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) } } @@ -180,55 +178,15 @@ func TestOneCablePerPort(t *testing.T) { } } -// An address is written down once, in its canonical form, and finds the -// narrowest network that contains it even when that network is recorded -// afterwards. -func TestAddressAssignment(t *testing.T) { - s := open(t) - wide, err := s.SaveSubnet(Subnet{Prefix: "10.10.0.0/16", Name: "Office"}) - if err != nil { - t.Fatal(err) - } - a, err := s.SaveAddr(Addr{IP: " 10.10.3.27 ", Name: "Finance PC", MAC: "aabb.ccdd.eeff"}) - if err != nil { - t.Fatal(err) - } - if a.IP != "10.10.3.27" { - t.Fatalf("address stored as %q", a.IP) - } - if a.MAC != "aa:bb:cc:dd:ee:ff" { - t.Fatalf("MAC stored as %q", a.MAC) - } - if a.SubnetID != wide.ID { - t.Fatalf("address landed in subnet %d, want %d", a.SubnetID, wide.ID) - } - if _, err := s.SaveAddr(Addr{IP: "10.10.3.27", Name: "Someone else"}); err == nil { - t.Fatal("the address was assigned twice") - } - // a narrower network recorded later takes the address over - narrow, err := s.SaveSubnet(Subnet{Prefix: "10.10.3.0/24", Name: "Finance"}) - if err != nil { - t.Fatal(err) - } - as, _ := s.Addrs(AddrQuery{}) - if len(as) != 1 || as[0].SubnetID != narrow.ID { - t.Fatalf("after recording %s the address sits in %d, want %d", narrow.Prefix, as[0].SubnetID, narrow.ID) - } - // and deleting that network hands it back, without losing it - if err := s.DeleteSubnet(narrow.ID); err != nil { - t.Fatal(err) - } - as, _ = s.Addrs(AddrQuery{}) - if len(as) != 1 || as[0].SubnetID != wide.ID { - t.Fatalf("after deleting the narrow network: %+v", as) - } -} - // 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) - sn, err := s.SaveSubnet(Subnet{Prefix: "192.168.5.37/24", Gateway: "192.168.5.1"}) + 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) } @@ -244,82 +202,11 @@ func TestSubnetCanonical(t *testing.T) { if _, err := s.SaveSubnet(Subnet{Prefix: "not a network"}); err == nil { t.Fatal("rubbish was accepted as a network") } -} - -// Free addresses skip what is assigned, the gateway, and the network and -// broadcast addresses. -func TestFreeAddrs(t *testing.T) { - s := open(t) - sn, err := s.SaveSubnet(Subnet{Prefix: "10.1.1.0/29", Gateway: "10.1.1.1"}) - if err != nil { - t.Fatal(err) - } - if _, err := s.SaveAddr(Addr{IP: "10.1.1.2"}); err != nil { - t.Fatal(err) - } - free, err := s.FreeAddrs(sn.ID, 10) - if err != nil { - t.Fatal(err) - } - want := []string{"10.1.1.3", "10.1.1.4", "10.1.1.5", "10.1.1.6"} - if len(free) != len(want) { - t.Fatalf("free %v, want %v", free, want) - } - for i := range want { - if free[i] != want[i] { - t.Fatalf("free %v, want %v", free, want) - } - } - use, err := s.Use() - if err != nil { - t.Fatal(err) - } - if len(use) != 1 || use[0].Hosts != 6 || use[0].Assigned != 1 || use[0].Free != 5 { - t.Fatalf("use: %+v", use) - } - if !use[0].Listable { - t.Fatal("a /29 should be listable") - } -} - -// Networks too large to enumerate say so rather than pretending. -func TestLargeSubnet(t *testing.T) { - s := open(t) - if _, err := s.SaveSubnet(Subnet{Prefix: "2001:db8::/32"}); err != nil { - t.Fatal(err) - } - use, err := s.Use() - if err != nil { - t.Fatal(err) - } - if use[0].Hosts != -1 || use[0].Free != -1 || use[0].Listable { - t.Fatalf("a /32 of IPv6 was counted: %+v", use[0]) - } -} - -// The conflict report reads only the ledger: addresses in no recorded -// network, and one MAC on several addresses. -func TestConflicts(t *testing.T) { - s := open(t) - if _, err := s.SaveSubnet(Subnet{Prefix: "10.0.0.0/24"}); err != nil { - t.Fatal(err) - } - if _, err := s.SaveAddr(Addr{IP: "10.0.0.5", MAC: "aa:bb:cc:dd:ee:01", Name: "a"}); err != nil { - t.Fatal(err) - } - if _, err := s.SaveAddr(Addr{IP: "10.0.1.5", MAC: "aa:bb:cc:dd:ee:01", Name: "b"}); err != nil { - t.Fatal(err) - } - cs, err := s.Conflicts() - if err != nil { - t.Fatal(err) - } - kinds := map[string]int{} - for _, c := range cs { - kinds[c.Kind]++ + 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 kinds["outside"] != 1 || kinds["duplicate_mac"] != 1 { - t.Fatalf("conflicts: %+v", cs) + if sn.DeviceID != dev.ID { + t.Fatalf("the network is not on the device: %+v", sn) } } diff --git a/internal/assets/crud.go b/internal/assets/crud.go index a2d4807..ca287c5 100644 --- a/internal/assets/crud.go +++ b/internal/assets/crud.go @@ -288,7 +288,11 @@ func (s *Store) DeleteDevice(id int64) error { OR b_port IN (SELECT id FROM ports WHERE device_id = ?)`, id, id); err != nil { return err } - if _, err := s.DB.Exec(`UPDATE addrs SET device_id = 0, port_id = 0, updated = ? WHERE device_id = ?`, now(), id); err != nil { + // 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 { diff --git a/internal/assets/csv.go b/internal/assets/csv.go index 668b2a4..143fdb6 100644 --- a/internal/assets/csv.go +++ b/internal/assets/csv.go @@ -25,12 +25,11 @@ var csvColumns = map[string][]string{ "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", "site", "gateway", "tags", "note", "hidden"}, - "addr": {"id", "ip", "name", "kind", "device", "port", "mac", "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", "addr"} +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 @@ -177,18 +176,9 @@ func (s *Store) ExportCSV(w io.Writer, kind string) error { return err } for _, v := range vs { - rows = append(rows, []string{id(v.ID), v.Prefix, v.Name, num(v.VLAN), n.site[v.SiteID], + 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)}) } - case "addr": - vs, err := s.Addrs(AddrQuery{Limit: 20000}) - if err != nil { - return err - } - for _, v := range vs { - rows = append(rows, []string{id(v.ID), v.IP, v.Name, v.Kind, n.device[v.DeviceID], n.port[v.PortID], - v.MAC, strings.Join(v.Tags, " "), v.Note, b(v.Hidden)}) - } } for _, r := range rows { for i := range r { @@ -363,34 +353,17 @@ func (s *Store) ImportCSV(r io.Reader, kind string, apply bool) (Plan, error) { _, werr = s.SaveCircuit(v) } case "subnet": - site, err := ref(n.site, "site") + 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")), SiteID: site, + 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) } - case "addr": - dev, err := ref(n.device, "device") - if err != nil { - p.add("addr", act, get("ip"), err) - continue - } - port, err := ref(n.port, "port") - if err != nil { - p.add("addr", act, get("ip"), err) - continue - } - v := Addr{ID: rid, IP: get("ip"), Name: get("name"), Kind: get("kind"), DeviceID: dev, PortID: port, - MAC: get("mac"), Tags: strings.Fields(get("tags")), Note: get("note"), Hidden: yes(get("hidden"))} - what = v.IP - if apply { - _, werr = s.SaveAddr(v) - } } if werr != nil { werr = fmt.Errorf("line %d: %v", line, werr) diff --git a/internal/assets/ipam.go b/internal/assets/ipam.go deleted file mode 100644 index 125e434..0000000 --- a/internal/assets/ipam.go +++ /dev/null @@ -1,461 +0,0 @@ -package assets - -import ( - "database/sql" - "errors" - "fmt" - "net/netip" - "strings" -) - -// Address kinds. The kind is what the address is for, not what was seen: -// nothing here is set by watching traffic. -const ( - KindStatic = "static" - KindDHCP = "dhcp" // inside a pool handed out by a DHCP server - KindReserved = "reserved" // kept free on purpose - KindGateway = "gateway" - KindVIP = "vip" -) - -// MaxSubnetHosts is how many addresses a subnet page will enumerate. A -// /16 has 65,534 of them; listing every one is neither useful nor fast, so -// larger subnets are summarised and searched instead. -const MaxSubnetHosts = 4096 - -// 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("sites", v.SiteID, "site"); 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 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.Prefix, v.Name, v.VLAN, v.SiteID, v.Gateway, tags, v.Note, v.Hidden, now(), now()); err != nil { - return v, err - } - } else { - r, err := s.DB.Exec(`UPDATE subnets SET prefix = ?, name = ?, vlan = ?, site_id = ?, gateway = ?, - tags = ?, note = ?, hidden = ?, updated = ? WHERE id = ?`, - v.Prefix, v.Name, v.VLAN, v.SiteID, v.Gateway, tags, v.Note, v.Hidden, now(), v.ID) - if err := affected(r, err); err != nil { - return v, err - } - } - // addresses find their subnet by containment, so re-home them whenever - // the networks change; an address inside no recorded network keeps 0 - return v, s.rehome() -} - -// 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, site_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.SiteID, &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. Its addresses are kept: they were -// assigned to machines, not to the record of the network, and losing them -// because someone re-drew the network is how a ledger stops being trusted. -func (s *Store) DeleteSubnet(id int64) error { - r, err := s.DB.Exec(`DELETE FROM subnets WHERE id = ?`, id) - if err := affected(r, err); err != nil { - return err - } - return s.rehome() -} - -// SaveAddr assigns an address, or edits an assignment. -func (s *Store) SaveAddr(v Addr) (Addr, error) { - v.Name, v.Note = trim(v.Name, 120), trim(v.Note, 2000) - ip, err := canonIP(v.IP) - if err != nil { - return v, err - } - v.IP = ip - if v.MAC != "" { - m := CanonMAC(v.MAC) - if m == "" { - return v, fmt.Errorf("%q is not a MAC address", v.MAC) - } - v.MAC = m - } - switch v.Kind { - case "", KindStatic, KindDHCP, KindReserved, KindGateway, KindVIP: - default: - return v, fmt.Errorf("%q is not an address kind", v.Kind) - } - if v.Kind == "" { - v.Kind = KindStatic - } - if err := s.exists("devices", v.DeviceID, "device"); err != nil { - return v, err - } - if err := s.exists("ports", v.PortID, "port"); err != nil { - return v, err - } - var other int64 - var name string - err = s.DB.QueryRow(`SELECT id, name FROM addrs WHERE ip = ? AND id <> ?`, v.IP, v.ID).Scan(&other, &name) - if err == nil { - if name == "" { - name = "another record" - } - return v, fmt.Errorf("%s is already assigned to %s", v.IP, name) - } - if !errors.Is(err, sql.ErrNoRows) { - return v, err - } - v.SubnetID = s.subnetOf(v.IP) - 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 addrs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ID, v.SubnetID, v.IP, v.Name, v.Kind, v.DeviceID, v.PortID, v.MAC, tags, v.Note, v.Hidden, now(), now()) - return v, err - } - r, err := s.DB.Exec(`UPDATE addrs SET subnet_id = ?, ip = ?, name = ?, kind = ?, device_id = ?, - port_id = ?, mac = ?, tags = ?, note = ?, hidden = ?, updated = ? WHERE id = ?`, - v.SubnetID, v.IP, v.Name, v.Kind, v.DeviceID, v.PortID, v.MAC, tags, v.Note, v.Hidden, now(), v.ID) - return v, affected(r, err) -} - -// AddrQuery selects assignments. -type AddrQuery struct { - SubnetID int64 - DeviceID int64 - Tag string - Search string - Limit int -} - -// Addrs lists assignments in address order. -func (s *Store) Addrs(q AddrQuery) ([]Addr, error) { - conds, args := []string{"true"}, []any{} - if q.SubnetID != 0 { - conds, args = append(conds, "subnet_id = ?"), append(args, q.SubnetID) - } - if q.DeviceID != 0 { - conds, args = append(conds, "device_id = ?"), append(args, q.DeviceID) - } - if t := strings.ToLower(strings.TrimSpace(q.Tag)); t != "" { - conds, args = append(conds, "list_contains(str_split(tags, ','), ?)"), append(args, t) - } - if t := strings.TrimSpace(q.Search); t != "" { - conds = append(conds, `(ip ILIKE ? OR name ILIKE ? OR mac ILIKE ? OR note ILIKE ?)`) - like := "%" + t + "%" - args = append(args, like, like, like, like) - } - if q.Limit <= 0 || q.Limit > 20000 { - q.Limit = 5000 - } - rows, err := s.DB.Query(fmt.Sprintf(`SELECT id, subnet_id, ip, name, kind, device_id, port_id, mac, tags, note, hidden - FROM addrs WHERE %s ORDER BY ip LIMIT %d`, strings.Join(conds, " AND "), q.Limit), args...) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Addr{} - for rows.Next() { - var v Addr - var tags string - if err := rows.Scan(&v.ID, &v.SubnetID, &v.IP, &v.Name, &v.Kind, &v.DeviceID, &v.PortID, &v.MAC, &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 - } - sortAddrs(out) - return out, nil -} - -// DeleteAddr frees an assignment. -func (s *Store) DeleteAddr(id int64) error { - r, err := s.DB.Exec(`DELETE FROM addrs WHERE id = ?`, id) - return affected(r, err) -} - -// subnetOf returns the id of the narrowest recorded network containing an -// address, or 0. -func (s *Store) subnetOf(ip string) int64 { - a, err := netip.ParseAddr(ip) - if err != nil { - return 0 - } - subs, err := s.Subnets() - if err != nil { - return 0 - } - for _, sn := range subs { // narrowest first - if p, err := netip.ParsePrefix(sn.Prefix); err == nil && p.Contains(a) { - return sn.ID - } - } - return 0 -} - -// rehome re-reads which network each address falls in. It runs after the -// networks change, which is rare and cheap next to being wrong. -func (s *Store) rehome() error { - subs, err := s.Subnets() - if err != nil { - return err - } - rows, err := s.DB.Query(`SELECT id, ip, subnet_id FROM addrs`) - if err != nil { - return err - } - type move struct { - id, to int64 - } - var moves []move - for rows.Next() { - var id, cur int64 - var ip string - if err := rows.Scan(&id, &ip, &cur); err != nil { - rows.Close() - return err - } - to := int64(0) - if a, err := netip.ParseAddr(ip); err == nil { - for _, sn := range subs { - if p, err := netip.ParsePrefix(sn.Prefix); err == nil && p.Contains(a) { - to = sn.ID - break - } - } - } - if to != cur { - moves = append(moves, move{id, to}) - } - } - rows.Close() - for _, m := range moves { - if _, err := s.DB.Exec(`UPDATE addrs SET subnet_id = ? WHERE id = ?`, m.to, m.id); err != nil { - return err - } - } - return nil -} - -// SubnetUse is how full a network is. -type SubnetUse struct { - Subnet - Hosts int64 `json:"hosts"` // usable addresses, -1 when too large to count - Assigned int `json:"assigned"` // addresses written down in it - Free int64 `json:"free"` // -1 when hosts is -1 - Listable bool `json:"listable"` // small enough to show address by address -} - -// Use reports how full each network is. -func (s *Store) Use() ([]SubnetUse, error) { - subs, err := s.Subnets() - if err != nil { - return nil, err - } - counts := map[int64]int{} - rows, err := s.DB.Query(`SELECT subnet_id, count(*) FROM addrs GROUP BY 1`) - if err != nil { - return nil, err - } - for rows.Next() { - var id int64 - var n int - if err := rows.Scan(&id, &n); err != nil { - rows.Close() - return nil, err - } - counts[id] = n - } - rows.Close() - out := make([]SubnetUse, 0, len(subs)) - for _, sn := range subs { - u := SubnetUse{Subnet: sn, Assigned: counts[sn.ID], Hosts: -1, Free: -1} - if p, err := netip.ParsePrefix(sn.Prefix); err == nil { - if n, ok := hostCount(p); ok { - u.Hosts, u.Free = n, max64(0, n-int64(u.Assigned)) - u.Listable = n <= MaxSubnetHosts - } - } - out = append(out, u) - } - return out, nil -} - -// hostCount is how many addresses a network can assign. A /31 and a /32 -// are whole-prefix cases (point to point, single host); otherwise the -// network and broadcast addresses are not assignable. IPv6 networks are -// counted only when small enough to mean anything. -func hostCount(p netip.Prefix) (int64, bool) { - bits := p.Addr().BitLen() - p.Bits() - if bits > 20 { - return 0, false - } - n := int64(1) << bits - if p.Addr().Is4() && n > 2 { - n -= 2 - } - return n, true -} - -// FreeAddrs returns the first n addresses of a network that nothing is -// assigned to, skipping the network and broadcast addresses of an IPv4 -// network and the gateway. -func (s *Store) FreeAddrs(subnetID int64, n int) ([]string, error) { - if n <= 0 || n > 256 { - n = 16 - } - var prefix, gw string - if err := s.DB.QueryRow(`SELECT prefix, gateway FROM subnets WHERE id = ?`, subnetID).Scan(&prefix, &gw); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - p, err := netip.ParsePrefix(prefix) - if err != nil { - return nil, err - } - taken := map[string]bool{} - rows, err := s.DB.Query(`SELECT ip FROM addrs WHERE subnet_id = ?`, subnetID) - if err != nil { - return nil, err - } - for rows.Next() { - var ip string - if err := rows.Scan(&ip); err != nil { - rows.Close() - return nil, err - } - taken[ip] = true - } - rows.Close() - if gw != "" { - taken[gw] = true - } - out := []string{} - total, _ := hostCount(p) - a := p.Addr() - if p.Addr().Is4() && total > 2 { - a = a.Next() // the network address is not assignable - } - for i := int64(0); i < total && len(out) < n; i++ { - if !p.Contains(a) { - break - } - if !taken[a.String()] { - out = append(out, a.String()) - } - a = a.Next() - } - return out, nil -} - -// Conflict is an address the ledger and the network disagree about, or -// that the ledger disagrees with itself about. -type Conflict struct { - IP string `json:"ip"` - Kind string `json:"kind"` // outside, duplicate_mac, unrecorded - Detail string `json:"detail"` -} - -// Conflicts reports what does not add up in the assignments themselves: -// addresses in no recorded network, and one MAC written against several -// addresses. What the network is actually doing is a separate report; this -// one only reads the ledger, so it is the same answer offline. -func (s *Store) Conflicts() ([]Conflict, error) { - out := []Conflict{} - rows, err := s.DB.Query(`SELECT ip, name FROM addrs WHERE subnet_id = 0 ORDER BY ip LIMIT 500`) - if err != nil { - return nil, err - } - for rows.Next() { - var ip, name string - if err := rows.Scan(&ip, &name); err != nil { - rows.Close() - return nil, err - } - out = append(out, Conflict{IP: ip, Kind: "outside", Detail: name}) - } - rows.Close() - rows, err = s.DB.Query(`SELECT mac, string_agg(ip, ', ' ORDER BY ip) FROM addrs - WHERE mac <> '' GROUP BY mac HAVING count(*) > 1 LIMIT 500`) - if err != nil { - return nil, err - } - defer rows.Close() - for rows.Next() { - var mac, ips string - if err := rows.Scan(&mac, &ips); err != nil { - return nil, err - } - out = append(out, Conflict{IP: mac, Kind: "duplicate_mac", Detail: ips}) - } - return out, rows.Err() -} - -func max64(a, b int64) int64 { - if a > b { - return a - } - return b -} diff --git a/internal/assets/paste.go b/internal/assets/paste.go index e2d3b4e..c43fe98 100644 --- a/internal/assets/paste.go +++ b/internal/assets/paste.go @@ -22,13 +22,16 @@ import ( const ( FormatNeighbors = "neighbors" // show lldp neighbors, show cdp neighbors FormatIfStatus = "ifstatus" // show interface status - FormatARP = "arp" // show ip arp, show arp - FormatMACTable = "mactable" // show mac address-table ) +// 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, addr + 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"` @@ -90,16 +93,14 @@ func (c columns) cut(line, name string) string { // 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"}, - "address": {"address", "ip address", "ip"}, - "hardware": {"hardware addr", "hardware address", "mac address", "mac addr", "mac"}, + "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 @@ -247,10 +248,6 @@ func guessFormat(c columns) string { switch { case c.has("device", "local", "remote"): return FormatNeighbors - case c.has("address", "hardware"): - return FormatARP - case c.has("hardware", "port"): - return FormatMACTable case c.has("port", "speed"), c.has("port", "vlan"), c.has("port", "name"): return FormatIfStatus } @@ -337,7 +334,7 @@ type PasteOptions struct { 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, show interface status, show ip arp or show mac address-table") + 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 @@ -346,10 +343,6 @@ func (s *Store) Paste(o PasteOptions) (Plan, error) { err = s.pasteNeighbors(&p, c, rows, o) case FormatIfStatus: err = s.pasteIfStatus(&p, c, rows, o) - case FormatARP: - err = s.pasteARP(&p, c, rows, o) - case FormatMACTable: - err = s.pasteMACTable(&p, c, rows, o) } return p, err } @@ -515,99 +508,3 @@ func (s *Store) pasteIfStatus(p *Plan, c columns, rows []string, o PasteOptions) } return nil } - -// pasteARP reads show ip arp: which address answered from which MAC. It -// is the half of the picture the ledger cannot work out on its own, and -// the half a mac-address-table paste needs to be worth anything. -func (s *Store) pasteARP(p *Plan, c columns, rows []string, o PasteOptions) error { - have, err := s.Addrs(AddrQuery{Limit: 20000}) - if err != nil { - return err - } - by := make(map[string]Addr, len(have)) - for _, a := range have { - by[a.IP] = a - } - for _, r := range rows { - ip, err := canonIP(c.cut(r, "address")) - mac := CanonMAC(c.cut(r, "hardware")) - if err != nil || mac == "" { - p.Skipped = append(p.Skipped, strings.TrimSpace(r)) - continue - } - cur, known := by[ip] - if known && cur.MAC == mac { - p.add("addr", "same", ip+" "+mac, nil) - continue - } - cur.IP, cur.MAC = ip, mac - act := "update" - if !known { - act, cur.Kind = "add", KindStatic - } - if p.Applied { - if _, err := s.SaveAddr(cur); err != nil { - p.add("addr", act, ip+" "+mac, err) - continue - } - } - p.add("addr", act, ip+" "+mac, nil) - } - return nil -} - -// pasteMACTable reads show mac address-table: which MAC is on which port. -// On its own that is a list of MACs; after an ARP paste it is what finally -// says which port a given address is plugged into, which is the question -// the ledger exists to answer and the one nobody can answer from memory. -func (s *Store) pasteMACTable(p *Plan, c columns, rows []string, o PasteOptions) error { - me, err := s.needDevice(o.DeviceID) - if err != nil { - return err - } - have, err := s.Addrs(AddrQuery{Limit: 20000}) - if err != nil { - return err - } - byMAC := map[string][]Addr{} - for _, a := range have { - if a.MAC != "" { - byMAC[a.MAC] = append(byMAC[a.MAC], a) - } - } - for _, r := range rows { - mac, pname := CanonMAC(c.cut(r, "hardware")), c.cut(r, "port") - if mac == "" || pname == "" { - p.Skipped = append(p.Skipped, strings.TrimSpace(r)) - continue - } - as := byMAC[mac] - if len(as) == 0 { - p.add("addr", "same", mac+" — "+pname, fmt.Errorf("no address recorded for this MAC; paste the ARP table first")) - continue - } - pt, added, err := s.port(me.ID, pname, true, p) - if err != nil { - p.add("port", "add", me.Name+" "+pname, err) - continue - } - if added { - p.add("port", "add", me.Name+" "+pname, nil) - } - for _, a := range as { - if a.DeviceID == me.ID && a.PortID == pt.ID { - p.add("addr", "same", a.IP+" — "+me.Name+" "+pname, nil) - continue - } - a.DeviceID, a.PortID = me.ID, pt.ID - if p.Applied { - if _, err := s.SaveAddr(a); err != nil { - p.add("addr", "update", a.IP+" — "+me.Name+" "+pname, err) - continue - } - } - p.add("addr", "update", a.IP+" — "+me.Name+" "+pname, nil) - } - } - return nil -} diff --git a/internal/assets/paste_test.go b/internal/assets/paste_test.go index 0cfce98..17d1cf8 100644 --- a/internal/assets/paste_test.go +++ b/internal/assets/paste_test.go @@ -1,7 +1,6 @@ package assets import ( - "strings" "testing" ) @@ -153,70 +152,6 @@ Te1/1 storage connected 20 full 10G SFP-10GBase-S } } -// show ip arp records which address answered from which MAC, and a -// mac-address-table paste then says which port that address is on. Each -// half is worth little alone; together they answer "where is this host -// plugged in", which is the question nobody can answer from memory. -func TestPasteARPThenMACTable(t *testing.T) { - s := open(t) - me, _ := s.SaveDevice(Device{Name: "core-sw1"}) - arp := `Protocol Address Age (min) Hardware Addr Type Interface -Internet 10.0.0.1 - aabb.ccdd.ee01 ARPA Vlan1 -Internet 10.0.0.42 12 aabb.ccdd.ee42 ARPA Vlan1 -` - p, err := s.Paste(PasteOptions{Text: arp, Apply: true}) - if err != nil { - t.Fatal(err) - } - if p.Format != FormatARP { - t.Fatalf("read as %q", p.Format) - } - if c := counts(p); c["addr/add"] != 2 || c["errors"] != 0 { - t.Fatalf("arp: %v\n%+v", c, p.Changes) - } - // reading it again recognises that nothing changed - p, _ = s.Paste(PasteOptions{Text: arp, Apply: true}) - if c := counts(p); c["addr/same"] != 2 { - t.Fatalf("arp again: %v", c) - } - - mac := ` Mac Address Table -------------------------------------------- - -Vlan Mac Address Type Ports ----- ----------- -------- ----- - 10 aabb.ccdd.ee42 DYNAMIC Gi0/7 - 10 aabb.ccdd.ee99 DYNAMIC Gi0/8 -` - p, err = s.Paste(PasteOptions{Text: mac, DeviceID: me.ID, Apply: true}) - if err != nil { - t.Fatal(err) - } - if p.Format != FormatMACTable { - t.Fatalf("read as %q", p.Format) - } - as, _ := s.Addrs(AddrQuery{}) - var found bool - for _, a := range as { - if a.IP == "10.0.0.42" { - found = a.DeviceID == me.ID && a.PortID != 0 - } - } - if !found { - t.Fatalf("the address was not put on its port: %+v", as) - } - // the MAC with no address recorded is reported, not invented - var said bool - for _, c := range p.Changes { - if strings.Contains(c.What, "ee:99") && c.Error != "" { - said = true - } - } - if !said { - t.Fatalf("an unknown MAC was not reported: %+v", p.Changes) - } -} - // 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) { diff --git a/internal/assets/record.go b/internal/assets/record.go index 098e51e..8a598eb 100644 --- a/internal/assets/record.go +++ b/internal/assets/record.go @@ -85,29 +85,15 @@ type Circuit struct { Hidden bool `json:"hidden,omitempty"` } -// Subnet is a network the ledger assigns addresses from. +// 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"` - Prefix string `json:"prefix"` - Name string `json:"name,omitempty"` - VLAN int `json:"vlan,omitempty"` - SiteID int64 `json:"site_id,omitempty"` - Gateway string `json:"gateway,omitempty"` - Tags []string `json:"tags,omitempty"` - Note string `json:"note,omitempty"` - Hidden bool `json:"hidden,omitempty"` -} - -// Addr is one assigned address. -type Addr struct { ID int64 `json:"id"` - SubnetID int64 `json:"subnet_id,omitempty"` - IP string `json:"ip"` - Name string `json:"name,omitempty"` - Kind string `json:"kind,omitempty"` // static, dhcp, reserved, gateway, vip DeviceID int64 `json:"device_id,omitempty"` - PortID int64 `json:"port_id,omitempty"` - MAC string `json:"mac,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"` diff --git a/internal/assets/sort.go b/internal/assets/sort.go index ea7c730..e7fb5d6 100644 --- a/internal/assets/sort.go +++ b/internal/assets/sort.go @@ -25,16 +25,3 @@ func sortSubnets(v []Subnet) { return a.Addr().Less(b.Addr()) }) } - -// sortAddrs sorts by address, not by the text of it, so that .9 comes -// before .10. -func sortAddrs(v []Addr) { - sort.SliceStable(v, func(i, j int) bool { - a, aerr := netip.ParseAddr(v[i].IP) - b, berr := netip.ParseAddr(v[j].IP) - if aerr != nil || berr != nil { - return v[i].IP < v[j].IP - } - return a.Less(b) - }) -} diff --git a/internal/assets/subnets.go b/internal/assets/subnets.go new file mode 100644 index 0000000..ebadaae --- /dev/null +++ b/internal/assets/subnets.go @@ -0,0 +1,102 @@ +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/license/keyring.go b/internal/license/keyring.go index 9f66082..87aa927 100644 --- a/internal/license/keyring.go +++ b/internal/license/keyring.go @@ -99,11 +99,15 @@ func (r Keyring) Verify(f File) (Key, int, error) { continue } if ed25519.Verify(k.Pub, Message(f), sig) { + return k, 3, nil + } + // A v2 licence does not say who it is made out to. + if !namesLicensee(f) && ed25519.Verify(k.Pub, MessageV2(f), sig) { return k, 2, nil } // A v1 licence carries no key id, no quotas and no features; it // registers an installation and nothing more. - if !grants(f) && ed25519.Verify(k.Pub, MessageV1(f), sig) { + if !namesLicensee(f) && !grants(f) && ed25519.Verify(k.Pub, MessageV1(f), sig) { return k, 1, nil } } diff --git a/internal/license/keyring_test.go b/internal/license/keyring_test.go index 9667e83..72d6908 100644 --- a/internal/license/keyring_test.go +++ b/internal/license/keyring_test.go @@ -31,8 +31,8 @@ func TestRingPicksTheNamedKey(t *testing.T) { if err != nil { t.Fatal(err) } - if got.ID != keyA.ID || ver != 2 { - t.Fatalf("got key %s v%d, want %s v2", got.ID, ver, keyA.ID) + if got.ID != keyA.ID || ver != 3 { + t.Fatalf("got key %s v%d, want %s v3", got.ID, ver, keyA.ID) } } diff --git a/internal/license/license.go b/internal/license/license.go index f5fd7ad..cdd2136 100644 --- a/internal/license/license.go +++ b/internal/license/license.go @@ -9,12 +9,18 @@ // of flow analysis. What it loses is the registered-only features and the // clean, unwatermarked reports. // -// Two versions of the signed message exist. v1 is what traffic66 shipped +// Three versions of the signed message exist. v1 is what traffic66 shipped // first (installation, customer, product, licence type, expiry). v2 adds // the signing key's id, the tier, the update window, the quotas and the -// feature list. A v1 signature can only ever authorise a v1 file: a file -// carrying quotas or features is checked against the v2 message alone, so -// an old signature cannot be replayed to grant them. +// feature list. v3 adds who the licence is made out to — a person or an +// organisation — so the page can say "licensed to" rather than only +// "registered", and so a licence bought by one person cannot be passed off +// as a company's. +// +// An older signature can only ever authorise what its own version covers: +// a file carrying quotas or features is checked against v2 or later, and +// one naming a licensee against v3 alone, so an old signature cannot be +// replayed to grant something it never said. // // Verification goes against a Keyring rather than one key, because an // offline licence cannot be withdrawn over the network: if an issuing key @@ -53,6 +59,18 @@ const ( TypePerpetual = "perpetual" ) +// Who a licence is made out to. It is signed, so it cannot be edited into +// a file after issuing, and it is what the licence page shows beside the +// name: a licence bought by one person says so. +const ( + LicenseeIndividual = "individual" + LicenseeOrganization = "organization" +) + +// Licensees is every licensee kind, for validation and for the issuing +// tool. +var Licensees = []string{LicenseeIndividual, LicenseeOrganization} + // Tiers. const ( TierTrial = "trial" @@ -66,7 +84,7 @@ const ( // stays free; these are the registered-only parts. const ( FeatSyslog = "syslog" // syslog output, including several targets - FeatDCIM = "dcim" // rooms, racks, ports, circuits, IP assignment + FeatDCIM = "dcim" // rooms, racks, ports, circuits, the networks on a device // 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, @@ -110,6 +128,7 @@ type File struct { KeyID string `json:"key_id,omitempty"` InstallationID string `json:"installation_id,omitempty"` Customer string `json:"customer,omitempty"` + Licensee string `json:"licensee,omitempty"` // LicenseeIndividual or LicenseeOrganization Product string `json:"product,omitempty"` License string `json:"license,omitempty"` Tier string `json:"tier,omitempty"` @@ -173,7 +192,7 @@ func CanonFeatures(in []string) []string { // fields are the free-text parts of a message; a newline in any of them // would let one field pose as another, so they are rejected. func fields(f File) []string { - return []string{f.KeyID, f.InstallationID, f.Customer, f.Product, f.License, f.Tier, + return []string{f.KeyID, f.InstallationID, f.Customer, f.Licensee, f.Product, f.License, f.Tier, strings.Join(CanonFeatures(f.Features), ",")} } @@ -198,11 +217,24 @@ func orNull(p *string) string { return *p } -// Message is what a v2 signature covers. -func Message(f File) []byte { +// Message is what the current signature covers, and what the issuing tool +// signs. It is v3: v2's fields with the licensee after the customer. +func Message(f File) []byte { return message("traffic66-license-v3", f, true) } + +// MessageV2 is what traffic66 signed before a licence said who it was made +// out to. It is kept so licences issued then keep working. +func MessageV2(f File) []byte { return message("traffic66-license-v2", f, false) } + +func message(head string, f File, licensee bool) []byte { var b strings.Builder - b.WriteString("traffic66-license-v2\n") - for _, s := range []string{f.KeyID, f.InstallationID, f.Customer, f.Product, f.License, f.Tier} { + b.WriteString(head) + b.WriteByte('\n') + parts := []string{f.KeyID, f.InstallationID, f.Customer} + if licensee { + parts = append(parts, f.Licensee) + } + parts = append(parts, f.Product, f.License, f.Tier) + for _, s := range parts { b.WriteString(s) b.WriteByte('\n') } @@ -223,13 +255,19 @@ func MessageV1(f File) []byte { f.InstallationID, f.Customer, f.Product, f.License, orNull(f.Expires))) } -// grants reports whether the file carries anything only a v2 message -// covers. Such a file is never accepted on a v1 signature. +// grants reports whether the file carries anything only a v2 message or +// later covers. Such a file is never accepted on a v1 signature. func grants(f File) bool { return f.KeyID != "" || f.Tier != "" || f.UpdatesUntil != nil || len(f.Features) > 0 || f.MaxUsers != 0 || f.MaxSites != 0 || f.MaxRacks != 0 || f.MaxDevices != 0 } +// namesLicensee reports whether the file says who it is made out to. Only +// a v3 signature covers that, so such a file is never accepted on an older +// one: otherwise a licence issued to a person could have "organization" +// written into it afterwards and still verify. +func namesLicensee(f File) bool { return f.Licensee != "" } + // Verify checks f against one key and returns the message version that // matched. Keyring.Verify is what traffic66 uses; this is the single-key // form, for issuing and for tests. diff --git a/internal/license/state.go b/internal/license/state.go index 5b53e31..da4e293 100644 --- a/internal/license/state.go +++ b/internal/license/state.go @@ -27,6 +27,7 @@ type State struct { Kind string // KindUnregistered, KindLicensed, KindExpired Reason string // why, when not licensed; for the footer and support Customer string + Licensee string // LicenseeIndividual, LicenseeOrganization, or empty on a licence issued before v3 Product string Type string // TypeTrial, TypeSubscription, TypePerpetual Tier string @@ -67,7 +68,7 @@ func Check(ring Keyring, f File, installID string, now time.Time) State { } s := State{ - Kind: KindLicensed, Customer: f.Customer, Product: f.Product, + Kind: KindLicensed, Customer: f.Customer, Licensee: f.Licensee, Product: f.Product, Type: f.License, Tier: f.Tier, InstallationID: f.InstallationID, KeyID: key.ID, Days: -1, features: map[string]bool{}, } @@ -196,6 +197,7 @@ type Public struct { Notice string `json:"notice,omitempty"` Reason string `json:"reason,omitempty"` Customer string `json:"customer,omitempty"` + Licensee string `json:"licensee,omitempty"` Product string `json:"product,omitempty"` Type string `json:"type,omitempty"` Tier string `json:"tier,omitempty"` @@ -215,7 +217,7 @@ type Public struct { func (s State) Public() Public { return Public{ Kind: s.Kind, Registered: s.Registered(), Watermark: s.Watermark(), Notice: s.Notice(), - Reason: s.Reason, Customer: s.Customer, Product: s.Product, + Reason: s.Reason, Customer: s.Customer, Licensee: s.Licensee, Product: s.Product, Type: s.Type, Tier: s.Tier, InstallationID: s.InstallationID, KeyID: s.KeyID, Days: s.Days, Expires: s.Expires, UpdatesUntil: s.UpdatesUntil, Users: s.UserLimit(), diff --git a/internal/license/state_test.go b/internal/license/state_test.go index fabe840..181933b 100644 --- a/internal/license/state_test.go +++ b/internal/license/state_test.go @@ -161,8 +161,9 @@ func TestPerpetual(t *testing.T) { // A v1 licence registers the installation but grants no quotas or features. func TestLegacyGrantsNothingExtra(t *testing.T) { _, pub := testKey(t) - f := vectors()[3].file - f.Signature = vectors()[3].sig + v := vectorNamed(t, "legacy v1") + f := v.file + f.Signature = v.sig s := Check(SingleKey(pub), f, "7f3a1c9e", now) if !s.Registered() { t.Fatalf("a v1 licence should still register: %s", s.Reason) @@ -221,3 +222,38 @@ func TestPublicShape(t *testing.T) { t.Fatal("no features without a licence") } } + +// The licensee is signed, so it cannot be written into a file after it was +// issued: a licence sold to one person must not be able to call itself a +// company's by editing a line. +func TestLicenseeIsSigned(t *testing.T) { + _, pub := testKey(t) + v := vectorNamed(t, "individual") + f := v.file + f.Signature = v.sig + s := Check(SingleKey(pub), f, "7f3a1c9e", now) + if !s.Registered() || s.Licensee != LicenseeIndividual || s.Customer != "Ada Lovelace" { + t.Fatalf("licensed to %q as %q: %s", s.Customer, s.Licensee, s.Reason) + } + if p := s.Public(); p.Licensee != LicenseeIndividual { + t.Fatalf("the API says %q", p.Licensee) + } + // the same file, now claiming to be a company's + f.Licensee = LicenseeOrganization + if s := Check(SingleKey(pub), f, "7f3a1c9e", now); s.Registered() { + t.Fatal("an edited licensee was accepted") + } + // and a licence issued before v3 carries none, rather than a guess + v2 := vectorNamed(t, "subscription") + f2 := v2.file + f2.Signature = v2.sig + s2 := Check(SingleKey(pub), f2, "", now) + if !s2.Registered() || s2.Licensee != "" { + t.Fatalf("a v2 licence says %q: %s", s2.Licensee, s2.Reason) + } + // nor can one be added to it + f2.Licensee = LicenseeOrganization + if s := Check(SingleKey(pub), f2, "", now); s.Registered() { + t.Fatal("a licensee added to a v2 licence was accepted") + } +} diff --git a/internal/license/vectors_test.go b/internal/license/vectors_test.go index 3a66f44..e5a1ea0 100644 --- a/internal/license/vectors_test.go +++ b/internal/license/vectors_test.go @@ -18,6 +18,8 @@ const testKeyID = "65b60673" const ( testPub = "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=" + sigOrg = "PmZUlDhglvR7Ry2M7nIas/bpaLu0dYYvQ95WLJ9un8pntBCTYtnZwiaHms1hn9p5sa9o7UntJYLmrB5gGap+Cg==" + sigPerson = "UYrv9p/uGa8waIxPdEhP6iG3Xa+qgrsDfLGg9P+vKcnLd2HU59fzYM7xvut66aAncFac5tbXQuKOWIhHUwpODw==" sigV2 = "DL9Y+Ajkw3OsQmC4owY0NNiGUAnQSLYwbRdSwW2RI6PurXDtdE4Nlt/2sTq+jauT1wqU8oI0ayDR1h/QcDOeCA==" sigTrial = "laCeKuijrFNXzPBOOjbKQS5DV7cvBn1ATrTDsh4cc7jfT4GuYSb9c2b3x41a+1M3wYL0+WtHyMNmktFZweE1BA==" sigPerp = "yniSBg+/WiNbTj3Xwx4chq8r57ywRAn5UVRvSX7nKEmyrY+QvByaXQWOSomM4ZJ9CpOlIPypNRnqaW7TILDsBA==" @@ -53,6 +55,19 @@ type vector struct { func vectors() []vector { return []vector{ + // v3: the licence says who it is made out to + {"organization", File{ + KeyID: testKeyID, + Customer: "Big Co", Licensee: LicenseeOrganization, Product: "traffic66", License: TypePerpetual, + Tier: TierEnterprise, UpdatesUntil: str("2027-10-31"), Features: Features, + }, sigOrg, 3}, + {"individual", File{ + KeyID: testKeyID, + InstallationID: "7f3a1c9e", Customer: "Ada Lovelace", Licensee: LicenseeIndividual, + Product: "traffic66", License: TypeSubscription, Tier: TierTeam, Expires: str("2027-10-31"), + MaxUsers: 10, MaxSites: 1, MaxRacks: 10, MaxDevices: 100, + Features: []string{FeatSyslog, FeatDCIM}, + }, sigPerson, 3}, {"subscription", File{ KeyID: testKeyID, Customer: "株式会社サンプル", Product: "traffic66", License: TypeSubscription, @@ -79,6 +94,20 @@ func vectors() []vector { } } +// vectorNamed finds one pinned licence. Tests ask for it by name: the +// list grows at the front as message versions are added, and an index +// would quietly become a different licence. +func vectorNamed(t *testing.T, name string) vector { + t.Helper() + for _, v := range vectors() { + if v.name == name { + return v + } + } + t.Fatalf("no pinned licence called %q", name) + return vector{} +} + func TestVectorsVerify(t *testing.T) { _, pub := testKey(t) if KeyID(pub) != testKeyID { @@ -102,8 +131,11 @@ func TestVectorsStable(t *testing.T) { priv, _ := testKey(t) for _, v := range vectors() { msg := Message(v.file) - if v.ver == 1 { + switch v.ver { + case 1: msg = MessageV1(v.file) + case 2: + msg = MessageV2(v.file) } got := b64.EncodeToString(ed25519.Sign(priv, msg)) if got != v.sig { diff --git a/internal/store/asnseries_test.go b/internal/store/asnseries_test.go new file mode 100644 index 0000000..2a2d2ab --- /dev/null +++ b/internal/store/asnseries_test.go @@ -0,0 +1,73 @@ +package store + +import ( + "testing" + "time" +) + +// The two network charts are drawn from asn_src and asn_dst. Traffic whose +// peer has no AS number has to count, as AS 0: an installation without a +// good AS database has minutes that carry nothing else, and leaving those +// minutes out drew the chart as a run of blocks with gaps between them +// while the AS table under it showed the same traffic as "unknown". +func TestASSeriesCountsUnknownAS(t *testing.T) { + s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + to := time.Now().UTC().Truncate(time.Minute) + from := to.Add(-30 * time.Minute) + // Every minute carries traffic. Only every third minute has a peer with + // an AS number; the rest is traffic the AS database could not place. + var rows []Row + for i := 0; i < 30; i++ { + ts := from.Add(time.Duration(i)*time.Minute + 10*time.Second) + r := Row{TS: ts, Src: "203.0.113.9", Dst: "10.1.1.5", Client: "10.1.1.5", Server: "203.0.113.9", + Cli4: 0x0A010105, Srv4: 0xCB007109, Dir: DirOutbound, App: "HTTPS", SvcPort: 443, Proto: 6, + Wire: 1_000_000, Bytes: 1_000_000, Pkts: 800, Flows: 1, Exporter: "10.0.0.1", InIf: 1, CliInt: true} + if i%3 == 0 { + r.PeerASN, r.PeerOrg = 64501, "Example Net" + } + // the other half of the same conversation, so both charts have data + b := r + b.Src, b.Dst = r.Dst, r.Src + b.Wire, b.Bytes, b.Pkts = r.Wire/4, r.Bytes/4, r.Pkts/4 + rows = append(rows, r, b) + } + if err := s.AppendRows(rows); err != nil { + t.Fatal(err) + } + q := Query{From: from, To: to} + for _, by := range []string{"asn_src", "asn_dst"} { + se, err := s.SeriesBy(q, by, "wire", 8) + if err != nil { + t.Fatal(err) + } + if len(se.Names) == 0 { + t.Fatalf("%s: no series", by) + } + var unknown, known bool + for _, n := range se.Names { + switch { + case n == "0\t": + unknown = true + case n == "64501\tExample Net": + known = true + } + } + if !unknown || !known { + t.Fatalf("%s: series %q, want both the placed AS and AS 0", by, se.Names) + } + // no bucket of a minute that carried traffic may be empty + for i := range se.Times { + sum := 0.0 + for _, v := range se.Values { + sum += v[i] + } + if sum == 0 { + t.Errorf("%s: bucket %d of %d is empty although that minute carried traffic", by, i, len(se.Times)) + } + } + } +} diff --git a/internal/store/rates.go b/internal/store/rates.go index 88b6f4c..7379a42 100644 --- a/internal/store/rates.go +++ b/internal/store/rates.go @@ -172,3 +172,41 @@ func (s *Store) Bills(from, to time.Time, exporter string) ([]Bill, error) { } 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/series.go b/internal/store/series.go index 4084306..63045ae 100644 --- a/internal/store/series.go +++ b/internal/store/series.go @@ -38,9 +38,17 @@ var seriesDims = map[string]seriesDim{ return `SELECT ` + b + `, val, sum(wire), sum(pkts) FROM r_dim WHERE ts >= ? AND ts < ? AND dim = 'port' AND NOT starts_with(val, '0/') GROUP BY 1,2` }}, // AS of the sender and of the receiver of each packet, when that side - // is the external one - "asn_src": {key: "CAST(peer_asn AS VARCHAR) || chr(9) || peer_org", cond: "peer_asn <> 0 AND src = " + peerExpr}, - "asn_dst": {key: "CAST(peer_asn AS VARCHAR) || chr(9) || peer_org", cond: "peer_asn <> 0 AND dst = " + peerExpr}, + // is the external one. + // + // Traffic whose peer has no AS number counts as AS 0, the way the AS + // table beside these charts counts it. Leaving it out instead made the + // chart disagree with the table under it, and worse: on an installation + // without a good AS database most minutes carried no AS at all, so + // whole buckets came back empty and the chart was drawn as a run of + // blocks with nothing between them. A bucket is empty here only when + // nothing went through it. + "asn_src": {key: "CAST(peer_asn AS VARCHAR) || chr(9) || peer_org", cond: "src = " + peerExpr}, + "asn_dst": {key: "CAST(peer_asn AS VARCHAR) || chr(9) || peer_org", cond: "dst = " + peerExpr}, // interfaces of the flow exporters, as "exporter/ifindex" "if_in": {key: "exporter || '/' || in_if", cond: "in_if <> 0"}, "if_out": {key: "exporter || '/' || out_if", cond: "out_if <> 0"}, diff --git a/internal/web/static/app.css b/internal/web/static/app.css index e0378f3..8902a29 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -54,7 +54,7 @@ button,input,select,textarea{font:inherit;color:inherit} [hidden]{display:none!important} .app{display:grid;grid-template-columns:236px 1fr;min-height:100vh} -aside{background:var(--side);border-inline-end:1px solid var(--side-line);display:flex;flex-direction:column;position:sticky;top:0;height:100vh} +aside{background:var(--side);border-inline-end:1px solid var(--side-line);display:flex;flex-direction:column;position:sticky;top:0;height:calc(100vh - var(--sp-h, 0px))} .brand{padding:12px 14px 10px;flex-wrap:wrap;position:relative;font-weight:700;font-size:18px;letter-spacing:-.01em;border-bottom:1px solid var(--side-line);display:flex;align-items:center;gap:8px} /* the logo fills the sidebar's width (236px less the brand padding = 208px) and keeps its aspect ratio; 72px tall at most. Recommended upload: @@ -73,18 +73,6 @@ nav.dash button:hover{background:var(--side-hover);color:var(--ink);transform:tr nav.dash button[aria-current="page"]{background:var(--surface);box-shadow:0 1px 2px var(--shadow);color:var(--accent-ink);font-weight:700} nav.dash button[aria-current="page"]::before{content:"";position:absolute;inset-inline-start:0;top:7px;bottom:7px;width:3px;border-radius:3px;background:var(--accent);animation:navbar .22s ease-out} @keyframes navbar{from{transform:scaleY(0)}to{transform:scaleY(1)}} -/* A named group in the nav: its own heading, its pages indented under it, - and a chevron that folds them away. The heading is marked as the - current section while any page inside it is open, so a folded group - still shows where you are. */ -nav.dash .grphead .chev{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round; - stroke-linejoin:round;opacity:.6;transition:transform .18s} -nav.dash .grphead[aria-expanded="false"] .chev{transform:rotate(-90deg)} -[dir=rtl] nav.dash .grphead[aria-expanded="false"] .chev{transform:rotate(90deg)} -nav.dash .grphead[data-in="1"]{color:var(--accent-ink)} -nav.dash .grpitems{overflow:hidden} -nav.dash .grphead[aria-expanded="false"]+.grpitems{display:none} -nav.dash .grpitems button{padding-inline-start:30px;font-size:14px} .ni{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;opacity:.85} nav.dash button[aria-current="page"] .ni{opacity:1} .ver{display:flex;justify-content:space-between;gap:8px;padding:6px 16px 8px;font-size:11.5px;color:var(--ink-3);border-bottom:1px solid var(--side-line);font-variant-numeric:tabular-nums} @@ -95,18 +83,28 @@ nav.dash button[aria-current="page"] .ni{opacity:1} .self .live i{width:8px;height:8px;border-radius:50%;background:currentColor} .self .live.ok{color:var(--good)}.self .live.idle{color:var(--ink-3)} .foot{display:flex;flex-direction:column;gap:8px;padding:0 16px 14px} -.sponsor{display:flex;flex-direction:column;gap:4px} +/* The sponsor strip of an unregistered installation: the full width of the + 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. */ +.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)} -.sponsor .sp-text{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-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-track{position:relative;aspect-ratio:728/90;width:100%;border-radius:6px;overflow:hidden;background:var(--surface);border:1px solid var(--line-2)} -.spc-slide{position:absolute;inset:0;opacity:0;transition:opacity .45s ease;pointer-events:none;display:block} -.spc-slide.on{opacity:1;pointer-events:auto} -.spc-slide img{width:100%;height:100%;object-fit:cover;display:block} -.spc-dots{display:flex;gap:6px;justify-content:center} +.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>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} .spc-dot{width:6px;height:6px;padding:0;border-radius:50%;border:0;cursor:pointer;background:var(--line-2)} .spc-dot.on{background:var(--accent)} +@media print{.sponsor{display:none}} .foot .pick{display:grid;grid-template-columns:1fr auto;gap:6px} #theme{display:flex;align-items:center;gap:6px;border:1px solid var(--line-2);border-radius:6px;padding:4px 10px;background:var(--surface);color:var(--ink);cursor:pointer;font-size:13px;white-space:nowrap} #theme i{width:12px;height:12px;border-radius:50%;background:var(--accent);box-shadow:inset 0 0 0 2px var(--surface),0 0 0 1px var(--line-2)} @@ -467,6 +465,9 @@ body.mustchange #nav,body.mustchange #acct,body.mustchange .ifsel{display:none} .licbadge{display:block;margin:8px 14px;text-align:center;font-size:12.5px;font-weight:600;text-decoration:none; padding:3px 9px;border-radius:999px;border:1px solid var(--line);color:var(--muted); background:var(--surface);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +/* who the licence is made out to, under the word: a long company name is + cut rather than widening the sidebar */ +.licbadge .who{display:block;font-weight:500;font-size:11.5px;opacity:.85;overflow:hidden;text-overflow:ellipsis} .licbadge:hover{border-color:var(--accent);color:var(--accent)} .licbadge.unregistered,.licbadge.expired{border-color:var(--warn,#b9770e);color:var(--warn,#b9770e)} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 5ee270b..40f97ff 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -2,17 +2,14 @@ 'use strict'; // ------------------------------------------------------------ the evaluation mark -// Every page an unregistered installation might hand to somebody else -// carries the mark; these three do not. The overview is the first thing a -// new installation sees and the mark would be the first thing it says, and -// the settings and the account are a person working on their own machine -// rather than a page anybody reads twice. +// Every page of an unregistered installation carries the mark, with no +// exceptions. The overview, the sources, the account and the help page +// used to be left out as pages nobody hands on; but a screenshot of the +// overview is exactly what gets pasted into a report, and a list of +// exceptions is a list somebody has to keep right as pages are added. // -// It is a list of what is NOT marked on purpose: a page added later is -// marked unless somebody decides otherwise, which is the right way round — -// the cost of forgetting is then an extra mark, not a report that leaves -// the building looking finished when it is not. -const NOMARK = new Set(['overview', 'sources', 'account', 'help']); +// The login screen is not one of these views, so it is never marked: +// nobody has been shown anything yet. let marked = false; // from the licence: true until the installation is registered let markNotice = ''; // the server's wording, so a copy says what a file says @@ -26,10 +23,14 @@ function markTile() { // read here and written into the tile instead, which is also why the // tile is rebuilt whenever the theme changes. const ink = getComputedStyle(document.body).getPropertyValue('--ink-3').trim() || '#8a8a8a'; - // One large glyph per tile rather than many small ones: the tile is - // repeated across the whole view. - const svg = `` + - `` + + `${esc(text)}`; return `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}")`; @@ -37,7 +38,7 @@ function markTile() { // applyMark decides whether the view on screen carries the mark. function applyMark() { - const on = marked && !NOMARK.has(state.v); + const on = marked; document.body.classList.toggle('marked', on); if (on) document.body.style.setProperty('--mark', markTile()); markChip(on); @@ -162,7 +163,7 @@ async function loadLang(code) { try { localStorage.setItem('t66.lang', code); } catch (e) {} regionNames = null; document.querySelectorAll('[data-i]').forEach(el => { el.textContent = t(el.dataset.i); }); - $('#refresh').textContent = t('top.refresh'); + refreshLabel(); themeLabel(); } // the theme button shows the current theme; a click goes to the next one @@ -313,8 +314,22 @@ async function resolveNames() { } // ------------------------------------------------------------ state & API -const VIEWS = ['help', 'account', 'overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'racks', 'devices', 'ipam', 'cleanup', 'sandbox', 'ifaces', 'billing', 'names', 'sources', 'detail']; +const VIEWS = ['help', 'account', 'overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'racks', 'devices', 'cleanup', 'sandbox', 'ifaces', 'billing', '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 +// buckets the range is drawn in: a 15-minute or 1-hour range is drawn per +// minute, so half a minute is as often as it can say anything new, while a +// 30-day range is drawn in three-hour buckets and a redraw every five +// minutes is already generous. A range the user typed ends in the past and +// never changes, so it has no beat at all. +const REFRESH_MS = {'15m': 20000, '1h': 30000, '6h': 60000, '24h': 60000, '7d': 300000, '30d': 300000}; + +// The pages that show incoming data. The others (help, the account, the +// asset and name pages, data cleanup, the sandbox, the bill) change when +// somebody changes them, not on their own. +const LIVE_VIEWS = new Set(['overview', 'findings', 'topn', 'traffic', 'detail', 'sankey', 'geo', 'threats', 'records', 'ifaces', 'sources']); + const state = {v: 'overview', r: '24h', f: [], dim: 'conv', ifc: null, ifdir: 'both', sort: {k: 'wire', asc: false}, det: null}; function readHash() { const p = new URLSearchParams(location.hash.slice(1)); @@ -322,7 +337,7 @@ function readHash() { if (RANGES.includes(p.get('r'))) state.r = p.get('r'); if (p.get('r') === 'custom' && +p.get('from') > 0 && +p.get('to') > +p.get('from')) { state.r = 'custom'; state.cf = +p.get('from'); state.ct = +p.get('to'); } if (p.get('dim')) state.dim = p.get('dim'); - state.tm = p.get('tm') === 'talkers' ? 'talkers' : 'table'; // Top 66 opens on the table + state.tm = p.get('tm') === 'table' ? 'table' : 'talkers'; // Top 66 opens on the two ends state.sk = ['segment', 'conv'].includes(p.get('by')) ? p.get('by') : 'host'; state.ra = p.get('ra') === 'client' ? 'client' : 'server'; state.ds = p.get('ds') === 'sb' ? 'sb' : ''; @@ -339,7 +354,7 @@ function writeHash(push) { const p = new URLSearchParams({v: state.v, r: state.r}); if (state.r === 'custom') { p.set('from', state.cf); p.set('to', state.ct); } if (state.ds) p.set('ds', state.ds); - if (state.v === 'topn') { if (state.tm === 'talkers') p.set('tm', 'talkers'); else p.set('dim', state.dim); } + if (state.v === 'topn') { if (state.tm === 'table') { p.set('tm', 'table'); p.set('dim', state.dim); } } if (state.v === 'sankey' && state.sk !== 'host') p.set('by', state.sk); if (state.v === 'records' && state.rl) p.set('live', '1'); if (state.ifv && state.ifv !== 'all' && IFV_VIEWS.has(state.v)) p.set('if', state.ifv); @@ -408,7 +423,7 @@ 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 === 'billing') return {bills: []}; + 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 @@ -1116,7 +1131,7 @@ function seriesLabel(by, k) { if (k === '__other__') return t('other'); if (by === 'client' || by === 'server') return names.get(k) || k; if (by === 'service') { const [p, a] = k.split('\t'); return a ? p + ' ' + a : p; } - if (by.startsWith('asn')) { const [n, o] = k.split('\t'); return 'AS' + n + (o ? ' ' + o : ''); } + if (by.startsWith('asn')) { const [n, o] = k.split('\t'); return n === '0' ? t('unknown') : 'AS' + n + (o ? ' ' + o : ''); } if (by.startsWith('if_')) return ifaceNames.get(k) || k; return appLabel(k); } @@ -1482,8 +1497,11 @@ function drawMap(el, w, rows, gl) { // over the lines' own range, cut into 15 equal steps), not by quantiles: // a few huge flows then do not flatten the rest, and equal traffic always // gets an equal level. Level l -> width LINE_W[floor(l/5)], shade l%5. +// The three widths are close together on purpose: a widest line several +// times the narrowest one dominated the map and the shades stopped being +// read at all, so the step carries less of the difference than the colour. // The palette follows the theme unless one is picked in the legend. -const LINE_W = [1.5, 4, 8]; +const LINE_W = [1.5, 3, 4.5]; const LINE_PAL = { gb: ['#7fd6a0', '#3fb8a0', '#1f97b5', '#1f6fb8', '#163f8f'], or: ['#ffc266', '#ff9a3c', '#f26a2e', '#d63a2a', '#9e1622'], @@ -1683,7 +1701,7 @@ const cleanupDaysUI = [120, 90, 60, 30, 7]; // 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, subnet: 0}; +const assetSel = {site: 0, rack: 0, device: 0}; async function loadAssets() { const res = await fetch('/api/assets'); @@ -1743,12 +1761,9 @@ const ASSET_FIELDS = { 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'], ['site_id', 'site'], + subnet: [['prefix', 'text', 1], ['name', 'text'], ['vlan', 'num'], ['device_id', 'device'], ['gateway', 'text'], ['tags', 'tags'], ['note', 'area'], ['hidden', 'check']], - addr: [['ip', 'text', 1], ['name', 'text'], ['kind', 'akind'], ['device_id', 'device'], ['port_id', 'port'], - ['mac', 'text'], ['tags', 'tags'], ['note', 'area'], ['hidden', 'check']], }; -const ADDR_KINDS = ['static', 'dhcp', 'reserved', 'gateway', 'vip']; function assetOptions(type, cur) { const none = ``; @@ -1757,7 +1772,6 @@ function assetOptions(type, cur) { 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(''); - if (type === 'akind') return ADDR_KINDS.map(k => opt(k, t('as.kind_' + k))).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; @@ -1777,7 +1791,7 @@ function assetEdit(kind, rec) { if (type === 'tags') return `
${lab}
`; if (type === 'num') return `
${lab}
`; if (type === 'day') return `
${lab}
`; - if (['site', 'rack', 'device', 'port', 'akind'].includes(type)) return `
${lab}
`; + if (['site', 'rack', 'device', 'port'].includes(type)) return `
${lab}
`; return `
${lab}
`; }; const dlg = document.createElement('dialog'); @@ -1834,7 +1848,7 @@ const quotaLine = (have, limit) => limit > 0 ? `${nf(have)} / ${nf(limit)}` : nf // 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', 'ipam', 'names']); +const ASSET_VIEWS = new Set(['racks', 'devices']); // goDevice opens one device's own page. function goDevice(id) { assetSel.device = id; go('devices'); } @@ -1948,6 +1962,7 @@ views.devices = async (el) => { $('#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}
` : ''; @@ -1967,6 +1982,7 @@ views.devices = async (el) => { ${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(); }; @@ -1980,8 +1996,31 @@ views.devices = async (el) => { }); 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(); + }); + 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) { @@ -2134,70 +2173,6 @@ function wireCSV() { } // ------------------------------------------------------------- addresses -views.ipam = async (el) => { - const d = await assetPage(el, 'nav.ipam', 'as.what_ipam'); - if (!d) return; - const res = await fetch('/api/assets/ipam'); - const ip = await res.json().catch(() => ({})); - if (!res.ok) { el.innerHTML = `
${panel('c12', t('nav.ipam'), '', `
${esc(ip.error || res.statusText)}
`)}
`; return; } - const subs = ip.subnets || [], conflicts = ip.conflicts || []; - if (assetSel.subnet && !subs.some(s => s.id === assetSel.subnet)) assetSel.subnet = 0; - const sel = subs.find(s => s.id === assetSel.subnet) || null; - const [addrs, free] = await Promise.all([ - sel ? fetch(`/api/assets/addrs?subnet=${sel.id}`).then(r => r.json()).then(j => j.addrs || []) : Promise.resolve([]), - sel ? fetch(`/api/assets/free?subnet=${sel.id}&n=8`).then(r => r.json()).then(j => j.free || []) : Promise.resolve([]), - ]); - const fill = s => s.hosts > 0 ? Math.min(100, Math.round(s.assigned / s.hosts * 100)) : 0; - const subRow = s => ` - ${s.hidden ? `${esc(t('as.f_hidden'))}` : ''} - ${(s.tags || []).map(tg => `${esc(tg)}`).join('')} - ${esc(s.name || '')} - ${s.vlan ? nf(s.vlan) : ''} - ${esc(d.by.site.get(s.site_id)?.name || '')} - ${nf(s.assigned)}${s.hosts >= 0 ? ' / ' + nf(s.hosts) : ''} - ${s.hosts >= 0 ? bar(s.assigned, s.hosts, fill(s) > 90 ? 'var(--crit)' : fill(s) > 75 ? 'var(--warn)' : '') : `${esc(t('as.too_large'))}`} - ${adminOnly(` - `)}`; - const addrRow = a => `${esc(a.ip)}${a.hidden ? ` ${esc(t('as.f_hidden'))}` : ''} - ${esc(a.name || '')} - ${esc(t('as.kind_' + (a.kind || 'static')))} - ${esc(d.by.device.get(a.device_id)?.name || '')} - ${esc(a.mac || '')} - ${(a.tags || []).map(tg => `${esc(tg)}`).join('')} - ${adminOnly(` - `)}`; - el.innerHTML = `
- ${panel('c12', t('nav.ipam'), '', `

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

- - - ${subs.map(subRow).join('') || ``}
${esc(t('as.f_prefix'))}${esc(t('as.f_name'))}${esc(t('as.f_vlan'))}${esc(t('as.f_site_id'))}${esc(t('as.assigned'))}
${esc(t('as.no_subnets'))}
- ${adminOnly(`
`)}`)} - ${sel ? panel('c12', t('as.addrs_of', {x: sel.prefix}), sel.hosts >= 0 ? t('as.free_count', {n: nf(sel.free)}) : '', - `${free.length ? `

${esc(t('as.next_free'))}: ${free.map(f => ``).join('')}

` : ''} - - - ${addrs.map(addrRow).join('') || ``}
${esc(t('as.f_ip'))}${esc(t('as.f_name'))}${esc(t('as.f_kind'))}${esc(t('as.f_device_id'))}${esc(t('as.f_mac'))}${esc(t('as.f_tags'))}
${esc(t('as.no_addrs'))}
- ${adminOnly(`
`)}`) : ''} - ${conflicts.length ? panel('c12', t('as.conflicts'), t('as.conflicts_sub'), - ` - ${conflicts.map(c => ``).join('')}
${esc(t('as.f_ip'))}${esc(t('as.what'))}${esc(t('as.detail'))}
${esc(c.ip)}${esc(t('as.cf_' + c.kind))}${esc(c.detail)}
`) : ''} -
`; - el.querySelectorAll('[data-sub]').forEach(b => b.onclick = () => { assetSel.subnet = assetSel.subnet === +b.dataset.sub ? 0 : +b.dataset.sub; render(); }); - el.querySelectorAll('[data-esub]').forEach(b => b.onclick = async () => { if (await assetEdit('subnet', {...subs.find(s => s.id === +b.dataset.esub)})) render(); }); - el.querySelectorAll('[data-dsub]').forEach(b => b.onclick = async () => { - const s = subs.find(x => x.id === +b.dataset.dsub); - if (await assetRemove('subnet', s, s.prefix)) render(); - }); - el.querySelectorAll('[data-eaddr]').forEach(b => b.onclick = async () => { if (await assetEdit('addr', {...addrs.find(a => a.id === +b.dataset.eaddr)})) render(); }); - el.querySelectorAll('[data-daddr]').forEach(b => b.onclick = async () => { - const a = addrs.find(x => x.id === +b.dataset.daddr); - if (await assetRemove('addr', a, a.ip)) render(); - }); - el.querySelectorAll('[data-free]').forEach(b => b.onclick = async () => { if (await assetEdit('addr', {ip: b.dataset.free, kind: 'static'})) render(); }); - const addS = $('#asAddSub'); if (addS) addS.onclick = async () => { if (await assetEdit('subnet', {})) render(); }; - const addA = $('#asAddAddr'); if (addA) addA.onclick = async () => { if (await assetEdit('addr', {ip: free[0] || '', kind: 'static'})) render(); }; -}; - // ------------------------------------------------------------ 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. @@ -2510,6 +2485,33 @@ const billPeriod = () => { 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([ @@ -2562,8 +2564,29 @@ views.billing = async (el) => { ${b.marked ? admOnly(``) : ''}`; }).join('')}

${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'); @@ -2621,6 +2644,7 @@ views.sources = async (el) => { ${(d.stale || []).length ? `

${t('src.stale')} ${d.stale.map(x => `${esc(x.name || x.exporter)} / ${x.domain} · ${esc(x.proto)} · ${esc(ago(Date.parse(x.last_seen)))}`).join(' ')}

` : ''} ${snmp ? `

${t('src.snmp')} ${snmp}

` : ''}`)} ${admOnly(panel('c12', t('lic.install'), t('lic.install_sub'), `
+ ${lic?.registered ? `

${esc(t('lic.who'))}: ${esc(lic.customer || '—')} ${esc(licenseeText(lic))}${lic.tier ? ` ${esc(lic.tier)}` : ''}

` : ''}

${esc(t('lic.id', {id: lic?.installation_id || '—'}))}

@@ -2886,20 +2910,28 @@ 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', 'ipam', 'billing', 'names']); +const NOBAR_VIEWS = new Set(['help', 'sources', 'cleanup', 'sandbox', 'account', 'racks', 'devices', 'billing', 'names']); function renderBar() { const off = NOBAR_VIEWS.has(state.v) || (state.v === 'records' && state.rl); if (off) $('#range').hidden = true; - $('#refresh').hidden = off || $('#range').hidden; + $('#refresh').hidden = off || $('#range').hidden || !REFRESH_MS[state.r]; + refreshLabel(); $('#share').hidden = off; } +// The label says the beat this range actually redraws on, rather than one +// number for every range; a range that ends in the past has no beat and +// the label is hidden with it. +function refreshLabel() { + const beat = REFRESH_MS[state.r]; + $('#refresh').textContent = beat ? t('top.refresh', {n: beat / 1000}) : ''; +} // The sandbox bar replaces the time range while capture files are shown. function renderSB() { - const on = inSB() && !['ifaces', 'sources', 'racks', 'devices', 'ipam'].includes(state.v); + const on = inSB() && !['ifaces', 'sources', 'racks', 'devices'].includes(state.v); $('#sbbar').hidden = !on; $('#range').hidden = on; $('#refresh').hidden = on; - document.querySelectorAll('#nav [data-v=ifaces], #nav [data-v=sources], #nav [data-v=cleanup], #nav .navgrp').forEach(b => b.hidden = inSB() || offlineMode); + document.querySelectorAll('#nav [data-v=ifaces], #nav [data-v=sources], #nav [data-v=cleanup], #nav [data-v=racks], #nav [data-v=names]').forEach(b => b.hidden = inSB() || offlineMode); if (!on) return; const r = sbRange(), span = r.to - r.from; const df = new Intl.DateTimeFormat(LANG, {dateStyle: 'medium', timeStyle: 'short'}), tf = new Intl.DateTimeFormat(LANG, {timeStyle: 'short'}); @@ -2932,6 +2964,8 @@ async function loadSB() { if (offlineMode && sbInfo.ready) state.ds = 'sb'; // nothing else to show } +// When the page on screen was last drawn, for the refresh beat below. +let drawnAt = 0; async function render(push) { // the default password of a new installation is changed before anything else if (me?.must_change) state.v = 'account'; @@ -2944,10 +2978,10 @@ 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')); - // a group whose page is open says so even when it is folded away - document.querySelectorAll('#nav .navgrp').forEach(g => { - const inside = [...g.querySelectorAll('[data-v]')].some(b => b.dataset.v === state.v) || (g.id === 'grpAssets' && ASSET_VIEWS.has(state.v)); - g.querySelector('.grphead').dataset.in = inside ? '1' : '0'; + // 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(); @@ -2960,10 +2994,16 @@ async function render(push) { } catch (e) { if (e.message !== 'login' && seq === loadSeq) el.innerHTML = errorBox(e); } - if (seq === loadSeq) resolveNames(); + if (seq === loadSeq) { drawnAt = Date.now(); resolveNames(); } } function go(v) { state.v = v; render(true); window.scrollTo(0, 0); } +// What a licence says it is made out to: a person, an organisation, or — +// for one issued before licences carried it — nothing, which is said as +// "not stated" rather than guessed at. +function licenseeText(L) { + return t(L.licensee === 'individual' ? 'lic.as_ind' : L.licensee === 'organization' ? 'lic.as_org' : 'lic.as_unknown'); +} async function loadStatus() { try { const res = await fetch('/api/status'); @@ -2984,16 +3024,22 @@ async function loadStatus() { else if (L.kind === 'expired') head = t('lic.exp', {c: L.customer, d: L.expires}); else head = t('lic.unreg'); const tail = L.registered ? '' : `
${esc(t('lic.free'))} ${esc(t('lic.mark'))} `; - $('#lic').innerHTML = `${esc(head)}${tail ? tail : ' '}${id}`; + // registered to whom, and as what: a licence bought by one person is + // not a company's, and the footer is where a screenshot shows it + const as = L.registered ? ` ${esc(licenseeText(L))}` : ''; + $('#lic').innerHTML = `${esc(head)}${as}${tail ? tail : ' '}${id}`; $('#lic').className = 'lic ' + L.kind; $('#lic').hidden = false; marked = !!L.watermark; markNotice = L.notice || ''; applyMark(); const b = $('#licBadge'); - b.textContent = L.registered ? (L.days < 0 ? t('lic.badge_ok') : t('lic.badge_days', {n: L.days})) : t('lic.badge_get'); + const bt = L.registered ? (L.days < 0 ? t('lic.badge_ok') : t('lic.badge_days', {n: L.days})) : t('lic.badge_get'); + // the badge carries the name under the word, so the sidebar says who + // this installation belongs to and not only that it is licensed + b.innerHTML = esc(bt) + (L.registered && L.customer ? `${esc(L.customer)}` : ''); b.className = 'licbadge ' + L.kind; - b.title = head; + b.title = head + (L.registered ? ' · ' + licenseeText(L) : ''); b.hidden = false; } $('#verNo').textContent = 'v' + s.version + (s.build && s.build !== 'official' ? ' · ' + s.build : ''); @@ -3062,13 +3108,24 @@ function startTimers() { if (document.activeElement?.closest?.('.cols, #recPer')) return; render(); }, 5000); + // Every live page, on a beat that follows the range. The old rule named + // four pages and two ranges, which left the traffic, Top 66, flow paths, + // geo and interface pages standing still on a 6-hour or a 24-hour range + // until somebody clicked something: the records were arriving and the + // numbers were right, and the page was simply old. + // + // The beat is checked every few seconds and a page is redrawn once what + // is on it is older than its range's beat, so changing the range takes + // effect at once instead of at the end of a cycle somebody cannot see. setInterval(() => { // not while something is being typed or picked (the names form, filters) if (document.hidden || pop.style.display === 'block' || ['TEXTAREA', 'INPUT', 'SELECT'].includes(document.activeElement?.tagName)) return; - if (state.v === 'records' && state.rl) return; - if (state.v === 'help') return; - if (state.v === 'sources' || state.v === 'records' || state.v === 'overview' || state.v === 'findings' || state.r === '15m' || state.r === '1h') render(); - }, 30000); + if (state.v === 'records' && state.rl) return; // its own, faster beat + if (!LIVE_VIEWS.has(state.v)) return; + const beat = REFRESH_MS[state.r]; + if (!beat || Date.now() - drawnAt < beat) return; // a range in the past never moves + render(); + }, 5000); } $('#logout').onclick = async () => { await fetch('/api/logout', {method: 'POST'}); me = null; showLogin(); }; $('#acct').onclick = () => go('account'); @@ -3091,18 +3148,11 @@ async function init() { }; document.addEventListener('click', e => { const p = $('#rangePop'); if (p && !p.hidden && !e.target.closest('#rangePop')) p.hidden = true; }); $('#nav').onclick = e => { - const g = e.target.closest('[data-grp]'); - if (g) { const open = g.getAttribute('aria-expanded') !== 'true'; g.setAttribute('aria-expanded', open); try { localStorage.setItem('grp.' + g.dataset.grp, open ? '1' : '0'); } catch (_) {} return; } const b = e.target.closest('[data-v]'); if (!b) return; if (b.dataset.v === 'records') state.rl = true; go(b.dataset.v); }; - document.querySelectorAll('#nav [data-grp]').forEach(g => { - let open = '1'; - try { open = localStorage.getItem('grp.' + g.dataset.grp) ?? '1'; } catch (_) {} - g.setAttribute('aria-expanded', open === '1'); - }); $('#share').onclick = () => { navigator.clipboard?.writeText(location.href).then(() => toast(t('top.copied')), () => toast(location.href)); }; document.addEventListener('click', e => { if (!e.target.closest('.cols')) { const m = $('#colMenu'); if (m) m.hidden = true; } }); readHash(); diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index ddcf6b9..67e0ff2 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -15,7 +15,7 @@ "self.dropped": "المُسقَط", "self.disk": "القرص", "top.search": "ابحث عن IP أو شبكة أو منفذ أو دولة (اختياري)", -"top.refresh": "يتحدّث كل 30 ثانية", +"top.refresh": "يتحدّث كل {n} ثانية", "top.copy": "نسخ الرابط", "top.copied": "تم نسخ الرابط", "top.logout": "تسجيل الخروج", @@ -495,19 +495,14 @@ "as.f_prefix": "الشبكة", "as.f_vlan": "VLAN", "as.f_gateway": "البوابة", -"as.f_ip": "عنوان IP", "as.f_device_id": "الجهاز", -"as.f_mac": "MAC", "nav.racks": "المواقع والخزانات", "nav.devices": "الأجهزة والمنافذ", -"nav.ipam": "عناوين IP", -"nav.assets": "إدارة الأصول", "as.device": "جهاز", "as.devices": "الأجهزة", "as.devices_sub": "انقر على أحدها لعرض منافذه وتوصيلاته", "as.what_racks": "أين تقف المعدات: المواقع والخزانات وما يشغل كل وحدة. كل ما هنا مُدوَّن بأيدي الناس لا مُكتشَفًا آليًا — ولذلك حين يختلف السجل عن الشبكة يظل السجل يقول ما كان ينبغي أن يكون هناك.", "as.what_devices": "ما هو الجهاز وبماذا يتصل: المُصنّع والطراز والرقم التسلسلي وعنوان الإدارة، وكل منفذ ومعه المنفذ في الطرف الآخر من الكابل.", -"as.what_ipam": "ما الشبكات الموجودة ولمن كل عنوان. يعرض مدى امتلاء الشبكة، ويقترح العناوين الحرة التالية، ويبلّغ عمّا لا يستقيم.", "as.locked": "يتطلب هذا نسخة مسجَّلة. كل ما دُوِّن من قبل يبقى، ويعود متاحًا فور تثبيت الترخيص.", "as.none": "—", "as.cancel": "إلغاء", @@ -527,8 +522,6 @@ "as.edit_circuit": "تعديل الدائرة", "as.add_subnet": "إضافة شبكة", "as.edit_subnet": "تعديل الشبكة", -"as.add_addr": "تخصيص عنوان", -"as.edit_addr": "تعديل التخصيص", "as.hidden_hint": "لا يرى هذا السجل إلا المسؤول.", "as.tags_hint": "core, prod, edge", "as.racks": "الخزانات", @@ -536,8 +529,9 @@ "as.no_racks": "لا خزانات بعد.", "as.no_devices": "لا أجهزة بعد.", "as.no_ports": "لا منافذ بعد.", +"as.subnets_of": "الشبكات على {x}", +"as.subnets_sub": "تُكتب بجانب المنافذ: ما يحمله هذا الجهاز. لا يُبحث هنا عن شيء — لا رسم بياني ولا اسم ولا مرشِّح يأتي من هذا الجدول.", "as.no_subnets": "لا شبكات بعد.", -"as.no_addrs": "لا تخصيصات بعد.", "as.c_sites": "المواقع {x}", "as.c_racks": "الخزانات {x}", "as.c_devices": "الأجهزة {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "موصول بـ", "as.cable_title": "توصيل {x}", "as.no_cable": "بلا كابل", -"as.kind_static": "ثابت", -"as.kind_dhcp": "مجال DHCP", -"as.kind_reserved": "محجوز", -"as.kind_gateway": "بوابة", -"as.kind_vip": "افتراضي (VIP)", -"as.assigned": "مُخصَّصة", -"as.too_large": "أكبر من أن تُحصى", -"as.addrs_of": "عناوين {x}", -"as.free_count": "{n} حرة", -"as.next_free": "التالية الحرة", -"as.conflicts": "ما لا يستقيم", -"as.conflicts_sub": "مقروء من السجل وحده، فالجواب نفسه دون اتصال", -"as.cf_outside": "خارج كل شبكة مُسجَّلة", -"as.cf_duplicate_mac": "MAC واحد على عدة عناوين", "as.what": "ماذا", "as.detail": "التفصيل", "as.paste_title": "لصق مخرجات محوّل", "as.paste_sub": "ما يطبعه الجهاز أصلًا", -"as.paste_what": "نفّذ الأمر الذي تنفّذه أصلًا والصق ما طبعه: show lldp neighbors، show cdp neighbors، show interface status، show ip arp، show mac address-table. لا يُثبَّت شيء على الجهاز ولا يُحفَظ أي community أو كلمة مرور في أي مكان. الصق جدول ARP قبل جدول عناوين MAC: معًا يقولان في أي منفذ يتصل المضيف.", +"as.paste_what": "شغّل الأمر الذي تستخدمه أصلاً والصق ما طبعه: show lldp neighbors و show cdp neighbors و show interface status. لا يُثبَّت شيء على الجهاز ولا تُحفَظ أي سلسلة مجتمع أو كلمة مرور في أي مكان.", "as.paste_from": "نُفِّذ على", "as.paste_hint": "الصق المخرجات هنا", "as.preview": "اعرض ما سيحدث", @@ -597,7 +577,6 @@ "as.kind_of_link": "كابل", "as.kind_of_circuit": "دائرة", "as.kind_of_subnet": "شبكة", -"as.kind_of_addr": "عنوان", "nm.k_host": "مضيف", "nm.k_net": "شبكة", "nm.k_device": "جهاز", @@ -635,6 +614,10 @@ "nm.country_none": "الدولة/المنطقة/المنطقة (اختياري)", "lic.free": "يظل تحليل الحركة والكشف يعملان بالكامل، ولا تُحذف أي بيانات؛ أما الميزات التي تتطلب التسجيل فمُعلَّمة في أماكنها.", "lic.ok": "مرخّص لـ {c}: بقي {n} يوماً", +"lic.who": "مرخّص لـ", +"lic.as_ind": "فرد", +"lic.as_org": "مؤسسة", +"lic.as_unknown": "النوع غير مذكور", "lic.id": "رقم التثبيت {id}", "lic.unreg": "غير مُسجَّل.", "lic.exp": "انتهى ترخيص {c} في {d}.", @@ -736,6 +719,8 @@ "bill.avg": "المتوسط", "bill.coverage": "التغطية", "bill.empty": "لا توجد عدادات واجهات في هذه الفترة. الرقم يأتي من العدادات التي يبلّغ عنها الجهاز، لا من الحركة المأخوذة بالمعاينة.", +"bill.p95": "المئين 95", +"bill.chart_sub": "عيّنات كل خمس دقائق؛ الخط المتقطع هو القيمة المفوترة", "bill.gap_note": "فترات الخمس دقائق التي لا قياس فيها تُستبعد ولا تُحسب صفراً. حسابها صفراً يخفض الرقم. والتغطية تبيّن على أي جزء من الفترة يستند.", "bill.how": "كيف يُحسب الرقم", "bill.how_note": "يُرتَّب الوارد والصادر كلٌّ على حدة خلال الفترة، وتُستبعد أعلى خمسة بالمئة من عيّنات الخمس دقائق، ويُحسب أعلى ما بقي — الأكبر من الاتجاهين. وتُحسب الأشهر بالتوقيت المحلي لهذا المتصفّح.", diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index a58d303..767202e 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -15,7 +15,7 @@ "self.dropped": "বাদ পড়েছে", "self.disk": "ডিস্ক", "top.search": "IP, নেটওয়ার্ক, পোর্ট, দেশ খুঁজুন (ঐচ্ছিক)", -"top.refresh": "প্রতি ৩০ সেকেন্ডে হালনাগাদ", +"top.refresh": "প্রতি {n} সেকেন্ডে হালনাগাদ", "top.copy": "লিংক কপি করুন", "top.copied": "লিংক কপি হয়েছে", "top.logout": "লগ আউট", @@ -495,19 +495,14 @@ "as.f_prefix": "নেটওয়ার্ক", "as.f_vlan": "VLAN", "as.f_gateway": "গেটওয়ে", -"as.f_ip": "IP ঠিকানা", "as.f_device_id": "ডিভাইস", -"as.f_mac": "MAC", "nav.racks": "সাইট ও র‍্যাক", "nav.devices": "ডিভাইস ও পোর্ট", -"nav.ipam": "IP ঠিকানা", -"nav.assets": "সম্পদ ব্যবস্থাপনা", "as.device": "ডিভাইস", "as.devices": "ডিভাইস", "as.devices_sub": "পোর্ট ও কেবলিং দেখতে যেকোনো একটিতে ক্লিক করুন", "as.what_racks": "যন্ত্রপাতি কোথায় আছে: সাইট, ক্যাবিনেট, এবং কোন ইউনিটে কী আছে। এখানকার সবই হাতে লেখা, আবিষ্কার করা নয় — তাই এটি আর নেটওয়ার্ক যখন আলাদা কথা বলে, তখনও এটি বলে কী থাকার কথা ছিল।", "as.what_devices": "যন্ত্রটি কী এবং কীসের সঙ্গে যুক্ত: নির্মাতা, মডেল, সিরিয়াল নম্বর, ম্যানেজমেন্ট ঠিকানা, এবং প্রতিটি পোর্ট ও তার কেবলের অন্য প্রান্তের পোর্ট।", -"as.what_ipam": "কোন নেটওয়ার্কগুলি আছে এবং কোন ঠিকানা কার। নেটওয়ার্ক কতটা ভরা তা দেখায়, পরের খালি ঠিকানাগুলি প্রস্তাব করে, এবং যা মেলে না তা জানায়।", "as.locked": "এর জন্য নিবন্ধিত সংস্করণ দরকার। যা আগে লেখা হয়েছে তা থেকে যায়, এবং লাইসেন্স বসানোর সঙ্গে সঙ্গেই আবার ব্যবহারযোগ্য হয়।", "as.none": "—", "as.cancel": "বাতিল", @@ -527,8 +522,6 @@ "as.edit_circuit": "সার্কিট সম্পাদনা", "as.add_subnet": "নেটওয়ার্ক যোগ করুন", "as.edit_subnet": "নেটওয়ার্ক সম্পাদনা", -"as.add_addr": "ঠিকানা বরাদ্দ করুন", -"as.edit_addr": "বরাদ্দ সম্পাদনা", "as.hidden_hint": "এই রেকর্ড কেবল প্রশাসক দেখতে পান।", "as.tags_hint": "core, prod, edge", "as.racks": "র‍্যাক", @@ -536,8 +529,9 @@ "as.no_racks": "এখনও কোনও র‍্যাক নেই।", "as.no_devices": "এখনও কোনও ডিভাইস নেই।", "as.no_ports": "এখনও কোনও পোর্ট নেই।", +"as.subnets_of": "{x}-এর নেটওয়ার্ক", +"as.subnets_sub": "পোর্টের পাশে লেখা থাকে: এই যন্ত্র কোন নেটওয়ার্ক বহন করে। এখান থেকে কিছু খোঁজা হয় না — কোনো চার্ট, নাম বা ফিল্টার এই টেবিল থেকে আসে না।", "as.no_subnets": "এখনও কোনও নেটওয়ার্ক নেই।", -"as.no_addrs": "এখনও কিছু বরাদ্দ হয়নি।", "as.c_sites": "সাইট {x}", "as.c_racks": "র‍্যাক {x}", "as.c_devices": "ডিভাইস {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "যুক্ত আছে", "as.cable_title": "{x}-এর কেবল", "as.no_cable": "কেবল নেই", -"as.kind_static": "স্থির", -"as.kind_dhcp": "DHCP পুল", -"as.kind_reserved": "সংরক্ষিত", -"as.kind_gateway": "গেটওয়ে", -"as.kind_vip": "ভার্চুয়াল (VIP)", -"as.assigned": "বরাদ্দ", -"as.too_large": "গুনতে পারার পক্ষে বড্ড বড়", -"as.addrs_of": "{x}-এর ঠিকানা", -"as.free_count": "{n}টি খালি", -"as.next_free": "পরের খালি", -"as.conflicts": "যা মেলে না", -"as.conflicts_sub": "কেবল খাতা থেকে পড়া, তাই অফলাইনেও একই উত্তর", -"as.cf_outside": "কোনও নথিভুক্ত নেটওয়ার্কে নেই", -"as.cf_duplicate_mac": "একটি MAC একাধিক ঠিকানায়", "as.what": "কী", "as.detail": "বিবরণ", "as.paste_title": "সুইচ থেকে পেস্ট করুন", "as.paste_sub": "যন্ত্র যা ইতিমধ্যেই ছাপে", -"as.paste_what": "আপনি যে কমান্ড এমনিতেই চালান সেটিই চালিয়ে আউটপুট এখানে পেস্ট করুন: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table। যন্ত্রে কিছুই ইনস্টল হয় না, কোনও কমিউনিটি বা পাসওয়ার্ড কোথাও রাখা হয় না। MAC ঠিকানা টেবিলের আগে ARP টেবিল পেস্ট করুন: দুটি মিলে বলে কোন হোস্ট কোন পোর্টে লাগানো।", +"as.paste_what": "আপনি যে কমান্ড এমনিতেই চালান সেটি চালিয়ে আউটপুট এখানে পেস্ট করুন: show lldp neighbors, show cdp neighbors, show interface status। যন্ত্রে কিছু ইনস্টল করা হয় না এবং কোনো কমিউনিটি স্ট্রিং বা পাসওয়ার্ড কোথাও রাখা হয় না।", "as.paste_from": "কোন যন্ত্রে চালানো", "as.paste_hint": "আউটপুট এখানে পেস্ট করুন", "as.preview": "কী হবে দেখুন", @@ -597,7 +577,6 @@ "as.kind_of_link": "কেবল", "as.kind_of_circuit": "সার্কিট", "as.kind_of_subnet": "নেটওয়ার্ক", -"as.kind_of_addr": "ঠিকানা", "nm.k_host": "হোস্ট", "nm.k_net": "নেটওয়ার্ক", "nm.k_device": "ডিভাইস", @@ -635,6 +614,10 @@ "nm.country_none": "দেশ/অঞ্চল/অঞ্চল (ঐচ্ছিক)", "lic.free": "ট্রাফিক বিশ্লেষণ ও শনাক্তকরণ সম্পূর্ণভাবে কাজ করতে থাকে, এবং কোনও ডেটা মোছা হয় না; যেসব সুবিধার জন্য নিবন্ধন দরকার, সেগুলি নিজ নিজ জায়গায় চিহ্নিত থাকে।", "lic.ok": "{c}-কে লাইসেন্স: {n} দিন বাকি", +"lic.who": "লাইসেন্সপ্রাপ্ত", +"lic.as_ind": "ব্যক্তিগত", +"lic.as_org": "প্রতিষ্ঠান", +"lic.as_unknown": "ধরন উল্লেখ নেই", "lic.id": "ইনস্টলেশন {id}", "lic.unreg": "নিবন্ধিত নয়।", "lic.exp": "{c}-এর লাইসেন্স {d} তারিখে শেষ হয়েছে।", @@ -736,6 +719,8 @@ "bill.avg": "গড়", "bill.coverage": "আওতা", "bill.empty": "এই সময়কালে কোনো ইন্টারফেস কাউন্টার নেই। সংখ্যাটি ডিভাইসের জানানো কাউন্টার থেকে আসে, নমুনা করা ট্রাফিক থেকে নয়।", +"bill.p95": "৯৫তম পার্সেন্টাইল", +"bill.chart_sub": "পাঁচ মিনিটের নমুনা; ড্যাশ করা রেখা হলো বিল করা মান", "bill.gap_note": "মাপ নেই এমন পাঁচ মিনিটের খোপ বাদ দেওয়া হয়, শূন্য ধরা হয় না। শূন্য ধরলে সংখ্যাটি নেমে যেত। আওতা বলে সংখ্যাটি সময়কালের কতটুকুর উপর দাঁড়িয়ে।", "bill.how": "সংখ্যাটি কীভাবে আসে", "bill.how_note": "সময়কালে আগত ও নির্গত আলাদা করে সাজানো হয়, পাঁচ মিনিটের নমুনার উপরের পাঁচ শতাংশ ফেলে দেওয়া হয়, আর বাকিগুলোর সর্বোচ্চটি বিল হয় — দুই দিকের মধ্যে বড়টি। মাস গণনা হয় এই ব্রাউজারের সময় অঞ্চলে।", diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index 8122a3d..f1c0e9b 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -15,7 +15,7 @@ "self.dropped": "Verworfen", "self.disk": "Datenträger", "top.search": "IP, Netz, Port, Land suchen (optional)", -"top.refresh": "Aktualisierung alle 30 s", +"top.refresh": "Aktualisierung alle {n} s", "top.copy": "Link kopieren", "top.copied": "Link kopiert", "top.logout": "Abmelden", @@ -495,19 +495,14 @@ "as.f_prefix": "Netz", "as.f_vlan": "VLAN", "as.f_gateway": "Gateway", -"as.f_ip": "IP-Adresse", "as.f_device_id": "Gerät", -"as.f_mac": "MAC", "nav.racks": "Standorte & Racks", "nav.devices": "Geräte & Ports", -"nav.ipam": "IP-Adressen", -"nav.assets": "Bestandsverwaltung", "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.what_ipam": "Welche Netze es gibt und wem welche Adresse gehört. Zeigt, wie voll ein Netz ist, schlägt die nächsten freien Adressen vor und meldet, was nicht zusammenpasst.", "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", @@ -527,8 +522,6 @@ "as.edit_circuit": "Leitung bearbeiten", "as.add_subnet": "Netz anlegen", "as.edit_subnet": "Netz bearbeiten", -"as.add_addr": "Adresse vergeben", -"as.edit_addr": "Vergabe bearbeiten", "as.hidden_hint": "Diesen Eintrag sieht nur die Administration.", "as.tags_hint": "core, prod, edge", "as.racks": "Racks", @@ -536,8 +529,9 @@ "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.no_addrs": "Noch nichts vergeben.", "as.c_sites": "Standorte {x}", "as.c_racks": "Racks {x}", "as.c_devices": "Geräte {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Verbunden mit", "as.cable_title": "{x} verkabeln", "as.no_cable": "Kein Kabel", -"as.kind_static": "Statisch", -"as.kind_dhcp": "DHCP-Bereich", -"as.kind_reserved": "Reserviert", -"as.kind_gateway": "Gateway", -"as.kind_vip": "Virtuell (VIP)", -"as.assigned": "Vergeben", -"as.too_large": "zu groß zum Zählen", -"as.addrs_of": "Adressen in {x}", -"as.free_count": "{n} frei", -"as.next_free": "Nächste freie", -"as.conflicts": "Was nicht zusammenpasst", -"as.conflicts_sub": "Allein aus dem Verzeichnis gelesen, also offline dieselbe Antwort", -"as.cf_outside": "Außerhalb jedes erfassten Netzes", -"as.cf_duplicate_mac": "Eine MAC auf mehreren Adressen", "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, show ip arp, show mac address-table. Auf dem Gerät wird nichts installiert, und weder Community-String noch Passwort werden irgendwo gespeichert. Fügen Sie die ARP-Tabelle vor der MAC-Adresstabelle ein: zusammen sagen sie, an welchem Port ein Host steckt.", +"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", @@ -597,7 +577,6 @@ "as.kind_of_link": "Kabel", "as.kind_of_circuit": "Leitung", "as.kind_of_subnet": "Netz", -"as.kind_of_addr": "Adresse", "nm.k_host": "Host", "nm.k_net": "Netz", "nm.k_device": "Gerät", @@ -635,6 +614,10 @@ "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", +"lic.who": "Lizenziert für", +"lic.as_ind": "Privatperson", +"lic.as_org": "Organisation", +"lic.as_unknown": "Art nicht angegeben", "lic.id": "Installation {id}", "lic.unreg": "Nicht registriert.", "lic.exp": "Die Lizenz für {c} endete am {d}.", @@ -736,6 +719,8 @@ "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.", diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index 0da0d59..91042c9 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -15,7 +15,7 @@ "self.dropped": "Dropped", "self.disk": "Disk", "top.search": "Search IP, network, port, country/region (optional)", -"top.refresh": "Refreshes every 30 s", +"top.refresh": "Refreshes every {n} s", "top.copy": "Copy link", "top.copied": "Link copied", "top.logout": "Log out", @@ -495,19 +495,14 @@ "as.f_prefix": "Network", "as.f_vlan": "VLAN", "as.f_gateway": "Gateway", -"as.f_ip": "IP address", "as.f_device_id": "Device", -"as.f_mac": "MAC", "nav.racks": "Sites & racks", "nav.devices": "Devices & ports", -"nav.ipam": "IP addresses", -"nav.assets": "Asset management", "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.what_ipam": "Which networks exist and which address is whose. Shows how full a network is, suggests the next free addresses, and reports what does not add up.", "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", @@ -527,8 +522,6 @@ "as.edit_circuit": "Edit the circuit", "as.add_subnet": "Add a network", "as.edit_subnet": "Edit the network", -"as.add_addr": "Assign an address", -"as.edit_addr": "Edit the assignment", "as.hidden_hint": "Only the administrator sees this record.", "as.tags_hint": "core, prod, edge", "as.racks": "Racks", @@ -536,8 +529,9 @@ "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.no_addrs": "Nothing assigned yet.", "as.c_sites": "Sites {x}", "as.c_racks": "Racks {x}", "as.c_devices": "Devices {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Cabled to", "as.cable_title": "Cable {x}", "as.no_cable": "No cable", -"as.kind_static": "Static", -"as.kind_dhcp": "DHCP pool", -"as.kind_reserved": "Reserved", -"as.kind_gateway": "Gateway", -"as.kind_vip": "Virtual (VIP)", -"as.assigned": "Assigned", -"as.too_large": "too large to count", -"as.addrs_of": "Addresses in {x}", -"as.free_count": "{n} free", -"as.next_free": "Next free", -"as.conflicts": "What does not add up", -"as.conflicts_sub": "Read from the ledger alone, so it is the same answer offline", -"as.cf_outside": "Outside every recorded network", -"as.cf_duplicate_mac": "One MAC on several addresses", "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, show ip arp, show mac address-table. Nothing is installed on the device and no community string or password is stored anywhere. Paste the ARP table before the MAC address table: together they say which port a host is plugged into.", +"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", @@ -597,7 +577,6 @@ "as.kind_of_link": "Cable", "as.kind_of_circuit": "Circuit", "as.kind_of_subnet": "Network", -"as.kind_of_addr": "Address", "nm.k_host": "Host", "nm.k_net": "Network", "nm.k_device": "Device", @@ -635,6 +614,10 @@ "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", +"lic.who": "Licensed to", +"lic.as_ind": "Individual", +"lic.as_org": "Organization", +"lic.as_unknown": "Kind not stated", "lic.id": "Installation {id}", "lic.unreg": "Not registered.", "lic.exp": "The licence for {c} ended on {d}.", @@ -736,6 +719,8 @@ "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.", diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index 27a4060..fa98766 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -15,7 +15,7 @@ "self.dropped": "Descartados", "self.disk": "Disco", "top.search": "Buscar IP, red, puerto, país (opcional)", -"top.refresh": "Se actualiza cada 30 s", +"top.refresh": "Se actualiza cada {n} s", "top.copy": "Copiar enlace", "top.copied": "Enlace copiado", "top.logout": "Cerrar sesión", @@ -495,19 +495,14 @@ "as.f_prefix": "Red", "as.f_vlan": "VLAN", "as.f_gateway": "Puerta de enlace", -"as.f_ip": "Dirección IP", "as.f_device_id": "Equipo", -"as.f_mac": "MAC", "nav.racks": "Sedes y racks", "nav.devices": "Equipos y puertos", -"nav.ipam": "Direcciones IP", -"nav.assets": "Gestión de activos", "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.what_ipam": "Qué redes existen y de quién es cada dirección. Muestra lo llena que está una red, sugiere las siguientes direcciones libres e informa de lo que no cuadra.", "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", @@ -527,8 +522,6 @@ "as.edit_circuit": "Editar el circuito", "as.add_subnet": "Añadir red", "as.edit_subnet": "Editar la red", -"as.add_addr": "Asignar dirección", -"as.edit_addr": "Editar la asignación", "as.hidden_hint": "Solo la administración ve este registro.", "as.tags_hint": "core, prod, edge", "as.racks": "Racks", @@ -536,8 +529,9 @@ "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.no_addrs": "Aún no hay nada asignado.", "as.c_sites": "Sedes {x}", "as.c_racks": "Racks {x}", "as.c_devices": "Equipos {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Cableado a", "as.cable_title": "Cablear {x}", "as.no_cable": "Sin cable", -"as.kind_static": "Estática", -"as.kind_dhcp": "Rango DHCP", -"as.kind_reserved": "Reservada", -"as.kind_gateway": "Puerta de enlace", -"as.kind_vip": "Virtual (VIP)", -"as.assigned": "Asignadas", -"as.too_large": "demasiado grande para contar", -"as.addrs_of": "Direcciones de {x}", -"as.free_count": "{n} libres", -"as.next_free": "Siguientes libres", -"as.conflicts": "Lo que no cuadra", -"as.conflicts_sub": "Leído solo del inventario: la misma respuesta sin conexión", -"as.cf_outside": "Fuera de toda red registrada", -"as.cf_duplicate_mac": "Una MAC en varias direcciones", "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 ejecuta y pegue lo que imprimió: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table. No se instala nada en el equipo y no se guarda ninguna comunidad ni contraseña. Pegue la tabla ARP antes que la de direcciones MAC: juntas dicen en qué puerto está conectado un host.", +"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", @@ -597,7 +577,6 @@ "as.kind_of_link": "Cable", "as.kind_of_circuit": "Circuito", "as.kind_of_subnet": "Red", -"as.kind_of_addr": "Dirección", "nm.k_host": "Host", "nm.k_net": "Red", "nm.k_device": "Dispositivo", @@ -635,6 +614,10 @@ "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", +"lic.who": "Licencia a nombre de", +"lic.as_ind": "Particular", +"lic.as_org": "Organización", +"lic.as_unknown": "Tipo no indicado", "lic.id": "Instalación {id}", "lic.unreg": "Sin registrar.", "lic.exp": "La licencia de {c} terminó el {d}.", @@ -736,6 +719,8 @@ "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.", diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index b586a99..d1a5152 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -15,7 +15,7 @@ "self.dropped": "Rejetés", "self.disk": "Disque", "top.search": "Rechercher une IP, un réseau, un port, un pays (facultatif)", -"top.refresh": "Actualisé toutes les 30 s", +"top.refresh": "Actualisé toutes les {n} s", "top.copy": "Copier le lien", "top.copied": "Lien copié", "top.logout": "Se déconnecter", @@ -495,19 +495,14 @@ "as.f_prefix": "Réseau", "as.f_vlan": "VLAN", "as.f_gateway": "Passerelle", -"as.f_ip": "Adresse IP", "as.f_device_id": "Équipement", -"as.f_mac": "MAC", "nav.racks": "Sites et baies", "nav.devices": "Équipements et ports", -"nav.ipam": "Adresses IP", -"nav.assets": "Gestion du parc", "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.what_ipam": "Quels réseaux existent et à qui appartient chaque adresse. Montre le remplissage d'un réseau, propose les prochaines adresses libres et signale ce qui ne colle pas.", "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", @@ -527,8 +522,6 @@ "as.edit_circuit": "Modifier le lien", "as.add_subnet": "Ajouter un réseau", "as.edit_subnet": "Modifier le réseau", -"as.add_addr": "Attribuer une adresse", -"as.edit_addr": "Modifier l'attribution", "as.hidden_hint": "Seule l'administration voit cette fiche.", "as.tags_hint": "core, prod, edge", "as.racks": "Baies", @@ -536,8 +529,9 @@ "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.no_addrs": "Rien d'attribué pour l'instant.", "as.c_sites": "Sites {x}", "as.c_racks": "Baies {x}", "as.c_devices": "Équipements {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Raccordé à", "as.cable_title": "Raccorder {x}", "as.no_cable": "Aucun câble", -"as.kind_static": "Statique", -"as.kind_dhcp": "Plage DHCP", -"as.kind_reserved": "Réservée", -"as.kind_gateway": "Passerelle", -"as.kind_vip": "Virtuelle (VIP)", -"as.assigned": "Attribuées", -"as.too_large": "trop grand pour être compté", -"as.addrs_of": "Adresses de {x}", -"as.free_count": "{n} libres", -"as.next_free": "Prochaines libres", -"as.conflicts": "Ce qui ne colle pas", -"as.conflicts_sub": "Lu dans le seul inventaire : même réponse hors ligne", -"as.cf_outside": "Hors de tout réseau enregistré", -"as.cf_duplicate_mac": "Une MAC sur plusieurs adresses", "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 lancez déjà et collez ce qu'elle a affiché : show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table. Rien n'est installé sur l'équipement et aucune communauté ni mot de passe n'est conservé. Collez la table ARP avant la table d'adresses MAC : ensemble elles disent sur quel port un hôte est branché.", +"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", @@ -597,7 +577,6 @@ "as.kind_of_link": "Câble", "as.kind_of_circuit": "Lien", "as.kind_of_subnet": "Réseau", -"as.kind_of_addr": "Adresse", "nm.k_host": "Hôte", "nm.k_net": "Réseau", "nm.k_device": "Équipement", @@ -635,6 +614,10 @@ "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", +"lic.who": "Licence accordée à", +"lic.as_ind": "Particulier", +"lic.as_org": "Organisation", +"lic.as_unknown": "Type non précisé", "lic.id": "Installation {id}", "lic.unreg": "Non enregistré.", "lic.exp": "La licence de {c} a expiré le {d}.", @@ -736,6 +719,8 @@ "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.", diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index 7baacfe..304c6d8 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -15,7 +15,7 @@ "self.dropped": "छोड़े गए", "self.disk": "डिस्क", "top.search": "IP, नेटवर्क, पोर्ट, देश खोजें (वैकल्पिक)", -"top.refresh": "हर 30 सेकंड में ताज़ा", +"top.refresh": "हर {n} सेकंड में ताज़ा", "top.copy": "लिंक कॉपी करें", "top.copied": "लिंक कॉपी हो गया", "top.logout": "लॉग आउट", @@ -495,19 +495,14 @@ "as.f_prefix": "नेटवर्क", "as.f_vlan": "VLAN", "as.f_gateway": "गेटवे", -"as.f_ip": "IP पता", "as.f_device_id": "उपकरण", -"as.f_mac": "MAC", "nav.racks": "साइट और रैक", "nav.devices": "उपकरण और पोर्ट", -"nav.ipam": "IP पते", -"nav.assets": "संपत्ति प्रबंधन", "as.device": "उपकरण", "as.devices": "उपकरण", "as.devices_sub": "पोर्ट और केबलिंग देखने के लिए किसी एक पर क्लिक करें", "as.what_racks": "उपकरण कहाँ खड़ा है: साइट, कैबिनेट, और किस यूनिट में क्या है। यहाँ सब कुछ लिखकर दर्ज किया जाता है, खोजा नहीं जाता — इसलिए जब यह और नेटवर्क अलग-अलग बताएँ, तब भी यह बताता है कि वहाँ क्या होना चाहिए था।", "as.what_devices": "उपकरण क्या है और किससे जुड़ा है: निर्माता, मॉडल, सीरियल नंबर, प्रबंधन पता, और हर पोर्ट के साथ उसके केबल के दूसरे सिरे का पोर्ट।", -"as.what_ipam": "कौन-से नेटवर्क हैं और कौन-सा पता किसका है। दिखाता है कि नेटवर्क कितना भरा है, अगले खाली पते सुझाता है, और जो मेल नहीं खाता उसकी रिपोर्ट देता है।", "as.locked": "इसके लिए पंजीकृत प्रति चाहिए। जो पहले से दर्ज है वह बना रहता है, और लाइसेंस लगाते ही फिर से उपलब्ध हो जाता है।", "as.none": "—", "as.cancel": "रद्द करें", @@ -527,8 +522,6 @@ "as.edit_circuit": "सर्किट संपादित करें", "as.add_subnet": "नेटवर्क जोड़ें", "as.edit_subnet": "नेटवर्क संपादित करें", -"as.add_addr": "पता आवंटित करें", -"as.edit_addr": "आवंटन संपादित करें", "as.hidden_hint": "यह रिकॉर्ड केवल प्रशासक को दिखता है।", "as.tags_hint": "core, prod, edge", "as.racks": "रैक", @@ -536,8 +529,9 @@ "as.no_racks": "अभी कोई रैक नहीं।", "as.no_devices": "अभी कोई उपकरण नहीं।", "as.no_ports": "अभी कोई पोर्ट नहीं।", +"as.subnets_of": "{x} पर नेटवर्क", +"as.subnets_sub": "पोर्ट के बगल में लिखा रिकॉर्ड: यह डिवाइस कौन से नेटवर्क ले जाता है। यहाँ से कुछ नहीं खोजा जाता — कोई चार्ट, नाम या फ़िल्टर इस तालिका से नहीं आता।", "as.no_subnets": "अभी कोई नेटवर्क नहीं।", -"as.no_addrs": "अभी कुछ आवंटित नहीं।", "as.c_sites": "साइट {x}", "as.c_racks": "रैक {x}", "as.c_devices": "उपकरण {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "जुड़ा है", "as.cable_title": "{x} की केबल", "as.no_cable": "कोई केबल नहीं", -"as.kind_static": "स्थिर", -"as.kind_dhcp": "DHCP पूल", -"as.kind_reserved": "आरक्षित", -"as.kind_gateway": "गेटवे", -"as.kind_vip": "वर्चुअल (VIP)", -"as.assigned": "आवंटित", -"as.too_large": "गिनने के लिए बहुत बड़ा", -"as.addrs_of": "{x} के पते", -"as.free_count": "{n} खाली", -"as.next_free": "अगले खाली", -"as.conflicts": "जो मेल नहीं खाता", -"as.conflicts_sub": "केवल रजिस्टर से पढ़ा गया, इसलिए ऑफ़लाइन भी वही उत्तर", -"as.cf_outside": "किसी दर्ज नेटवर्क में नहीं", -"as.cf_duplicate_mac": "एक MAC कई पतों पर", "as.what": "क्या", "as.detail": "विवरण", "as.paste_title": "स्विच से चिपकाएँ", "as.paste_sub": "जो उपकरण पहले से छापता है", -"as.paste_what": "जो कमांड आप पहले से चलाते हैं वही चलाएँ और उसका आउटपुट यहाँ चिपकाएँ: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table। उपकरण पर कुछ भी इंस्टॉल नहीं होता और कोई कम्युनिटी या पासवर्ड कहीं नहीं रखा जाता। MAC पते की तालिका से पहले ARP तालिका चिपकाएँ: दोनों मिलकर बताती हैं कि कोई होस्ट किस पोर्ट में लगा है।", +"as.paste_what": "जो कमांड आप पहले से चलाते हैं उसे चलाइए और उसका आउटपुट यहाँ चिपकाइए: show lldp neighbors, show cdp neighbors, show interface status. डिवाइस पर कुछ भी इंस्टॉल नहीं होता और कोई कम्युनिटी स्ट्रिंग या पासवर्ड कहीं सेव नहीं होता।", "as.paste_from": "किस उपकरण पर चला", "as.paste_hint": "आउटपुट यहाँ चिपकाएँ", "as.preview": "देखें क्या होगा", @@ -597,7 +577,6 @@ "as.kind_of_link": "केबल", "as.kind_of_circuit": "सर्किट", "as.kind_of_subnet": "नेटवर्क", -"as.kind_of_addr": "पता", "nm.k_host": "होस्ट", "nm.k_net": "नेटवर्क", "nm.k_device": "डिवाइस", @@ -635,6 +614,10 @@ "nm.country_none": "देश/क्षेत्र/क्षेत्र (वैकल्पिक)", "lic.free": "ट्रैफ़िक विश्लेषण और पहचान पूरी तरह काम करते रहते हैं, और कोई डेटा नहीं हटाया जाता; जिन सुविधाओं के लिए पंजीकरण चाहिए, वे अपनी जगह पर चिह्नित हैं।", "lic.ok": "{c} को लाइसेंस: {n} दिन बचे", +"lic.who": "लाइसेंस प्राप्तकर्ता", +"lic.as_ind": "व्यक्तिगत", +"lic.as_org": "संगठन", +"lic.as_unknown": "प्रकार नहीं बताया", "lic.id": "इंस्टॉलेशन {id}", "lic.unreg": "पंजीकृत नहीं है।", "lic.exp": "{c} का लाइसेंस {d} को समाप्त हो गया।", @@ -736,6 +719,8 @@ "bill.avg": "औसत", "bill.coverage": "कवरेज", "bill.empty": "इस अवधि में कोई इंटरफ़ेस काउंटर नहीं है। यह आंकड़ा उपकरण के बताए काउंटरों से आता है, सैंपल किए गए ट्रैफ़िक से नहीं।", +"bill.p95": "95वाँ पर्सेंटाइल", +"bill.chart_sub": "पाँच मिनट के नमूने; बिंदुदार रेखा वह दर है जिसका बिल बनता है", "bill.gap_note": "जिन पाँच-मिनट खंडों में कोई माप नहीं है उन्हें छोड़ दिया जाता है, शून्य नहीं गिना जाता। शून्य गिनने से आंकड़ा नीचे आ जाएगा। कवरेज बताता है कि यह आंकड़ा अवधि के कितने हिस्से पर टिका है।", "bill.how": "यह आंकड़ा कैसे निकलता है", "bill.how_note": "अवधि में आने और जाने वाले ट्रैफ़िक को अलग-अलग क्रम में रखा जाता है, पाँच-मिनट नमूनों के ऊपरी पाँच प्रतिशत हटा दिए जाते हैं, और बचा हुआ सबसे ऊँचा मान बिल बनता है — दोनों दिशाओं में से बड़ा। महीने इसी ब्राउज़र के समय क्षेत्र में गिने जाते हैं।", diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index 82d6e99..275554d 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -15,7 +15,7 @@ "self.dropped": "Dibuang", "self.disk": "Disk", "top.search": "Cari IP, jaringan, port, negara (opsional)", -"top.refresh": "Diperbarui tiap 30 dtk", +"top.refresh": "Diperbarui tiap {n} dtk", "top.copy": "Salin tautan", "top.copied": "Tautan disalin", "top.logout": "Keluar", @@ -495,19 +495,14 @@ "as.f_prefix": "Jaringan", "as.f_vlan": "VLAN", "as.f_gateway": "Gerbang", -"as.f_ip": "Alamat IP", "as.f_device_id": "Perangkat", -"as.f_mac": "MAC", "nav.racks": "Lokasi & rak", "nav.devices": "Perangkat & port", -"nav.ipam": "Alamat IP", -"nav.assets": "Manajemen aset", "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.what_ipam": "Jaringan apa saja yang ada dan alamat siapa. Menunjukkan seberapa penuh sebuah jaringan, menyarankan alamat kosong berikutnya, dan melaporkan apa yang tidak cocok.", "as.locked": "Ini memerlukan salinan terdaftar. Semua yang sudah dicatat tetap ada, dan dapat diakses lagi begitu lisensi dipasang.", "as.none": "—", "as.cancel": "Batal", @@ -527,8 +522,6 @@ "as.edit_circuit": "Ubah sirkuit", "as.add_subnet": "Tambah jaringan", "as.edit_subnet": "Ubah jaringan", -"as.add_addr": "Tetapkan alamat", -"as.edit_addr": "Ubah penetapan", "as.hidden_hint": "Hanya administrator yang melihat catatan ini.", "as.tags_hint": "core, prod, edge", "as.racks": "Rak", @@ -536,8 +529,9 @@ "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.no_addrs": "Belum ada yang ditetapkan.", "as.c_sites": "Lokasi {x}", "as.c_racks": "Rak {x}", "as.c_devices": "Perangkat {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Tersambung ke", "as.cable_title": "Kabel {x}", "as.no_cable": "Tanpa kabel", -"as.kind_static": "Statis", -"as.kind_dhcp": "Kumpulan DHCP", -"as.kind_reserved": "Dicadangkan", -"as.kind_gateway": "Gerbang", -"as.kind_vip": "Virtual (VIP)", -"as.assigned": "Ditetapkan", -"as.too_large": "terlalu besar untuk dihitung", -"as.addrs_of": "Alamat di {x}", -"as.free_count": "{n} kosong", -"as.next_free": "Kosong berikutnya", -"as.conflicts": "Yang tidak cocok", -"as.conflicts_sub": "Dibaca dari catatan saja, jadi jawabannya sama saat luring", -"as.cf_outside": "Di luar semua jaringan tercatat", -"as.cf_duplicate_mac": "Satu MAC pada beberapa alamat", "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 memang sudah Anda jalankan lalu tempel keluarannya: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table. Tidak ada yang dipasang di perangkat dan tidak ada community atau kata sandi yang disimpan. Tempel tabel ARP sebelum tabel alamat MAC: keduanya bersama menunjukkan di port mana sebuah host tercolok.", +"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", @@ -597,7 +577,6 @@ "as.kind_of_link": "Kabel", "as.kind_of_circuit": "Sirkuit", "as.kind_of_subnet": "Jaringan", -"as.kind_of_addr": "Alamat", "nm.k_host": "Host", "nm.k_net": "Jaringan", "nm.k_device": "Perangkat", @@ -635,6 +614,10 @@ "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", +"lic.who": "Dilisensikan kepada", +"lic.as_ind": "Perorangan", +"lic.as_org": "Organisasi", +"lic.as_unknown": "Jenis tidak disebutkan", "lic.id": "Instalasi {id}", "lic.unreg": "Belum terdaftar.", "lic.exp": "Lisensi {c} berakhir pada {d}.", @@ -736,6 +719,8 @@ "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.", diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index d6219d4..9a6674f 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -15,7 +15,7 @@ "self.dropped": "破棄", "self.disk": "ディスク", "top.search": "IP・ネットワーク・ポート・国・地域で検索(任意)", -"top.refresh": "30 秒ごとに更新", +"top.refresh": "{n} 秒ごとに更新", "top.copy": "リンクをコピー", "top.copied": "リンクをコピーしました", "top.logout": "ログアウト", @@ -495,19 +495,14 @@ "as.f_prefix": "ネットワーク", "as.f_vlan": "VLAN", "as.f_gateway": "ゲートウェイ", -"as.f_ip": "IPアドレス", "as.f_device_id": "機器", -"as.f_mac": "MAC", "nav.racks": "拠点とラック", "nav.devices": "機器とポート", -"nav.ipam": "IPアドレス", -"nav.assets": "資産管理", "as.device": "機器", "as.devices": "機器一覧", "as.devices_sub": "クリックするとポートと結線が見られます", "as.what_racks": "機器がどこにあるか:拠点、ラック、どのUに何が入っているか。ここにあるものはすべて人が記録したもので、自動検出ではありません。だからこそ実際のネットワークと食い違ったときに、本来あるべき姿を示せます。", "as.what_devices": "機器が何であり、何とつながっているか:メーカー、型番、シリアル番号、管理アドレス、そして各ポートとケーブルの対向ポート。", -"as.what_ipam": "どのネットワークが存在し、どのアドレスが誰のものか。使用率を表示し、次に空いているアドレスを提示し、辻褄の合わない点を報告します。", "as.locked": "この機能は登録版が必要です。すでに記録した内容はそのまま保持され、ライセンスを入れればすぐに再び利用できます。", "as.none": "—", "as.cancel": "取消", @@ -527,8 +522,6 @@ "as.edit_circuit": "回線を編集", "as.add_subnet": "ネットワークを追加", "as.edit_subnet": "ネットワークを編集", -"as.add_addr": "アドレスを割り当て", -"as.edit_addr": "割り当てを編集", "as.hidden_hint": "この記録は管理者だけに表示されます。", "as.tags_hint": "core, prod, edge", "as.racks": "ラック", @@ -536,8 +529,9 @@ "as.no_racks": "ラックがまだありません。", "as.no_devices": "機器がまだありません。", "as.no_ports": "ポートがまだありません。", +"as.subnets_of": "{x} のネットワーク", +"as.subnets_sub": "ポートの横に書き留めるもの。この機器が運ぶネットワークです。ここは参照されません — グラフも名前もフィルターもこの表からは作られません。", "as.no_subnets": "ネットワークがまだありません。", -"as.no_addrs": "まだ割り当てがありません。", "as.c_sites": "拠点 {x}", "as.c_racks": "ラック {x}", "as.c_devices": "機器 {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "接続先", "as.cable_title": "{x} の結線", "as.no_cable": "未接続", -"as.kind_static": "固定", -"as.kind_dhcp": "DHCP プール", -"as.kind_reserved": "予約", -"as.kind_gateway": "ゲートウェイ", -"as.kind_vip": "仮想 (VIP)", -"as.assigned": "割り当て済み", -"as.too_large": "大きすぎて集計できません", -"as.addrs_of": "{x} のアドレス", -"as.free_count": "空き {n}", -"as.next_free": "次の空き", -"as.conflicts": "辻褄の合わない点", -"as.conflicts_sub": "台帳だけを読んだ結果です。オフラインでも同じ答えになります", -"as.cf_outside": "どの登録ネットワークにも入っていません", -"as.cf_duplicate_mac": "同じ MAC が複数のアドレスに", "as.what": "内容", "as.detail": "詳細", "as.paste_title": "スイッチの出力を貼り付け", "as.paste_sub": "機器がすでに表示しているものを使う", -"as.paste_what": "普段使っているコマンドを実行して、その出力をそのまま貼り付けてください:show lldp neighbors、show cdp neighbors、show interface status、show ip arp、show mac address-table。機器側に何もインストールせず、コミュニティ名やパスワードもどこにも保存しません。MAC アドレステーブルより先に ARP テーブルを貼ると、どのホストがどのポートに挿さっているかが分かります。", +"as.paste_what": "普段使っているコマンドの出力をそのまま貼り付けてください: show lldp neighbors、show cdp neighbors、show interface status。機器には何もインストールせず、コミュニティ名やパスワードをどこにも保存しません。", "as.paste_from": "実行した機器", "as.paste_hint": "ここに出力を貼り付け", "as.preview": "何が起きるか確認", @@ -597,7 +577,6 @@ "as.kind_of_link": "ケーブル", "as.kind_of_circuit": "回線", "as.kind_of_subnet": "ネットワーク", -"as.kind_of_addr": "アドレス", "nm.k_host": "ホスト", "nm.k_net": "ネットワーク", "nm.k_device": "機器", @@ -635,6 +614,10 @@ "nm.country_none": "国・地域(任意)", "lic.free": "トラフィック分析と検知はすべてそのまま動作し、データが消えることもありません。登録が必要な機能は、その場所に明示されます。", "lic.ok": "{c} にライセンス済み:残り {n} 日", +"lic.who": "ライセンス先", +"lic.as_ind": "個人", +"lic.as_org": "法人", +"lic.as_unknown": "種別の記載なし", "lic.id": "インストール番号 {id}", "lic.unreg": "未登録です。", "lic.exp": "{c} のライセンスは {d} に終了しました。", @@ -736,6 +719,8 @@ "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% を捨て、残りの最大値を請求します。両方向のうち大きい方を採ります。月はこのブラウザのタイムゾーンで数えます。", diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index 8196dac..739075c 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -15,7 +15,7 @@ "self.dropped": "누락", "self.disk": "디스크", "top.search": "IP, 네트워크, 포트, 국가 검색 (선택)", -"top.refresh": "30초마다 새로고침", +"top.refresh": "{n}초마다 새로고침", "top.copy": "링크 복사", "top.copied": "링크를 복사했습니다", "top.logout": "로그아웃", @@ -495,19 +495,14 @@ "as.f_prefix": "네트워크", "as.f_vlan": "VLAN", "as.f_gateway": "게이트웨이", -"as.f_ip": "IP 주소", "as.f_device_id": "장비", -"as.f_mac": "MAC", "nav.racks": "사이트와 랙", "nav.devices": "장비와 포트", -"nav.ipam": "IP 주소", -"nav.assets": "자산 관리", "as.device": "장비", "as.devices": "장비 목록", "as.devices_sub": "클릭하면 포트와 결선을 볼 수 있습니다", "as.what_racks": "장비가 어디에 있는지: 사이트, 랙, 그리고 어느 U에 무엇이 들어 있는지. 여기 있는 내용은 모두 사람이 적은 것이며 자동 탐지가 아닙니다. 그래서 실제 네트워크와 어긋났을 때도 원래 무엇이 있어야 했는지를 말해 줍니다.", "as.what_devices": "장비가 무엇이고 어디에 연결되어 있는지: 제조사, 모델, 일련번호, 관리 주소, 그리고 각 포트와 케이블 반대쪽 포트.", -"as.what_ipam": "어떤 네트워크가 있고 각 주소가 누구 것인지. 네트워크 사용률을 보여 주고, 다음 빈 주소를 제안하며, 맞지 않는 부분을 보고합니다.", "as.locked": "등록판이 필요합니다. 이미 적어 둔 내용은 그대로 남아 있으며, 라이선스를 넣는 즉시 다시 사용할 수 있습니다.", "as.none": "—", "as.cancel": "취소", @@ -527,8 +522,6 @@ "as.edit_circuit": "회선 편집", "as.add_subnet": "네트워크 추가", "as.edit_subnet": "네트워크 편집", -"as.add_addr": "주소 할당", -"as.edit_addr": "할당 편집", "as.hidden_hint": "이 기록은 관리자만 볼 수 있습니다.", "as.tags_hint": "core, prod, edge", "as.racks": "랙", @@ -536,8 +529,9 @@ "as.no_racks": "아직 랙이 없습니다.", "as.no_devices": "아직 장비가 없습니다.", "as.no_ports": "아직 포트가 없습니다.", +"as.subnets_of": "{x}의 네트워크", +"as.subnets_sub": "포트 옆에 적어 두는 기록으로, 이 장비가 담당하는 네트워크입니다. 여기서는 아무것도 조회하지 않습니다 — 차트, 이름, 필터 모두 이 표에서 오지 않습니다.", "as.no_subnets": "아직 네트워크가 없습니다.", -"as.no_addrs": "아직 할당된 것이 없습니다.", "as.c_sites": "사이트 {x}", "as.c_racks": "랙 {x}", "as.c_devices": "장비 {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "연결 대상", "as.cable_title": "{x} 케이블 연결", "as.no_cable": "케이블 없음", -"as.kind_static": "고정", -"as.kind_dhcp": "DHCP 풀", -"as.kind_reserved": "예약", -"as.kind_gateway": "게이트웨이", -"as.kind_vip": "가상 (VIP)", -"as.assigned": "할당됨", -"as.too_large": "너무 커서 셀 수 없음", -"as.addrs_of": "{x}의 주소", -"as.free_count": "{n}개 비어 있음", -"as.next_free": "다음 빈 주소", -"as.conflicts": "맞지 않는 부분", -"as.conflicts_sub": "대장만 읽은 결과이므로 오프라인에서도 같은 답입니다", -"as.cf_outside": "등록된 어느 네트워크에도 속하지 않음", -"as.cf_duplicate_mac": "하나의 MAC이 여러 주소에", "as.what": "무엇", "as.detail": "상세", "as.paste_title": "스위치 출력 붙여넣기", "as.paste_sub": "장비가 이미 출력하는 내용", -"as.paste_what": "평소에 쓰는 명령을 실행하고 그 출력을 그대로 붙여 넣으세요: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table. 장비에 아무것도 설치하지 않으며 커뮤니티 문자열이나 비밀번호를 어디에도 저장하지 않습니다. MAC 주소 테이블보다 ARP 테이블을 먼저 붙여 넣으면, 어떤 호스트가 어느 포트에 꽂혀 있는지 알 수 있습니다.", +"as.paste_what": "평소 쓰던 명령의 출력을 그대로 붙여 넣으세요: show lldp neighbors, show cdp neighbors, show interface status. 장비에 아무것도 설치하지 않으며 커뮤니티 문자열이나 비밀번호도 저장하지 않습니다.", "as.paste_from": "실행한 장비", "as.paste_hint": "여기에 출력을 붙여 넣으세요", "as.preview": "무엇을 할지 미리 보기", @@ -597,7 +577,6 @@ "as.kind_of_link": "케이블", "as.kind_of_circuit": "회선", "as.kind_of_subnet": "네트워크", -"as.kind_of_addr": "주소", "nm.k_host": "호스트", "nm.k_net": "네트워크", "nm.k_device": "장비", @@ -635,6 +614,10 @@ "nm.country_none": "국가/지역/지역(선택)", "lic.free": "트래픽 분석과 탐지는 모두 그대로 작동하며 데이터가 삭제되지도 않습니다. 등록이 필요한 기능은 해당 위치에 표시됩니다.", "lic.ok": "{c}에 라이선스됨: {n}일 남음", +"lic.who": "라이선스 대상", +"lic.as_ind": "개인", +"lic.as_org": "법인", +"lic.as_unknown": "유형 미표기", "lic.id": "설치 번호 {id}", "lic.unreg": "등록되지 않았습니다.", "lic.exp": "{c}의 라이선스가 {d}에 끝났습니다.", @@ -736,6 +719,8 @@ "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%를 버리고 남은 최고값을 과금합니다. 두 방향 중 더 큰 값을 씁니다. 월은 이 브라우저의 시간대로 계산합니다.", diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index db251c8..a64e1ae 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -15,7 +15,7 @@ "self.dropped": "Descartados", "self.disk": "Disco", "top.search": "Buscar IP, rede, porta, país (opcional)", -"top.refresh": "Atualiza a cada 30 s", +"top.refresh": "Atualiza a cada {n} s", "top.copy": "Copiar link", "top.copied": "Link copiado", "top.logout": "Sair", @@ -495,19 +495,14 @@ "as.f_prefix": "Rede", "as.f_vlan": "VLAN", "as.f_gateway": "Gateway", -"as.f_ip": "Endereço IP", "as.f_device_id": "Equipamento", -"as.f_mac": "MAC", "nav.racks": "Sites e racks", "nav.devices": "Equipamentos e portas", -"nav.ipam": "Endereços IP", -"nav.assets": "Gestão de ativos", "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.what_ipam": "Que redes existem e de quem é cada endereço. Mostra o quanto uma rede está cheia, sugere os próximos endereços livres e relata o que não fecha.", "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", @@ -527,8 +522,6 @@ "as.edit_circuit": "Editar o circuito", "as.add_subnet": "Adicionar rede", "as.edit_subnet": "Editar a rede", -"as.add_addr": "Atribuir endereço", -"as.edit_addr": "Editar a atribuição", "as.hidden_hint": "Só a administração vê este registro.", "as.tags_hint": "core, prod, edge", "as.racks": "Racks", @@ -536,8 +529,9 @@ "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.no_addrs": "Nada atribuído ainda.", "as.c_sites": "Sites {x}", "as.c_racks": "Racks {x}", "as.c_devices": "Equipamentos {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Ligada a", "as.cable_title": "Cabear {x}", "as.no_cable": "Sem cabo", -"as.kind_static": "Estático", -"as.kind_dhcp": "Faixa DHCP", -"as.kind_reserved": "Reservado", -"as.kind_gateway": "Gateway", -"as.kind_vip": "Virtual (VIP)", -"as.assigned": "Atribuídos", -"as.too_large": "grande demais para contar", -"as.addrs_of": "Endereços de {x}", -"as.free_count": "{n} livres", -"as.next_free": "Próximos livres", -"as.conflicts": "O que não fecha", -"as.conflicts_sub": "Lido só do registro: a mesma resposta offline", -"as.cf_outside": "Fora de toda rede registrada", -"as.cf_duplicate_mac": "Um MAC em vários endereços", "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á executa e cole o que ele imprimiu: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table. Nada é instalado no equipamento e nenhuma community ou senha fica guardada. Cole a tabela ARP antes da tabela de endereços MAC: juntas elas dizem em que porta um host está ligado.", +"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", @@ -597,7 +577,6 @@ "as.kind_of_link": "Cabo", "as.kind_of_circuit": "Circuito", "as.kind_of_subnet": "Rede", -"as.kind_of_addr": "Endereço", "nm.k_host": "Host", "nm.k_net": "Rede", "nm.k_device": "Dispositivo", @@ -635,6 +614,10 @@ "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", +"lic.who": "Licenciado para", +"lic.as_ind": "Pessoa física", +"lic.as_org": "Organização", +"lic.as_unknown": "Tipo não indicado", "lic.id": "Instalação {id}", "lic.unreg": "Não registado.", "lic.exp": "A licença de {c} terminou em {d}.", @@ -736,6 +719,8 @@ "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.", diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index baaf41e..18cbb8f 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -15,7 +15,7 @@ "self.dropped": "Отброшено", "self.disk": "Диск", "top.search": "Поиск по IP, сети, порту, стране (необязательно)", -"top.refresh": "Обновление каждые 30 с", +"top.refresh": "Обновление каждые {n} с", "top.copy": "Скопировать ссылку", "top.copied": "Ссылка скопирована", "top.logout": "Выйти", @@ -495,19 +495,14 @@ "as.f_prefix": "Сеть", "as.f_vlan": "VLAN", "as.f_gateway": "Шлюз", -"as.f_ip": "IP-адрес", "as.f_device_id": "Устройство", -"as.f_mac": "MAC", "nav.racks": "Площадки и стойки", "nav.devices": "Устройства и порты", -"nav.ipam": "IP-адреса", -"nav.assets": "Учёт оборудования", "as.device": "Устройство", "as.devices": "Устройства", "as.devices_sub": "Нажмите на любое, чтобы увидеть его порты и кабели", "as.what_racks": "Где стоит оборудование: площадки, стойки и что занимает каждый юнит. Всё здесь записывается вручную, а не обнаруживается, — поэтому, когда учёт и сеть расходятся, учёт по-прежнему говорит, как должно было быть.", "as.what_devices": "Что это за оборудование и к чему оно подключено: производитель, модель, серийный номер, адрес управления и каждый порт вместе с портом на другом конце кабеля.", -"as.what_ipam": "Какие сети есть и чей каждый адрес. Показывает заполненность сети, предлагает ближайшие свободные адреса и сообщает о нестыковках.", "as.locked": "Нужна зарегистрированная версия. Всё уже записанное сохраняется и снова станет доступным, как только будет установлена лицензия.", "as.none": "—", "as.cancel": "Отмена", @@ -527,8 +522,6 @@ "as.edit_circuit": "Изменить линию", "as.add_subnet": "Добавить сеть", "as.edit_subnet": "Изменить сеть", -"as.add_addr": "Назначить адрес", -"as.edit_addr": "Изменить назначение", "as.hidden_hint": "Эту запись видит только администратор.", "as.tags_hint": "core, prod, edge", "as.racks": "Стойки", @@ -536,8 +529,9 @@ "as.no_racks": "Стоек пока нет.", "as.no_devices": "Устройств пока нет.", "as.no_ports": "Портов пока нет.", +"as.subnets_of": "Сети на {x}", +"as.subnets_sub": "Записаны рядом с портами: что несёт это устройство. Здесь ничего не ищется — ни график, ни имя, ни фильтр не берутся из этой таблицы.", "as.no_subnets": "Сетей пока нет.", -"as.no_addrs": "Пока ничего не назначено.", "as.c_sites": "Площадки {x}", "as.c_racks": "Стойки {x}", "as.c_devices": "Устройства {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Подключён к", "as.cable_title": "Подключение {x}", "as.no_cable": "Без кабеля", -"as.kind_static": "Статический", -"as.kind_dhcp": "Пул DHCP", -"as.kind_reserved": "Зарезервирован", -"as.kind_gateway": "Шлюз", -"as.kind_vip": "Виртуальный (VIP)", -"as.assigned": "Назначено", -"as.too_large": "слишком велика для подсчёта", -"as.addrs_of": "Адреса в {x}", -"as.free_count": "свободно {n}", -"as.next_free": "Ближайшие свободные", -"as.conflicts": "Что не сходится", -"as.conflicts_sub": "Прочитано только из учёта, поэтому ответ тот же и офлайн", -"as.cf_outside": "Вне всех записанных сетей", -"as.cf_duplicate_mac": "Один MAC на нескольких адресах", "as.what": "Что", "as.detail": "Подробности", "as.paste_title": "Вставить вывод коммутатора", "as.paste_sub": "То, что устройство и так печатает", -"as.paste_what": "Выполните команду, которую и так выполняете, и вставьте её вывод: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table. На устройство ничего не ставится, community и пароли нигде не хранятся. Вставьте таблицу ARP перед таблицей MAC-адресов: вместе они говорят, в какой порт включён узел.", +"as.paste_what": "Выполните команду, которой вы и так пользуетесь, и вставьте её вывод: show lldp neighbors, show cdp neighbors, show interface status. На устройство ничего не устанавливается, ни community-строка, ни пароль нигде не сохраняются.", "as.paste_from": "Выполнено на", "as.paste_hint": "Вставьте вывод сюда", "as.preview": "Посмотреть, что получится", @@ -597,7 +577,6 @@ "as.kind_of_link": "Кабель", "as.kind_of_circuit": "Линия", "as.kind_of_subnet": "Сеть", -"as.kind_of_addr": "Адрес", "nm.k_host": "Хост", "nm.k_net": "Сеть", "nm.k_device": "Устройство", @@ -635,6 +614,10 @@ "nm.country_none": "страна/регион (необязательно)", "lic.free": "Анализ трафика и обнаружение работают полностью, и никакие данные не удаляются; функции, требующие регистрации, отмечены там, где они находятся.", "lic.ok": "Лицензия выдана {c}: осталось {n} дн.", +"lic.who": "Лицензия выдана", +"lic.as_ind": "Частное лицо", +"lic.as_org": "Организация", +"lic.as_unknown": "Тип не указан", "lic.id": "Установка {id}", "lic.unreg": "Не зарегистрировано.", "lic.exp": "Лицензия для {c} закончилась {d}.", @@ -736,6 +719,8 @@ "bill.avg": "Среднее", "bill.coverage": "Охват", "bill.empty": "За этот период нет счётчиков интерфейсов. Цифра берётся из счётчиков, которые сообщает устройство, а не из выборочного трафика.", +"bill.p95": "95-й процентиль", +"bill.chart_sub": "Пятиминутные отсчёты; пунктирная линия — тарифицируемое значение", "bill.gap_note": "Пятиминутные интервалы без измерения не учитываются, а не считаются нулём. Нули снизили бы цифру. Охват показывает, на какой части периода она держится.", "bill.how": "Как получена цифра", "bill.how_note": "Вход и выход сортируются за период по отдельности, верхние пять процентов пятиминутных значений отбрасываются, и к оплате идёт наибольшее из оставшихся — большее из двух направлений. Месяцы считаются в часовом поясе этого браузера.", diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index 05312f9..cae9cb3 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -15,7 +15,7 @@ "self.dropped": "ضائع شدہ", "self.disk": "ڈسک", "top.search": "IP، نیٹ ورک، پورٹ یا ملک تلاش کریں (اختیاری)", -"top.refresh": "ہر 30 سیکنڈ میں تازہ", +"top.refresh": "ہر {n} سیکنڈ میں تازہ", "top.copy": "لنک کاپی کریں", "top.copied": "لنک کاپی ہو گیا", "top.logout": "لاگ آؤٹ", @@ -495,19 +495,14 @@ "as.f_prefix": "نیٹ ورک", "as.f_vlan": "VLAN", "as.f_gateway": "گیٹ وے", -"as.f_ip": "IP پتہ", "as.f_device_id": "ڈیوائس", -"as.f_mac": "MAC", "nav.racks": "سائٹس اور ریک", "nav.devices": "ڈیوائسز اور پورٹس", -"nav.ipam": "IP پتے", -"nav.assets": "اثاثہ جات کا انتظام", "as.device": "ڈیوائس", "as.devices": "ڈیوائسز", "as.devices_sub": "پورٹس اور کیبلنگ دیکھنے کے لیے کسی ایک پر کلک کریں", "as.what_racks": "سامان کہاں رکھا ہے: سائٹس، کیبنٹ، اور کس یونٹ میں کیا ہے۔ یہاں کی ہر چیز لکھ کر درج کی جاتی ہے، خود سے دریافت نہیں ہوتی — اسی لیے جب یہ اور نیٹ ورک مختلف بات کہیں، تب بھی یہ بتاتا ہے کہ وہاں کیا ہونا چاہیے تھا۔", "as.what_devices": "ڈیوائس کیا ہے اور کس سے جُڑی ہے: بنانے والا، ماڈل، سیریل نمبر، مینجمنٹ پتہ، اور ہر پورٹ کے ساتھ کیبل کے دوسرے سرے کا پورٹ۔", -"as.what_ipam": "کون سے نیٹ ورک موجود ہیں اور کون سا پتہ کس کا ہے۔ دکھاتا ہے کہ نیٹ ورک کتنا بھرا ہے، اگلے خالی پتے تجویز کرتا ہے، اور جو میل نہیں کھاتا اس کی اطلاع دیتا ہے۔", "as.locked": "اس کے لیے رجسٹرڈ نسخہ درکار ہے۔ جو پہلے درج ہو چکا ہے وہ برقرار رہتا ہے، اور لائسنس لگاتے ہی دوبارہ دستیاب ہو جاتا ہے۔", "as.none": "—", "as.cancel": "منسوخ کریں", @@ -527,8 +522,6 @@ "as.edit_circuit": "سرکٹ میں ترمیم", "as.add_subnet": "نیٹ ورک شامل کریں", "as.edit_subnet": "نیٹ ورک میں ترمیم", -"as.add_addr": "پتہ مختص کریں", -"as.edit_addr": "تخصیص میں ترمیم", "as.hidden_hint": "یہ ریکارڈ صرف منتظم کو نظر آتا ہے۔", "as.tags_hint": "core, prod, edge", "as.racks": "ریک", @@ -536,8 +529,9 @@ "as.no_racks": "ابھی کوئی ریک نہیں۔", "as.no_devices": "ابھی کوئی ڈیوائس نہیں۔", "as.no_ports": "ابھی کوئی پورٹ نہیں۔", +"as.subnets_of": "{x} پر نیٹ ورکس", +"as.subnets_sub": "پورٹس کے ساتھ لکھا ریکارڈ: یہ آلہ کون سے نیٹ ورک اٹھاتا ہے۔ یہاں سے کچھ تلاش نہیں ہوتا — کوئی چارٹ، نام یا فلٹر اس جدول سے نہیں آتا۔", "as.no_subnets": "ابھی کوئی نیٹ ورک نہیں۔", -"as.no_addrs": "ابھی کچھ مختص نہیں ہوا۔", "as.c_sites": "سائٹس {x}", "as.c_racks": "ریک {x}", "as.c_devices": "ڈیوائسز {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "جُڑا ہے", "as.cable_title": "{x} کی کیبل", "as.no_cable": "کوئی کیبل نہیں", -"as.kind_static": "مستقل", -"as.kind_dhcp": "DHCP پول", -"as.kind_reserved": "محفوظ", -"as.kind_gateway": "گیٹ وے", -"as.kind_vip": "ورچوئل (VIP)", -"as.assigned": "مختص", -"as.too_large": "گننے کے لیے بہت بڑا", -"as.addrs_of": "{x} کے پتے", -"as.free_count": "{n} خالی", -"as.next_free": "اگلے خالی", -"as.conflicts": "جو میل نہیں کھاتا", -"as.conflicts_sub": "صرف رجسٹر سے پڑھا گیا، اس لیے آف لائن بھی وہی جواب", -"as.cf_outside": "کسی درج نیٹ ورک میں نہیں", -"as.cf_duplicate_mac": "ایک MAC کئی پتوں پر", "as.what": "کیا", "as.detail": "تفصیل", "as.paste_title": "سوئچ سے پیسٹ کریں", "as.paste_sub": "جو ڈیوائس پہلے ہی چھاپتی ہے", -"as.paste_what": "وہی کمانڈ چلائیں جو آپ پہلے سے چلاتے ہیں اور اس کا آؤٹ پٹ یہاں پیسٹ کریں: show lldp neighbors، show cdp neighbors، show interface status، show ip arp، show mac address-table۔ ڈیوائس پر کچھ انسٹال نہیں ہوتا اور کوئی community یا پاس ورڈ کہیں محفوظ نہیں ہوتا۔ MAC ایڈریس ٹیبل سے پہلے ARP ٹیبل پیسٹ کریں: دونوں مل کر بتاتے ہیں کہ کوئی ہوسٹ کس پورٹ میں لگا ہے۔", +"as.paste_what": "جو کمانڈ آپ پہلے ہی چلاتے ہیں وہ چلائیں اور اس کا آؤٹ پٹ یہاں پیسٹ کریں: show lldp neighbors، show cdp neighbors، show interface status۔ آلے پر کچھ انسٹال نہیں ہوتا اور نہ کوئی کمیونٹی اسٹرنگ یا پاس ورڈ کہیں محفوظ ہوتا ہے۔", "as.paste_from": "کس ڈیوائس پر چلایا", "as.paste_hint": "آؤٹ پٹ یہاں پیسٹ کریں", "as.preview": "دیکھیں کیا ہوگا", @@ -597,7 +577,6 @@ "as.kind_of_link": "کیبل", "as.kind_of_circuit": "سرکٹ", "as.kind_of_subnet": "نیٹ ورک", -"as.kind_of_addr": "پتہ", "nm.k_host": "ہوسٹ", "nm.k_net": "نیٹ ورک", "nm.k_device": "ڈیوائس", @@ -635,6 +614,10 @@ "nm.country_none": "ملک/خطہ/خطہ (اختیاری)", "lic.free": "ٹریفک تجزیہ اور شناخت مکمل طور پر کام کرتے رہتے ہیں، اور کوئی ڈیٹا حذف نہیں ہوتا؛ جن خصوصیات کے لیے رجسٹریشن درکار ہے، وہ اپنی جگہ پر نشان زد ہیں۔", "lic.ok": "{c} کو لائسنس: {n} دن باقی", +"lic.who": "لائسنس یافتہ", +"lic.as_ind": "انفرادی", +"lic.as_org": "ادارہ", +"lic.as_unknown": "قسم درج نہیں", "lic.id": "انسٹالیشن {id}", "lic.unreg": "رجسٹرڈ نہیں ہے۔", "lic.exp": "{c} کا لائسنس {d} کو ختم ہو گیا۔", @@ -736,6 +719,8 @@ "bill.avg": "اوسط", "bill.coverage": "احاطہ", "bill.empty": "اس مدت میں کوئی انٹرفیس کاؤنٹر نہیں۔ یہ عدد اُن کاؤنٹرز سے آتا ہے جو ڈیوائس خود بتاتی ہے، نمونے والے ٹریفک سے نہیں۔", +"bill.p95": "95واں پرسنٹائل", +"bill.chart_sub": "پانچ منٹ کے نمونے؛ ڈیش لائن وہ قدر ہے جس کا بل بنتا ہے", "bill.gap_note": "جن پانچ منٹ کے خانوں میں کوئی پیمائش نہیں، اُنہیں چھوڑ دیا جاتا ہے، صفر نہیں گنا جاتا۔ صفر گننے سے عدد نیچے آ جاتا۔ احاطہ بتاتا ہے کہ یہ عدد مدت کے کتنے حصے پر قائم ہے۔", "bill.how": "یہ عدد کیسے نکلتا ہے", "bill.how_note": "مدت کے دوران آنے اور جانے والے ٹریفک کو الگ الگ ترتیب دیا جاتا ہے، پانچ منٹ کے نمونوں کے اوپر والے پانچ فیصد نکال دیے جاتے ہیں، اور بچا ہوا سب سے بلند عدد بل بنتا ہے — دونوں سمتوں میں سے بڑا۔ مہینے اسی براؤزر کے ٹائم زون میں گنے جاتے ہیں۔", diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index 52366fe..52de54f 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -15,7 +15,7 @@ "self.dropped": "Bị loại bỏ", "self.disk": "Ổ đĩa", "top.search": "Tìm IP, mạng, cổng, quốc gia (tùy chọn)", -"top.refresh": "Làm mới mỗi 30 s", +"top.refresh": "Làm mới mỗi {n} s", "top.copy": "Sao chép liên kết", "top.copied": "Đã sao chép liên kết", "top.logout": "Đăng xuất", @@ -495,19 +495,14 @@ "as.f_prefix": "Mạng", "as.f_vlan": "VLAN", "as.f_gateway": "Cổng mặc định", -"as.f_ip": "Địa chỉ IP", "as.f_device_id": "Thiết bị", -"as.f_mac": "MAC", "nav.racks": "Địa điểm & tủ rack", "nav.devices": "Thiết bị & cổng", -"nav.ipam": "Địa chỉ IP", -"nav.assets": "Quản lý tài sản", "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.what_ipam": "Có những mạng nào và địa chỉ nào của ai. Cho biết mạng đã dùng bao nhiêu, gợi ý các địa chỉ trống kế tiếp, và báo những chỗ không khớp.", "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", @@ -527,8 +522,6 @@ "as.edit_circuit": "Sửa đường truyền", "as.add_subnet": "Thêm mạng", "as.edit_subnet": "Sửa mạng", -"as.add_addr": "Cấp địa chỉ", -"as.edit_addr": "Sửa việc cấp phát", "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", @@ -536,8 +529,9 @@ "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.no_addrs": "Chưa cấp phát gì.", "as.c_sites": "Địa điểm {x}", "as.c_racks": "Tủ rack {x}", "as.c_devices": "Thiết bị {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "Nối tới", "as.cable_title": "Đi dây {x}", "as.no_cable": "Không có dây", -"as.kind_static": "Tĩnh", -"as.kind_dhcp": "Dải DHCP", -"as.kind_reserved": "Dành riêng", -"as.kind_gateway": "Cổng mặc định", -"as.kind_vip": "Ảo (VIP)", -"as.assigned": "Đã cấp", -"as.too_large": "quá lớn để đếm", -"as.addrs_of": "Địa chỉ trong {x}", -"as.free_count": "trống {n}", -"as.next_free": "Trống kế tiếp", -"as.conflicts": "Những chỗ không khớp", -"as.conflicts_sub": "Chỉ đọc từ sổ, nên ngoại tuyến vẫn cho cùng kết quả", -"as.cf_outside": "Không thuộc mạng nào đã ghi", -"as.cf_duplicate_mac": "Một MAC trên nhiều địa chỉ", "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 đúng lệnh bạn vẫn chạy rồi dán kết quả vào đây: show lldp neighbors, show cdp neighbors, show interface status, show ip arp, show mac address-table. Không cài gì lên thiết bị, không lưu community hay mật khẩu ở đâu cả. Hãy dán bảng ARP trước bảng địa chỉ MAC: hai bảng cùng nhau cho biết một máy đang cắm ở cổng nào.", +"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", @@ -597,7 +577,6 @@ "as.kind_of_link": "Dây", "as.kind_of_circuit": "Đường truyền", "as.kind_of_subnet": "Mạng", -"as.kind_of_addr": "Địa chỉ", "nm.k_host": "Máy", "nm.k_net": "Mạng", "nm.k_device": "Thiết bị", @@ -635,6 +614,10 @@ "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", +"lic.who": "Cấp phép cho", +"lic.as_ind": "Cá nhân", +"lic.as_org": "Tổ chức", +"lic.as_unknown": "Không ghi loại", "lic.id": "Bản cài đặt {id}", "lic.unreg": "Chưa đăng ký.", "lic.exp": "Giấy phép của {c} đã hết hạn ngày {d}.", @@ -736,6 +719,8 @@ "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.", diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 4ed817f..3ce19d3 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -15,7 +15,7 @@ "self.dropped": "丢弃", "self.disk": "磁盘", "top.search": "搜索 IP、网段、端口、国家/地区(不用也行)", -"top.refresh": "每 30 秒刷新", +"top.refresh": "每 {n} 秒刷新", "top.copy": "复制链接", "top.copied": "链接已复制", "top.logout": "退出登录", @@ -495,19 +495,14 @@ "as.f_prefix": "网段", "as.f_vlan": "VLAN", "as.f_gateway": "网关", -"as.f_ip": "IP 地址", "as.f_device_id": "设备", -"as.f_mac": "MAC", "nav.racks": "机房与机柜", "nav.devices": "设备与端口", -"nav.ipam": "IP 地址", -"nav.assets": "资产管理", "as.device": "设备", "as.devices": "设备列表", "as.devices_sub": "点击某台设备查看它的端口和接线", "as.what_racks": "设备在哪里:机房、机柜,以及每个 U 位里装的是什么。这里的内容全部由人录入,而非自动发现——正因如此,当它和实际网络对不上时,台账仍然说明了本该是什么样子。", "as.what_devices": "设备是什么、连到了哪里:厂商、型号、序列号、管理地址,以及每个端口和它另一端连接的端口。", -"as.what_ipam": "有哪些网段,每个地址归谁。显示网段使用率,给出下一批空闲地址,并报告对不上的地方。", "as.locked": "此功能需要注册版。已经录入的内容会完整保留,装上授权后立刻可以继续使用。", "as.none": "—", "as.cancel": "取消", @@ -527,8 +522,6 @@ "as.edit_circuit": "编辑线路", "as.add_subnet": "添加网段", "as.edit_subnet": "编辑网段", -"as.add_addr": "分配地址", -"as.edit_addr": "编辑分配", "as.hidden_hint": "此记录只有管理员能看到。", "as.tags_hint": "core, prod, edge", "as.racks": "机柜", @@ -536,8 +529,9 @@ "as.no_racks": "还没有机柜。", "as.no_devices": "还没有设备。", "as.no_ports": "还没有端口。", +"as.subnets_of": "{x} 上的网段", +"as.subnets_sub": "记在端口旁边:这台设备承载的网段。这里不参与任何查询——图表、名称和筛选都不取自这张表。", "as.no_subnets": "还没有网段。", -"as.no_addrs": "还没有分配任何地址。", "as.c_sites": "机房 {x}", "as.c_racks": "机柜 {x}", "as.c_devices": "设备 {x}", @@ -555,25 +549,11 @@ "as.cabled_to": "对端", "as.cable_title": "{x} 的接线", "as.no_cable": "未接线", -"as.kind_static": "静态", -"as.kind_dhcp": "DHCP 地址池", -"as.kind_reserved": "保留", -"as.kind_gateway": "网关", -"as.kind_vip": "虚拟 (VIP)", -"as.assigned": "已分配", -"as.too_large": "过大,无法统计", -"as.addrs_of": "{x} 的地址", -"as.free_count": "空闲 {n}", -"as.next_free": "下一批空闲", -"as.conflicts": "对不上的地方", -"as.conflicts_sub": "只读取台账本身,离线时结果相同", -"as.cf_outside": "不属于任何已登记网段", -"as.cf_duplicate_mac": "同一个 MAC 出现在多个地址上", "as.what": "问题", "as.detail": "详情", "as.paste_title": "从交换机粘贴", "as.paste_sub": "直接用设备本来就会打印的内容", -"as.paste_what": "运行你平时就在用的命令,把输出原样粘贴进来:show lldp neighbors、show cdp neighbors、show interface status、show ip arp、show mac address-table。设备上不需要安装任何东西,也不会保存 community 或密码。先贴 ARP 表再贴 MAC 地址表,两者合起来就能知道某台主机插在哪个端口上。", +"as.paste_what": "运行你平时就在用的命令,把输出粘贴进来:show lldp neighbors、show cdp neighbors、show interface status。不会在设备上安装任何东西,也不会保存任何 community 字符串或密码。", "as.paste_from": "运行在哪台设备", "as.paste_hint": "把输出粘贴到这里", "as.preview": "先看看会做什么", @@ -597,7 +577,6 @@ "as.kind_of_link": "线缆", "as.kind_of_circuit": "线路", "as.kind_of_subnet": "网段", -"as.kind_of_addr": "地址", "nm.k_host": "主机", "nm.k_net": "网段", "nm.k_device": "设备", @@ -635,6 +614,10 @@ "nm.country_none": "国家/地区(可选)", "lic.free": "流量分析和威胁检测全部照常可用,数据也不会被删除;需要注册才能使用的功能,会在各自的位置上标明。", "lic.ok": "已授权给 {c},授权还有 {n} 天", +"lic.who": "授权给", +"lic.as_ind": "个人", +"lic.as_org": "企业", +"lic.as_unknown": "未标注类型", "lic.id": "安装编号 {id}", "lic.unreg": "未注册。", "lic.exp": "{c} 的授权已于 {d} 到期。", @@ -736,6 +719,8 @@ "bill.avg": "均值", "bill.coverage": "覆盖率", "bill.empty": "这个周期里没有接口计数器数据。这个数字来自设备自己上报的计数器,不是抽样流量。", +"bill.p95": "95 百分位", +"bill.chart_sub": "五分钟采样;虚线是计费值", "bill.gap_note": "没有测到的 5 分钟桶是不计入,而不是当作 0。当作 0 会把数字拉低。覆盖率说明这个数字建立在周期的多少比例上。", "bill.how": "这个数是怎么算出来的", "bill.how_note": "进、出两个方向各自在周期内排序,丢掉最高的 5% 的五分钟采样,取剩下最高的那个——两个方向取较大者。月份按本浏览器所在时区计算。", diff --git a/internal/web/static/index.html b/internal/web/static/index.html index ffae465..c032390 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -11,6 +11,7 @@