System Log
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user