NAME
Punk::Observe::Config - reading and writing what the screens are configured with
SYNOPSIS
my $d = Punk::Observe::Config::dashboards($db, 'default', 'checkout');
my $r = Punk::Observe::Config::save_panel($db, 'default', 'checkout', {
title => 'errors', query => 'log | where severity >= error | count',
viz => 'bar', span => 2,
});
$r->{ok} or warn $r->{error};
DESCRIPTION
The configuration layer over Punk::Observe::Backend: dashboards, panels, alert rules and silences. It answers in the same shapes the alerts and dashboards seams do, so a screen cannot tell whether it was given the built-in store or a host's own reader.
Three outcomes, never two
Every writer answers { ok => 1, id => ... }, or { ok => 0, refused => 1, error => ... }, or { ok => 0, error => ... }.
The middle one is a reason the person can act on - a slug with a slash in it, a query that does not parse, a dashboard that is not there. The last is something they cannot - the database is gone. An editor that collapses them says "something went wrong" when the real answer was "that query does not parse, at character 14".
A panel is validated before it is stored
save_panel runs the query through "check_panel" in Punk::Observe::Dashboard, which parses it with the same parser that will execute it. A panel that cannot parse never reaches the table, so the dashboard page never has to apologise for one it stored.
FUNCTIONS
dashboards
my $d = Punk::Observe::Config::dashboards($db, $tenant, $slug);
One dashboard and the list of the others, in the shape the dashboard screen reads. An absent or empty slug answers with the first dashboard, because an index that shows nothing is a worse landing page than one that shows something.
save_dashboard / delete_dashboard
my $r = Punk::Observe::Config::save_dashboard($db, $tenant, \%spec);
my $r = Punk::Observe::Config::delete_dashboard($db, $tenant, $slug);
Create or update by (tenant, slug). cols is clamped to the 1..6 the stylesheet has rules for. Deleting takes the panels with it, by ON DELETE CASCADE rather than by remembering to.
save_panel / delete_panel
my $r = Punk::Observe::Config::save_panel($db, $tenant, $slug, \%spec);
my $r = Punk::Observe::Config::delete_panel($db, $tenant, $slug, $id);
An id in the spec updates that panel; without one a panel is added. span is clamped the same way cols is.
save_panels
my $r = Punk::Observe::Config::save_panels($db, $tenant, $slug, \@rows);
Every panel row in one call - the editor's single save button. Each row carries an id and the save_panel fields. All rows are validated before any is written, the refusal names the row it is about, and the writes run in one transaction: the result is the whole edit or none of it.
saved_views
my $views = Punk::Observe::Config::saved_views($db, $tenant);
my $views = Punk::Observe::Config::saved_views($db, $tenant, $page);
The views saved for a screen, in name order. Each carries its params - the query string it stands for - and, for the list to show, the query inside it and whether its window is frozen.
A saved view is a link with a label, not a second source of view state. It navigates to the URL it stands for, so it is still shareable, still bookmarkable, and still means the same thing in somebody else's browser - which is the property brush.js gives its reasons for and which anything here has to keep.
save_view / delete_view
my $r = Punk::Observe::Config::save_view($db, $tenant, \%spec);
my $r = Punk::Observe::Config::delete_view($db, $tenant, $id);
Create or update by (tenant, page, name).
The query is checked by "check_panel" in Punk::Observe::Dashboard - the parser that will run it - for the same reason a panel is: a stored query string is a stored panel by another name, and a column renamed under a saved view fails the same way.
The window is stored as it was given and never normalised. A view saved with range=1h still means "the last hour" next week; one saved with from/to still means those two instants. Flattening the first into the second would make "the last 15 minutes" quietly stop meaning that fifteen minutes later, which is the trap "window" in Punk::Observe::View and the range picker both already avoid.
The mount prefix is not stored. It is configuration, so a view saved under /observe opens under /telemetry.
alerts
my $a = Punk::Observe::Config::alerts($db, $tenant, $req);
Rules, active silences and the transition events for the alert timeline, one rule entry per series - which is how the state machine keys them and how the screen expects them. held is nanoseconds, because the screen formats it and a pre-formatted string is read as a number. A rule in the error state carries reason - why it could not be evaluated, written by the evaluator and cleared on recovery.
The events are recorded transitions, never inferred from current state - a timeline built from "it is firing now" would be a straight line claiming the present has always been the case. A series with no transition inside the window gets one entry stamped at the start of it, which is an inference and a sound one: the events table is complete, so no transition means the state held across the whole window.
alert / save_alert / delete_alert
my $rule = Punk::Observe::Config::alert($db, $tenant, $id);
my $r = Punk::Observe::Config::save_alert($db, $tenant, \%rule);
my $r = Punk::Observe::Config::delete_alert($db, $tenant, $id);
alert is one rule as stored - for the editor, which needs the operator, the threshold and the durations that the per-series screen reader carries no columns for.
A rule takes name, query, op, threshold, for, every and enabled; id updates rather than creates.
The query is validated by the parser that will run it - the rule panels already obey - and must be bucketed, because the evaluator feeds the state machine one tick per bucket and for cannot be measured over a single number. op is the state machine's own closed vocabulary. for and every are durations through the UI's own parser, so 30s here and on any other screen cannot disagree; every is clamped to ten seconds at the bottom, and both store as nanoseconds.
Saving resets the rule's evaluation schedule, so a changed threshold is judged on the next pass rather than whenever the old cadence next came round. Deleting takes state, events and pending notifications with it, by ON DELETE CASCADE rather than by remembering to.
save_silence / delete_silence
my $r = Punk::Observe::Config::save_silence($db, $tenant, \%s);
my $r = Punk::Observe::Config::delete_silence($db, $tenant, $id);
A silence takes pattern, until, and optionally reason, by and is_prefix. The pattern matches the key <rule name>/<series>; a trailing slash implies the prefix flag, so error rate/ silences a whole rule and error rate/cards exactly one series of it. until is a duration from now - 1h, 2d - because "silence this for an hour" is the sentence somebody says during an incident.
Deleting expires rather than erases: the until moves to now, so suppression stops immediately and the record of who silenced what, and why, survives - a silence is part of the story of an incident, and deleting the row deletes the explanation.
silence_match
my $hit = Punk::Observe::Config::silence_match($pattern, $is_prefix, $key);
Whether one silence covers one rule/series key. This is the only matcher - the reader uses it to badge rule rows and delivery uses it to suppress - because two copies of "does this silence cover that series" would eventually disagree about exactly one page.
health_targets
my $t = Punk::Observe::Config::health_targets($db, $tenant);
my $t = Punk::Observe::Config::health_targets($db, $tenant, $name);
The endpoints this installation polls, in name order, or the one named.
save_health_target / delete_health_target
my $r = Punk::Observe::Config::save_health_target($db, $tenant, \%spec,
$allowlist);
my $r = Punk::Observe::Config::delete_health_target($db, $tenant, $name);
Create or update by (tenant, name).
The URL is checked before it is stored, through "check" in Punk::Observe::Target - the same SSRF policy the webhook targets use, because both are a URL a person typed that the server will then fetch. A refusal says which it was and whether an allowlist would let it through, since an installation polling services on a private network is the case the default policy correctly refuses and an allowlist correctly permits.
The timeout is clamped rather than refused: there is no spelling of "wait forever" here, because a target with no timeout blocks the runner behind it.
ok / refused / failed
The three answers, exported by nobody and called by the writers above.
SEE ALSO
Punk::Observe::Backend, Punk::Plugin::Observe
AUTHOR
LNATION, <email at lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under the Artistic License 2.0.