System Log
This commit is contained in:
@@ -15,6 +15,8 @@ PEARDATA_DEFAULT_ROLE=viewer
|
|||||||
# PEARDATA_DATA_DIR=./data
|
# PEARDATA_DATA_DIR=./data
|
||||||
# PEARDATA_RATE_LIMIT_RPM=120
|
# PEARDATA_RATE_LIMIT_RPM=120
|
||||||
# PEARDATA_MAX_RECONNECT=20
|
# PEARDATA_MAX_RECONNECT=20
|
||||||
|
# Host journal for Logs tab / GET /api/v*/logs?source=journal (Linux; needs journal ACL)
|
||||||
|
# PEARDATA_JOURNAL=0
|
||||||
|
|
||||||
# ── Metrics pipeline ─────────────────────────────────────────
|
# ── Metrics pipeline ─────────────────────────────────────────
|
||||||
# PEARDATA_SAMPLE_MS=1000
|
# PEARDATA_SAMPLE_MS=1000
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ peardata/
|
|||||||
|
|
||||||
| Doc | Contents |
|
| 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 |
|
| [Getting started](./docs/GETTING-STARTED.md) | Install, agent, REST, desktop, systemd |
|
||||||
| [Docs index](./docs/README.md) | Full engineer / operator doc set |
|
| [Docs index](./docs/README.md) | Full engineer / operator doc set |
|
||||||
| [Roadmap](./docs/ROADMAP.md) | MVP → advanced phases |
|
| [Roadmap](./docs/ROADMAP.md) | MVP → advanced phases |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* PearData desktop — multi-view shell, live charts, settings.
|
* PearData desktop — multi-view shell, live charts, settings.
|
||||||
*/
|
*/
|
||||||
import { manager } from './client/manager.js'
|
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 { getClientIdentity } from './client/identity.js'
|
||||||
import {
|
import {
|
||||||
loadBookmarks,
|
loadBookmarks,
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
summarizeFleet,
|
summarizeFleet,
|
||||||
renderFleetCards,
|
renderFleetCards,
|
||||||
} from './ui/fleet.js'
|
} from './ui/fleet.js'
|
||||||
|
import { createLogsView } from './ui/logs.js'
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id)
|
const $ = (id) => document.getElementById(id)
|
||||||
|
|
||||||
@@ -138,6 +139,8 @@ const peerCpu = new Map()
|
|||||||
|
|
||||||
/** @type {object|null} last getFleetHealth payload for Fleet card chips */
|
/** @type {object|null} last getFleetHealth payload for Fleet card chips */
|
||||||
let lastFleetHealth = null
|
let lastFleetHealth = null
|
||||||
|
/** @type {string} */
|
||||||
|
let currentRole = Roles.viewer
|
||||||
|
|
||||||
const metricsDashboard = createMetricsDashboard({
|
const metricsDashboard = createMetricsDashboard({
|
||||||
els: {
|
els: {
|
||||||
@@ -165,6 +168,9 @@ const metricsDashboard = createMetricsDashboard({
|
|||||||
liveEl: $('metrics-live'),
|
liveEl: $('metrics-live'),
|
||||||
liveLabel: $('metrics-live-label'),
|
liveLabel: $('metrics-live-label'),
|
||||||
retentionHint: $('metrics-retention-hint'),
|
retentionHint: $('metrics-retention-hint'),
|
||||||
|
filtersBtn: $('metrics-filters-btn'),
|
||||||
|
filtersPanel: $('metrics-filters-panel'),
|
||||||
|
filterChip: $('metrics-filter-chip'),
|
||||||
},
|
},
|
||||||
getCatalog: () => chartCatalog,
|
getCatalog: () => chartCatalog,
|
||||||
queryData: (args) => manager.request(Methods.queryData, args),
|
queryData: (args) => manager.request(Methods.queryData, args),
|
||||||
@@ -178,6 +184,7 @@ const metricsDashboard = createMetricsDashboard({
|
|||||||
pinned: settings.metricsPinned,
|
pinned: settings.metricsPinned,
|
||||||
group: settings.metricsGroup,
|
group: settings.metricsGroup,
|
||||||
forcePlay: settings.metricsForcePlay,
|
forcePlay: settings.metricsForcePlay,
|
||||||
|
filtersOpen: settings.metricsFiltersOpen,
|
||||||
}),
|
}),
|
||||||
savePrefs: (patch) => {
|
savePrefs: (patch) => {
|
||||||
/** @type {Partial<typeof settings>} */
|
/** @type {Partial<typeof settings>} */
|
||||||
@@ -189,6 +196,7 @@ const metricsDashboard = createMetricsDashboard({
|
|||||||
if (patch.pinned != null) mapped.metricsPinned = patch.pinned
|
if (patch.pinned != null) mapped.metricsPinned = patch.pinned
|
||||||
if (patch.group != null) mapped.metricsGroup = patch.group
|
if (patch.group != null) mapped.metricsGroup = patch.group
|
||||||
if (patch.forcePlay != null) mapped.metricsForcePlay = patch.forcePlay
|
if (patch.forcePlay != null) mapped.metricsForcePlay = patch.forcePlay
|
||||||
|
if (patch.filtersOpen != null) mapped.metricsFiltersOpen = patch.filtersOpen
|
||||||
if (Object.keys(mapped).length) persist(mapped)
|
if (Object.keys(mapped).length) persist(mapped)
|
||||||
},
|
},
|
||||||
getAnomaly: (chartId) => {
|
getAnomaly: (chartId) => {
|
||||||
@@ -199,6 +207,33 @@ const metricsDashboard = createMetricsDashboard({
|
|||||||
getWeights: (args) => manager.request(Methods.getWeights, args || {}),
|
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() {
|
function seriesMax() {
|
||||||
// Overview sparks follow Charts' active window when available (capped for density)
|
// Overview sparks follow Charts' active window when available (capped for density)
|
||||||
try {
|
try {
|
||||||
@@ -917,13 +952,15 @@ async function refreshMeta() {
|
|||||||
if (els.serverInfo) {
|
if (els.serverInfo) {
|
||||||
els.serverInfo.textContent = JSON.stringify({ info, node, health, fleet }, null, 2)
|
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 || '—'
|
els.statHealth.textContent = health.status || '—'
|
||||||
if (els.statHealth?.parentElement) {
|
if (els.statHealth?.parentElement) {
|
||||||
els.statHealth.parentElement.dataset.health = health.status || ''
|
els.statHealth.parentElement.dataset.health = health.status || ''
|
||||||
}
|
}
|
||||||
const id = getClientIdentity()
|
const id = getClientIdentity()
|
||||||
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
|
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
|
||||||
|
logsView.syncSourceUi()
|
||||||
renderPeers()
|
renderPeers()
|
||||||
renderFleetStrip(fleet, manager.list())
|
renderFleetStrip(fleet, manager.list())
|
||||||
await populateExploreCharts()
|
await populateExploreCharts()
|
||||||
@@ -1055,6 +1092,7 @@ function showView(name) {
|
|||||||
metricsDashboard.render()
|
metricsDashboard.render()
|
||||||
requestAnimationFrame(() => metricsDashboard.redrawVisible())
|
requestAnimationFrame(() => metricsDashboard.redrawVisible())
|
||||||
}
|
}
|
||||||
|
if (name === 'logs') logsView.enter()
|
||||||
if (name === 'fleet') loadFleetView()
|
if (name === 'fleet') loadFleetView()
|
||||||
if (name === 'settings') syncSettingsUi()
|
if (name === 'settings') syncSettingsUi()
|
||||||
requestAnimationFrame(() => redrawAll())
|
requestAnimationFrame(() => redrawAll())
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
|
|||||||
* metricsPinned: string[],
|
* metricsPinned: string[],
|
||||||
* metricsGroup: 'average'|'min'|'max'|'sum',
|
* metricsGroup: 'average'|'min'|'max'|'sum',
|
||||||
* metricsForcePlay: boolean,
|
* metricsForcePlay: boolean,
|
||||||
|
* metricsFiltersOpen: boolean,
|
||||||
* reconnectMaxAttempts: number,
|
* reconnectMaxAttempts: number,
|
||||||
* autoRestorePeers: boolean,
|
* autoRestorePeers: boolean,
|
||||||
* }} UiSettings */
|
* }} UiSettings */
|
||||||
@@ -52,6 +53,7 @@ export function defaultSettings() {
|
|||||||
metricsPinned: [],
|
metricsPinned: [],
|
||||||
metricsGroup: 'average',
|
metricsGroup: 'average',
|
||||||
metricsForcePlay: false,
|
metricsForcePlay: false,
|
||||||
|
metricsFiltersOpen: false,
|
||||||
reconnectMaxAttempts: 20,
|
reconnectMaxAttempts: 20,
|
||||||
autoRestorePeers: true,
|
autoRestorePeers: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ NoNewPrivileges=true
|
|||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
EnvironmentFile=-/opt/peardata/.env
|
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
|
# Identity + HyperDB / metric buffers
|
||||||
ReadWritePaths=/opt/peardata
|
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/store.js` | Tiered buffers + query |
|
||||||
| `server/services/anomaly.js` | Thresholds |
|
| `server/services/anomaly.js` | Thresholds |
|
||||||
| `server/services/weights.js` | Metric Correlations scoring (`volume` / `ks2` / …) |
|
| `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/alerts.js` | Alert CRUD helpers |
|
||||||
| `server/services/subscriptions.js` | Push fan-out |
|
| `server/services/subscriptions.js` | Push fan-out |
|
||||||
| `server/services/jobs.js` | On-demand jobs |
|
| `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_STORAGE` | — | Electron/Pear storage dir override (bookmarks prefer `Pear.config.storage`) |
|
||||||
| `PEARDATA_RATE_LIMIT_RPM` | `120` | Per-peer RPC requests per minute |
|
| `PEARDATA_RATE_LIMIT_RPM` | `120` | Per-peer RPC requests per minute |
|
||||||
| `PEARDATA_MAX_RECONNECT` | `20` | Client manager reconnect attempts per peer |
|
| `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/
|
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 |
|
| Engine | Server `weights.js` (`volume` / `ks2` / …) | Client taxonomy + Pearson |
|
||||||
| UX | Toolbar Correlate → brush ≥15s → Find Correlations → filtered wall | Per-card ⇢ / dblclick → related panel |
|
| 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`.
|
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
|
## REST ↔ RPC parity
|
||||||
|
|
||||||
| Concept | RPC | REST |
|
| Concept | RPC | REST |
|
||||||
@@ -185,6 +203,7 @@ Desktop UX: [user-guide/metric-correlations.md](../user-guide/metric-correlation
|
|||||||
| Charts | `listCharts` | `GET /api/v1/charts` |
|
| Charts | `listCharts` | `GET /api/v1/charts` |
|
||||||
| Data | `queryData` | `GET /api/v3/data` |
|
| Data | `queryData` | `GET /api/v3/data` |
|
||||||
| Weights | `getWeights` | `GET /api/v1\|v2\|v3/weights` |
|
| Weights | `getWeights` | `GET /api/v1\|v2\|v3/weights` |
|
||||||
|
| Logs | `queryLogs` | `GET /api/v1\|v2\|v3/logs` |
|
||||||
| Contexts | `listContexts` | `GET /api/v3/contexts` |
|
| Contexts | `listContexts` | `GET /api/v3/contexts` |
|
||||||
| Nodes | `getNodeInfo` | `GET /api/v3/nodes` |
|
| Nodes | `getNodeInfo` | `GET /api/v3/nodes` |
|
||||||
| Alerts | `listAlerts` | `GET /api/v3/alerts` |
|
| 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** |
|
| `ui/styles.css` | Layout, theme, **titlebar drag regions** |
|
||||||
| `client/*` | HyperDHT connection stack used by the UI |
|
| `client/*` | HyperDHT connection stack used by the UI |
|
||||||
| `docs/DASHBOARD.md` | Master Charts / metrics wall plan |
|
| `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
|
```bash
|
||||||
npm start # pear run -d .
|
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) |
|
| 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) |
|
| **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) |
|
| **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`.
|
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 |
|
| `getChart` | viewer | `{ id }` | Chart summary |
|
||||||
| `queryData` | viewer | `{ chart, after, before, points, group, tier }` | Time series |
|
| `queryData` | viewer | `{ chart, after, before, points, group, tier }` | Time series |
|
||||||
| `getWeights` | viewer | see below | Metric Correlations / alert weights |
|
| `getWeights` | viewer | see below | Metric Correlations / alert weights |
|
||||||
|
| `queryLogs` | viewer* | see below | Anomaly / audit / journal lines |
|
||||||
| `getAllMetrics` | viewer | `{ format: json\|prometheus\|shell }` | Latest export |
|
| `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.
|
`after` / `before`: absolute unix seconds, or relative (negative = relative to `before`/`now`), agent-style.
|
||||||
|
|
||||||
#### `getWeights`
|
#### `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`.
|
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
|
### Live subscriptions
|
||||||
|
|
||||||
| Method | Role | Args |
|
| Method | Role | Args |
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| Doc | Contents |
|
| 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 |
|
| [GETTING-STARTED.md](./GETTING-STARTED.md) | Install agent / desktop, first dial, systemd |
|
||||||
|
|
||||||
## Engineers & operators
|
## 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/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/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
|
### Alerts
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
|||||||
| Notifications | Done (desktop + `PEARDATA_WEBHOOK_URL`) |
|
| Notifications | Done (desktop + `PEARDATA_WEBHOOK_URL`) |
|
||||||
| Streaming z-score / retrain job | Done (`PEARDATA_ANOMALY_MODE`, job `retrainAnomaly`) |
|
| 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/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 |
|
| Taxonomy coverage test + EXTENDING note | Done |
|
||||||
| Desktop polish (layout fill, plot domain, legacy detail removed, Fleet chips) | Done |
|
| Desktop polish (layout fill, plot domain, legacy detail removed, Fleet chips) | Done |
|
||||||
| Metric Correlations (weights engine + Correlate brush UI) | 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 |
|
| Multi-named custom boards | Later |
|
||||||
|
|
||||||
**Phase 6 exit (P0)** — Charts tab lists every agent chart in sections; shared
|
**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/`)
|
- [ ] Run under systemd with `ProtectSystem` / `NoNewPrivileges` (see `deploy/`)
|
||||||
- [ ] Do not embed seed in frontend builds, CI logs, or crash reports
|
- [ ] Do not embed seed in frontend builds, CI logs, or crash reports
|
||||||
- [ ] Review `LOG_LEVEL=debug` before production (avoid verbose auth noise)
|
- [ ] 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`)
|
- [ ] Keep Pear / dependency updates current (`npm outdated`)
|
||||||
|
|
||||||
## Threat notes
|
## Threat notes
|
||||||
@@ -84,7 +85,8 @@ Implementation: `shared/crypto-auth.js`.
|
|||||||
|----------|-------------|----------|
|
|----------|-------------|----------|
|
||||||
| `.env` | Critical | Never commit; backup offline |
|
| `.env` | Critical | Never commit; backup offline |
|
||||||
| `data/peer-policy.json` | High | Contains roles & JTIs |
|
| `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 |
|
| `identity.json` | High for that user | Per-machine client secret |
|
||||||
| Release tarballs | Low | Source only; no secrets |
|
| Release tarballs | Low | Source only; no secrets |
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
| `test/protocol.test.js` | Constants, monitoring `MethodRoles`, schema validators |
|
| `test/protocol.test.js` | Constants, monitoring `MethodRoles`, schema validators |
|
||||||
| `test/store.test.js` | Metric ring buffer ingest + query |
|
| `test/store.test.js` | Metric ring buffer ingest + query |
|
||||||
| `test/weights.test.js` | Metric Correlations engine (`volume` / `ks2` / windows / errors) |
|
| `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/hyperdb.test.js` | HyperDB model (node, links, warm points, alerts) |
|
||||||
| `test/rest.test.js` | agent-style `/api/v3` routes |
|
| `test/rest.test.js` | agent-style `/api/v3` routes |
|
||||||
| `test/integration.test.js` | Live HyperDHT agent + handshake + metrics query |
|
| `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` |
|
| Health | With server up: `SERVER_PUBLIC_KEY=… npm run healthcheck` |
|
||||||
| Soak | `SERVER_PUBLIC_KEY=… SERVER_SEED=… npm run soak` |
|
| Soak | `SERVER_PUBLIC_KEY=… SERVER_SEED=… npm run soak` |
|
||||||
| Metric Correlations | Charts → Correlate → brush ≥15s → Find Correlations → wall filters; Clear restores; Related ⇢ still works |
|
| 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'` |
|
| 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
|
## Soak test
|
||||||
|
|
||||||
|
|||||||
+68
-11
@@ -50,6 +50,9 @@
|
|||||||
<button type="button" class="nav-link" data-view="alerts">
|
<button type="button" class="nav-link" data-view="alerts">
|
||||||
<span class="nav-ico">⚑</span><span class="nav-label">Alerts</span>
|
<span class="nav-ico">⚑</span><span class="nav-label">Alerts</span>
|
||||||
</button>
|
</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>
|
<div class="nav-group-label">Fleet</div>
|
||||||
<button type="button" class="nav-link" data-view="fleet">
|
<button type="button" class="nav-link" data-view="fleet">
|
||||||
<span class="nav-ico">◎</span><span class="nav-label">Fleet</span>
|
<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>
|
<button type="button" class="metrics-tf" data-preset="6h" title="Last 6 hours">6h</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="metrics-toolbar-right">
|
<div class="metrics-toolbar-right">
|
||||||
<label class="metrics-group-label muted">
|
<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>
|
||||||
Group
|
<span id="metrics-filter-chip" class="metrics-filter-chip hidden" role="status"></span>
|
||||||
<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>
|
|
||||||
<span id="metrics-meta" class="muted metrics-meta"></span>
|
<span id="metrics-meta" class="muted metrics-meta"></span>
|
||||||
<span id="metrics-hover" class="muted metrics-hover"></span>
|
<span id="metrics-hover" class="muted metrics-hover"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +229,19 @@
|
|||||||
<div id="metrics-related" class="related-panel hidden"></div>
|
<div id="metrics-related" class="related-panel hidden"></div>
|
||||||
<div id="metrics-mc-results" class="related-panel mc-results-panel hidden"></div>
|
<div id="metrics-mc-results" class="related-panel mc-results-panel hidden"></div>
|
||||||
<div class="metrics-shell">
|
<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" />
|
<input id="chart-search" type="search" placeholder="Filter charts…" autocomplete="off" />
|
||||||
<nav id="metrics-toc" class="metrics-toc-nav" aria-label="Chart sections"></nav>
|
<nav id="metrics-toc" class="metrics-toc-nav" aria-label="Chart sections"></nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -260,6 +267,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Fleet -->
|
||||||
<section id="fleet-view" class="view hidden">
|
<section id="fleet-view" class="view hidden">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ const MUTATING = new Set([
|
|||||||
'handshake',
|
'handshake',
|
||||||
])
|
])
|
||||||
|
|
||||||
function auditPath() {
|
export function getAuditPath() {
|
||||||
const dir = process.env.PEARDATA_DATA_DIR || path.resolve('data')
|
const dir = process.env.PEARDATA_DATA_DIR || path.resolve('data')
|
||||||
return path.join(dir, 'audit.log')
|
return path.join(dir, 'audit.log')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated use getAuditPath */
|
||||||
|
function auditPath() {
|
||||||
|
return getAuditPath()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} method
|
* @param {string} method
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { getCollector } from '../services/collector.js'
|
|||||||
import { getStore } from '../services/store.js'
|
import { getStore } from '../services/store.js'
|
||||||
import { getAnomalyEngine } from '../services/anomaly.js'
|
import { getAnomalyEngine } from '../services/anomaly.js'
|
||||||
import { computeWeights } from '../services/weights.js'
|
import { computeWeights } from '../services/weights.js'
|
||||||
|
import { queryLogs } from '../services/logs.js'
|
||||||
import {
|
import {
|
||||||
listAlerts,
|
listAlerts,
|
||||||
getAlert,
|
getAlert,
|
||||||
@@ -150,6 +151,10 @@ export function registerMonitorHandlers(session) {
|
|||||||
session.respond('queryData', async (args) => store.query(args), { hot: true })
|
session.respond('queryData', async (args) => store.query(args), { hot: true })
|
||||||
session.respond('getWeights', async (args) => computeWeights(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 () => {
|
session.respond('getDbInfo', async () => {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
if (!db) return { enabled: false }
|
if (!db) return { enabled: false }
|
||||||
|
|||||||
+29
-1
@@ -4,7 +4,13 @@
|
|||||||
* Local-agent style GET endpoints for charts/data/contexts/nodes/info/allmetrics.
|
* Local-agent style GET endpoints for charts/data/contexts/nodes/info/allmetrics.
|
||||||
*/
|
*/
|
||||||
import os from 'os'
|
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 {
|
import {
|
||||||
getAllChartDefs,
|
getAllChartDefs,
|
||||||
getContextIds,
|
getContextIds,
|
||||||
@@ -15,6 +21,7 @@ import { getStore } from '../services/store.js'
|
|||||||
import { getCollector } from '../services/collector.js'
|
import { getCollector } from '../services/collector.js'
|
||||||
import { getAnomalyEngine } from '../services/anomaly.js'
|
import { getAnomalyEngine } from '../services/anomaly.js'
|
||||||
import { computeWeights } from '../services/weights.js'
|
import { computeWeights } from '../services/weights.js'
|
||||||
|
import { queryLogs } from '../services/logs.js'
|
||||||
import { listAlerts, getAlert } from '../services/alerts.js'
|
import { listAlerts, getAlert } from '../services/alerts.js'
|
||||||
import { getServerPublicKeyHex } from '../core/auth-keys.js'
|
import { getServerPublicKeyHex } from '../core/auth-keys.js'
|
||||||
import { formatAllMetrics } from './formatters.js'
|
import { formatAllMetrics } from './formatters.js'
|
||||||
@@ -179,6 +186,27 @@ export async function handleRest(pathname, query) {
|
|||||||
}
|
}
|
||||||
return json(result)
|
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') {
|
if (path === '/api/v3/q' || path === '/api/v2/q') {
|
||||||
const q = (query.get('q') || query.get('query') || '').toLowerCase()
|
const q = (query.get('q') || query.get('query') || '').toLowerCase()
|
||||||
const hits = getAllChartDefs()
|
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,
|
listAnomalies: Roles.viewer,
|
||||||
listAlerts: Roles.viewer,
|
listAlerts: Roles.viewer,
|
||||||
getAlert: Roles.viewer,
|
getAlert: Roles.viewer,
|
||||||
|
/** Anomaly source: viewer; audit/journal enforced admin inside handler */
|
||||||
|
queryLogs: Roles.viewer,
|
||||||
|
|
||||||
// alerts / thresholds (write)
|
// alerts / thresholds (write)
|
||||||
setAlertConfig: Roles.operator,
|
setAlertConfig: Roles.operator,
|
||||||
|
|||||||
@@ -34,6 +34,29 @@ export function validateMethodArgs(method, args = {}) {
|
|||||||
case 'getWeights':
|
case 'getWeights':
|
||||||
return { ok: true, args }
|
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': {
|
case 'setDisplayName': {
|
||||||
const name = String(args.name ?? '').trim()
|
const name = String(args.name ?? '').trim()
|
||||||
if (!name) return { ok: false, error: 'name is required' }
|
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',
|
'getFleetHealth',
|
||||||
'listChildPeers',
|
'listChildPeers',
|
||||||
'getWeights',
|
'getWeights',
|
||||||
|
'queryLogs',
|
||||||
]) {
|
]) {
|
||||||
t.ok(MethodRoles[m], m)
|
t.ok(MethodRoles[m], m)
|
||||||
t.is(Methods[m], m)
|
t.is(Methods[m], m)
|
||||||
|
|||||||
+72
-2
@@ -80,6 +80,7 @@ function smoothScrollIntoView(el, block = 'start') {
|
|||||||
* pinned?: string[],
|
* pinned?: string[],
|
||||||
* group?: string,
|
* group?: string,
|
||||||
* forcePlay?: boolean,
|
* forcePlay?: boolean,
|
||||||
|
* filtersOpen?: boolean,
|
||||||
* },
|
* },
|
||||||
* savePrefs?: (patch: object) => void,
|
* savePrefs?: (patch: object) => void,
|
||||||
* getAnomaly?: (chartId: string) => { severity?: string, threshold?: number|null }|null,
|
* 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} */
|
/** @type {{ startFrac: number, endFrac: number, chartId: string }|null} */
|
||||||
mcBrush: null,
|
mcBrush: null,
|
||||||
mcRunning: false,
|
mcRunning: false,
|
||||||
|
/** Filters panel (search / TOC / group / sort) — default closed for full-width wall */
|
||||||
|
filtersOpen: Boolean(prefs().filtersOpen),
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleHoverPaint() {
|
function scheduleHoverPaint() {
|
||||||
@@ -1536,11 +1539,65 @@ export function createMetricsDashboard(opts) {
|
|||||||
for (const id of state.visible) paintCard(id)
|
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() {
|
function bindChrome() {
|
||||||
opts.els.search?.addEventListener('input', () => {
|
opts.els.search?.addEventListener('input', () => {
|
||||||
state.filter = opts.els.search?.value || ''
|
state.filter = opts.els.search?.value || ''
|
||||||
|
syncFilterChip()
|
||||||
render()
|
render()
|
||||||
})
|
})
|
||||||
|
opts.els.filtersBtn?.addEventListener('click', () => setFiltersOpen(!state.filtersOpen))
|
||||||
opts.els.playBtn?.addEventListener('click', () => setPlaying(!state.playing))
|
opts.els.playBtn?.addEventListener('click', () => setPlaying(!state.playing))
|
||||||
opts.els.presets?.querySelectorAll('[data-preset]').forEach((btn) => {
|
opts.els.presets?.querySelectorAll('[data-preset]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => setPreset(btn.getAttribute('data-preset') || '5m'))
|
btn.addEventListener('click', () => setPreset(btn.getAttribute('data-preset') || '5m'))
|
||||||
@@ -1574,6 +1631,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
syncPresetUi()
|
syncPresetUi()
|
||||||
syncLiveUi()
|
syncLiveUi()
|
||||||
syncMcUi()
|
syncMcUi()
|
||||||
|
syncFiltersUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMcMode(on) {
|
function setMcMode(on) {
|
||||||
@@ -1746,7 +1804,17 @@ export function createMetricsDashboard(opts) {
|
|||||||
if (!root || root.classList.contains('hidden')) return
|
if (!root || root.classList.contains('hidden')) return
|
||||||
const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || ''
|
const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || ''
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (ev.key === ' ' || ev.code === 'Space') {
|
if (ev.key === ' ' || ev.code === 'Space') {
|
||||||
@@ -1754,7 +1822,8 @@ export function createMetricsDashboard(opts) {
|
|||||||
setPlaying(!state.playing)
|
setPlaying(!state.playing)
|
||||||
} else if (ev.key === '/') {
|
} else if (ev.key === '/') {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
opts.els.search?.focus()
|
if (!state.filtersOpen) setFiltersOpen(true)
|
||||||
|
requestAnimationFrame(() => opts.els.search?.focus())
|
||||||
} else if (ev.key === 'r' || ev.key === 'R') {
|
} else if (ev.key === 'r' || ev.key === 'R') {
|
||||||
resetWindow()
|
resetWindow()
|
||||||
} else if (ev.key === 'f' || ev.key === 'F') {
|
} else if (ev.key === 'f' || ev.key === 'F') {
|
||||||
@@ -1763,6 +1832,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
setBoardOnly(!state.boardOnly)
|
setBoardOnly(!state.boardOnly)
|
||||||
} else if (ev.key === 'Escape') {
|
} else if (ev.key === 'Escape') {
|
||||||
if (state.mcResults || state.mcMode) clearMcResults()
|
if (state.mcResults || state.mcMode) clearMcResults()
|
||||||
|
else if (state.filtersOpen) setFiltersOpen(false)
|
||||||
else clearRelated()
|
else clearRelated()
|
||||||
} else if (ev.key === 'c' || ev.key === 'C') {
|
} else if (ev.key === 'c' || ev.key === 'C') {
|
||||||
setMcMode(!state.mcMode)
|
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 {
|
.metrics-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: minmax(0, 1fr);
|
grid-template-rows: minmax(0, 1fr);
|
||||||
gap: var(--space);
|
gap: var(--space);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1329,6 +1335,59 @@ button.metrics-tf.thin-history:not(.active) {
|
|||||||
border: 1px solid var(--border-color);
|
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 {
|
.metrics-toc #chart-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
@@ -2317,12 +2376,12 @@ code {
|
|||||||
.dash-kpis {
|
.dash-kpis {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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-columns: 1fr;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
#charts-view .metrics-toc {
|
#charts-view.charts-filters-open .metrics-filters-panel {
|
||||||
max-height: 140px;
|
max-height: 200px;
|
||||||
}
|
}
|
||||||
.fleet-summary-bar {
|
.fleet-summary-bar {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -2356,3 +2415,108 @@ code {
|
|||||||
grid-template-columns: 1fr 1fr;
|
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) |
|
| [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 |
|
| [Related metrics](./related-metrics.md) | Per-chart ⇢ related (taxonomy + Pearson) — different from Correlate |
|
||||||
| [Alerts](./alerts.md) | Anomaly list, Show / Correlate actions |
|
| [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 |
|
| [Fleet](./fleet.md) | Multi-host roster, set active, reconnect, forget |
|
||||||
| [Settings](./settings.md) | Theme, motion, spark depth, reconnect |
|
| [Settings](./settings.md) | Theme, motion, spark depth, reconnect |
|
||||||
| [Keyboard shortcuts](./keyboard.md) | Charts and shell shortcuts |
|
| [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 |
|
| 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 |
|
| **Correlate bar** | Appears in Correlate mode — method, Find Correlations, Clear |
|
||||||
| **Related / MC panels** | Ranked lists above the wall |
|
| **Related / MC panels** | Ranked lists above the wall |
|
||||||
| **TOC** | Search + jump to sections |
|
|
||||||
| **Wall** | Scrollable cards (only this pane scrolls on Charts) |
|
| **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
|
## Time controls
|
||||||
|
|
||||||
| Control | Meaning |
|
| 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) |
|
| **1m … 6h** | Shared window length (live edge when playing) |
|
||||||
| **Pause / Play** | Stop or resume live advance (`Space`) |
|
| **Pause / Play** | Stop or resume live advance (`Space`) |
|
||||||
| **Reset** | Back to live 5m |
|
| **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 |
|
| **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.
|
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 |
|
| **Space** | Pause / play shared Charts time |
|
||||||
| **c** | Toggle **Correlate** mode |
|
| **c** | Toggle **Correlate** mode |
|
||||||
| **Esc** | Clear Metric Correlation results, or dismiss Related panel |
|
| **/** | Open Charts **Filters** and focus search |
|
||||||
| **/** or focus search | Jump to Charts TOC search (when available) |
|
| **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).
|
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 |
|
| **Overview** | Compact home: KPIs + six spark panels + optional fleet strip |
|
||||||
| **Charts** | Full metrics wall — every catalog chart, shared time, investigation |
|
| **Charts** | Full metrics wall — every catalog chart, shared time, investigation |
|
||||||
| **Alerts** | Recent anomaly / threshold events |
|
| **Alerts** | Recent anomaly / threshold events |
|
||||||
|
| **Logs** | Search anomalies, audit trail, optional host journal |
|
||||||
| **Fleet** | Saved + live agents; set active, reconnect, open Charts |
|
| **Fleet** | Saved + live agents; set active, reconnect, open Charts |
|
||||||
| **Connect** | Dial a new key or invite |
|
| **Connect** | Dial a new key or invite |
|
||||||
| **Settings** | Theme, notifications, history depth, reconnect |
|
| **Settings** | Theme, notifications, history depth, reconnect |
|
||||||
|
|||||||
Reference in New Issue
Block a user