System Log
CI / test (push) Successful in 1m4s
Release rolling / release (push) Successful in 6m57s

This commit is contained in:
Raven Scott
2026-07-18 23:05:44 -04:00
parent 7815037737
commit 6ef3afe821
33 changed files with 1360 additions and 32 deletions
+2
View File
@@ -15,6 +15,8 @@ PEARDATA_DEFAULT_ROLE=viewer
# PEARDATA_DATA_DIR=./data
# PEARDATA_RATE_LIMIT_RPM=120
# PEARDATA_MAX_RECONNECT=20
# Host journal for Logs tab / GET /api/v*/logs?source=journal (Linux; needs journal ACL)
# PEARDATA_JOURNAL=0
# ── Metrics pipeline ─────────────────────────────────────────
# PEARDATA_SAMPLE_MS=1000
+1 -1
View File
@@ -122,7 +122,7 @@ peardata/
| Doc | Contents |
|-----|----------|
| **[User guide](./user-guide/README.md)** | Desktop workflows — Charts, Metric Correlations, Fleet, Alerts |
| **[User guide](./user-guide/README.md)** | Desktop workflows — Charts, Metric Correlations, Logs, Fleet, Alerts |
| [Getting started](./docs/GETTING-STARTED.md) | Install, agent, REST, desktop, systemd |
| [Docs index](./docs/README.md) | Full engineer / operator doc set |
| [Roadmap](./docs/ROADMAP.md) | MVP → advanced phases |
+40 -2
View File
@@ -2,7 +2,7 @@
* PearData desktop — multi-view shell, live charts, settings.
*/
import { manager } from './client/manager.js'
import { Methods, Pushes } from './shared/protocol.js'
import { Methods, Pushes, Roles } from './shared/protocol.js'
import { getClientIdentity } from './client/identity.js'
import {
loadBookmarks,
@@ -30,6 +30,7 @@ import {
summarizeFleet,
renderFleetCards,
} from './ui/fleet.js'
import { createLogsView } from './ui/logs.js'
const $ = (id) => document.getElementById(id)
@@ -138,6 +139,8 @@ const peerCpu = new Map()
/** @type {object|null} last getFleetHealth payload for Fleet card chips */
let lastFleetHealth = null
/** @type {string} */
let currentRole = Roles.viewer
const metricsDashboard = createMetricsDashboard({
els: {
@@ -165,6 +168,9 @@ const metricsDashboard = createMetricsDashboard({
liveEl: $('metrics-live'),
liveLabel: $('metrics-live-label'),
retentionHint: $('metrics-retention-hint'),
filtersBtn: $('metrics-filters-btn'),
filtersPanel: $('metrics-filters-panel'),
filterChip: $('metrics-filter-chip'),
},
getCatalog: () => chartCatalog,
queryData: (args) => manager.request(Methods.queryData, args),
@@ -178,6 +184,7 @@ const metricsDashboard = createMetricsDashboard({
pinned: settings.metricsPinned,
group: settings.metricsGroup,
forcePlay: settings.metricsForcePlay,
filtersOpen: settings.metricsFiltersOpen,
}),
savePrefs: (patch) => {
/** @type {Partial<typeof settings>} */
@@ -189,6 +196,7 @@ const metricsDashboard = createMetricsDashboard({
if (patch.pinned != null) mapped.metricsPinned = patch.pinned
if (patch.group != null) mapped.metricsGroup = patch.group
if (patch.forcePlay != null) mapped.metricsForcePlay = patch.forcePlay
if (patch.filtersOpen != null) mapped.metricsFiltersOpen = patch.filtersOpen
if (Object.keys(mapped).length) persist(mapped)
},
getAnomaly: (chartId) => {
@@ -199,6 +207,33 @@ const metricsDashboard = createMetricsDashboard({
getWeights: (args) => manager.request(Methods.getWeights, args || {}),
})
const logsView = createLogsView({
els: {
root: $('logs-view'),
sources: $('logs-sources'),
q: /** @type {HTMLInputElement|null} */ ($('logs-q')),
presets: $('logs-presets'),
priority: /** @type {HTMLSelectElement|null} */ ($('logs-priority')),
unit: /** @type {HTMLInputElement|null} */ ($('logs-unit')),
searchBtn: $('logs-search-btn'),
status: $('logs-status'),
list: $('logs-list'),
},
queryLogs: (args) => manager.request(Methods.queryLogs, args || {}),
getRole: () => currentRole,
onShowChart: (chartId, ts) => {
showView('charts')
metricsDashboard.focusChartAt(chartId, ts)
},
onCorrelate: (ts) => {
showView('charts')
metricsDashboard.correlateAround(ts || Date.now(), {
method: 'anomaly-rate',
halfWindowSec: 60,
})
},
})
function seriesMax() {
// Overview sparks follow Charts' active window when available (capped for density)
try {
@@ -917,13 +952,15 @@ async function refreshMeta() {
if (els.serverInfo) {
els.serverInfo.textContent = JSON.stringify({ info, node, health, fleet }, null, 2)
}
els.roleBadge.textContent = auth.role || '—'
currentRole = auth.role || Roles.viewer
els.roleBadge.textContent = currentRole || '—'
els.statHealth.textContent = health.status || '—'
if (els.statHealth?.parentElement) {
els.statHealth.parentElement.dataset.health = health.status || ''
}
const id = getClientIdentity()
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
logsView.syncSourceUi()
renderPeers()
renderFleetStrip(fleet, manager.list())
await populateExploreCharts()
@@ -1055,6 +1092,7 @@ function showView(name) {
metricsDashboard.render()
requestAnimationFrame(() => metricsDashboard.redrawVisible())
}
if (name === 'logs') logsView.enter()
if (name === 'fleet') loadFleetView()
if (name === 'settings') syncSettingsUi()
requestAnimationFrame(() => redrawAll())
+2
View File
@@ -29,6 +29,7 @@ export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
* metricsPinned: string[],
* metricsGroup: 'average'|'min'|'max'|'sum',
* metricsForcePlay: boolean,
* metricsFiltersOpen: boolean,
* reconnectMaxAttempts: number,
* autoRestorePeers: boolean,
* }} UiSettings */
@@ -52,6 +53,7 @@ export function defaultSettings() {
metricsPinned: [],
metricsGroup: 'average',
metricsForcePlay: false,
metricsFiltersOpen: false,
reconnectMaxAttempts: 20,
autoRestorePeers: true,
}
+2
View File
@@ -18,6 +18,8 @@ NoNewPrivileges=true
PrivateTmp=true
Environment=NODE_ENV=production
EnvironmentFile=-/opt/peardata/.env
# Optional host journal in Logs tab: set PEARDATA_JOURNAL=1 in .env and uncomment:
# SupplementaryGroups=systemd-journal
# Identity + HyperDB / metric buffers
ReadWritePaths=/opt/peardata
+1
View File
@@ -150,6 +150,7 @@ A heavier agent may subscribe to child agents over P2P, downsample into its own
| `server/services/store.js` | Tiered buffers + query |
| `server/services/anomaly.js` | Thresholds |
| `server/services/weights.js` | Metric Correlations scoring (`volume` / `ks2` / …) |
| `server/services/logs.js` | System log query (anomaly / audit / journalctl) |
| `server/services/alerts.js` | Alert CRUD helpers |
| `server/services/subscriptions.js` | Push fan-out |
| `server/services/jobs.js` | On-demand jobs |
+1
View File
@@ -40,6 +40,7 @@ Treat `SERVER_SEED` like a root password. Prefer `pd1.` invites for operators.
| `PEARDATA_STORAGE` | — | Electron/Pear storage dir override (bookmarks prefer `Pear.config.storage`) |
| `PEARDATA_RATE_LIMIT_RPM` | `120` | Per-peer RPC requests per minute |
| `PEARDATA_MAX_RECONNECT` | `20` | Client manager reconnect attempts per peer |
| `PEARDATA_JOURNAL` | off | `1` enables host `journalctl` for Logs / `queryLogs` (Linux; needs journal ACL) |
```
data/
+1 -1
View File
@@ -155,4 +155,4 @@ Do not name third-party products in code, commits, or user-facing copy.
| Engine | Server `weights.js` (`volume` / `ks2` / …) | Client taxonomy + Pearson |
| UX | Toolbar Correlate → brush ≥15s → Find Correlations → filtered wall | Per-card ⇢ / dblclick → related panel |
Full operator docs: [metric-correlations.md](../user-guide/metric-correlations.md) · [related-metrics.md](../user-guide/related-metrics.md) · [weights-api.md](../user-guide/weights-api.md).
Full operator docs: [metric-correlations.md](../user-guide/metric-correlations.md) · [related-metrics.md](../user-guide/related-metrics.md) · [weights-api.md](../user-guide/weights-api.md) · [logs.md](../user-guide/logs.md) · [charts.md](../user-guide/charts.md) (Filters menu).
+19
View File
@@ -178,6 +178,24 @@ Response shape:
Desktop UX: [user-guide/metric-correlations.md](../user-guide/metric-correlations.md). Related (Pearson/taxonomy) is separate: `shared/related-metrics.js`.
## Log entries
Normalized shape from `queryLogs` / `GET /api/v*/logs`:
```json
{
"id": "anomaly:1710000000000:system.cpu",
"ts": 1710000000000,
"source": "anomaly",
"severity": "warning",
"unit": "system.cpu",
"message": "…",
"fields": {}
}
```
Sources: `anomaly` | `audit` | `journal`. See [user-guide/logs.md](../user-guide/logs.md).
## REST ↔ RPC parity
| Concept | RPC | REST |
@@ -185,6 +203,7 @@ Desktop UX: [user-guide/metric-correlations.md](../user-guide/metric-correlation
| Charts | `listCharts` | `GET /api/v1/charts` |
| Data | `queryData` | `GET /api/v3/data` |
| Weights | `getWeights` | `GET /api/v1\|v2\|v3/weights` |
| Logs | `queryLogs` | `GET /api/v1\|v2\|v3/logs` |
| Contexts | `listContexts` | `GET /api/v3/contexts` |
| Nodes | `getNodeInfo` | `GET /api/v3/nodes` |
| Alerts | `listAlerts` | `GET /api/v3/alerts` |
+4 -1
View File
@@ -14,7 +14,8 @@ The client shell is a **Pear desktop application** built with `pear-electron` +
| `ui/styles.css` | Layout, theme, **titlebar drag regions** |
| `client/*` | HyperDHT connection stack used by the UI |
| `docs/DASHBOARD.md` | Master Charts / metrics wall plan |
| `user-guide/` | End-user workflows (Correlate, Related, Fleet, …) |
| `user-guide/` | End-user workflows (Correlate, Related, Logs, Fleet, …) |
| `ui/logs.js` | System Log tab controller |
```bash
npm start # pear run -d .
@@ -196,6 +197,8 @@ This template ships the **Pear run** path only. For Electron-forge / multi-arch
| Metrics wall, time, board, gestures | [user-guide/charts.md](../user-guide/charts.md) · [DASHBOARD.md](./DASHBOARD.md) |
| **Correlate** (highlight → Find Correlations) | [user-guide/metric-correlations.md](../user-guide/metric-correlations.md) |
| **Related (⇢)** (taxonomy + Pearson) | [user-guide/related-metrics.md](../user-guide/related-metrics.md) |
| **Filters** menu (search / TOC / group / sort) | [user-guide/charts.md](../user-guide/charts.md) |
| **Logs** tab | [user-guide/logs.md](../user-guide/logs.md) |
Implementation: `ui/dashboard.js`, `server/services/weights.js`, `shared/related-metrics.js`.
+15
View File
@@ -52,8 +52,11 @@ Auth modes at handshake: public key (viewer), capability token / `pd1.` invite,
| `getChart` | viewer | `{ id }` | Chart summary |
| `queryData` | viewer | `{ chart, after, before, points, group, tier }` | Time series |
| `getWeights` | viewer | see below | Metric Correlations / alert weights |
| `queryLogs` | viewer* | see below | Anomaly / audit / journal lines |
| `getAllMetrics` | viewer | `{ format: json\|prometheus\|shell }` | Latest export |
\* `queryLogs` method role is viewer so anomaly search works for all dialers; **audit** and **journal** sources require **admin** inside the handler.
`after` / `before`: absolute unix seconds, or relative (negative = relative to `before`/`now`), agent-style.
#### `getWeights`
@@ -69,6 +72,18 @@ Scores charts for [Metric Correlations](../user-guide/metric-correlations.md) (o
REST parity: `GET /api/v*/weights`. Engine: `server/services/weights.js`.
#### `queryLogs`
| Arg | Notes |
|-----|--------|
| `source` | `anomaly` (default) \| `audit` \| `journal` |
| `q` | Case-insensitive substring |
| `since` / `until` | Absolute ms/sec or relative (`-1h`) |
| `priority` / `unit` | Journal filters |
| `limit` / `cursor` | Cap (≤2000) + pagination offset |
Journal requires `PEARDATA_JOURNAL=1` on Linux. REST: `GET /api/v*/logs`. Engine: `server/services/logs.js`. UI: [user-guide/logs.md](../user-guide/logs.md).
### Live subscriptions
| Method | Role | Args |
+1 -1
View File
@@ -4,7 +4,7 @@
| Doc | Contents |
|-----|----------|
| **[User guide](../user-guide/README.md)** | Desktop workflows: Connect, Overview, Charts, Metric Correlations, Related, Alerts, Fleet, Settings |
| **[User guide](../user-guide/README.md)** | Desktop workflows: Connect, Overview, Charts, Metric Correlations, Logs, Related, Alerts, Fleet, Settings |
| [GETTING-STARTED.md](./GETTING-STARTED.md) | Install agent / desktop, first dial, systemd |
## Engineers & operators
+1
View File
@@ -104,6 +104,7 @@ Single-agent returns one node. With `PEARDATA_PARENT=1`, `/nodes` and `/fleet` i
|--------|------|-------|
| GET | `/api/v3/q?q=` | Full-text over chart ids/titles |
| GET | `/api/v1\|v2\|v3/weights` | Metric Correlations + alert weights — see [Weights](#weights--metric-correlations); user guide: [weights-api](../user-guide/weights-api.md) |
| GET | `/api/v1\|v2\|v3/logs` | Log search — `source=anomaly\|audit\|journal`, `q`, `since`, `until`, `priority`, `unit`, `limit` — [user-guide/logs](../user-guide/logs.md) |
### Alerts
+3
View File
@@ -93,6 +93,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
| Notifications | Done (desktop + `PEARDATA_WEBHOOK_URL`) |
| Streaming z-score / retrain job | Done (`PEARDATA_ANOMALY_MODE`, job `retrainAnomaly`) |
| `/api/v3/weights` / Metric Correlations | Done — highlight-vs-baseline scoring (`ks2`/`volume`/`anomaly-rate`/`value`) + Charts Correlate UI |
| `/api/v3/logs` / System Log tab | Done — anomaly + audit + opt-in journal (`queryLogs`); Charts Filters menu |
---
@@ -134,6 +135,8 @@ shared time, multi-dim cards, investigation). Detail checklist:
| Taxonomy coverage test + EXTENDING note | Done |
| Desktop polish (layout fill, plot domain, legacy detail removed, Fleet chips) | Done |
| Metric Correlations (weights engine + Correlate brush UI) | Done |
| Charts Filters menu (collapsible TOC / search / group / sort) | Done |
| System Log tab (`queryLogs` — anomaly / audit / journal) | Done |
| Multi-named custom boards | Later |
**Phase 6 exit (P0)** — Charts tab lists every agent chart in sections; shared
+3 -1
View File
@@ -47,6 +47,7 @@
- [ ] Run under systemd with `ProtectSystem` / `NoNewPrivileges` (see `deploy/`)
- [ ] Do not embed seed in frontend builds, CI logs, or crash reports
- [ ] Review `LOG_LEVEL=debug` before production (avoid verbose auth noise)
- [ ] Keep `PEARDATA_JOURNAL` off unless operators need host logs; journal access is **admin-only** over P2P and can expose secrets from other units — add `SupplementaryGroups=systemd-journal` only when enabling
- [ ] Keep Pear / dependency updates current (`npm outdated`)
## Threat notes
@@ -84,7 +85,8 @@ Implementation: `shared/crypto-auth.js`.
|----------|-------------|----------|
| `.env` | Critical | Never commit; backup offline |
| `data/peer-policy.json` | High | Contains roles & JTIs |
| `data/audit.log` | Medium | Peer activity metadata |
| `data/audit.log` | Medium | Peer activity metadata; readable via Logs/audit (admin) |
| Host journal (`PEARDATA_JOURNAL`) | High | May include secrets from other units; admin-only; off by default |
| `identity.json` | High for that user | Per-machine client secret |
| Release tarballs | Low | Source only; no secrets |
+4
View File
@@ -17,6 +17,7 @@
| `test/protocol.test.js` | Constants, monitoring `MethodRoles`, schema validators |
| `test/store.test.js` | Metric ring buffer ingest + query |
| `test/weights.test.js` | Metric Correlations engine (`volume` / `ks2` / windows / errors) |
| `test/logs.test.js` | Log query engine (roles, filters, journal argv, audit file) |
| `test/hyperdb.test.js` | HyperDB model (node, links, warm points, alerts) |
| `test/rest.test.js` | agent-style `/api/v3` routes |
| `test/integration.test.js` | Live HyperDHT agent + handshake + metrics query |
@@ -86,7 +87,10 @@ When adding an RPC method:
| Health | With server up: `SERVER_PUBLIC_KEY=… npm run healthcheck` |
| Soak | `SERVER_PUBLIC_KEY=… SERVER_SEED=… npm run soak` |
| Metric Correlations | Charts → Correlate → brush ≥15s → Find Correlations → wall filters; Clear restores; Related ⇢ still works |
| Charts Filters | Filters closed → full-width wall; `/` opens search; chip when filter active; Esc closes panel |
| Weights REST | `curl -sG 'http://127.0.0.1:19999/api/v3/weights' --data-urlencode 'method=volume' --data-urlencode 'after=-60' --data-urlencode 'before=0'` |
| Logs | Logs tab → Anomalies search; admin → Audit; with `PEARDATA_JOURNAL=1` → Journal |
| Logs REST | `curl -sG 'http://127.0.0.1:19999/api/v3/logs' --data-urlencode 'source=anomaly' --data-urlencode 'limit=20'` |
## Soak test
+68 -11
View File
@@ -50,6 +50,9 @@
<button type="button" class="nav-link" data-view="alerts">
<span class="nav-ico"></span><span class="nav-label">Alerts</span>
</button>
<button type="button" class="nav-link" data-view="logs">
<span class="nav-ico"></span><span class="nav-label">Logs</span>
</button>
<div class="nav-group-label">Fleet</div>
<button type="button" class="nav-link" data-view="fleet">
<span class="nav-ico"></span><span class="nav-label">Fleet</span>
@@ -202,16 +205,8 @@
<button type="button" class="metrics-tf" data-preset="6h" title="Last 6 hours">6h</button>
</div>
<div class="metrics-toolbar-right">
<label class="metrics-group-label muted">
Group
<select id="metrics-group" aria-label="Downsample aggregation">
<option value="average">average</option>
<option value="min">min</option>
<option value="max">max</option>
<option value="sum">sum</option>
</select>
</label>
<button type="button" id="metrics-dim-sort" class="btn btn-ghost">Sort: name</button>
<button type="button" id="metrics-filters-btn" class="btn btn-ghost" aria-expanded="false" aria-controls="metrics-filters-panel" title="Search, sections, group, sort">Filters</button>
<span id="metrics-filter-chip" class="metrics-filter-chip hidden" role="status"></span>
<span id="metrics-meta" class="muted metrics-meta"></span>
<span id="metrics-hover" class="muted metrics-hover"></span>
</div>
@@ -234,7 +229,19 @@
<div id="metrics-related" class="related-panel hidden"></div>
<div id="metrics-mc-results" class="related-panel mc-results-panel hidden"></div>
<div class="metrics-shell">
<aside class="metrics-toc">
<aside id="metrics-filters-panel" class="metrics-toc metrics-filters-panel" hidden>
<div class="metrics-filters-controls">
<label class="metrics-group-label muted">
Group
<select id="metrics-group" aria-label="Downsample aggregation">
<option value="average">average</option>
<option value="min">min</option>
<option value="max">max</option>
<option value="sum">sum</option>
</select>
</label>
<button type="button" id="metrics-dim-sort" class="btn btn-ghost">Sort: name</button>
</div>
<input id="chart-search" type="search" placeholder="Filter charts…" autocomplete="off" />
<nav id="metrics-toc" class="metrics-toc-nav" aria-label="Chart sections"></nav>
</aside>
@@ -260,6 +267,56 @@
</div>
</section>
<!-- Logs -->
<section id="logs-view" class="view hidden">
<header class="page-header">
<div>
<p class="dash-kicker">Investigate</p>
<h1 class="dash-title">Logs</h1>
<p class="page-subtitle">Anomalies, agent audit trail, and optional host journal</p>
</div>
</header>
<div class="logs-toolbar">
<div id="logs-sources" class="logs-sources" role="tablist" aria-label="Log source">
<button type="button" class="metrics-tf active" data-log-source="anomaly" role="tab" aria-selected="true">Anomalies</button>
<button type="button" class="metrics-tf" data-log-source="audit" role="tab" aria-selected="false" title="Admin only">Audit</button>
<button type="button" class="metrics-tf" data-log-source="journal" role="tab" aria-selected="false" title="Admin only · PEARDATA_JOURNAL=1">Journal</button>
</div>
<div class="logs-search-row">
<input id="logs-q" type="search" placeholder="Search message…" autocomplete="off" />
<div id="logs-presets" class="metrics-presets" role="group" aria-label="Time range">
<button type="button" class="metrics-tf" data-log-range="15m">15m</button>
<button type="button" class="metrics-tf active" data-log-range="1h">1h</button>
<button type="button" class="metrics-tf" data-log-range="6h">6h</button>
<button type="button" class="metrics-tf" data-log-range="24h">24h</button>
</div>
<label class="metrics-group-label muted logs-journal-only">
Priority
<select id="logs-priority" aria-label="Journal priority">
<option value="">any</option>
<option value="0">0 emerg</option>
<option value="1">1 alert</option>
<option value="2">2 crit</option>
<option value="3">3 err</option>
<option value="4">4 warning</option>
<option value="5">5 notice</option>
<option value="6">6 info</option>
<option value="7">7 debug</option>
</select>
</label>
<label class="metrics-group-label muted logs-journal-only">
Unit
<input id="logs-unit" type="text" placeholder="e.g. peardata" autocomplete="off" />
</label>
<button type="button" id="logs-search-btn" class="btn btn-primary">Search</button>
</div>
</div>
<p id="logs-status" class="muted logs-status" role="status"></p>
<div class="dash-card logs-card">
<ul id="logs-list" class="event-list logs-list"></ul>
</div>
</section>
<!-- Fleet -->
<section id="fleet-view" class="view hidden">
<header class="page-header">
+6 -1
View File
@@ -18,11 +18,16 @@ const MUTATING = new Set([
'handshake',
])
function auditPath() {
export function getAuditPath() {
const dir = process.env.PEARDATA_DATA_DIR || path.resolve('data')
return path.join(dir, 'audit.log')
}
/** @deprecated use getAuditPath */
function auditPath() {
return getAuditPath()
}
/**
* @param {string} method
*/
+5
View File
@@ -28,6 +28,7 @@ import { getCollector } from '../services/collector.js'
import { getStore } from '../services/store.js'
import { getAnomalyEngine } from '../services/anomaly.js'
import { computeWeights } from '../services/weights.js'
import { queryLogs } from '../services/logs.js'
import {
listAlerts,
getAlert,
@@ -150,6 +151,10 @@ export function registerMonitorHandlers(session) {
session.respond('queryData', async (args) => store.query(args), { hot: true })
session.respond('getWeights', async (args) => computeWeights(args || {}), { hot: true })
session.respond('queryLogs', async (args) =>
queryLogs({ ...(args || {}), role: session.role })
)
session.respond('getDbInfo', async () => {
const db = getDb()
if (!db) return { enabled: false }
+29 -1
View File
@@ -4,7 +4,13 @@
* Local-agent style GET endpoints for charts/data/contexts/nodes/info/allmetrics.
*/
import os from 'os'
import { APP_NAME, APP_VERSION, PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
import {
APP_NAME,
APP_VERSION,
PROTOCOL,
PROTOCOL_VERSION,
Roles,
} from '../../shared/protocol.js'
import {
getAllChartDefs,
getContextIds,
@@ -15,6 +21,7 @@ import { getStore } from '../services/store.js'
import { getCollector } from '../services/collector.js'
import { getAnomalyEngine } from '../services/anomaly.js'
import { computeWeights } from '../services/weights.js'
import { queryLogs } from '../services/logs.js'
import { listAlerts, getAlert } from '../services/alerts.js'
import { getServerPublicKeyHex } from '../core/auth-keys.js'
import { formatAllMetrics } from './formatters.js'
@@ -179,6 +186,27 @@ export async function handleRest(pathname, query) {
}
return json(result)
}
// ── logs (anomaly / audit / journal) ─────────────────────
if (path === '/api/v3/logs' || path === '/api/v2/logs' || path === '/api/v1/logs') {
const result = await queryLogs({
source: query.get('source') || 'anomaly',
q: query.get('q') || query.get('query') || '',
since: query.get('since') || undefined,
until: query.get('until') || undefined,
priority: query.get('priority') || undefined,
unit: query.get('unit') || undefined,
limit: query.get('limit') || undefined,
cursor: query.get('cursor') || undefined,
// REST is localhost-open; treat as admin for audit/journal
role: Roles.admin,
})
if (!result.ok) {
const status = result.code === 'PERMISSION_DENIED' ? 403 : 400
return { status, contentType: 'application/json', body: JSON.stringify(result) }
}
return json(result)
}
if (path === '/api/v3/q' || path === '/api/v2/q') {
const q = (query.get('q') || query.get('query') || '').toLowerCase()
const hits = getAllChartDefs()
+407
View File
@@ -0,0 +1,407 @@
/**
* Unified log query: anomalies, agent audit.log, optional host journalctl.
*/
import { spawn } from 'child_process'
import fs from 'fs'
import os from 'os'
import { Roles, roleAllows } from '../../shared/protocol.js'
import { getAnomalyEngine } from './anomaly.js'
import { getAuditPath } from '../core/audit.js'
const DEFAULT_LIMIT = 200
const MAX_LIMIT = 2000
const JOURNAL_TIMEOUT_MS = 3000
/**
* @param {string} role
* @param {string} source
*/
export function assertLogSourceAllowed(role, source) {
const src = String(source || 'anomaly').toLowerCase()
if (src === 'anomaly') return { ok: true, source: src }
if (src === 'audit' || src === 'journal') {
if (!roleAllows(role || Roles.viewer, Roles.admin)) {
return {
ok: false,
source: src,
error: 'PERMISSION_DENIED',
code: 'PERMISSION_DENIED',
hint: 'Audit and journal sources require admin role',
}
}
return { ok: true, source: src }
}
return { ok: false, source: src, error: `unknown source: ${src}` }
}
/**
* @param {unknown} n
* @param {number} fallback
*/
export function clampLimit(n, fallback = DEFAULT_LIMIT) {
const v = Number(n)
if (!Number.isFinite(v) || v < 1) return fallback
return Math.min(MAX_LIMIT, Math.floor(v))
}
/**
* @param {string|number|undefined|null} v
* @returns {number|null} unix ms
*/
export function parseTimeBound(v) {
if (v == null || v === '') return null
if (typeof v === 'number' && Number.isFinite(v)) {
// seconds vs ms heuristic
return v < 1e12 ? Math.floor(v * 1000) : Math.floor(v)
}
const s = String(v).trim()
if (!s) return null
// relative like -3600 or -1h
const rel = s.match(/^(-?\d+)([smhd])?$/i)
if (rel) {
const n = Number(rel[1])
const unit = (rel[2] || 's').toLowerCase()
const mult =
unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : unit === 'd' ? 86_400_000 : 1000
return Date.now() + n * mult
}
const ms = Date.parse(s)
return Number.isFinite(ms) ? ms : null
}
/**
* @param {{ q?: string, since?: number|null, until?: number|null }} filters
* @param {{ ts: number, message: string, unit?: string, severity?: string }} row
*/
export function matchesFilters(filters, row) {
const q = String(filters.q || '')
.trim()
.toLowerCase()
if (q) {
const hay = `${row.message || ''} ${row.unit || ''} ${row.severity || ''}`.toLowerCase()
if (!hay.includes(q)) return false
}
if (filters.since != null && row.ts < filters.since) return false
if (filters.until != null && row.ts > filters.until) return false
return true
}
/**
* @param {object} ev
* @param {number} index
*/
export function normalizeAnomaly(ev, index = 0) {
const ts = Number(ev.ts) || Date.now()
return {
id: `anomaly:${ts}:${ev.chart || index}`,
ts,
source: 'anomaly',
severity: String(ev.severity || (ev.cleared ? 'ok' : 'warning')),
unit: String(ev.chart || ev.context || ''),
message: String(ev.message || ''),
fields: {
chart: ev.chart || null,
context: ev.context || null,
score: ev.score ?? null,
cleared: Boolean(ev.cleared),
},
}
}
/**
* @param {object} entry
* @param {number} index
*/
export function normalizeAudit(entry, index = 0) {
const ts = entry.ts ? Date.parse(entry.ts) || Date.now() : Date.now()
const ok = entry.ok !== false
const msg = [
entry.method || 'rpc',
ok ? 'ok' : 'fail',
entry.peerId ? `peer=${entry.peerId}` : '',
entry.role ? `role=${entry.role}` : '',
entry.error ? `error=${entry.error}` : '',
]
.filter(Boolean)
.join(' ')
return {
id: `audit:${ts}:${index}`,
ts,
source: 'audit',
severity: ok ? 'info' : 'warning',
unit: String(entry.method || 'rpc'),
message: msg,
fields: {
peerId: entry.peerId || null,
role: entry.role || null,
ok,
error: entry.error || null,
},
}
}
/**
* @param {object} j
* @param {number} index
*/
export function normalizeJournal(j, index = 0) {
const usec = Number(j.__REALTIME_TIMESTAMP || j.TIMESTAMP || 0)
const ts = usec > 1e15 ? Math.floor(usec / 1000) : usec > 1e12 ? Math.floor(usec) : Date.now()
const priority = j.PRIORITY != null ? String(j.PRIORITY) : ''
const sevMap = {
0: 'emerg',
1: 'alert',
2: 'crit',
3: 'err',
4: 'warning',
5: 'notice',
6: 'info',
7: 'debug',
}
const severity = sevMap[priority] || priority || 'info'
const unit = String(j._SYSTEMD_UNIT || j.SYSLOG_IDENTIFIER || j._COMM || '')
const message = String(j.MESSAGE || j.message || '')
return {
id: `journal:${j.__CURSOR || ts}:${index}`,
ts,
source: 'journal',
severity,
unit,
message,
fields: {
priority: priority || null,
cursor: j.__CURSOR || null,
pid: j._PID || null,
},
}
}
/**
* Build journalctl argv (no shell). Exported for tests.
* @param {{
* limit?: number,
* sinceMs?: number|null,
* untilMs?: number|null,
* priority?: string,
* unit?: string,
* q?: string,
* }} opts
*/
export function buildJournalArgv(opts = {}) {
const limit = clampLimit(opts.limit)
const args = ['--output=json', '--no-pager', '-n', String(limit)]
if (opts.sinceMs != null) {
args.push('--since', new Date(opts.sinceMs).toISOString())
}
if (opts.untilMs != null) {
args.push('--until', new Date(opts.untilMs).toISOString())
}
if (opts.priority != null && opts.priority !== '') {
args.push('-p', String(opts.priority))
}
if (opts.unit) {
args.push('-u', String(opts.unit))
}
const q = String(opts.q || '').trim()
if (q) {
args.push('--grep', q)
}
return args
}
export function isJournalEnabled() {
const v = String(process.env.PEARDATA_JOURNAL || '').trim().toLowerCase()
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
}
/**
* @param {string} file
* @param {number} maxBytes
*/
export function readAuditFileLines(file, maxBytes = 512_000) {
if (!fs.existsSync(file)) return []
const stat = fs.statSync(file)
const start = Math.max(0, stat.size - maxBytes)
const buf = Buffer.alloc(stat.size - start)
const fd = fs.openSync(file, 'r')
try {
fs.readSync(fd, buf, 0, buf.length, start)
} finally {
fs.closeSync(fd)
}
const text = buf.toString('utf8')
const lines = text.split('\n').filter(Boolean)
// If we started mid-line, drop first fragment
if (start > 0 && lines.length) lines.shift()
/** @type {object[]} */
const parsed = []
for (const line of lines) {
try {
parsed.push(JSON.parse(line))
} catch {
// skip corrupt
}
}
return parsed
}
/**
* @param {object} args
* @param {{ anomalies?: { listRecent: (n: number) => object[] }, spawnJournal?: Function }} [deps]
*/
export async function queryLogs(args = {}, deps = {}) {
const gate = assertLogSourceAllowed(args.role || Roles.viewer, args.source || 'anomaly')
if (!gate.ok) {
return {
ok: false,
error: gate.error,
code: gate.code || undefined,
hint: gate.hint || undefined,
source: gate.source,
entries: [],
}
}
const source = gate.source
const limit = clampLimit(args.limit)
const cursor = Math.max(0, Math.floor(Number(args.cursor) || 0))
const since = parseTimeBound(args.since)
const until = parseTimeBound(args.until)
const q = String(args.q || '').trim()
const filters = { q, since, until }
if (source === 'anomaly') {
const engine = deps.anomalies || getAnomalyEngine()
const raw = engine.listRecent(Math.min(MAX_LIMIT, limit + cursor + 50))
const all = raw
.map((ev, i) => normalizeAnomaly(ev, i))
.filter((row) => matchesFilters(filters, row))
const slice = all.slice(cursor, cursor + limit)
return {
ok: true,
source,
entries: slice,
nextCursor: cursor + slice.length < all.length ? cursor + slice.length : null,
stats: { matched: all.length, returned: slice.length },
}
}
if (source === 'audit') {
const file = args.auditPath || getAuditPath()
const raw = readAuditFileLines(file).reverse()
const all = raw
.map((entry, i) => normalizeAudit(entry, i))
.filter((row) => matchesFilters(filters, row))
const slice = all.slice(cursor, cursor + limit)
return {
ok: true,
source,
entries: slice,
nextCursor: cursor + slice.length < all.length ? cursor + slice.length : null,
stats: { matched: all.length, returned: slice.length },
}
}
// journal
if (!isJournalEnabled()) {
return {
ok: false,
source,
error: 'journal_disabled',
hint: 'Set PEARDATA_JOURNAL=1 and ensure the agent user can read the journal (e.g. SupplementaryGroups=systemd-journal)',
entries: [],
}
}
if (os.platform() !== 'linux') {
return {
ok: false,
source,
error: 'unsupported',
hint: 'Host journal is only available on Linux agents',
entries: [],
}
}
const argv = buildJournalArgv({
limit,
sinceMs: since,
untilMs: until,
priority: args.priority,
unit: args.unit,
q,
})
const spawnFn = deps.spawnJournal || spawnJournalctl
try {
const { stdout, stderr, code } = await spawnFn(argv)
if (code !== 0 && !stdout.trim()) {
return {
ok: false,
source,
error: 'journalctl_failed',
hint: stderr.trim() || `journalctl exited ${code}`,
entries: [],
}
}
const entries = []
const lines = stdout.split('\n').filter(Boolean)
for (let i = 0; i < lines.length; i++) {
try {
const j = JSON.parse(lines[i])
const row = normalizeJournal(j, i)
// --grep already applied when q set; still apply since/until if journal ignored them
if (matchesFilters({ q: '', since, until }, row)) entries.push(row)
} catch {
// skip
}
}
// journalctl returns oldest-first typically; present newest first
entries.reverse()
return {
ok: true,
source,
entries: entries.slice(0, limit),
nextCursor: null,
stats: { matched: entries.length, returned: Math.min(limit, entries.length) },
}
} catch (err) {
return {
ok: false,
source,
error: 'journalctl_failed',
hint: err?.message || String(err),
entries: [],
}
}
}
/**
* @param {string[]} argv
* @returns {Promise<{ stdout: string, stderr: string, code: number }>}
*/
function spawnJournalctl(argv) {
return new Promise((resolve, reject) => {
const child = spawn('journalctl', argv, {
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`journalctl timed out after ${JOURNAL_TIMEOUT_MS}ms`))
}, JOURNAL_TIMEOUT_MS)
child.stdout.on('data', (d) => {
stdout += d.toString('utf8')
})
child.stderr.on('data', (d) => {
stderr += d.toString('utf8')
})
child.on('error', (err) => {
clearTimeout(timer)
reject(err)
})
child.on('close', (code) => {
clearTimeout(timer)
resolve({ stdout, stderr, code: code ?? 1 })
})
})
}
+2
View File
@@ -72,6 +72,8 @@ export const MethodRoles = Object.freeze({
listAnomalies: Roles.viewer,
listAlerts: Roles.viewer,
getAlert: Roles.viewer,
/** Anomaly source: viewer; audit/journal enforced admin inside handler */
queryLogs: Roles.viewer,
// alerts / thresholds (write)
setAlertConfig: Roles.operator,
+23
View File
@@ -34,6 +34,29 @@ export function validateMethodArgs(method, args = {}) {
case 'getWeights':
return { ok: true, args }
case 'queryLogs': {
const source = String(args.source || 'anomaly').toLowerCase()
if (!['anomaly', 'audit', 'journal'].includes(source)) {
return { ok: false, error: 'source must be anomaly|audit|journal' }
}
const limit = args.limit == null ? 200 : Number(args.limit)
if (!Number.isFinite(limit) || limit < 1 || limit > 2000) {
return { ok: false, error: 'limit must be 1..2000' }
}
return {
ok: true,
args: {
...args,
source,
limit,
q: args.q != null ? String(args.q) : '',
priority: args.priority != null ? String(args.priority) : '',
unit: args.unit != null ? String(args.unit) : '',
cursor: args.cursor != null ? Number(args.cursor) : 0,
},
}
}
case 'setDisplayName': {
const name = String(args.name ?? '').trim()
if (!name) return { ok: false, error: 'name is required' }
+201
View File
@@ -0,0 +1,201 @@
import test from 'brittle'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { Roles } from '../shared/protocol.js'
import { validateMethodArgs } from '../shared/schema.js'
import {
assertLogSourceAllowed,
buildJournalArgv,
clampLimit,
isJournalEnabled,
matchesFilters,
normalizeAnomaly,
normalizeAudit,
normalizeJournal,
parseTimeBound,
queryLogs,
readAuditFileLines,
} from '../server/services/logs.js'
test('clampLimit bounds', (t) => {
t.is(clampLimit(50), 50)
t.is(clampLimit(99999), 2000)
t.is(clampLimit(0), 200)
t.is(clampLimit('x'), 200)
})
test('parseTimeBound absolute and relative', (t) => {
const sec = 1_700_000_000
t.is(parseTimeBound(sec), sec * 1000)
t.ok(Math.abs((parseTimeBound('-1h') || 0) - (Date.now() - 3_600_000)) < 2000)
t.ok(parseTimeBound('2020-01-01T00:00:00.000Z') === Date.parse('2020-01-01T00:00:00.000Z'))
})
test('assertLogSourceAllowed roles', (t) => {
t.ok(assertLogSourceAllowed(Roles.viewer, 'anomaly').ok)
t.absent(assertLogSourceAllowed(Roles.viewer, 'audit').ok)
t.absent(assertLogSourceAllowed(Roles.operator, 'journal').ok)
t.ok(assertLogSourceAllowed(Roles.admin, 'audit').ok)
t.ok(assertLogSourceAllowed(Roles.admin, 'journal').ok)
t.is(assertLogSourceAllowed(Roles.viewer, 'audit').code, 'PERMISSION_DENIED')
})
test('buildJournalArgv fixed flags', (t) => {
const argv = buildJournalArgv({
limit: 50,
sinceMs: Date.parse('2024-01-01T00:00:00.000Z'),
untilMs: Date.parse('2024-01-01T01:00:00.000Z'),
priority: '3',
unit: 'peardata.service',
q: 'error',
})
t.ok(argv.includes('--output=json'))
t.ok(argv.includes('--no-pager'))
t.ok(argv.includes('-n'))
t.ok(argv.includes('50'))
t.ok(argv.includes('--since'))
t.ok(argv.includes('--until'))
t.ok(argv.includes('-p'))
t.ok(argv.includes('3'))
t.ok(argv.includes('-u'))
t.ok(argv.includes('peardata.service'))
t.ok(argv.includes('--grep'))
t.ok(argv.includes('error'))
t.absent(argv.some((a) => String(a).includes(';')))
})
test('normalize helpers', (t) => {
const a = normalizeAnomaly({
ts: 1_700_000_000_000,
chart: 'system.cpu',
severity: 'critical',
message: 'cpu high',
})
t.is(a.source, 'anomaly')
t.is(a.unit, 'system.cpu')
t.ok(a.message.includes('cpu'))
const audit = normalizeAudit({
ts: '2024-06-01T12:00:00.000Z',
method: 'mintInvite',
peerId: 'abcd',
role: 'admin',
ok: true,
})
t.is(audit.source, 'audit')
t.ok(audit.message.includes('mintInvite'))
const j = normalizeJournal({
__REALTIME_TIMESTAMP: String(1_700_000_000_000_000),
PRIORITY: '3',
_SYSTEMD_UNIT: 'sshd.service',
MESSAGE: 'Failed password',
})
t.is(j.source, 'journal')
t.is(j.severity, 'err')
t.is(j.unit, 'sshd.service')
})
test('matchesFilters q and window', (t) => {
const row = { ts: 1000, message: 'Hello World', unit: 'cpu', severity: 'warning' }
t.ok(matchesFilters({ q: 'hello' }, row))
t.absent(matchesFilters({ q: 'nope' }, row))
t.absent(matchesFilters({ since: 2000 }, row))
t.ok(matchesFilters({ since: 500, until: 1500 }, row))
})
test('queryLogs anomaly with mock engine', async (t) => {
const anomalies = {
listRecent() {
return [
{ ts: Date.now() - 1000, chart: 'system.cpu', severity: 'warning', message: 'cpu warn' },
{ ts: Date.now() - 500, chart: 'system.ram', severity: 'critical', message: 'ram crit' },
]
},
}
const res = await queryLogs(
{ source: 'anomaly', q: 'ram', role: Roles.viewer, limit: 50 },
{ anomalies }
)
t.ok(res.ok)
t.is(res.entries.length, 1)
t.is(res.entries[0].unit, 'system.ram')
})
test('queryLogs audit from temp file', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peardata-audit-'))
const file = path.join(dir, 'audit.log')
const lines = [
JSON.stringify({
ts: new Date(Date.now() - 60_000).toISOString(),
method: 'mintInvite',
peerId: 'aabb',
role: 'admin',
ok: true,
error: null,
}),
JSON.stringify({
ts: new Date().toISOString(),
method: 'runJob',
peerId: 'ccdd',
role: 'operator',
ok: false,
error: 'boom',
}),
]
fs.writeFileSync(file, lines.join('\n') + '\n')
t.is(readAuditFileLines(file).length, 2)
const denied = await queryLogs({ source: 'audit', role: Roles.viewer, auditPath: file })
t.absent(denied.ok)
t.is(denied.code, 'PERMISSION_DENIED')
const res = await queryLogs(
{ source: 'audit', role: Roles.admin, q: 'runJob', auditPath: file, limit: 20 },
{}
)
t.ok(res.ok)
t.is(res.entries.length, 1)
t.ok(res.entries[0].message.includes('runJob'))
})
test('queryLogs journal disabled / unsupported', async (t) => {
const prev = process.env.PEARDATA_JOURNAL
delete process.env.PEARDATA_JOURNAL
t.absent(isJournalEnabled())
const off = await queryLogs({ source: 'journal', role: Roles.admin })
t.absent(off.ok)
t.is(off.error, 'journal_disabled')
process.env.PEARDATA_JOURNAL = '1'
t.ok(isJournalEnabled())
if (os.platform() !== 'linux') {
const uns = await queryLogs({ source: 'journal', role: Roles.admin })
t.absent(uns.ok)
t.is(uns.error, 'unsupported')
} else {
// On Linux with journal enabled, inject a fake spawn that fails cleanly
const res = await queryLogs(
{ source: 'journal', role: Roles.admin, limit: 5 },
{
spawnJournal: async () => ({
stdout: '',
stderr: 'No journal files were found',
code: 1,
}),
}
)
t.absent(res.ok)
t.is(res.error, 'journalctl_failed')
}
if (prev == null) delete process.env.PEARDATA_JOURNAL
else process.env.PEARDATA_JOURNAL = prev
})
test('validateMethodArgs queryLogs', (t) => {
t.ok(validateMethodArgs('queryLogs', {}).ok)
t.ok(validateMethodArgs('queryLogs', { source: 'anomaly', limit: 10 }).ok)
t.absent(validateMethodArgs('queryLogs', { source: 'nope' }).ok)
t.absent(validateMethodArgs('queryLogs', { limit: 0 }).ok)
})
+1
View File
@@ -40,6 +40,7 @@ test('method roles cover monitoring surface', (t) => {
'getFleetHealth',
'listChildPeers',
'getWeights',
'queryLogs',
]) {
t.ok(MethodRoles[m], m)
t.is(Methods[m], m)
+72 -2
View File
@@ -80,6 +80,7 @@ function smoothScrollIntoView(el, block = 'start') {
* pinned?: string[],
* group?: string,
* forcePlay?: boolean,
* filtersOpen?: boolean,
* },
* savePrefs?: (patch: object) => void,
* getAnomaly?: (chartId: string) => { severity?: string, threshold?: number|null }|null,
@@ -135,6 +136,8 @@ export function createMetricsDashboard(opts) {
/** @type {{ startFrac: number, endFrac: number, chartId: string }|null} */
mcBrush: null,
mcRunning: false,
/** Filters panel (search / TOC / group / sort) — default closed for full-width wall */
filtersOpen: Boolean(prefs().filtersOpen),
}
function scheduleHoverPaint() {
@@ -1536,11 +1539,65 @@ export function createMetricsDashboard(opts) {
for (const id of state.visible) paintCard(id)
}
function setFiltersOpen(open) {
state.filtersOpen = Boolean(open)
persistPrefs({ filtersOpen: state.filtersOpen })
syncFiltersUi()
}
function syncFiltersUi() {
const root = opts.els.root
const panel = opts.els.filtersPanel
const btn = opts.els.filtersBtn
root?.classList.toggle('charts-filters-open', state.filtersOpen)
if (panel) {
if (state.filtersOpen) panel.removeAttribute('hidden')
else panel.setAttribute('hidden', '')
}
if (btn) {
btn.classList.toggle('active', state.filtersOpen)
btn.setAttribute('aria-expanded', state.filtersOpen ? 'true' : 'false')
}
syncFilterChip()
}
function syncFilterChip() {
const chip = opts.els.filterChip
if (!chip) return
const q = (state.filter || '').trim()
const show = Boolean(q) && !state.filtersOpen
chip.classList.toggle('hidden', !show)
if (!show) {
chip.innerHTML = ''
return
}
chip.innerHTML = ''
const text = document.createElement('span')
text.className = 'filter-chip-text'
text.textContent = `Filtered: ${q}`
text.title = q
const clear = document.createElement('button')
clear.type = 'button'
clear.textContent = '×'
clear.title = 'Clear filter'
clear.setAttribute('aria-label', 'Clear chart filter')
clear.addEventListener('click', () => {
state.filter = ''
if (opts.els.search) opts.els.search.value = ''
syncFilterChip()
render()
})
chip.appendChild(text)
chip.appendChild(clear)
}
function bindChrome() {
opts.els.search?.addEventListener('input', () => {
state.filter = opts.els.search?.value || ''
syncFilterChip()
render()
})
opts.els.filtersBtn?.addEventListener('click', () => setFiltersOpen(!state.filtersOpen))
opts.els.playBtn?.addEventListener('click', () => setPlaying(!state.playing))
opts.els.presets?.querySelectorAll('[data-preset]').forEach((btn) => {
btn.addEventListener('click', () => setPreset(btn.getAttribute('data-preset') || '5m'))
@@ -1574,6 +1631,7 @@ export function createMetricsDashboard(opts) {
syncPresetUi()
syncLiveUi()
syncMcUi()
syncFiltersUi()
}
function setMcMode(on) {
@@ -1746,7 +1804,17 @@ export function createMetricsDashboard(opts) {
if (!root || root.classList.contains('hidden')) return
const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || ''
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
if (ev.key === 'Escape') /** @type {HTMLElement} */ (ev.target).blur()
if (ev.key === 'Escape') {
const t = /** @type {HTMLElement} */ (ev.target)
t.blur()
if (
state.filtersOpen &&
opts.els.filtersPanel &&
opts.els.filtersPanel.contains(t)
) {
setFiltersOpen(false)
}
}
return
}
if (ev.key === ' ' || ev.code === 'Space') {
@@ -1754,7 +1822,8 @@ export function createMetricsDashboard(opts) {
setPlaying(!state.playing)
} else if (ev.key === '/') {
ev.preventDefault()
opts.els.search?.focus()
if (!state.filtersOpen) setFiltersOpen(true)
requestAnimationFrame(() => opts.els.search?.focus())
} else if (ev.key === 'r' || ev.key === 'R') {
resetWindow()
} else if (ev.key === 'f' || ev.key === 'F') {
@@ -1763,6 +1832,7 @@ export function createMetricsDashboard(opts) {
setBoardOnly(!state.boardOnly)
} else if (ev.key === 'Escape') {
if (state.mcResults || state.mcMode) clearMcResults()
else if (state.filtersOpen) setFiltersOpen(false)
else clearRelated()
} else if (ev.key === 'c' || ev.key === 'C') {
setMcMode(!state.mcMode)
+208
View File
@@ -0,0 +1,208 @@
/**
* System Log tab anomalies / audit / journal via queryLogs.
*/
import { Roles, roleAllows } from '../shared/protocol.js'
/**
* @param {{
* els: {
* root: HTMLElement|null,
* sources: HTMLElement|null,
* q: HTMLInputElement|null,
* presets: HTMLElement|null,
* priority: HTMLSelectElement|null,
* unit: HTMLInputElement|null,
* searchBtn: HTMLElement|null,
* status: HTMLElement|null,
* list: HTMLElement|null,
* },
* queryLogs: (args: object) => Promise<object>,
* getRole: () => string,
* onShowChart?: (chartId: string, ts: number) => void,
* onCorrelate?: (ts: number) => void,
* }} opts
*/
export function createLogsView(opts) {
const state = {
source: 'anomaly',
range: '1h',
loading: false,
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function isAdmin() {
return roleAllows(opts.getRole?.() || Roles.viewer, Roles.admin)
}
function rangeToSince() {
const now = Date.now()
const map = { '15m': 15, '1h': 60, '6h': 360, '24h': 1440 }
const mins = map[state.range] || 60
return now - mins * 60_000
}
function syncSourceUi() {
const admin = isAdmin()
opts.els.root?.classList.toggle('logs-source-journal', state.source === 'journal')
opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => {
const src = btn.getAttribute('data-log-source') || ''
const needsAdmin = src === 'audit' || src === 'journal'
btn.disabled = needsAdmin && !admin
btn.title = needsAdmin && !admin ? 'Admin role required' : btn.dataset.titleDefault || btn.title
const active = src === state.source
btn.classList.toggle('active', active)
btn.setAttribute('aria-selected', active ? 'true' : 'false')
})
if ((state.source === 'audit' || state.source === 'journal') && !admin) {
state.source = 'anomaly'
opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => {
const active = btn.getAttribute('data-log-source') === 'anomaly'
btn.classList.toggle('active', active)
btn.setAttribute('aria-selected', active ? 'true' : 'false')
})
opts.els.root?.classList.remove('logs-source-journal')
}
}
function syncRangeUi() {
opts.els.presets?.querySelectorAll('[data-log-range]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-log-range') === state.range)
})
}
function setStatus(text, isError = false) {
if (!opts.els.status) return
opts.els.status.textContent = text || ''
opts.els.status.classList.toggle('logs-status-error', Boolean(isError))
}
function renderEntries(entries) {
const list = opts.els.list
if (!list) return
list.innerHTML = ''
if (!entries.length) {
list.innerHTML = '<li class="muted">No matching log lines</li>'
return
}
for (const row of entries) {
const li = document.createElement('li')
const sev = String(row.severity || 'info')
li.className = `log-sev-${sev}`
const when = new Date(row.ts || Date.now()).toLocaleString()
const chart = row.fields?.chart
const actions =
row.source === 'anomaly' && chart
? `<span class="fleet-actions anomaly-actions">
<button type="button" class="linkish" data-act="focus">Show</button>
<button type="button" class="linkish" data-act="correlate">Correlate</button>
</span>`
: ''
li.innerHTML = `
<strong>${escapeHtml(sev)}</strong>
<span class="log-msg">${escapeHtml(row.message || '')}</span>
<span class="muted log-meta">
<span>${escapeHtml(when)}</span>
${row.unit ? `<span>${escapeHtml(row.unit)}</span>` : ''}
<span>${escapeHtml(row.source || '')}</span>
</span>
${actions}`
if (chart) {
li.querySelector('[data-act="focus"]')?.addEventListener('click', (e) => {
e.stopPropagation()
opts.onShowChart?.(chart, row.ts)
})
li.querySelector('[data-act="correlate"]')?.addEventListener('click', (e) => {
e.stopPropagation()
opts.onCorrelate?.(row.ts)
})
}
list.appendChild(li)
}
}
async function search() {
if (state.loading) return
syncSourceUi()
state.loading = true
setStatus('Searching…')
try {
const args = {
source: state.source,
q: opts.els.q?.value || '',
since: rangeToSince(),
until: Date.now(),
limit: 200,
}
if (state.source === 'journal') {
args.priority = opts.els.priority?.value || ''
args.unit = opts.els.unit?.value || ''
}
const res = await opts.queryLogs(args)
if (!res?.ok) {
setStatus(res?.hint || res?.error || 'Query failed', true)
renderEntries([])
return
}
const entries = Array.isArray(res.entries) ? res.entries : []
const n = res.stats?.returned ?? entries.length
setStatus(`${n} line${n === 1 ? '' : 's'} · ${state.source}`)
renderEntries(entries)
} catch (err) {
setStatus(err?.message || 'Query failed', true)
renderEntries([])
} finally {
state.loading = false
}
}
function bind() {
opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => {
if (!btn.dataset.titleDefault) btn.dataset.titleDefault = btn.title || ''
btn.addEventListener('click', () => {
if (btn.disabled) return
state.source = btn.getAttribute('data-log-source') || 'anomaly'
syncSourceUi()
search().catch(() => {})
})
})
opts.els.presets?.querySelectorAll('[data-log-range]').forEach((btn) => {
btn.addEventListener('click', () => {
state.range = btn.getAttribute('data-log-range') || '1h'
syncRangeUi()
search().catch(() => {})
})
})
opts.els.searchBtn?.addEventListener('click', () => search().catch(() => {}))
opts.els.q?.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
search().catch(() => {})
}
})
// Live filter for anomaly/audit: debounce input
let t = /** @type {ReturnType<typeof setTimeout>|null} */ (null)
opts.els.q?.addEventListener('input', () => {
if (state.source === 'journal') return
if (t) clearTimeout(t)
t = setTimeout(() => search().catch(() => {}), 280)
})
syncSourceUi()
syncRangeUi()
}
function enter() {
syncSourceUi()
search().catch(() => {})
}
bind()
return { search, enter, syncSourceUi }
}
+169 -5
View File
@@ -1308,16 +1308,22 @@ button.metrics-tf.thin-history:not(.active) {
.metrics-shell {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
gap: var(--space);
min-height: 0;
min-width: 0;
width: 100%;
overflow: hidden;
position: relative;
}
.metrics-toc {
#charts-view.charts-filters-open .metrics-shell {
grid-template-columns: 240px minmax(0, 1fr);
}
.metrics-toc,
.metrics-filters-panel {
display: flex;
flex-direction: column;
gap: 10px;
@@ -1329,6 +1335,59 @@ button.metrics-tf.thin-history:not(.active) {
border: 1px solid var(--border-color);
}
.metrics-filters-panel[hidden] {
display: none !important;
}
.metrics-filters-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.metrics-filters-controls .metrics-group-label {
flex: 1 1 auto;
min-width: 0;
}
#metrics-filters-btn.active {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-color));
}
.metrics-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.metrics-filter-chip button {
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font: inherit;
padding: 0 2px;
}
.metrics-filter-chip button:hover {
color: var(--text-primary);
}
.metrics-filter-chip .filter-chip-text {
overflow: hidden;
text-overflow: ellipsis;
}
.metrics-toc #chart-search {
width: 100%;
border-radius: 9px;
@@ -2317,12 +2376,12 @@ code {
.dash-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
#charts-view .metrics-shell {
#charts-view.charts-filters-open .metrics-shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
#charts-view .metrics-toc {
max-height: 140px;
#charts-view.charts-filters-open .metrics-filters-panel {
max-height: 200px;
}
.fleet-summary-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2356,3 +2415,108 @@ code {
grid-template-columns: 1fr 1fr;
}
}
/* ─── Logs tab ─────────────────────────────────────────── */
.logs-toolbar {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.logs-sources {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.logs-search-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.logs-search-row #logs-q {
flex: 1 1 180px;
min-width: 140px;
border-radius: 9px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
padding: 8px 10px;
font: inherit;
font-size: 13px;
}
.logs-search-row #logs-unit {
width: 120px;
border-radius: 9px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
padding: 6px 8px;
font: inherit;
font-size: 13px;
}
.logs-journal-only {
display: none;
}
#logs-view.logs-source-journal .logs-journal-only {
display: inline-flex;
}
.logs-status {
margin: 0 0 8px;
font-size: 13px;
min-height: 1.2em;
}
.logs-card {
min-height: 0;
flex: 1;
overflow: auto;
}
#logs-view {
display: flex;
flex-direction: column;
min-height: 0;
}
#logs-view.hidden {
display: none;
}
.logs-list .log-msg {
font-family: var(--font-mono);
font-size: 12px;
word-break: break-word;
}
.logs-list .log-meta {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
}
.logs-list li.log-sev-critical,
.logs-list li.log-sev-err,
.logs-list li.log-sev-emerg,
.logs-list li.log-sev-alert {
border-left-color: var(--danger, #e85d5d);
}
.logs-list li.log-sev-warning,
.logs-list li.log-sev-warn {
border-left-color: var(--warn, #e0a24a);
}
.logs-sources .metrics-tf:disabled {
opacity: 0.4;
cursor: not-allowed;
}
+1
View File
@@ -18,6 +18,7 @@ For engineers (protocol, REST deep-dive, architecture), see [docs/](../docs/READ
| [Metric Correlations](./metric-correlations.md) | Highlight → Find Correlations → filtered wall (full walkthrough) |
| [Related metrics](./related-metrics.md) | Per-chart ⇢ related (taxonomy + Pearson) — different from Correlate |
| [Alerts](./alerts.md) | Anomaly list, Show / Correlate actions |
| [Logs](./logs.md) | System log search — anomalies, audit, optional journal |
| [Fleet](./fleet.md) | Multi-host roster, set active, reconnect, forget |
| [Settings](./settings.md) | Theme, motion, spark depth, reconnect |
| [Keyboard shortcuts](./keyboard.md) | Charts and shell shortcuts |
+13 -3
View File
@@ -6,12 +6,22 @@ The Charts tab is the **metrics wall**: every chart the agent catalogs, grouped
| Region | Role |
|--------|------|
| **Toolbar** | Live/Pause, Force, Board, Correlate, Reset, time presets, Group, Sort |
| **Toolbar** | Live/Pause, Force, Board, Correlate, Reset, time presets, **Filters** |
| **Filters panel** | Search, section TOC, Group, Sort — collapsed by default for a full-width wall |
| **Correlate bar** | Appears in Correlate mode — method, Find Correlations, Clear |
| **Related / MC panels** | Ranked lists above the wall |
| **TOC** | Search + jump to sections |
| **Wall** | Scrollable cards (only this pane scrolls on Charts) |
## Filters menu
**Filters** toggles a left panel with chart search, section jump list, downsample **Group**, and dimension **Sort**.
- Closed by default so the wall uses the full width.
- Press **`/`** to open Filters and focus search.
- **Esc** closes Filters (after clearing Correlate / Related if those are open).
- With Filters closed and an active search, a **Filtered: …** chip appears — click **×** to clear.
- Open/closed state persists in desktop settings.
## Time controls
| Control | Meaning |
@@ -19,7 +29,7 @@ The Charts tab is the **metrics wall**: every chart the agent catalogs, grouped
| **1m … 6h** | Shared window length (live edge when playing) |
| **Pause / Play** | Stop or resume live advance (`Space`) |
| **Reset** | Back to live 5m |
| **Group** | Downsample aggregation: average / min / max / sum |
| **Group** | Downsample aggregation (inside Filters): average / min / max / sum |
| **thin-history** (preset style) | Advisory — buffered history may be shorter than the window |
Retention never hard-locks presets once an agent is connected; longer windows may look sparse until samples accumulate.
+4 -2
View File
@@ -6,7 +6,9 @@ Shortcuts apply when the Charts (or shell) focus is not in a text field.
|-----|--------|
| **Space** | Pause / play shared Charts time |
| **c** | Toggle **Correlate** mode |
| **Esc** | Clear Metric Correlation results, or dismiss Related panel |
| **/** or focus search | Jump to Charts TOC search (when available) |
| **/** | Open Charts **Filters** and focus search |
| **Esc** | Clear Correlate results → close Filters → dismiss Related |
| **f** | Toggle Force (wallboard live) |
| **b** | Toggle Board (pins only) |
Mouse / trackpad gestures for pan, zoom, and brush are documented in [Charts](./charts.md).
+50
View File
@@ -0,0 +1,50 @@
# Logs
The **Logs** tab searches recent events from the active agent: anomalies, the agent audit trail, and (optionally) the host systemd journal.
## Sources
| Source | Who can use it | What it shows |
|--------|----------------|---------------|
| **Anomalies** | Viewer+ | Threshold / z-score events (same family as Alerts) |
| **Audit** | Admin | Mutating RPC trail from `data/audit.log` |
| **Journal** | Admin | Host `journalctl` lines (Linux, opt-in) |
Audit and Journal tabs are disabled for non-admin sessions (tooltip explains why).
## Search
1. Open **Logs**.
2. Pick a source.
3. Choose a time preset (15m / 1h / 6h / 24h).
4. Type a substring in **Search message…** (live for Anomalies/Audit; press **Search** for Journal).
5. For Journal: optional **Priority** and **Unit** (e.g. `peardata`).
Results show severity, message (monospace), time, and unit. Anomaly rows offer **Show** / **Correlate** like the Alerts tab.
## Enabling host journal
Default is **off** (no `journalctl` spawn).
1. Set `PEARDATA_JOURNAL=1` on the agent.
2. Ensure the agent user can read the journal (often `SupplementaryGroups=systemd-journal` in the systemd unit).
3. Restart the agent.
Without this, Journal returns a clear “disabled” hint. Non-Linux agents always report journal as unsupported.
See [CONFIGURATION](../docs/CONFIGURATION.md) and [SECURITY](../docs/SECURITY.md).
## API
```http
GET /api/v3/logs?source=anomaly&q=cpu&since=<ms>&limit=100
```
RPC: `queryLogs` with the same fields. Audit/journal over P2P require **admin**.
## vs Alerts
| | Alerts | Logs |
|--|--------|------|
| Focus | Live anomaly list + notifications | Searchable multi-source history |
| Sources | Anomalies only | Anomalies + audit + journal |
+1
View File
@@ -19,6 +19,7 @@ You dial agents by **public key** (viewer) or **`pd1.` invite** / admin seed for
| **Overview** | Compact home: KPIs + six spark panels + optional fleet strip |
| **Charts** | Full metrics wall — every catalog chart, shared time, investigation |
| **Alerts** | Recent anomaly / threshold events |
| **Logs** | Search anomalies, audit trail, optional host journal |
| **Fleet** | Saved + live agents; set active, reconnect, open Charts |
| **Connect** | Dial a new key or invite |
| **Settings** | Theme, notifications, history depth, reconnect |