The Syslog page says where to send (this server, its port and protocols) and where the setting is on Synology, QNAP, TrueNAS, OpenWrt, UniFi and rsyslog, open until a device sends. A sender whose address is on Steward's own container network (Swarm ingress publishing hides the real sender) is flagged, with the host-mode ports to publish instead. compose files and the Dockerfile publish 5514/udp and /tcp; docs/plugins/syslog.md describes the plugin and the deploy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
515 lines
24 KiB
Python
515 lines
24 KiB
Python
# plugins/syslog/routes.py
|
|
"""The Syslog page: whether the listener is open, the sources whose lines are
|
|
kept, and the senders offered to be added. Every GET reads Steward's own rows
|
|
and the listener's state in memory; nothing here contacts a device (System
|
|
178's ruling). Adding, ignoring, editing and removing a source are an admin's,
|
|
and audited."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
|
|
from sqlalchemy import delete, select
|
|
|
|
from steward.auth.middleware import require_role
|
|
from steward.core.audit import log_audit
|
|
from steward.core.metric_series import latest_by_resource
|
|
from steward.core.time_range import RANGES
|
|
from steward.models.metrics import PluginMetricLatest
|
|
from steward.models.users import UserRole
|
|
|
|
from .models import SyslogLine, SyslogPattern, SyslogSource
|
|
from .network import on_own_network
|
|
from .parse import SEVERITIES, facility_name, severity_name
|
|
from .patterns import (PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, WINDOWS, PatternError, key_for, parse_form,
|
|
resource_of)
|
|
from .receiver import LISTENER, MAX_SENDERS, settings
|
|
from .service import (AROUND_LINES, AROUND_SEVERITY, MARK_SEVERITY, MAX_MARKS, PANEL_LINES, PANEL_SPAN,
|
|
SEVERITY_CHOICES, apps_of, filtered, line_filter, lines_between, place_for, rates, shown_on,
|
|
source_name)
|
|
|
|
syslog_bp = Blueprint("syslog", __name__, template_folder="templates")
|
|
|
|
INDEX = "/plugins/syslog/"
|
|
|
|
|
|
def _actor() -> tuple[str | None, str]:
|
|
return session.get("user_id"), session.get("username", "")
|
|
|
|
|
|
async def _hosts(db) -> list:
|
|
from steward.models.hosts import Host
|
|
return list((await db.execute(select(Host.id, Host.name, Host.address).order_by(Host.name))).all())
|
|
|
|
|
|
def listener_view(port: int, udp: bool, tcp: bool) -> dict:
|
|
"""What the page says about the listener: open on which protocols, or
|
|
why not."""
|
|
protocols = [p for p, on in (("UDP", udp), ("TCP", tcp)) if on]
|
|
return {"port": port, "protocols": protocols, "listening": LISTENER.listening,
|
|
"error": LISTENER.error, "bound_at": LISTENER.bound_at,
|
|
"received": LISTENER.received, "dropped": LISTENER.dropped,
|
|
"unoffered": LISTENER.unoffered, "connections": LISTENER.connections,
|
|
"senders_full": False, "max_senders": MAX_SENDERS}
|
|
|
|
|
|
def source_view(s: SyslogSource, hosts: dict, rate: int | None = None) -> dict:
|
|
host = hosts.get(s.host_id) if s.host_id else None
|
|
return {"id": s.id, "address": s.address, "state": s.state, "rate": rate,
|
|
"docker_address": on_own_network(s.address),
|
|
"name": s.name or s.hostname_seen or s.address,
|
|
"hostname_seen": s.hostname_seen, "host": host,
|
|
"first_seen_at": s.first_seen_at, "last_seen_at": s.last_seen_at,
|
|
"lines_total": s.lines_total or 0,
|
|
"severity": severity_name(s.last_severity) if s.last_severity is not None else None,
|
|
"level": s.last_severity, "last_line": s.last_line}
|
|
|
|
|
|
def suggested_host(source: SyslogSource, hosts: list) -> str | None:
|
|
"""The host whose address is the sender's, if one is."""
|
|
return next((h.id for h in hosts if h.address == source.address), None)
|
|
|
|
|
|
@syslog_bp.get("/")
|
|
@require_role(UserRole.viewer)
|
|
async def index():
|
|
async with current_app.db_sessionmaker() as db:
|
|
rows = list((await db.execute(
|
|
select(SyslogSource).order_by(SyslogSource.last_seen_at.desc()))).scalars())
|
|
hosts = await _hosts(db)
|
|
per_hour = await rates(db, [r.id for r in rows if r.state == "accepted"])
|
|
by_id = {h.id: h for h in hosts}
|
|
views = {state: [] for state in ("accepted", "offered", "ignored")}
|
|
for row in rows:
|
|
v = source_view(row, by_id, per_hour.get(row.id, 0) if row.state == "accepted" else None)
|
|
if row.state == "offered":
|
|
v["suggested_host"] = suggested_host(row, hosts)
|
|
views.setdefault(row.state, []).append(v)
|
|
views["accepted"].sort(key=lambda v: v["name"].lower())
|
|
listener = listener_view(*settings(current_app))
|
|
listener["senders_full"] = len(rows) >= MAX_SENDERS
|
|
return await render_template("syslog/index.html", listener=listener,
|
|
sources=views["accepted"], offered=views["offered"],
|
|
ignored=views["ignored"], hosts=hosts,
|
|
server=request.host.rsplit(":", 1)[0] if request.host.count(":") == 1 else request.host,
|
|
now=datetime.now(timezone.utc))
|
|
|
|
|
|
async def _source_or_404(db, source_id: str) -> SyslogSource:
|
|
source = await db.get(SyslogSource, source_id)
|
|
if source is None:
|
|
abort(404)
|
|
return source
|
|
|
|
|
|
async def _unique_name(db, source: SyslogSource, wanted: str) -> str:
|
|
"""The name a source is given, made its own: a source's pattern counts
|
|
and the alert rules on them are filed under its name, so two sources
|
|
sharing one would read as one. A name taken is kept with the address."""
|
|
name = wanted.strip()[:100] or source.hostname_seen or source.address
|
|
taken = (await db.execute(select(SyslogSource.id).where(
|
|
SyslogSource.name == name, SyslogSource.id != source.id).limit(1))).first()
|
|
return f"{name[:80]} ({source.address})" if taken else name
|
|
|
|
|
|
async def _move_resource(db, old: str, new: str) -> None:
|
|
"""Move a renamed source's counts, and the alert rules on them, to its new name."""
|
|
from sqlalchemy import update
|
|
|
|
from steward.models.alerts import AlertRule
|
|
from steward.models.metrics import PluginMetric, PluginMetricLatest
|
|
for model in (PluginMetric, PluginMetricLatest, AlertRule):
|
|
await db.execute(update(model).where(
|
|
model.source_module == SOURCE_MODULE, model.resource_name == old).values(resource_name=new))
|
|
|
|
|
|
async def _host_id(db, value: str | None) -> str | None:
|
|
from steward.models.hosts import Host
|
|
if not value:
|
|
return None
|
|
return value if await db.get(Host, value) is not None else None
|
|
|
|
|
|
@syslog_bp.post("/sources/<source_id>/add")
|
|
@require_role(UserRole.admin)
|
|
async def add_source(source_id: str):
|
|
"""Keep an offered sender's lines from now on, under the name given."""
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
source = await _source_or_404(db, source_id)
|
|
source.name = await _unique_name(db, source, form.get("name") or "")
|
|
source.host_id = await _host_id(db, form.get("host_id"))
|
|
source.state = "accepted"
|
|
detail = {"name": source.name, "address": source.address}
|
|
await log_audit(current_app, *_actor(), "syslog.source_added", entity_type="syslog_source",
|
|
entity_id=source_id, detail=detail)
|
|
return redirect(INDEX)
|
|
|
|
|
|
@syslog_bp.post("/sources/<source_id>/ignore")
|
|
@require_role(UserRole.admin)
|
|
async def ignore_source(source_id: str):
|
|
"""Stop offering a sender: it is still counted, never kept."""
|
|
return await _set_state(source_id, "ignored", "syslog.source_ignored")
|
|
|
|
|
|
@syslog_bp.post("/sources/<source_id>/offer")
|
|
@require_role(UserRole.admin)
|
|
async def offer_source(source_id: str):
|
|
"""Offer an ignored sender again."""
|
|
return await _set_state(source_id, "offered", "syslog.source_offered")
|
|
|
|
|
|
async def _set_state(source_id: str, state: str, action: str):
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
source = await _source_or_404(db, source_id)
|
|
source.state = state
|
|
detail = {"address": source.address}
|
|
await log_audit(current_app, *_actor(), action, entity_type="syslog_source",
|
|
entity_id=source_id, detail=detail)
|
|
return redirect(INDEX)
|
|
|
|
|
|
@syslog_bp.get("/sources/<source_id>/edit")
|
|
@require_role(UserRole.admin)
|
|
async def edit_source(source_id: str):
|
|
async with current_app.db_sessionmaker() as db:
|
|
source = await _source_or_404(db, source_id)
|
|
hosts = await _hosts(db)
|
|
return await render_template("syslog/edit.html", source=source, hosts=hosts,
|
|
label=source.name or source.hostname_seen or source.address)
|
|
|
|
|
|
@syslog_bp.post("/sources/<source_id>/edit")
|
|
@require_role(UserRole.admin)
|
|
async def save_source(source_id: str):
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
source = await _source_or_404(db, source_id)
|
|
old_resource = resource_of(source)
|
|
old_name = source.name
|
|
source.name = await _unique_name(db, source, form.get("name") or "")
|
|
source.host_id = await _host_id(db, form.get("host_id"))
|
|
detail = {"name": source.name}
|
|
if old_name != source.name:
|
|
detail["old_name"] = old_name
|
|
if old_resource != resource_of(source):
|
|
await _move_resource(db, old_resource, resource_of(source))
|
|
await log_audit(current_app, *_actor(), "syslog.source_updated", entity_type="syslog_source",
|
|
entity_id=source_id, detail=detail)
|
|
return redirect(f"/plugins/syslog/sources/{source_id}")
|
|
|
|
|
|
@syslog_bp.post("/sources/<source_id>/delete")
|
|
@require_role(UserRole.admin)
|
|
async def delete_source(source_id: str):
|
|
"""Remove a source and the lines kept from it. A device still sending is
|
|
offered again."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
source = await _source_or_404(db, source_id)
|
|
detail = {"name": source.name, "address": source.address}
|
|
resource = resource_of(source)
|
|
await db.delete(source)
|
|
# Its newest pattern counts go with it; its history ages out.
|
|
await db.execute(delete(PluginMetricLatest).where(
|
|
PluginMetricLatest.source_module == SOURCE_MODULE, PluginMetricLatest.resource_name == resource))
|
|
await log_audit(current_app, *_actor(), "syslog.source_removed", entity_type="syslog_source",
|
|
entity_id=source_id, detail=detail)
|
|
return redirect(INDEX)
|
|
|
|
|
|
# ── A source's lines, and the panel on a host's or an SNMP device's page ─────
|
|
|
|
def line_view(line) -> dict:
|
|
return {"id": line.id, "received_at": line.received_at, "sent_at": line.sent_at,
|
|
"level": line.severity, "severity": severity_name(line.severity),
|
|
"facility": facility_name(line.facility), "hostname": line.hostname,
|
|
"app": line.app, "message": line.message}
|
|
|
|
|
|
@syslog_bp.get("/sources/<source_id>")
|
|
@require_role(UserRole.viewer)
|
|
async def source_detail(source_id: str):
|
|
"""One source's kept lines, newest first, a page at a time as the reader
|
|
scrolls (core.paging), under a filter bar: severity and worse, app, text
|
|
and time. ?before=<cursor> is the next page's rows alone; a request from
|
|
the filter bar is the table alone."""
|
|
from urllib.parse import urlencode
|
|
|
|
from steward.audit.service import mark_day_groups
|
|
from steward.core import formatting as fmt
|
|
from steward.core.paging import keyset, next_page
|
|
|
|
f = line_filter(request.args)
|
|
before = request.args.get("before")
|
|
now = datetime.now(timezone.utc)
|
|
async with current_app.db_sessionmaker() as db:
|
|
source = await _source_or_404(db, source_id)
|
|
stmt = keyset(filtered(select(SyslogLine).where(SyslogLine.source_id == source.id), f, now),
|
|
SyslogLine.received_at, SyslogLine.id, before)
|
|
lines, after = next_page((await db.execute(stmt)).scalars().all(), lambda ln: (ln.received_at, ln.id))
|
|
page_only = bool(before) or bool(request.headers.get("HX-Request"))
|
|
hosts = {} if page_only else {h.id: h for h in await _hosts(db)}
|
|
apps = [] if page_only else await apps_of(db, source.id, f.since(now))
|
|
per_hour = {} if page_only else await rates(db, [source.id], now)
|
|
rows = [line_view(ln) for ln in lines]
|
|
last_group = mark_day_groups(rows, fmt.zone(session.get("tz")), request.args.get("group", ""),
|
|
at=lambda r: r["received_at"])
|
|
base = f"/plugins/syslog/sources/{source_id}?"
|
|
ctx = {"source": source_view(source, hosts, per_hour.get(source.id, 0)), "lines": rows,
|
|
"more_url": base + urlencode({**f.args(), "before": after, "group": last_group}) if after else None,
|
|
"f": f, "filtered": bool({k: v for k, v in f.args().items() if k != "range"}),
|
|
"severities": SEVERITY_CHOICES, "apps": apps, "ranges": list(RANGES)}
|
|
if before:
|
|
return await render_template("syslog/_lines_page.html", **ctx)
|
|
template = "syslog/_lines_table.html" if request.headers.get("HX-Request") else "syslog/source.html"
|
|
resp = await current_app.make_response(await render_template(template, **ctx))
|
|
resp.vary.add("HX-Request")
|
|
return resp
|
|
|
|
|
|
@syslog_bp.get("/panel")
|
|
@require_role(UserRole.viewer)
|
|
async def lines_panel():
|
|
"""The syslog panel on a host's page (?host_id=) or an SNMP device's
|
|
(?address=, and ?host_id= when it is bound): the newest lines of the
|
|
sources that map to it. Nothing when no added source does."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
matched = await shown_on(db, request.args.get("host_id") or None, request.args.get("address") or None)
|
|
if not matched:
|
|
return ""
|
|
names = {s.id: source_name(s) for s in matched}
|
|
since = datetime.now(timezone.utc) - PANEL_SPAN
|
|
lines = (await db.execute(
|
|
select(SyslogLine).where(SyslogLine.source_id.in_(list(names)), SyslogLine.received_at >= since)
|
|
.order_by(SyslogLine.received_at.desc(), SyslogLine.id.desc()).limit(PANEL_LINES))).scalars().all()
|
|
rows = [{**line_view(ln), "source": names[ln.source_id], "source_id": ln.source_id} for ln in lines]
|
|
return await render_template("syslog/panel.html", lines=rows,
|
|
sources=[{"id": sid, "name": name} for sid, name in names.items()])
|
|
|
|
|
|
# ── Patterns: telling phrases counted for alert rules (#6001) ─────────────────
|
|
|
|
PATTERNS = "/plugins/syslog/patterns"
|
|
|
|
|
|
def pattern_view(p: SyslogPattern, sources: dict, latest: dict) -> dict:
|
|
covered = [s for s in sources.values() if s.state == "accepted" and p.source_id in (None, s.id)]
|
|
return {"id": p.id, "key": p.key, "name": p.name, "metric": p.metric, "phrases": p.phrases or [],
|
|
"severity": severity_name(p.max_severity) if p.max_severity is not None else None,
|
|
"scope": sources[p.source_id].name or sources[p.source_id].address
|
|
if p.source_id in sources else None,
|
|
"window": p.window_minutes, "enabled": p.enabled,
|
|
"counts": [{"source": resource_of(s), "value": _value(latest, resource_of(s), p.metric)} for s in covered],
|
|
"alert_resource": resource_of(covered[0]) if covered else None}
|
|
|
|
|
|
def _value(latest: dict, resource: str, metric: str) -> int | None:
|
|
row = latest.get(resource, {}).get(metric)
|
|
return int(row.value) if row is not None else None
|
|
|
|
|
|
@syslog_bp.get("/patterns")
|
|
@require_role(UserRole.viewer)
|
|
async def patterns_index():
|
|
async with current_app.db_sessionmaker() as db:
|
|
patterns = list((await db.execute(select(SyslogPattern).order_by(SyslogPattern.name))).scalars())
|
|
sources = {s.id: s for s in (await db.execute(select(SyslogSource))).scalars()}
|
|
latest = await latest_by_resource(db, SOURCE_MODULE, {resource_of(s) for s in sources.values()},
|
|
[p.metric for p in patterns])
|
|
taken = {p.key for p in patterns}
|
|
return await render_template("syslog/patterns.html", patterns=[pattern_view(p, sources, latest) for p in patterns],
|
|
presets=[p for p in PRESETS if p.key not in taken],
|
|
any_sources=any(s.state == "accepted" for s in sources.values()))
|
|
|
|
|
|
async def _free_key(db, wanted: str) -> str:
|
|
taken = set((await db.execute(select(SyslogPattern.key))).scalars())
|
|
key, n = wanted, 2
|
|
while key in taken:
|
|
key = f"{wanted[:44]}_{n}"
|
|
n += 1
|
|
return key
|
|
|
|
|
|
async def _accepted_sources(db) -> list:
|
|
return list((await db.execute(select(SyslogSource).where(SyslogSource.state == "accepted")
|
|
.order_by(SyslogSource.name))).scalars())
|
|
|
|
|
|
async def _render_pattern_form(pattern, values: dict, error: str | None = None, status: int = 200):
|
|
async with current_app.db_sessionmaker() as db:
|
|
sources = await _accepted_sources(db)
|
|
return await render_template("syslog/pattern_form.html", pattern=pattern, v=values, error=error,
|
|
sources=sources, windows=WINDOWS, severities=list(enumerate(SEVERITIES))), status
|
|
|
|
|
|
@syslog_bp.post("/patterns/preset/<key>")
|
|
@require_role(UserRole.admin)
|
|
async def add_preset(key: str):
|
|
preset = PRESETS_BY_KEY.get(key)
|
|
if preset is None:
|
|
abort(404)
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
pattern = SyslogPattern(key=await _free_key(db, preset.key), name=preset.name,
|
|
phrases=list(preset.phrases), max_severity=preset.max_severity)
|
|
db.add(pattern)
|
|
await db.flush()
|
|
pattern_id = pattern.id
|
|
await log_audit(current_app, *_actor(), "syslog.pattern_added", entity_type="syslog_pattern",
|
|
entity_id=pattern_id, detail={"name": preset.name, "preset": preset.key})
|
|
return redirect(PATTERNS)
|
|
|
|
|
|
@syslog_bp.get("/patterns/new")
|
|
@require_role(UserRole.admin)
|
|
async def new_pattern():
|
|
return await _render_pattern_form(None, {"window_minutes": 15, "enabled": True})
|
|
|
|
|
|
def _typed_pattern(form) -> dict:
|
|
return {"name": form.get("name", ""), "phrases": form.get("phrases", ""),
|
|
"max_severity": form.get("max_severity", ""), "source_id": form.get("source_id", ""),
|
|
"window_minutes": form.get("window_minutes", 15, type=int), "enabled": "enabled" in form}
|
|
|
|
|
|
@syslog_bp.post("/patterns/new")
|
|
@require_role(UserRole.admin)
|
|
async def create_pattern():
|
|
form = await request.form
|
|
try:
|
|
values = parse_form(form)
|
|
except PatternError as exc:
|
|
return await _render_pattern_form(None, _typed_pattern(form), str(exc), 422)
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None:
|
|
values["source_id"] = None
|
|
pattern = SyslogPattern(key=await _free_key(db, key_for(values["name"])), **values)
|
|
db.add(pattern)
|
|
await db.flush()
|
|
pattern_id = pattern.id
|
|
await log_audit(current_app, *_actor(), "syslog.pattern_added", entity_type="syslog_pattern",
|
|
entity_id=pattern_id, detail={"name": values["name"]})
|
|
return redirect(PATTERNS)
|
|
|
|
|
|
async def _pattern_or_404(db, pattern_id: str) -> SyslogPattern:
|
|
pattern = await db.get(SyslogPattern, pattern_id)
|
|
if pattern is None:
|
|
abort(404)
|
|
return pattern
|
|
|
|
|
|
@syslog_bp.get("/patterns/<pattern_id>/edit")
|
|
@require_role(UserRole.admin)
|
|
async def edit_pattern(pattern_id: str):
|
|
async with current_app.db_sessionmaker() as db:
|
|
p = await _pattern_or_404(db, pattern_id)
|
|
return await _render_pattern_form(p, {
|
|
"name": p.name, "phrases": "\n".join(p.phrases or []),
|
|
"max_severity": "" if p.max_severity is None else str(p.max_severity),
|
|
"source_id": p.source_id or "", "window_minutes": p.window_minutes, "enabled": p.enabled})
|
|
|
|
|
|
@syslog_bp.post("/patterns/<pattern_id>/edit")
|
|
@require_role(UserRole.admin)
|
|
async def save_pattern(pattern_id: str):
|
|
"""Change what a pattern matches. Its key, and so its metric and the
|
|
alert rules on it, stay as they were."""
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
p = await _pattern_or_404(db, pattern_id)
|
|
try:
|
|
values = parse_form(form)
|
|
except PatternError as exc:
|
|
return await _render_pattern_form(p, _typed_pattern(form), str(exc), 422)
|
|
if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None:
|
|
values["source_id"] = None
|
|
for column, value in values.items():
|
|
setattr(p, column, value)
|
|
await log_audit(current_app, *_actor(), "syslog.pattern_updated", entity_type="syslog_pattern",
|
|
entity_id=pattern_id, detail={"name": values["name"]})
|
|
return redirect(PATTERNS)
|
|
|
|
|
|
@syslog_bp.post("/patterns/<pattern_id>/delete")
|
|
@require_role(UserRole.admin)
|
|
async def delete_pattern(pattern_id: str):
|
|
"""Remove a pattern. Its newest counts go with it, so nothing reads it as
|
|
a series that went quiet; its history ages out with retention."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
p = await _pattern_or_404(db, pattern_id)
|
|
name, metric = p.name, p.metric
|
|
await db.delete(p)
|
|
await db.execute(delete(PluginMetricLatest).where(
|
|
PluginMetricLatest.source_module == SOURCE_MODULE, PluginMetricLatest.metric_name == metric))
|
|
await log_audit(current_app, *_actor(), "syslog.pattern_removed", entity_type="syslog_pattern",
|
|
entity_id=pattern_id, detail={"name": name})
|
|
return redirect(PATTERNS)
|
|
|
|
|
|
# ── Lines beside the readings (#6002) ─────────────────────────────────────────
|
|
|
|
AROUND = timedelta(minutes=30)
|
|
AROUND_SHOWN = 15
|
|
|
|
|
|
def _iso_arg(name: str) -> datetime | None:
|
|
value = request.args.get(name) or ""
|
|
try:
|
|
at = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return at if at.tzinfo else at.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
@syslog_bp.get("/around")
|
|
@require_role(UserRole.viewer)
|
|
async def lines_around():
|
|
"""An anomaly's syslog: warnings and worse from the sources shown with
|
|
its resource (?source=&resource=), from half an hour before ?since= to
|
|
half an hour after ?until=, the lines nearest its start first."""
|
|
since = _iso_arg("since")
|
|
if since is None:
|
|
abort(400)
|
|
until = _iso_arg("until") or datetime.now(timezone.utc)
|
|
async with current_app.db_sessionmaker() as db:
|
|
host_id, address = await place_for(db, request.args.get("source", ""), request.args.get("resource", ""))
|
|
matched = await shown_on(db, host_id, address) if host_id or address else []
|
|
lines = await lines_between(db, matched, since - AROUND, until + AROUND, AROUND_SEVERITY, AROUND_LINES)
|
|
names = {s.id: source_name(s) for s in matched}
|
|
nearest = sorted(lines, key=lambda ln: abs((ln.received_at - since).total_seconds()))[:AROUND_SHOWN]
|
|
rows = [{**line_view(ln), "source": names[ln.source_id], "source_id": ln.source_id}
|
|
for ln in sorted(nearest, key=lambda ln: ln.received_at)]
|
|
return await render_template("syslog/_around.html", lines=rows, more=len(lines) - len(rows),
|
|
sources=[{"id": sid, "name": name} for sid, name in names.items()])
|
|
|
|
|
|
@syslog_bp.get("/marks")
|
|
@require_role(UserRole.viewer)
|
|
async def chart_marks():
|
|
"""Errors and worse from the sources a host's (?host_id=) or a device's
|
|
(?address=) page shows, over its chart's span (?range= or ?hours=), for
|
|
the chart to mark: {"marks": [{"at": epoch ms, "level", "text"}], "more"}."""
|
|
from steward.core.time_range import parse_range
|
|
now = datetime.now(timezone.utc)
|
|
hours = request.args.get("hours", type=int)
|
|
since = now - timedelta(hours=min(max(hours, 1), 24 * 30)) if hours else parse_range(request.args.get("range"))[0]
|
|
async with current_app.db_sessionmaker() as db:
|
|
matched = await shown_on(db, request.args.get("host_id") or None, request.args.get("address") or None)
|
|
lines = await lines_between(db, matched, since, now, MARK_SEVERITY, MAX_MARKS + 1)
|
|
names = {s.id: source_name(s) for s in matched}
|
|
return {"marks": [{"at": int(ln.received_at.timestamp() * 1000), "level": ln.severity,
|
|
"text": f"{names[ln.source_id]} {severity_name(ln.severity)}"
|
|
f"{' ' + ln.app if ln.app else ''}: {ln.message[:200]}"}
|
|
for ln in lines[:MAX_MARKS]],
|
|
"more": len(lines) > MAX_MARKS}
|