Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/documentation.html
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ <h3>Messages that bend the standard</h3>
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. <strong>Settings › Devices</strong> 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.
</p>
Expand Down
4 changes: 3 additions & 1 deletion frontend/screenshots/bridge/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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()}` };
Expand Down
15 changes: 15 additions & 0 deletions frontend/screenshots/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,14 +39,15 @@
// 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' },
{ id: 'storage', label: 'settings.storage' },
{ 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[] = [];
Expand Down Expand Up @@ -873,6 +875,9 @@
{:else if activeTab === 'sources'}
<SourceRules />

{:else if activeTab === 'devices'}
<SourceDevices />

{:else if activeTab === 'about'}
<div class="about-section">
<div class="about-app-name">SyslogStudio</div>
Expand Down
233 changes: 233 additions & 0 deletions frontend/src/components/SourceDevices.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<script lang="ts">
/**
* Settings › Devices: every address messages arrive from — heard since the
* engine started, and kept in the history — with how it is read. A device
* whose messages bend the RFCs or come without a hostname, and that no rule
* reads, is marked: it could use one, and the button beside it writes it.
*/
import { onDestroy, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { messages } from '../lib/stores';
import { queryMessageGroups } from '../lib/api';
import { activeZone, formatInZone } from '../lib/timezone';
import { toastError } from '../lib/toast';
import {
clearSourceDevices, getSourceDevices, getSourceRules, ruleDraft, ruleForDevice, ruleForSource,
rulesVersion, samplesForRule,
} from '../lib/sources';
import type { SourceDevice, SourceRule } from '../lib/sources';

// How often what was heard is read again while the tab is open.
const EVERY = 2000;

let devices: SourceDevice[] = [];
let stored = new Map<string, number>();
let rules: SourceRule[] = [];
let filter = '';
let onlyAttention = false;
let sort: 'last' | 'messages' | 'rate' | 'stored' | 'address' = 'last';
let timer: ReturnType<typeof setInterval> | undefined;

async function refresh() {
try { devices = await getSourceDevices(); } catch { /* the next read, then */ }
}

async function loadStored() {
try {
const groups = await queryMessageGroups({
severities: [], facilities: [], hostname: '', appName: '', sourceIP: '',
search: '', searchMode: 'text', dateFrom: '', dateTo: '',
} as any, 'sourceIP');
stored = new Map((groups || []).filter(g => g.key).map(g => [g.key, g.count]));
} catch { /* no history: the column stays empty */ }
}

onMount(() => {
refresh();
loadStored();
timer = setInterval(refresh, EVERY);
});
onDestroy(() => clearInterval(timer));
$: if ($rulesVersion >= 0) getSourceRules().then(r => (rules = r)).catch(() => {});

/** Whether it could use a rule: it bends the RFCs or sends no hostname, and none reads it. */
const attention = (d: SourceDevice) => !d.readBy && (d.nonStandard > 0 || d.noHostname > 0);

type Row = { address: string; device?: SourceDevice; stored: number };
$: rows = (() => {
const byAddress = new Map<string, Row>();
for (const d of devices) byAddress.set(d.address, { address: d.address, device: d, stored: stored.get(d.address) || 0 });
for (const [address, count] of stored) {
if (!byAddress.has(address)) byAddress.set(address, { address, stored: count });
}
const needle = filter.trim().toLowerCase();
const kept = [...byAddress.values()].filter(r =>
(!onlyAttention || (r.device && attention(r.device))) &&
(!needle || r.address.toLowerCase().includes(needle) ||
(r.device?.hostnames || []).some(h => h.toLowerCase().includes(needle))));
const last = (r: Row) => (r.device ? Date.parse(r.device.lastSeen) : 0);
const by: Record<typeof sort, (a: Row, b: Row) => number> = {
last: (a, b) => last(b) - last(a),
messages: (a, b) => (b.device?.messages || 0) - (a.device?.messages || 0),
rate: (a, b) => (b.device?.perMinute || 0) - (a.device?.perMinute || 0),
stored: (a, b) => b.stored - a.stored,
address: () => 0,
};
return kept.sort((a, b) => by[sort](a, b) || a.address.localeCompare(b.address, undefined, { numeric: true }));
})();
$: needing = devices.filter(attention).length;

function readDifferently(row: Row) {
const existing = ruleForSource(rules, row.address);
const rule = existing ? structuredClone(existing) : ruleForDevice(row.address, row.device);
let samples = samplesForRule($messages, rule);
if (!samples.some(s => s.sourceIP === row.address) && row.device?.lastRaw) {
samples = [{ raw: row.device.lastRaw, sourceIP: row.address, protocol: row.device.protocol }];
}
ruleDraft.set({ rule, samples });
}

async function countAgain() {
try {
await clearSourceDevices();
await refresh();
} catch (e: any) {
toastError(e?.message || String(e));
}
}

// The time alone for today, as a log line shows it; the date before it otherwise.
function heard(iso: string, zone: string): string {
const full = formatInZone(iso, zone) || iso;
const today = formatInZone(new Date().toISOString(), zone)?.slice(0, 10);
return today && full.startsWith(today) ? full.slice(11) : full;
}

const pct = (part: number, whole: number) => Math.max(1, Math.round((part / Math.max(1, whole)) * 100));
const n = (v: number) => (v || 0).toLocaleString();
</script>

<p class="intro">{$_('sources.devicesIntro')}</p>

<div class="controls">
<input class="filter" placeholder={$_('sources.devicesFilter')} bind:value={filter} />
<label class="check">
<input type="checkbox" bind:checked={onlyAttention} />
{$_('sources.devicesAttention')}
</label>
<label class="sort">
{$_('sources.devicesSort')}
<select bind:value={sort}>
<option value="last">{$_('sources.sortLast')}</option>
<option value="messages">{$_('sources.sortMessages')}</option>
<option value="rate">{$_('sources.sortRate')}</option>
<option value="stored">{$_('sources.sortStored')}</option>
<option value="address">{$_('sources.sortAddress')}</option>
</select>
</label>
<span class="spacer"></span>
<button class="action-btn" on:click={countAgain}>{$_('sources.devicesClear')}</button>
</div>

<p class="summary">{$_('sources.devicesSummary', { values: { devices: n(rows.length), attention: n(needing) } })}</p>

{#if rows.length === 0}
<p class="muted">{$_('sources.devicesEmpty')}</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>{$_('sources.colDevice')}</th>
<th>{$_('sources.colHeard')}</th>
<th class="num">{$_('sources.colMessages')}</th>
<th class="num">{$_('sources.colRate')}</th>
<th class="num">{$_('sources.colStored')}</th>
<th>{$_('sources.colReading')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each rows as row (row.address)}
{@const d = row.device}
<tr class:attention={d && attention(d)}>
<td>
<div class="mono">{row.address}{#if d}<span class="proto">{d.protocol}</span>{/if}</div>
{#if d}
<div class="host">
{d.hostname || '—'}
{#if d.hostnames.length > 1}
<span class="badge" title={$_('sources.badgeHostsHint') + ' ' + d.hostnames.join(', ')}>
{$_('sources.badgeHosts', { values: { count: d.hostnames.length } })}
</span>
{/if}
</div>
{/if}
</td>
<td class="muted-cell">{d ? heard(d.lastSeen, $activeZone) : $_('sources.devNotHeard')}</td>
<td class="num">{d ? n(d.messages) : ''}</td>
<td class="num">{d ? n(d.perMinute) : ''}</td>
<td class="num">{row.stored ? n(row.stored) : ''}</td>
<td>
{#if d}<div class="badges">
{#if d.readBy}<span class="badge rule">{$_('sources.badgeRule', { values: { name: d.readBy } })}</span>{/if}
{#if d.nonStandard > 0}<span class="badge warn">{$_('sources.badgeBends', { values: { pct: pct(d.nonStandard, d.messages) } })}</span>{/if}
{#if d.noPriority > 0}<span class="badge warn">{$_('sources.badgeNoPri')}</span>{/if}
{#if d.noHostname > 0}<span class="badge warn">{$_('sources.badgeNoHost')}</span>{/if}
{#if !d.readBy && !d.nonStandard && !d.noHostname}<span class="badge ok">{$_('sources.badgeStandard')}</span>{/if}
</div>{/if}
</td>
<td class="act">
<button class="action-btn" on:click={() => readDifferently(row)}
title={d?.lastRaw || ''}>
{ruleForSource(rules, row.address) ? $_('sources.devEditRule') : $_('sources.devRead')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

<style>
.intro { margin: 0 0 12px; font-size: 12px; color: var(--text-secondary); line-height: 1.5; }
.controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.filter, select {
background: var(--bg-primary); color: var(--text-primary);
border: 1px solid var(--border-color); border-radius: 4px; padding: 5px 8px; font-size: 12px;
}
.filter { width: 240px; }
.check, .sort { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); }
.spacer { flex: 1; }
.summary { margin: 0 0 8px; font-size: 11px; color: var(--text-secondary); }
.muted { font-size: 12px; color: var(--text-secondary); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th {
text-align: left; font-weight: 600; font-size: 11px; color: var(--text-secondary);
padding: 6px 8px; border-bottom: 1px solid var(--border-color); white-space: nowrap;
}
td { padding: 6px 8px; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
tr.attention td:first-child { box-shadow: inset 3px 0 0 var(--warning, #e5a50a); }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.mono { font-family: var(--font-mono, monospace); white-space: nowrap; }
.proto { margin-left: 6px; font-size: 10px; color: var(--text-secondary); font-family: inherit; }
.host { margin-top: 2px; font-size: 11px; color: var(--text-secondary); white-space: nowrap; }
.muted-cell { color: var(--text-secondary); white-space: nowrap; }
.badges { display: flex; flex-wrap: wrap; gap: 4px; }
.badge {
display: inline-block; padding: 1px 6px; border-radius: 9px; font-size: 10px; white-space: nowrap;
border: 1px solid var(--border-color); color: var(--text-secondary);
}
.badge.warn { border-color: var(--warning, #e5a50a); color: var(--warning, #e5a50a); }
.badge.ok { border-color: var(--success, #3fb950); color: var(--success, #3fb950); }
.badge.rule { border-color: var(--accent); color: var(--accent); }
.act { text-align: right; white-space: nowrap; }
.action-btn {
padding: 4px 10px; font-size: 11px;
background: var(--bg-secondary); color: var(--text-primary);
border: 1px solid var(--border-color); border-radius: 4px;
}
.action-btn:hover { background: var(--bg-hover); }
</style>
Loading