diff --git a/README.md b/README.md
index 154f575..ab60e61 100644
--- a/README.md
+++ b/README.md
@@ -135,7 +135,8 @@ starts with the computer, before anyone signs in, and that the window then drive
- **Source rules** for the devices that bend it further: right-click a message, say how that
device is read (any import format, a regex), fix its hostname, application, clock, severity
or encoding, and watch its latest messages change before saving — then read the stored
- history again with it. Start from a template, save your own, and export them to share
+ history again with it. Start from a template, save your own, and export them to share.
+ **Settings > Devices** lists every sender, and marks those that could use a rule
- **Mutual TLS** where senders must prove who they are
- **Allowed sources** by IP literal or CIDR (hygiene, not authentication — see the note below)
- **Per-IP connection cap** so one host cannot occupy every slot
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 6f2a505..1991b5f 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -196,6 +196,24 @@ that read a message.
Rules belong to the engine the window drives: while it drives the system service, they are the
service's. Installing the service copies the window's rules to it.
+#### Devices
+
+**Settings > Devices** lists every address messages arrive from: those heard since SyslogStudio
+started — or the service, while the window drives it — and those kept in the history.
+
+| Column | What it says |
+|---|---|
+| Device | The address and protocol, and the hostname of its last message. *N hostnames*: several behind one address — a relay, or a device that sends none and has the first word of each message taken for one |
+| Last heard, Since start, /min | When its last message arrived, how many since the start, how many in the last minute |
+| Stored | How many of its messages the history holds |
+| Reading | The rule that reads it; the share of its messages whose header bends the RFCs; *no priority*; *no hostname*; or *standard* |
+
+A device whose messages bend the RFCs or come without a hostname, and that no rule reads, is marked
+in the margin: it could use a rule. **Only those that could use a rule** lists just them, and the
+button on each line opens the editor on the rule that reads it, or on a new one for its address,
+tried on its latest messages — its last one at least, even when the live view holds none.
+**Count again** starts the counts from now.
+
#### Templates, and sharing rules
**Start from** in the rule editor fills the format and the corrections from a template, keeping the
diff --git a/docs/documentation.html b/docs/documentation.html
index 1344542..e5ed8b1 100644
--- a/docs/documentation.html
+++ b/docs/documentation.html
@@ -165,7 +165,9 @@
Messages that bend the standard
start from a template — a device that sends no hostname, a camera, JSON behind a syslog
header, garbled accents, a clock nobody set — and its way of reading, a regular
expression of your own included, can be saved as a template for the next rule or a file
- import. The messages already stored can be read
+ import. Settings › Devices lists every address messages arrive from,
+ how much each sends and how it is read, and marks the devices that bend the RFCs or
+ send no hostname with no rule to read them. The messages already stored can be read
again from their raw text with the rules as they are now — from every device or from
one rule's addresses, over the last day, week, month or all of it.
diff --git a/frontend/screenshots/bridge/App.js b/frontend/screenshots/bridge/App.js
index eb6a6cb..b228fdc 100644
--- a/frontend/screenshots/bridge/App.js
+++ b/frontend/screenshots/bridge/App.js
@@ -33,7 +33,7 @@ import {
IMPORT_PREVIEW,
SIMULATOR_CONFIG, SIMULATOR_STATUS,
NETWORK_INTERFACES, CERT_INFO,
- BACKGROUND_SETTINGS, SYSTEM_SERVICE, SERVICE_LINK, SOURCE_RULES, SOURCE_TEMPLATES,
+ BACKGROUND_SETTINGS, SYSTEM_SERVICE, SERVICE_LINK, SOURCE_RULES, SOURCE_TEMPLATES, SOURCE_DEVICES,
BACKUP_CONFIG, BACKUP_STATUS, BACKUP_FILES, BACKUP_LISTING, RECOVERY_KEY,
} from '../fixtures.js';
@@ -363,6 +363,8 @@ const HANDLERS = {
return Promise.resolve(null);
},
TrySourceRule: (rule, samples) => Promise.resolve(trySource(rule, samples || [])),
+ GetSourceDevices: () => Promise.resolve(clone(SOURCE_DEVICES)),
+ ClearSourceDevices: () => Promise.resolve(null),
GetSourceTemplates: () => Promise.resolve(clone(sourceTemplates)),
SaveSourceTemplate: (t) => {
const saved = { ...t, id: t.id || `mine-${Date.now()}` };
diff --git a/frontend/screenshots/fixtures.js b/frontend/screenshots/fixtures.js
index 6e0d567..39579b6 100644
--- a/frontend/screenshots/fixtures.js
+++ b/frontend/screenshots/fixtures.js
@@ -656,6 +656,21 @@ export const BACKUP_LISTING = { files: BACKUP_FILES, problems: [] };
* sends no hostname. Read the way the engine reads them, as far as a page can
* pretend to (bridge/App.js, TrySourceRule).
*/
+/**
+ * The devices heard from since the engine started, as GetSourceDevices says
+ * them: one its rule reads, a switch that bends the RFCs with no rule yet, a
+ * relay, and plain ones.
+ */
+const ago = (s) => new Date(Date.now() - s * 1000).toISOString();
+export const SOURCE_DEVICES = [
+ { address: '10.10.0.1', hostname: 'FGT60F', hostnames: ['FGT60F'], appName: '', protocol: 'UDP', firstSeen: ago(86400), lastSeen: ago(2), messages: 182340, perMinute: 214, nonStandard: 182340, noPriority: 0, noHostname: 0, ruled: 182340, readBy: 'Edge firewall', lastRaw: '<189>date=2026-10-27 time=09:14:02 devname="FGT60F" level="warning" msg="Admin login failed"' },
+ { address: '10.10.0.23', hostname: '', hostnames: [], appName: '%LINK-3-UPDOWN', protocol: 'UDP', firstSeen: ago(86400), lastSeen: ago(6), messages: 4821, perMinute: 12, nonStandard: 4821, noPriority: 0, noHostname: 4821, ruled: 0, lastRaw: '<187>123: *Oct 27 09:14:02.123: %LINK-3-UPDOWN: Interface Gi0/1, changed state to down' },
+ { address: '10.10.0.2', hostname: 'sw-core-01', hostnames: ['sw-core-01'], appName: 'stp', protocol: 'UDP', firstSeen: ago(86400), lastSeen: ago(9), messages: 9120, perMinute: 7, nonStandard: 0, noPriority: 0, noHostname: 0, ruled: 0, lastRaw: '<190>Oct 27 09:14:00 sw-core-01 stp: topology change on vlan 20' },
+ { address: '10.10.7.21', hostname: '10.10.7.21', hostnames: ['10.10.7.21'], appName: 'camera', protocol: 'UDP', firstSeen: ago(7200), lastSeen: ago(31), messages: 640, perMinute: 2, nonStandard: 640, noPriority: 640, noHostname: 0, ruled: 640, readBy: 'Lobby cameras', lastRaw: '2026-10-27 09:13:31 ERROR motion detection failed on channel 2' },
+ { address: '10.10.1.15', hostname: 'app-prod-02', hostnames: ['app-prod-02', 'jump-01', 'db-prod-01', 'backup-01'], appName: 'sshd', protocol: 'TCP', firstSeen: ago(86400), lastSeen: ago(44), messages: 25310, perMinute: 31, nonStandard: 0, noPriority: 0, noHostname: 0, ruled: 0, lastRaw: '<38>Oct 27 09:13:18 app-prod-02 sshd[4410]: Accepted publickey for deploy' },
+ { address: '10.10.3.40', hostname: 'printsrv', hostnames: ['printsrv'], appName: 'spooler', protocol: 'UDP', firstSeen: ago(5400), lastSeen: ago(380), messages: 58, perMinute: 0, nonStandard: 0, noPriority: 0, noHostname: 0, ruled: 0, lastRaw: '<13>Oct 27 09:07:42 printsrv spooler: Impression termin\uFFFDe' },
+];
+
/** The templates that come with SyslogStudio (sources.Templates), and one saved. */
export const SOURCE_TEMPLATES = [
{ id: 'no-hostname', rule: { format: { mode: 'auto' }, noHostname: true } },
diff --git a/frontend/src/components/Settings.svelte b/frontend/src/components/Settings.svelte
index 7c7d6d3..8080127 100644
--- a/frontend/src/components/Settings.svelte
+++ b/frontend/src/components/Settings.svelte
@@ -29,6 +29,7 @@
import BackgroundSettings from './BackgroundSettings.svelte';
import BackupSettings from './BackupSettings.svelte';
import SourceRules from './SourceRules.svelte';
+ import SourceDevices from './SourceDevices.svelte';
import { ruleDraft } from '../lib/sources';
import RecoveryKeySettings from './RecoveryKeySettings.svelte';
import { serviceLink } from '../lib/serviceLink';
@@ -38,7 +39,7 @@
// Opens Settings on a given tab — the service banner opens Background.
export let initialTab: Tab | '' = '';
- type Tab = 'general' | 'storage' | 'backups' | 'background' | 'sources' | 'about';
+ type Tab = 'general' | 'storage' | 'backups' | 'background' | 'sources' | 'devices' | 'about';
let activeTab: Tab = 'general';
const TABS: { id: Tab; label: string }[] = [
{ id: 'general', label: 'settings.general' },
@@ -46,6 +47,7 @@
{ id: 'backups', label: 'backup.tab' },
{ id: 'background', label: 'background.tab' },
{ id: 'sources', label: 'sources.tab' },
+ { id: 'devices', label: 'sources.devicesTab' },
{ id: 'about', label: 'settings.about' },
];
let tabButtons: HTMLButtonElement[] = [];
@@ -873,6 +875,9 @@
{:else if activeTab === 'sources'}
+ {:else if activeTab === 'devices'}
+
+
{:else if activeTab === 'about'}
+{/if}
+
+
diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json
index 4db7107..d6c8957 100644
--- a/frontend/src/lib/i18n/de.json
+++ b/frontend/src/lib/i18n/de.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "Quellen",
+ "devicesTab": "Geräte",
+ "devicesIntro": "Jede Adresse, von der Meldungen eintreffen: gehört seit dem Start von SyslogStudio — oder des Dienstes, wenn dieses Fenster ihn steuert — und im Verlauf gespeichert. Ein Gerät, dessen Meldungen von den RFCs abweichen oder ohne Hostnamen kommen und das keine Regel liest, wird markiert: Eine Regel würde ihm helfen.",
+ "devicesFilter": "Nach Adresse oder Hostname filtern",
+ "devicesAttention": "Nur solche, denen eine Regel helfen würde",
+ "devicesSort": "Sortieren nach",
+ "sortLast": "Zuletzt gehört",
+ "sortMessages": "Meldungen",
+ "sortRate": "Pro Minute",
+ "sortStored": "Gespeichert",
+ "sortAddress": "Adresse",
+ "devicesClear": "Neu zählen",
+ "devicesSummary": "{devices} Adressen · {attention} würde eine Regel helfen",
+ "devicesEmpty": "Es ist noch keine Meldung eingetroffen.",
+ "colDevice": "Gerät",
+ "colHeard": "Zuletzt gehört",
+ "colMessages": "Seit Start",
+ "colRate": "/min",
+ "colStored": "Gespeichert",
+ "colReading": "Lesart",
+ "devNotHeard": "nicht seit Start",
+ "badgeStandard": "Standard",
+ "badgeBends": "{pct} % weichen von den RFCs ab",
+ "badgeNoPri": "keine Priorität",
+ "badgeNoHost": "kein Hostname",
+ "badgeHosts": "{count} Hostnamen",
+ "badgeHostsHint": "Mehrere Hostnamen für eine Adresse: ein Relay, oder ein Gerät, das keinen sendet und bei dem das erste Wort jeder Meldung dafür gehalten wird.",
+ "badgeRule": "Regel: {name}",
+ "devRead": "Anders lesen…",
+ "devEditRule": "Seine Regel…",
"intro": "Manche Geräte weichen vom Syslog-Standard ab: kein Hostname, ein eigenes Format, überall dieselbe Priorität, eine Uhr in einer anderen Zeitzone, eine andere Kodierung als UTF-8. Eine Regel legt fest, wie die Meldungen eines solchen Geräts beim Eintreffen gelesen werden, bevor Live-Ansicht, Verlauf, Alarmregeln und Routing sie sehen. Die Rohmeldung bleibt immer erhalten.",
"rules": "Regeln",
"add": "Regel hinzufügen…",
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index 16a3927..3b26b67 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "Sources",
+ "devicesTab": "Devices",
+ "devicesIntro": "Every address messages arrive from: heard since SyslogStudio started — or the service, when this window drives it — and kept in the history. A device whose messages bend the RFCs or come without a hostname, and that no rule reads, is marked: it could use one.",
+ "devicesFilter": "Filter by address or hostname",
+ "devicesAttention": "Only those that could use a rule",
+ "devicesSort": "Sort by",
+ "sortLast": "Last heard from",
+ "sortMessages": "Messages",
+ "sortRate": "Per minute",
+ "sortStored": "Stored",
+ "sortAddress": "Address",
+ "devicesClear": "Count again",
+ "devicesSummary": "{devices} addresses · {attention} could use a rule",
+ "devicesEmpty": "No message has arrived yet.",
+ "colDevice": "Device",
+ "colHeard": "Last heard",
+ "colMessages": "Since start",
+ "colRate": "/min",
+ "colStored": "Stored",
+ "colReading": "Reading",
+ "devNotHeard": "not since start",
+ "badgeStandard": "standard",
+ "badgeBends": "{pct}% bend the RFCs",
+ "badgeNoPri": "no priority",
+ "badgeNoHost": "no hostname",
+ "badgeHosts": "{count} hostnames",
+ "badgeHostsHint": "Several hostnames from one address: a relay, or a device that sends none and has the first word of each message taken for one.",
+ "badgeRule": "rule: {name}",
+ "devRead": "Read differently…",
+ "devEditRule": "Its rule…",
"intro": "Some devices bend the syslog standard: no hostname, a format of their own, the same priority on everything, a clock in another zone, an encoding other than UTF-8. A rule says how the messages of such a device are read as they arrive, before the live view, the history, the alert rules and the routing see them. The raw message is always kept.",
"rules": "Rules",
"add": "Add a rule…",
diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json
index 3737e3c..60bb4b9 100644
--- a/frontend/src/lib/i18n/es.json
+++ b/frontend/src/lib/i18n/es.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "Fuentes",
+ "devicesTab": "Dispositivos",
+ "devicesIntro": "Cada dirección de la que llegan mensajes: oída desde que se inició SyslogStudio — o el servicio, cuando esta ventana lo maneja — y guardada en el historial. Un dispositivo cuyos mensajes se apartan de las RFC o llegan sin nombre de host, y que ninguna regla lee, queda marcado: una regla le vendría bien.",
+ "devicesFilter": "Filtrar por dirección o nombre de host",
+ "devicesAttention": "Solo los que necesitarían una regla",
+ "devicesSort": "Ordenar por",
+ "sortLast": "Último mensaje",
+ "sortMessages": "Mensajes",
+ "sortRate": "Por minuto",
+ "sortStored": "Guardados",
+ "sortAddress": "Dirección",
+ "devicesClear": "Volver a contar",
+ "devicesSummary": "{devices} direcciones · {attention} necesitarían una regla",
+ "devicesEmpty": "Aún no ha llegado ningún mensaje.",
+ "colDevice": "Dispositivo",
+ "colHeard": "Último mensaje",
+ "colMessages": "Desde el inicio",
+ "colRate": "/min",
+ "colStored": "Guardados",
+ "colReading": "Lectura",
+ "devNotHeard": "no desde el inicio",
+ "badgeStandard": "estándar",
+ "badgeBends": "{pct} % fuera de las RFC",
+ "badgeNoPri": "sin prioridad",
+ "badgeNoHost": "sin nombre de host",
+ "badgeHosts": "{count} nombres de host",
+ "badgeHostsHint": "Varios nombres de host para una dirección: un relé, o un dispositivo que no envía ninguno y del que se toma la primera palabra de cada mensaje por uno.",
+ "badgeRule": "regla: {name}",
+ "devRead": "Leer de otra forma…",
+ "devEditRule": "Su regla…",
"intro": "Algunos dispositivos se apartan del estándar syslog: sin nombre de host, un formato propio, la misma prioridad en todo, un reloj en otra zona, una codificación distinta de UTF-8. Una regla indica cómo leer los mensajes de un dispositivo así al llegar, antes de que la vista en directo, el historial, las alertas y el enrutamiento los vean. El mensaje bruto siempre se conserva.",
"rules": "Reglas",
"add": "Añadir una regla…",
diff --git a/frontend/src/lib/i18n/fr.json b/frontend/src/lib/i18n/fr.json
index 7c92c08..9cebd81 100644
--- a/frontend/src/lib/i18n/fr.json
+++ b/frontend/src/lib/i18n/fr.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "Sources",
+ "devicesTab": "Appareils",
+ "devicesIntro": "Toutes les adresses d’où arrivent des messages : entendues depuis le démarrage de SyslogStudio — ou du service, quand cette fenêtre le pilote — et conservées dans l’historique. Un appareil dont les messages s’écartent des RFC ou arrivent sans nom d’hôte, et qu’aucune règle ne lit, est signalé : une règle lui serait utile.",
+ "devicesFilter": "Filtrer par adresse ou nom d’hôte",
+ "devicesAttention": "Seulement ceux à qui une règle serait utile",
+ "devicesSort": "Trier par",
+ "sortLast": "Dernier message",
+ "sortMessages": "Messages",
+ "sortRate": "Par minute",
+ "sortStored": "Enregistrés",
+ "sortAddress": "Adresse",
+ "devicesClear": "Recompter",
+ "devicesSummary": "{devices} adresses · {attention} à qui une règle serait utile",
+ "devicesEmpty": "Aucun message n’est encore arrivé.",
+ "colDevice": "Appareil",
+ "colHeard": "Dernier message",
+ "colMessages": "Depuis le démarrage",
+ "colRate": "/min",
+ "colStored": "Enregistrés",
+ "colReading": "Lecture",
+ "devNotHeard": "pas depuis le démarrage",
+ "badgeStandard": "standard",
+ "badgeBends": "{pct} % hors RFC",
+ "badgeNoPri": "sans priorité",
+ "badgeNoHost": "sans nom d’hôte",
+ "badgeHosts": "{count} noms d’hôte",
+ "badgeHostsHint": "Plusieurs noms d’hôte pour une adresse : un relais, ou un appareil qui n’en envoie pas et dont le premier mot de chaque message est pris pour un.",
+ "badgeRule": "règle : {name}",
+ "devRead": "Lire autrement…",
+ "devEditRule": "Sa règle…",
"intro": "Certains appareils s’écartent du standard syslog : pas de nom d’hôte, un format à eux, la même priorité sur tout, une horloge dans un autre fuseau, un encodage autre qu’UTF-8. Une règle dit comment lire les messages d’un tel appareil à leur arrivée, avant que la vue en direct, l’historique, les alertes et le routage ne les voient. Le message brut est toujours conservé.",
"rules": "Règles",
"add": "Ajouter une règle…",
diff --git a/frontend/src/lib/i18n/it.json b/frontend/src/lib/i18n/it.json
index b1d933d..8d65c6c 100644
--- a/frontend/src/lib/i18n/it.json
+++ b/frontend/src/lib/i18n/it.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "Sorgenti",
+ "devicesTab": "Dispositivi",
+ "devicesIntro": "Ogni indirizzo da cui arrivano messaggi: ascoltato dall’avvio di SyslogStudio — o del servizio, quando questa finestra lo pilota — e conservato nella cronologia. Un dispositivo i cui messaggi si discostano dalle RFC o arrivano senza nome host, e che nessuna regola legge, viene segnalato: una regola gli sarebbe utile.",
+ "devicesFilter": "Filtra per indirizzo o nome host",
+ "devicesAttention": "Solo quelli a cui servirebbe una regola",
+ "devicesSort": "Ordina per",
+ "sortLast": "Ultimo messaggio",
+ "sortMessages": "Messaggi",
+ "sortRate": "Al minuto",
+ "sortStored": "Salvati",
+ "sortAddress": "Indirizzo",
+ "devicesClear": "Ricomincia a contare",
+ "devicesSummary": "{devices} indirizzi · {attention} a cui servirebbe una regola",
+ "devicesEmpty": "Non è ancora arrivato alcun messaggio.",
+ "colDevice": "Dispositivo",
+ "colHeard": "Ultimo messaggio",
+ "colMessages": "Dall’avvio",
+ "colRate": "/min",
+ "colStored": "Salvati",
+ "colReading": "Lettura",
+ "devNotHeard": "non dall’avvio",
+ "badgeStandard": "standard",
+ "badgeBends": "{pct}% fuori dalle RFC",
+ "badgeNoPri": "senza priorità",
+ "badgeNoHost": "senza nome host",
+ "badgeHosts": "{count} nomi host",
+ "badgeHostsHint": "Più nomi host per un indirizzo: un relay, o un dispositivo che non ne invia e di cui la prima parola di ogni messaggio viene presa per uno.",
+ "badgeRule": "regola: {name}",
+ "devRead": "Leggi diversamente…",
+ "devEditRule": "La sua regola…",
"intro": "Alcuni dispositivi si discostano dallo standard syslog: nessun nome host, un formato proprio, la stessa priorità su tutto, un orologio in un altro fuso, una codifica diversa da UTF-8. Una regola dice come leggere i messaggi di un tale dispositivo all’arrivo, prima che la vista in diretta, la cronologia, gli avvisi e l’instradamento li vedano. Il messaggio grezzo viene sempre conservato.",
"rules": "Regole",
"add": "Aggiungi una regola…",
diff --git a/frontend/src/lib/i18n/ja.json b/frontend/src/lib/i18n/ja.json
index 6688fe1..9b7b5ca 100644
--- a/frontend/src/lib/i18n/ja.json
+++ b/frontend/src/lib/i18n/ja.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "ソース",
+ "devicesTab": "デバイス",
+ "devicesIntro": "メッセージの送信元となるすべてのアドレス: SyslogStudio (このウィンドウがサービスを操作している場合はサービス) の起動以降に受信したものと、履歴に保存されているもの。RFC から外れたメッセージやホスト名のないメッセージを送り、どのルールにも読まれていないデバイスには印が付きます。ルールを作ると役立ちます。",
+ "devicesFilter": "アドレスまたはホスト名で絞り込み",
+ "devicesAttention": "ルールが役立つものだけ",
+ "devicesSort": "並べ替え",
+ "sortLast": "最終受信",
+ "sortMessages": "メッセージ数",
+ "sortRate": "毎分",
+ "sortStored": "保存済み",
+ "sortAddress": "アドレス",
+ "devicesClear": "数え直す",
+ "devicesSummary": "{devices} 件のアドレス · ルールが役立つもの {attention} 件",
+ "devicesEmpty": "まだメッセージが届いていません。",
+ "colDevice": "デバイス",
+ "colHeard": "最終受信",
+ "colMessages": "起動以降",
+ "colRate": "/分",
+ "colStored": "保存済み",
+ "colReading": "読み方",
+ "devNotHeard": "起動以降なし",
+ "badgeStandard": "標準",
+ "badgeBends": "{pct}% が RFC 外",
+ "badgeNoPri": "優先度なし",
+ "badgeNoHost": "ホスト名なし",
+ "badgeHosts": "ホスト名 {count} 件",
+ "badgeHostsHint": "1 つのアドレスに複数のホスト名: リレー、またはホスト名を送らず各メッセージの最初の語がホスト名と見なされているデバイスです。",
+ "badgeRule": "ルール: {name}",
+ "devRead": "別の方法で読む…",
+ "devEditRule": "そのルール…",
"intro": "一部の機器は syslog 標準から外れています:ホスト名がない、独自の形式、すべて同じ優先度、別のタイムゾーンの時計、UTF-8 以外のエンコーディング。ルールは、そのような機器のメッセージを受信時にどう読むかを決めます。ライブビュー、履歴、アラート、ルーティングより前に適用され、生のメッセージは常に保持されます。",
"rules": "ルール",
"add": "ルールを追加…",
diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json
index 0d6609a..e864500 100644
--- a/frontend/src/lib/i18n/pt.json
+++ b/frontend/src/lib/i18n/pt.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "Fontes",
+ "devicesTab": "Dispositivos",
+ "devicesIntro": "Cada endereço de onde chegam mensagens: ouvido desde o arranque do SyslogStudio — ou do serviço, quando esta janela o comanda — e guardado no histórico. Um dispositivo cujas mensagens fogem às RFC ou chegam sem nome de host, e que nenhuma regra lê, fica assinalado: uma regra ser-lhe-ia útil.",
+ "devicesFilter": "Filtrar por endereço ou nome de host",
+ "devicesAttention": "Só os que precisariam de uma regra",
+ "devicesSort": "Ordenar por",
+ "sortLast": "Última mensagem",
+ "sortMessages": "Mensagens",
+ "sortRate": "Por minuto",
+ "sortStored": "Guardadas",
+ "sortAddress": "Endereço",
+ "devicesClear": "Contar de novo",
+ "devicesSummary": "{devices} endereços · {attention} precisariam de uma regra",
+ "devicesEmpty": "Ainda não chegou nenhuma mensagem.",
+ "colDevice": "Dispositivo",
+ "colHeard": "Última mensagem",
+ "colMessages": "Desde o arranque",
+ "colRate": "/min",
+ "colStored": "Guardadas",
+ "colReading": "Leitura",
+ "devNotHeard": "não desde o arranque",
+ "badgeStandard": "padrão",
+ "badgeBends": "{pct} % fora das RFC",
+ "badgeNoPri": "sem prioridade",
+ "badgeNoHost": "sem nome de host",
+ "badgeHosts": "{count} nomes de host",
+ "badgeHostsHint": "Vários nomes de host para um endereço: um relay, ou um dispositivo que não envia nenhum e cuja primeira palavra de cada mensagem é tomada por um.",
+ "badgeRule": "regra: {name}",
+ "devRead": "Ler de outra forma…",
+ "devEditRule": "A sua regra…",
"intro": "Alguns dispositivos afastam-se da norma syslog: sem nome de host, um formato próprio, a mesma prioridade em tudo, um relógio noutro fuso, uma codificação diferente de UTF-8. Uma regra diz como ler as mensagens de um dispositivo assim à chegada, antes de a vista em direto, o histórico, os alertas e o encaminhamento as verem. A mensagem bruta é sempre conservada.",
"rules": "Regras",
"add": "Adicionar uma regra…",
diff --git a/frontend/src/lib/i18n/zh.json b/frontend/src/lib/i18n/zh.json
index 581b02d..23f491a 100644
--- a/frontend/src/lib/i18n/zh.json
+++ b/frontend/src/lib/i18n/zh.json
@@ -703,6 +703,35 @@
},
"sources": {
"tab": "来源",
+ "devicesTab": "设备",
+ "devicesIntro": "所有发送消息的地址:自 SyslogStudio(或此窗口控制服务时的服务)启动以来收到的,以及保存在历史记录中的。消息不符合 RFC 或没有主机名、且没有规则读取的设备会被标记:为它编写规则会有帮助。",
+ "devicesFilter": "按地址或主机名筛选",
+ "devicesAttention": "只显示需要规则的设备",
+ "devicesSort": "排序方式",
+ "sortLast": "最近收到",
+ "sortMessages": "消息数",
+ "sortRate": "每分钟",
+ "sortStored": "已存储",
+ "sortAddress": "地址",
+ "devicesClear": "重新计数",
+ "devicesSummary": "{devices} 个地址 · {attention} 个需要规则",
+ "devicesEmpty": "尚未收到任何消息。",
+ "colDevice": "设备",
+ "colHeard": "最近收到",
+ "colMessages": "启动以来",
+ "colRate": "/分",
+ "colStored": "已存储",
+ "colReading": "读取",
+ "devNotHeard": "启动以来未收到",
+ "badgeStandard": "标准",
+ "badgeBends": "{pct}% 不符合 RFC",
+ "badgeNoPri": "无优先级",
+ "badgeNoHost": "无主机名",
+ "badgeHosts": "{count} 个主机名",
+ "badgeHostsHint": "一个地址对应多个主机名:可能是中继,或者设备不发送主机名,每条消息的第一个词被当作主机名。",
+ "badgeRule": "规则:{name}",
+ "devRead": "换一种方式读取…",
+ "devEditRule": "其规则…",
"intro": "有些设备偏离 syslog 标准:没有主机名、自有格式、所有消息同一优先级、时钟在其他时区、编码不是 UTF-8。规则规定此类设备的消息到达时如何读取,先于实时视图、历史记录、告警规则和路由。原始消息始终保留。",
"rules": "规则",
"add": "添加规则…",
diff --git a/frontend/src/lib/sources.ts b/frontend/src/lib/sources.ts
index fb10969..22f1703 100644
--- a/frontend/src/lib/sources.ts
+++ b/frontend/src/lib/sources.ts
@@ -113,6 +113,40 @@ export function withTemplate(rule: SourceRule, t: SourceTemplate): SourceRule {
};
}
+/** One address messages arrive from, as the engine has heard it since it started. */
+export interface SourceDevice {
+ address: string;
+ hostname: string;
+ /** The different hostnames read lately, the last first. */
+ hostnames: string[];
+ appName: string;
+ protocol: string;
+ firstSeen: string;
+ lastSeen: string;
+ messages: number;
+ perMinute: number;
+ /** With a header that is not the RFCs' (no priority included). */
+ nonStandard: number;
+ noPriority: number;
+ /** Left without a hostname once read. */
+ noHostname: number;
+ ruled: number;
+ readBy?: string;
+ /** Its last message as it arrived, cut short. */
+ lastRaw: string;
+}
+
+export const getSourceDevices = async (): Promise => (await callGo('GetSourceDevices')) || [];
+export const clearSourceDevices = (): Promise => callGo('ClearSourceDevices');
+
+/** A new rule for a device of the inventory. */
+export function ruleForDevice(address: string, d?: SourceDevice): SourceRule {
+ const rule = emptyRule();
+ rule.match = { sources: [address] };
+ rule.name = d?.hostname && d.hostname !== address ? `${d.hostname} (${address})` : address;
+ return rule;
+}
+
/** Which stored messages to read again with the rules. */
export interface RereadOptions {
/** Received at or after this instant (RFC 3339); '' is every one. */
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 79b2ed1..8dbf630 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -30,6 +30,8 @@ export function ClearMessages():Promise;
export function ClearNotifyLog():Promise;
+export function ClearSourceDevices():Promise;
+
export function CloseBackup():Promise;
export function CompactDatabase():Promise;
@@ -144,6 +146,8 @@ export function GetSimulatorConfig():Promise;
export function GetSimulatorStatus():Promise;
+export function GetSourceDevices():Promise>;
+
export function GetSourceRules():Promise>;
export function GetSourceTemplates():Promise>;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index 2dd14bb..471ed41 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -50,6 +50,10 @@ export function ClearNotifyLog() {
return window['go']['main']['App']['ClearNotifyLog']();
}
+export function ClearSourceDevices() {
+ return window['go']['main']['App']['ClearSourceDevices']();
+}
+
export function CloseBackup() {
return window['go']['main']['App']['CloseBackup']();
}
@@ -278,6 +282,10 @@ export function GetSimulatorStatus() {
return window['go']['main']['App']['GetSimulatorStatus']();
}
+export function GetSourceDevices() {
+ return window['go']['main']['App']['GetSourceDevices']();
+}
+
export function GetSourceRules() {
return window['go']['main']['App']['GetSourceRules']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 8503a88..6f76f36 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -1408,6 +1408,66 @@ export namespace models {
}
}
+ export class SourceDevice {
+ address: string;
+ hostname: string;
+ hostnames: string[];
+ appName: string;
+ protocol: string;
+ // Go type: time
+ firstSeen: any;
+ // Go type: time
+ lastSeen: any;
+ messages: number;
+ perMinute: number;
+ nonStandard: number;
+ noPriority: number;
+ noHostname: number;
+ ruled: number;
+ readBy?: string;
+ lastRaw: string;
+
+ static createFrom(source: any = {}) {
+ return new SourceDevice(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.address = source["address"];
+ this.hostname = source["hostname"];
+ this.hostnames = source["hostnames"];
+ this.appName = source["appName"];
+ this.protocol = source["protocol"];
+ this.firstSeen = this.convertValues(source["firstSeen"], null);
+ this.lastSeen = this.convertValues(source["lastSeen"], null);
+ this.messages = source["messages"];
+ this.perMinute = source["perMinute"];
+ this.nonStandard = source["nonStandard"];
+ this.noPriority = source["noPriority"];
+ this.noHostname = source["noHostname"];
+ this.ruled = source["ruled"];
+ this.readBy = source["readBy"];
+ this.lastRaw = source["lastRaw"];
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
export class SourceSample {
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 63b5ff5..9d3e5ff 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -39,6 +39,8 @@ type Engine struct {
logStore *storage.LogStore
// reader reads what the server receives: the source rules' reading.
reader *sources.Reader
+ // inventory is the devices it has read messages from since Open.
+ inventory *sources.Inventory
// rereading reads stored messages again with it (reread.go).
rereading rereading
simulator *simulator.Simulator
@@ -126,6 +128,8 @@ func (e *Engine) Open(emitter event.EventEmitter) error {
// source rule the way its rule says (package sources).
e.reader = sources.New()
e.applySourceRules()
+ e.inventory = sources.NewInventory()
+ e.reader.SetInventory(e.inventory)
e.server.SetReader(e.reader.Read)
e.simulator = simulator.New(emitter)
diff --git a/internal/engine/sources.go b/internal/engine/sources.go
index 38e1d01..c94f8c9 100644
--- a/internal/engine/sources.go
+++ b/internal/engine/sources.go
@@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"strings"
+ "time"
"SyslogStudio/internal/models"
"SyslogStudio/internal/sources"
@@ -26,6 +27,22 @@ func (e *Engine) applySourceRules() {
}
}
+// GetSourceDevices returns the devices messages have arrived from since the
+// engine started, the last heard from first.
+func (e *Engine) GetSourceDevices() []models.SourceDevice {
+ if e.inventory == nil {
+ return []models.SourceDevice{}
+ }
+ return e.inventory.Devices(time.Now())
+}
+
+// ClearSourceDevices forgets them: counting starts again from now.
+func (e *Engine) ClearSourceDevices() {
+ if e.inventory != nil {
+ e.inventory.Clear()
+ }
+}
+
// GetSourceRules returns the rules, in the order they are tried.
func (e *Engine) GetSourceRules() []models.SourceRule {
rules := e.configStore.LoadSourceRules()
diff --git a/internal/engine/sources_test.go b/internal/engine/sources_test.go
index 8948f37..99bf905 100644
--- a/internal/engine/sources_test.go
+++ b/internal/engine/sources_test.go
@@ -2,6 +2,7 @@ package engine
import (
"testing"
+ "time"
"SyslogStudio/internal/event"
"SyslogStudio/internal/models"
@@ -71,3 +72,22 @@ func TestSourceRules_SavedOrderedAppliedAndBundled(t *testing.T) {
t.Fatalf("after deleting: %q", got)
}
}
+
+// What the server reads is counted, by address; reading again is not.
+func TestSourceDevices(t *testing.T) {
+ e, _ := openEngine(t, nil)
+ if d := e.GetSourceDevices(); d == nil || len(d) != 0 {
+ t.Fatalf("before any message: %v", d)
+ }
+ e.reader.Read([]byte("<14>Oct 27 09:14:02 dhcp: lease assigned"), "10.0.0.5", "UDP")
+ e.reader.Read([]byte("no priority at all"), "10.0.0.5", "UDP")
+ e.reader.ReadAt([]byte("<14>Oct 27 09:14:02 dhcp: lease assigned"), "10.0.0.6", "UDP", time.Now())
+ devs := e.GetSourceDevices()
+ if len(devs) != 1 || devs[0].Address != "10.0.0.5" || devs[0].Messages != 2 || devs[0].NoPriority != 1 {
+ t.Fatalf("devices: %+v", devs)
+ }
+ e.ClearSourceDevices()
+ if len(e.GetSourceDevices()) != 0 {
+ t.Fatal("not cleared")
+ }
+}
diff --git a/internal/models/sources.go b/internal/models/sources.go
index 64346e1..f5dd98e 100644
--- a/internal/models/sources.go
+++ b/internal/models/sources.go
@@ -4,6 +4,7 @@ import (
"fmt"
"path"
"strings"
+ "time"
)
// SourceRule says how the messages from a device, or a group of devices, are
@@ -120,6 +121,39 @@ func (r SourceRule) AsTemplate() SourceRule {
return r
}
+// SourceDevice is one address messages arrive from, as the engine has seen
+// it since it started: what it sends, how much, and how it is read — so that
+// a device that bends the RFCs, or sends no hostname, shows before anyone
+// wonders why its messages look wrong.
+type SourceDevice struct {
+ Address string `json:"address"`
+ // Hostname and AppName are those of its last message, as read; Hostnames
+ // the different ones read lately, the last first — several behind one
+ // address is a relay.
+ Hostname string `json:"hostname"`
+ Hostnames []string `json:"hostnames"`
+ AppName string `json:"appName"`
+ Protocol string `json:"protocol"`
+ FirstSeen time.Time `json:"firstSeen"`
+ LastSeen time.Time `json:"lastSeen"`
+ Messages int64 `json:"messages"`
+ // PerMinute is how many arrived in the last minute.
+ PerMinute int64 `json:"perMinute"`
+ // NonStandard is how many had a header that is not the RFCs' (no
+ // priority included), NoPriority how many had no priority at all, and
+ // NoHostname how many were left without a hostname once read.
+ NonStandard int64 `json:"nonStandard"`
+ NoPriority int64 `json:"noPriority"`
+ NoHostname int64 `json:"noHostname"`
+ // Ruled is how many a source rule read; ReadBy the rule that read the
+ // last one, "" if none did.
+ Ruled int64 `json:"ruled"`
+ ReadBy string `json:"readBy,omitempty"`
+ // LastRaw is its last message as it arrived, cut short: what to try a
+ // rule on when the live view holds none of its messages.
+ LastRaw string `json:"lastRaw"`
+}
+
// RereadOptions says which stored messages to read again with the source
// rules. Only messages received from a device are: an imported line was read
// by its file's format.
diff --git a/internal/remote/backend.go b/internal/remote/backend.go
index 8d60901..7fc95a8 100644
--- a/internal/remote/backend.go
+++ b/internal/remote/backend.go
@@ -128,6 +128,9 @@ type Backend interface {
DeleteSourceRule(id string)
ReorderSourceRules(ids []string) error
TrySourceRule(rule models.SourceRule, samples []models.SourceSample) ([]models.SourceTrial, error)
+ // The devices messages arrive from, since the engine started.
+ GetSourceDevices() []models.SourceDevice
+ ClearSourceDevices()
// Stored messages read again with the rules; history:reread follows it.
RereadHistory(opts models.RereadOptions) error
GetHistoryReread() models.HistoryReread
diff --git a/internal/remote/client_gen.go b/internal/remote/client_gen.go
index 9a5e95d..1f2771f 100644
--- a/internal/remote/client_gen.go
+++ b/internal/remote/client_gen.go
@@ -445,6 +445,16 @@ func (c *Client) TrySourceRule(rule models.SourceRule, samples []models.SourceSa
return r0, err
}
+func (c *Client) GetSourceDevices() []models.SourceDevice {
+ var r0 []models.SourceDevice
+ c.logLost("GetSourceDevices", c.call("GetSourceDevices", []any{}, &r0))
+ return r0
+}
+
+func (c *Client) ClearSourceDevices() {
+ c.logLost("ClearSourceDevices", c.call("ClearSourceDevices", []any{}))
+}
+
func (c *Client) RereadHistory(opts models.RereadOptions) error {
err := c.call("RereadHistory", []any{opts})
return err
diff --git a/internal/remote/switch_gen.go b/internal/remote/switch_gen.go
index aaa40cb..77d896c 100644
--- a/internal/remote/switch_gen.go
+++ b/internal/remote/switch_gen.go
@@ -328,6 +328,14 @@ func (s *Switch) TrySourceRule(rule models.SourceRule, samples []models.SourceSa
return s.Current().TrySourceRule(rule, samples)
}
+func (s *Switch) GetSourceDevices() []models.SourceDevice {
+ return s.Current().GetSourceDevices()
+}
+
+func (s *Switch) ClearSourceDevices() {
+ s.Current().ClearSourceDevices()
+}
+
func (s *Switch) RereadHistory(opts models.RereadOptions) error {
return s.Current().RereadHistory(opts)
}
diff --git a/internal/sources/inventory.go b/internal/sources/inventory.go
new file mode 100644
index 0000000..f1b6cd4
--- /dev/null
+++ b/internal/sources/inventory.go
@@ -0,0 +1,142 @@
+package sources
+
+import (
+ "slices"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "SyslogStudio/internal/models"
+ "SyslogStudio/internal/syslog"
+)
+
+// Inventory is the devices messages arrive from since the engine started
+// (models.SourceDevice). Bounded: past maxDevices addresses, a new one is not
+// kept — a scan from a whole network would otherwise be remembered forever.
+type Inventory struct {
+ mu sync.Mutex
+ devices map[string]*device
+}
+
+const (
+ maxDevices = 5000
+ maxHostnames = 8
+ maxRaw = 400
+ // rateSlot is the width of the slots the last minute is counted in.
+ rateSlot = 10 * time.Second
+ rateSlots = 6
+)
+
+type device struct {
+ models.SourceDevice
+ // counts[i] is how many arrived in the slot slots[i], a count of
+ // rateSlot since the epoch.
+ counts [rateSlots]int64
+ slots [rateSlots]int64
+}
+
+// NewInventory makes an empty inventory.
+func NewInventory() *Inventory {
+ return &Inventory{devices: map[string]*device{}}
+}
+
+// note counts a message as it was read: rd is how its header was.
+func (inv *Inventory) note(msg models.SyslogMessage, rd syslog.Reading) {
+ at := msg.ReceivedAt
+ inv.mu.Lock()
+ defer inv.mu.Unlock()
+ d := inv.devices[msg.SourceIP]
+ if d == nil {
+ if len(inv.devices) >= maxDevices {
+ return
+ }
+ d = &device{SourceDevice: models.SourceDevice{Address: msg.SourceIP, FirstSeen: at}}
+ inv.devices[msg.SourceIP] = d
+ }
+ d.Messages++
+ d.LastSeen = at
+ d.Protocol = msg.Protocol
+ d.Hostname = msg.Hostname
+ d.AppName = msg.AppName
+ if msg.Hostname == "" {
+ d.NoHostname++
+ } else {
+ // The last first, each once.
+ d.Hostnames = slices.DeleteFunc(d.Hostnames, func(h string) bool { return h == msg.Hostname })
+ d.Hostnames = append([]string{msg.Hostname}, d.Hostnames...)
+ if len(d.Hostnames) > maxHostnames {
+ d.Hostnames = d.Hostnames[:maxHostnames]
+ }
+ }
+ if !rd.HasPRI {
+ d.NoPriority++
+ }
+ if !rd.Standard {
+ d.NonStandard++
+ }
+ d.ReadBy = msg.ReadBy
+ if msg.ReadBy != "" {
+ d.Ruled++
+ }
+ d.LastRaw = cut(msg.RawMessage, maxRaw)
+
+ slot := at.UnixNano() / int64(rateSlot)
+ i := int(slot % rateSlots)
+ if d.slots[i] != slot {
+ d.slots[i], d.counts[i] = slot, 0
+ }
+ d.counts[i]++
+}
+
+// Devices returns them, the last heard from first.
+func (inv *Inventory) Devices(now time.Time) []models.SourceDevice {
+ current := now.UnixNano() / int64(rateSlot)
+ inv.mu.Lock()
+ out := make([]models.SourceDevice, 0, len(inv.devices))
+ for _, d := range inv.devices {
+ dev := d.SourceDevice
+ dev.Hostnames = slices.Clone(d.Hostnames)
+ for i := range d.slots {
+ if d.slots[i] > current-rateSlots && d.slots[i] <= current {
+ dev.PerMinute += d.counts[i]
+ }
+ }
+ out = append(out, dev)
+ }
+ inv.mu.Unlock()
+ slices.SortFunc(out, func(a, b models.SourceDevice) int {
+ if c := b.LastSeen.Compare(a.LastSeen); c != 0 {
+ return c
+ }
+ return compareText(a.Address, b.Address)
+ })
+ return out
+}
+
+// Clear forgets every device: counting starts again from now.
+func (inv *Inventory) Clear() {
+ inv.mu.Lock()
+ defer inv.mu.Unlock()
+ inv.devices = map[string]*device{}
+}
+
+func compareText(a, b string) int {
+ switch {
+ case a < b:
+ return -1
+ case a > b:
+ return 1
+ }
+ return 0
+}
+
+// cut shortens s to at most n bytes, on a character boundary.
+func cut(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ for n > 0 && !utf8.RuneStart(s[n]) {
+ n--
+ }
+ return s[:n] + "…"
+}
diff --git a/internal/sources/inventory_test.go b/internal/sources/inventory_test.go
new file mode 100644
index 0000000..e8b360e
--- /dev/null
+++ b/internal/sources/inventory_test.go
@@ -0,0 +1,88 @@
+package sources
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "SyslogStudio/internal/models"
+ "SyslogStudio/internal/syslog"
+)
+
+// What each device sends is counted as it is read: how many, how lately,
+// with which header, which hostnames, which rule.
+func TestInventory(t *testing.T) {
+ r := reader(t, models.SourceRule{Name: "camera", Enabled: true, Match: bySource("10.0.0.17"), NoHostname: true})
+ inv := NewInventory()
+ r.SetInventory(inv)
+
+ r.Read([]byte("<14>1 2026-10-27T09:14:02Z web-01 nginx - - - hello"), "10.0.0.5", "UDP")
+ r.Read([]byte("<14>Oct 27 09:14:03 relay-a sshd: hi"), "10.0.0.9", "UDP")
+ r.Read([]byte("<14>Oct 27 09:14:04 relay-b sshd: hi"), "10.0.0.9", "UDP")
+ r.Read([]byte("<14>Oct 27 09:14:05 relay-a sshd: again"), "10.0.0.9", "UDP")
+ r.Read([]byte("2026-10-27 09:14:02 ERROR motion detection failed"), "10.0.0.17", "UDP")
+ r.Read([]byte(`<189>date=2026-10-27 time=09:14:02 devname="FGT60F" level="warning" msg="x"`), "10.0.0.1", "UDP")
+ // Read again and tried: not received, not counted.
+ r.ReadAt([]byte("<14>Oct 27 09:14:03 relay-c sshd: hi"), "10.0.0.9", "UDP", time.Now())
+ Try(models.SourceRule{Name: "t", Enabled: true, Match: bySource("10.0.0.9")}, []models.SourceSample{{Raw: "x", SourceIP: "10.0.0.9", Protocol: "UDP"}})
+
+ devs := inv.Devices(time.Now())
+ by := map[string]models.SourceDevice{}
+ for _, d := range devs {
+ by[d.Address] = d
+ }
+ if len(devs) != 4 || devs[0].Address != "10.0.0.1" {
+ t.Fatalf("devices, the last heard from first: %+v", devs)
+ }
+ if d := by["10.0.0.5"]; d.Messages != 1 || d.NonStandard != 0 || d.NoPriority != 0 || d.Hostname != "web-01" || d.AppName != "nginx" {
+ t.Errorf("a standard sender: %+v", d)
+ }
+ if d := by["10.0.0.9"]; d.Messages != 3 || d.PerMinute != 3 || strings.Join(d.Hostnames, ",") != "relay-a,relay-b" || d.LastRaw != "<14>Oct 27 09:14:05 relay-a sshd: again" {
+ t.Errorf("behind a relay: %+v", d)
+ }
+ if d := by["10.0.0.17"]; d.NoPriority != 1 || d.NonStandard != 1 || d.Ruled != 1 || d.ReadBy != "camera" || d.Hostname != "10.0.0.17" {
+ t.Errorf("a camera its rule reads: %+v", d)
+ }
+ if d := by["10.0.0.1"]; d.NonStandard != 1 || d.NoPriority != 0 || d.NoHostname != 0 {
+ t.Errorf("key=value after a priority: %+v", d)
+ }
+
+ // A minute later, nothing arrived in the last one.
+ if d := inv.Devices(time.Now().Add(2 * time.Minute)); d[0].PerMinute != 0 {
+ t.Errorf("per minute, two minutes on: %d", d[0].PerMinute)
+ }
+
+ inv.Clear()
+ if len(inv.Devices(time.Now())) != 0 {
+ t.Error("not cleared")
+ }
+}
+
+// Past maxDevices addresses, a new one is not kept; those kept still count.
+func TestInventory_Bounded(t *testing.T) {
+ inv := NewInventory()
+ at := time.Now()
+ for i := 0; i < maxDevices+10; i++ {
+ inv.note(models.SyslogMessage{SourceIP: fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255), ReceivedAt: at}, syslog.Reading{HasPRI: true, Standard: true})
+ }
+ inv.note(models.SyslogMessage{SourceIP: "10.0.0.0", ReceivedAt: at}, syslog.Reading{})
+ devs := inv.Devices(at)
+ if len(devs) != maxDevices {
+ t.Fatalf("%d devices kept", len(devs))
+ }
+ for _, d := range devs {
+ if d.Address == "10.0.0.0" && (d.Messages != 2 || d.NoPriority != 1) {
+ t.Errorf("a device kept stopped counting: %+v", d)
+ }
+ }
+}
+
+func TestCut(t *testing.T) {
+ if got := cut("abcdé", 5); got != "abcd…" {
+ t.Errorf("cut in the middle of é: %q", got)
+ }
+ if got := cut("abc", 5); got != "abc" {
+ t.Errorf("short: %q", got)
+ }
+}
diff --git a/internal/sources/sources.go b/internal/sources/sources.go
index 4b03e78..f50693c 100644
--- a/internal/sources/sources.go
+++ b/internal/sources/sources.go
@@ -31,8 +31,14 @@ import (
type Reader struct {
wire *importer.WireReader
rules atomic.Pointer[[]*rule]
+ // inventory, when set, counts what Read reads: messages received, not
+ // those read again or tried.
+ inventory atomic.Pointer[Inventory]
}
+// SetInventory has what is read from now on counted in inv.
+func (r *Reader) SetInventory(inv *Inventory) { r.inventory.Store(inv) }
+
// New makes a reader with no rules.
func New() *Reader {
return &Reader{wire: importer.NewWireReader()}
@@ -115,25 +121,35 @@ func (r *Reader) SetRules(rules []models.SourceRule) error {
// Read reads one message received from sourceIP over protocol.
func (r *Reader) Read(raw []byte, sourceIP, protocol string) models.SyslogMessage {
- return r.ReadAt(raw, sourceIP, protocol, time.Now())
+ msg, rd := r.read(raw, sourceIP, protocol, time.Now())
+ if inv := r.inventory.Load(); inv != nil {
+ inv.note(msg, rd)
+ }
+ return msg
}
// ReadAt is Read for a message that arrived at a given time: a stored one,
// read again with the rules as they are now.
func (r *Reader) ReadAt(raw []byte, sourceIP, protocol string, at time.Time) models.SyslogMessage {
+ msg, _ := r.read(raw, sourceIP, protocol, at)
+ return msg
+}
+
+// read reads a message, and says how its header was as it arrived.
+func (r *Reader) read(raw []byte, sourceIP, protocol string, at time.Time) (models.SyslogMessage, syslog.Reading) {
msg, rd := syslog.ReadAt(raw, sourceIP, protocol, at)
pri := msg.Severity
r.wire.Reread(&msg, rd)
rules := r.rules.Load()
if rules == nil {
- return msg
+ return msg, rd
}
for _, c := range *rules {
if c.matches(msg) {
- return r.apply(c, raw, msg, rd, pri)
+ return r.apply(c, raw, msg, rd, pri), rd
}
}
- return msg
+ return msg, rd
}
func (c *rule) matches(msg models.SyslogMessage) bool {