Update tickets
This commit is contained in:
+40
-15
@@ -1,24 +1,49 @@
|
||||
# 017 — Ntfy.sh alerts (future idea)
|
||||
|
||||
> The README lists this under "Ideas... that can be implemented in the future, but
|
||||
> not now." Not scoped for current implementation — tracked here so it isn't lost.
|
||||
# 017 — Alert definitions & settings persistence
|
||||
|
||||
## Summary
|
||||
|
||||
Let users configure matchers of interest and an ntfy.sh channel, and send a
|
||||
notification to that channel when a matching event occurs.
|
||||
Foundation for the alerting feature: a persisted `AlertRule` model plus CRUD,
|
||||
following the shape `Settings` already uses for `CssEntry`/`Profiles`
|
||||
([008](008-settings-model.md)) — one process-wide list, saved to disk on every
|
||||
mutation.
|
||||
|
||||
## Scope (sketch, not final)
|
||||
An `AlertRule` has:
|
||||
|
||||
- Matcher definition, likely reusing the FilterBar query language
|
||||
([005](005-filterbar-pest-parser.md)) or the metric declaration language
|
||||
([002](002-metric-declarations.md)) rather than inventing a third syntax.
|
||||
- Ntfy channel config, persisted via settings ([008](008-settings-model.md)).
|
||||
- Trigger evaluation on log ingest (engine-side, to avoid re-scanning) or on bucket
|
||||
update, sending an HTTP request to the configured ntfy.sh channel.
|
||||
- A **condition** (see [018](018-alert-trigger-evaluation.md) for the types and
|
||||
how they're evaluated) — for this ticket, just store it as an opaque, already
|
||||
parsed/validated value; don't implement evaluation here.
|
||||
- A **sink**: for now, only `Ntfy { topic: String, server: Option<String> }`
|
||||
(`server` defaults to `https://ntfy.sh` when absent) — modeled as an enum so
|
||||
[additional sinks](020-alerts-webapp-ui.md) can be added later without
|
||||
reshaping `AlertRule`.
|
||||
- Bookkeeping: `id`, `name`, `enabled: bool`.
|
||||
|
||||
## Scope
|
||||
|
||||
- `AlertRule` struct (or similar) in a new `src/alerts.rs`, `Vec<AlertRule>`
|
||||
added to `Settings` (`src/settings.rs`), persisted the same way
|
||||
`css_entries`/`profiles` already are.
|
||||
- CRUD methods on `Settings` (create/update/delete/list), mirroring
|
||||
`import_css`/`remove_css`'s pattern: mutate in memory, then `self.save(...)`.
|
||||
- HTTP endpoints in `src/api.rs` for CRUD, matching the existing settings
|
||||
endpoints' conventions (routes, status codes, error JSON shape).
|
||||
- Id generation: reuse the approach in `generate_imported_css_id` (hash
|
||||
name+content+timestamp) rather than adding a `uuid` dependency.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Alert rules round-trip through `save`/`load` like the rest of `Settings`
|
||||
(see `round_trips_through_save_and_load` for the existing pattern to extend).
|
||||
- CRUD endpoints create, list, update, and delete rules, and reject unknown
|
||||
ids the same way `CssError::NotFound` does today.
|
||||
- No evaluation or delivery logic lives here — this ticket only stores rules.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [008 — Settings model](008-settings-model.md)
|
||||
- [005 — FilterBar pest parser](005-filterbar-pest-parser.md) (likely, for matcher
|
||||
syntax reuse)
|
||||
|
||||
## Depended on by
|
||||
|
||||
- [018 — Alert trigger evaluation](018-alert-trigger-evaluation.md)
|
||||
- [019 — Ntfy sink delivery](019-ntfy-sink-delivery.md)
|
||||
- [020 — Alerts webapp UI](020-alerts-webapp-ui.md)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 018 — Alert trigger evaluation
|
||||
|
||||
## Summary
|
||||
|
||||
Evaluate `AlertRule` conditions ([017](017-ntfy-alerts.md)) against the live log
|
||||
stream and fire a delivery ([019](019-ntfy-sink-delivery.md)) when one matches,
|
||||
without re-scanning already-ingested logs — hook into the same ingest path
|
||||
`engine.rs` already uses to update timelines/metrics/dashboards on each new
|
||||
log.
|
||||
|
||||
## Scope
|
||||
|
||||
Condition types to support, all built on primitives ottyl already has (the
|
||||
FilterBar query language, [005](005-filterbar-pest-parser.md), and metric
|
||||
declarations, [002](002-metric-declarations.md)) rather than a new syntax:
|
||||
|
||||
- **Immediate** — fire once per log line matching a FilterBar query, as soon
|
||||
as it's ingested.
|
||||
- **Threshold** — a declared metric's value, or a FilterBar query's match
|
||||
count, crosses a fixed level within a rolling time window.
|
||||
- **New value** — a specified log field takes on a value that hasn't appeared
|
||||
before (within the alert's lifetime, or a bounded lookback — decide and
|
||||
document which).
|
||||
- **Ratio** — the match counts of two FilterBar queries, compared over the
|
||||
same rolling window, cross a configured ratio.
|
||||
|
||||
Each condition stores its own window/threshold parameters (already sketched as
|
||||
an opaque value on `AlertRule` in 017 — this ticket defines and parses that
|
||||
shape).
|
||||
|
||||
- Evaluation runs inline on ingest (or on bucket update, whichever the engine
|
||||
already does per-log) — no polling loop and no periodic full re-scan.
|
||||
- Rolling-window state (counts, last-seen values) is kept in memory per rule;
|
||||
it does not need to survive a restart.
|
||||
- On a match, hand off to the configured sink ([019](019-ntfy-sink-delivery.md))
|
||||
— this ticket owns *deciding* to fire, not *delivering*.
|
||||
- Respect `AlertRule.enabled`: disabled rules are skipped entirely.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Each of the four condition types fires exactly when its condition is met,
|
||||
and not otherwise (covered by unit tests per type, feeding synthetic logs
|
||||
through the same ingest path).
|
||||
- Evaluation cost is proportional to new logs ingested, not to total log
|
||||
volume — no scan of the full log history per evaluation.
|
||||
- A disabled rule never fires.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [017 — Alert definitions & settings persistence](017-ntfy-alerts.md)
|
||||
- [005 — FilterBar pest parser](005-filterbar-pest-parser.md)
|
||||
- [002 — Metric declarations](002-metric-declarations.md)
|
||||
|
||||
## Depended on by
|
||||
|
||||
- [019 — Ntfy sink delivery](019-ntfy-sink-delivery.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
# 018 — Sequence diagram of events (future idea)
|
||||
|
||||
> The README lists this under "Ideas... that can be implemented in the future, but
|
||||
> not now." Not scoped for current implementation — tracked here so it isn't lost.
|
||||
|
||||
## Summary
|
||||
|
||||
Let users declare sequence-diagram participants from params or matchers (e.g.
|
||||
`node_id`), then render a sequence diagram of captured events of interest. No
|
||||
relation between nodes is modeled — only event sequence/ordering determines the
|
||||
diagram.
|
||||
|
||||
## Scope (sketch, not final)
|
||||
|
||||
- Participant declaration referencing an existing param
|
||||
([001](001-param-declarations.md)), e.g. "one participant per distinct value of
|
||||
`node_id`".
|
||||
- Event-of-interest selection, likely reusing FilterBar syntax
|
||||
([005](005-filterbar-pest-parser.md)) to define which log lines become diagram
|
||||
events.
|
||||
- Rendering: a sequence-diagram view (e.g. mermaid `sequenceDiagram`) driven by
|
||||
ordered matching events per participant.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [001 — Param declarations](001-param-declarations.md)
|
||||
- [005 — FilterBar pest parser](005-filterbar-pest-parser.md) (likely, for
|
||||
event-matcher syntax reuse)
|
||||
@@ -1,29 +0,0 @@
|
||||
# 019 — Default port should be 0
|
||||
|
||||
## Summary
|
||||
|
||||
`--port` currently defaults to `8420` (`Args` in `main.rs`). The wishlist asks for a
|
||||
default of `0`, i.e. let the OS assign an ephemeral free port rather than colliding
|
||||
with whatever else is running on a fixed port.
|
||||
|
||||
## Scope
|
||||
|
||||
- Change `Args::port`'s default to `0`.
|
||||
- After `TcpListener::bind` succeeds, read back the actual bound port (via
|
||||
`listener.local_addr()`) since binding to `0` means the requested port and the
|
||||
real one differ.
|
||||
- Print the real bound port in the startup log line (it already prints
|
||||
`args.port`, which would otherwise always read `0`).
|
||||
- Consider whether the actual port needs to be surfaced anywhere else (e.g. a
|
||||
`GET /api/stats`-style endpoint) — not required unless something already depends
|
||||
on knowing it programmatically today; check before adding.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Running `ottyl` with no `--port` binds an OS-assigned free port instead of a fixed
|
||||
one, and the startup message prints that actual port.
|
||||
- `--port <n>` still binds the requested fixed port as before.
|
||||
|
||||
## Depends on
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 019 — Ntfy sink delivery
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver a fired alert ([018](018-alert-trigger-evaluation.md)) to the
|
||||
[ntfy](https://ntfy.sh) topic configured on its `AlertRule` ([017](017-ntfy-alerts.md)):
|
||||
a plain HTTP POST of the notification body to `{server}/{topic}`, no SDK
|
||||
needed.
|
||||
|
||||
## Scope
|
||||
|
||||
- A small async sender: `POST {server}/{topic}` with the alert's message as
|
||||
the request body (ntfy's plain-text publish API — no auth, no extra
|
||||
dependency beyond an HTTP client ottyl can already reach for, or a minimal
|
||||
one if none exists yet).
|
||||
- Message content: rule name plus enough context to be useful (matched log
|
||||
line for Immediate/New value, current value vs. threshold for
|
||||
Threshold/Ratio).
|
||||
- Delivery runs off the ingest hot path — spawn it (e.g.
|
||||
`tokio::spawn`/an mpsc queue) so a slow or unreachable ntfy server never
|
||||
blocks log ingestion.
|
||||
- Failure handling: log the error to stderr and drop the notification — no
|
||||
retry queue or backoff for this ticket (matches the project's existing
|
||||
"best-effort, log and move on" style, e.g. `remove_css`'s file-removal
|
||||
fallback).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A fired alert results in exactly one HTTP POST to the rule's configured
|
||||
`{server}/{topic}`.
|
||||
- Ntfy being unreachable/erroring is logged but does not panic, block
|
||||
ingestion, or crash the process.
|
||||
- Covered by a test against a local mock HTTP server asserting the request
|
||||
path/body.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [017 — Alert definitions & settings persistence](017-ntfy-alerts.md)
|
||||
- [018 — Alert trigger evaluation](018-alert-trigger-evaluation.md)
|
||||
|
||||
## Depended on by
|
||||
|
||||
- [020 — Alerts webapp UI](020-alerts-webapp-ui.md) (for a "send test
|
||||
notification" action)
|
||||
@@ -0,0 +1,41 @@
|
||||
# 020 — Alerts webapp UI
|
||||
|
||||
## Summary
|
||||
|
||||
A webapp page for managing `AlertRule`s ([017](017-ntfy-alerts.md)) end to
|
||||
end: create/edit/enable/delete rules and configure their ntfy sink, without
|
||||
touching `settings.json` by hand.
|
||||
|
||||
## Scope
|
||||
|
||||
- A new route/panel (matching how `Dashboards`/`Metrics` are already surfaced
|
||||
in `App.tsx`) listing existing alert rules: name, condition summary,
|
||||
enabled state.
|
||||
- A rule editor:
|
||||
- Condition type picker (Immediate / Threshold / New value / Ratio, see
|
||||
[018](018-alert-trigger-evaluation.md)) with type-specific fields, reusing
|
||||
`FilterBar`'s query input for the query-based conditions rather than a
|
||||
separate text field.
|
||||
- Ntfy sink fields: topic and optional server override.
|
||||
- Enabled toggle.
|
||||
- "Send test notification" button wired to the sink delivery endpoint from
|
||||
[019](019-ntfy-sink-delivery.md), so a topic/server can be verified without
|
||||
waiting for a real trigger.
|
||||
- Contextual "?" help icons on the condition-type picker and sink fields,
|
||||
matching [021](021-docs-contextual-help.md)'s pattern.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Rules created/edited/deleted in the UI persist across a page reload (i.e.
|
||||
round-trip through the CRUD endpoints from 017).
|
||||
- Each condition type's fields validate before save (e.g. Ratio requires two
|
||||
queries and a threshold).
|
||||
- "Send test notification" delivers to the currently-entered topic/server
|
||||
without requiring the rule to be saved first.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [017 — Alert definitions & settings persistence](017-ntfy-alerts.md)
|
||||
- [018 — Alert trigger evaluation](018-alert-trigger-evaluation.md)
|
||||
- [019 — Ntfy sink delivery](019-ntfy-sink-delivery.md)
|
||||
- [021 — Docs contextual help](021-docs-contextual-help.md)
|
||||
@@ -1,39 +0,0 @@
|
||||
# 020 — File input for ingestion, with restart-safe tailing
|
||||
|
||||
## Summary
|
||||
|
||||
`ingest.rs` only reads stdin today. The wishlist asks for an optional file path as
|
||||
an alternative input source, plus resilience to the log file being recreated (e.g.
|
||||
the host process being tailed restarts and truncates/recreates its log file) —
|
||||
ottyl should keep tailing rather than treating that as EOF-and-done.
|
||||
|
||||
## Scope
|
||||
|
||||
- New `clap` arg (e.g. `--file <path>`), mutually exclusive-in-spirit with stdin:
|
||||
when given, ingest reads from the file instead of stdin; when absent, behavior is
|
||||
unchanged (stdin, as today).
|
||||
- File-mode ingestion must behave like `tail -f`: keep reading as new lines are
|
||||
appended, don't exit on reaching current EOF.
|
||||
- Detect file recreation (the common "log rotated / process restarted and reopened
|
||||
the path" case) — e.g. by tracking the inode/file id and re-opening when it
|
||||
changes, or noticing truncation (current read offset > file size) — and resume
|
||||
tailing the new file from its start rather than exiting or getting stuck.
|
||||
- Sequence numbers continue incrementing across a recreation event (per
|
||||
`IndexKey::IngestedLog`, sequence is ottyl's own line counter, not tied to the
|
||||
source file).
|
||||
- Reuse the existing `IngestedLine` / `EngineMessage::Ingest` plumbing — this only
|
||||
changes where lines come from, not how they're indexed.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `ottyl --file some.log` ingests existing content and then picks up appended
|
||||
lines live.
|
||||
- Deleting and recreating the file at the same path (simulating a host restart)
|
||||
does not kill ottyl or stop ingestion — new content from the recreated file
|
||||
continues to be ingested.
|
||||
- Running with no `--file` is unchanged: stdin ingestion as today.
|
||||
|
||||
## Depends on
|
||||
|
||||
None — parallels the existing `ingest::run`, doesn't require any of the metrics/
|
||||
filter machinery.
|
||||
@@ -1,35 +0,0 @@
|
||||
# 021 — Contextual "?" help icons linking into Docs
|
||||
|
||||
## Summary
|
||||
|
||||
Every major UI section should have a "?" icon that opens the Docs panel scrolled
|
||||
to (and highlighting) the relevant section, instead of the user having to find it
|
||||
themselves in the Docs accordion.
|
||||
|
||||
## Scope
|
||||
|
||||
- A small reusable `HelpIcon`/`HelpLink` component: renders a "?" affordance,
|
||||
accepts a target docs section id, and on click opens the Docs panel
|
||||
(`NavMenu`'s `docs` panel / `Docs.tsx`) navigated to that section.
|
||||
- Add one to each major component per the README's block diagram: `FilterBar`,
|
||||
`LogTimeline`, `LogPreview`, `Metrics`, `Dashboards`, and NavMenu's own
|
||||
Settings/Presets panels.
|
||||
- Docs panel: support being opened with a target section id (not just opened to
|
||||
the top/last-open accordion item) — expand the right accordion section and
|
||||
scroll it into view.
|
||||
- "Flash" behavior: when navigated to via a "?" icon, the target paragraph/section
|
||||
visually flashes (e.g. a brief background-color pulse) so the user's eye finds it
|
||||
immediately, then settles back to normal styling.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Clicking a "?" icon on any of the listed components opens Docs, expands the
|
||||
correct accordion section, scrolls it into view, and it visibly flashes.
|
||||
- Clicking the same icon twice in a row re-triggers the flash (not just a no-op
|
||||
because the panel is already open to that section).
|
||||
- Docs opened normally (via the NavMenu Docs button, not a "?" icon) behaves as
|
||||
today — no flash, no forced section.
|
||||
|
||||
## Depends on
|
||||
|
||||
None — builds on the existing `Docs.tsx` / NavMenu docs panel.
|
||||
@@ -1,53 +0,0 @@
|
||||
# 022 — Metrics rethink: counters as the primitive, histograms/gauges derived
|
||||
|
||||
## Summary
|
||||
|
||||
The wishlist calls for rethinking the metrics model: everything a metric declares
|
||||
should fundamentally be a **counter** (an occurrence at a point in time, optionally
|
||||
labeled). Histograms and gauges are not separate declaration kinds — they're
|
||||
*derived views* computed from counter data (bucketing counter events into
|
||||
histogram buckets; reducing counter series into a gauge-style "current value").
|
||||
This changes how `MetricDeclaration`/`BucketValue` are modeled and how the
|
||||
already-shipped PromQL-style query support (`promql.rs`) evaluates
|
||||
`rate()`/`sum()`/histogram/gauge-shaped queries.
|
||||
|
||||
## Scope
|
||||
|
||||
- Re-express metric declarations as counter declarations: a match increments a
|
||||
counter for the resolved label set at the matched timestamp. This is close to
|
||||
today's `BucketValue{values: Vec<(Sequence, Timestamp, MetricPrimitive)>}` — audit
|
||||
whether the existing bucket representation already is this, or whether captured
|
||||
non-counter values (e.g. a captured `Number` used as a gauge reading today) need
|
||||
to be reinterpreted as counter increments plus a separately-derived gauge.
|
||||
Document the resulting semantics clearly since this is a model change to code
|
||||
that already exists and has callers (`engine.rs`, `promql.rs`, `Metrics.tsx`,
|
||||
`Dashboards.tsx`).
|
||||
- Histogram derivation: given a counter's events over a time range, bucket them
|
||||
(fixed-width or user-specified boundaries) and expose bucket counts — likely as
|
||||
a new PromQL-evaluable form (e.g. `histogram(metric_name[param])`) or a query
|
||||
option, consumed the same way existing series are.
|
||||
- Gauge derivation: given a counter's events, derive a "last value" / "value at
|
||||
time T" series from the most recent matching counter event — likely as another
|
||||
derived query form (e.g. `gauge(metric_name[param])`), rather than a distinct
|
||||
declaration/storage kind.
|
||||
- Update `promql.rs`'s evaluator to support these derived forms without breaking
|
||||
existing raw counter queries.
|
||||
- Update `Metrics.tsx`'s declaration form/help text and `Dashboards.tsx`'s panel
|
||||
config if the query surface changes.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A declared metric behaves as a counter: each match is an increment/event, not an
|
||||
arbitrary overwritten value.
|
||||
- A histogram-derived query against a counter with several distinct captured
|
||||
values buckets them correctly across a seeded fixture.
|
||||
- A gauge-derived query against a counter returns the most-recent value at a given
|
||||
point in the selected time range.
|
||||
- Existing raw counter queries (sum/rate over a plain counter, as today) continue
|
||||
to work unchanged.
|
||||
|
||||
## Depends on
|
||||
|
||||
None — this is a rework of the already-shipped metric/bucket/PromQL code, not new
|
||||
foundational work. [023](023-duration-param.md), [025](025-default-dashboard-seed.md)
|
||||
build on the resulting model.
|
||||
@@ -1,37 +0,0 @@
|
||||
# 023 — Duration-type parameter
|
||||
|
||||
## Summary
|
||||
|
||||
Support a `Duration` parameter kind: its value is the time elapsed since the
|
||||
previous trigger of the *same* metric with the *same* labels/params, measured
|
||||
using whichever timestamp field the user has configured (ingestion time by
|
||||
default, or a param-derived timestamp).
|
||||
|
||||
## Scope
|
||||
|
||||
- New `MetricPrimitive`/param-kind variant for durations (e.g. milliseconds as an
|
||||
`i64`, consistent with how `Timestamp` is already stored).
|
||||
- Derivation rule: for a metric match with a given label set, look up that same
|
||||
label set's previous counter event ([022](022-metrics-counters-rethink.md)) and
|
||||
compute `this_event_time - previous_event_time` using the configured time
|
||||
source (ingestion timestamp, or a declared timestamp param per the README's
|
||||
"deriving a timestamp different from the ingested timestamp" note).
|
||||
- First occurrence of a given label set has no previous event — define and
|
||||
document the behavior (e.g. omit the duration value, or emit `null`/zero;
|
||||
omitting is likely correct so it doesn't pollute histograms with a bogus zero).
|
||||
- Expose duration values the same way other derived/counter data is queried
|
||||
(bucket lookup, PromQL, Metrics UI) so it composes with existing dashboards.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Two log lines matching the same metric+labels, seconds apart, produce a
|
||||
duration value equal to that elapsed time on the second occurrence.
|
||||
- The first occurrence of a given label set produces no duration value (not a
|
||||
zero or garbage value).
|
||||
- Duration values are queryable via the same bucket/PromQL surface as other
|
||||
metric data.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [022 — Metrics rethink](022-metrics-counters-rethink.md) (durations are computed
|
||||
between successive counter events of the same metric).
|
||||
@@ -1,41 +0,0 @@
|
||||
# 024 — Time range controls (Grafana-style presets)
|
||||
|
||||
## Summary
|
||||
|
||||
Today the only way to set the active time range is dragging over `LogTimeline`
|
||||
(`App.tsx`'s `range` state, passed to `LogPreview`/`Metrics`/`Dashboards` as
|
||||
`startMs`/`endMs`). The wishlist wants explicit controls like Grafana's: an "All"
|
||||
option and relative presets ("last 5 minutes", etc.), not just drag-to-select.
|
||||
|
||||
## Scope
|
||||
|
||||
- A time-range control component (dropdown or button row) offering at least:
|
||||
"All" (no range — today's `range === null` default) and a handful of relative
|
||||
presets (e.g. last 5m / 15m / 1h / 24h), placed near `LogTimeline` alongside the
|
||||
existing drag-select interaction.
|
||||
- Selecting a preset computes `{startMs, endMs}` relative to now and feeds the
|
||||
same `range` state `App.tsx` already threads through to `LogTimeline`,
|
||||
`LogPreview`, `Metrics`, and `Dashboards` — no new plumbing needed downstream,
|
||||
since all four already accept/consume a range.
|
||||
- Relative presets need to "move with time" while active (e.g. "last 5 minutes"
|
||||
should mean *rolling* last 5 minutes, not a frozen window from selection time) —
|
||||
decide and implement a refresh cadence (e.g. re-resolve the relative range on
|
||||
the same interval the app already polls for new data) versus a one-shot
|
||||
resolved range; document the choice.
|
||||
- Manually dragging on `LogTimeline` after a preset is active should override it
|
||||
back to a fixed (non-rolling) range, same as today.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Selecting "All" clears the range the same way today's implicit default does.
|
||||
- Selecting a relative preset narrows `LogPreview`, `LogTimeline`, `Metrics`, and
|
||||
`Dashboards` to that window.
|
||||
- A rolling preset's window visibly advances over time without user interaction,
|
||||
if that's the chosen behavior (see Scope).
|
||||
- Dragging on `LogTimeline` while a preset is active switches back to a fixed
|
||||
range bounded by the drag.
|
||||
|
||||
## Depends on
|
||||
|
||||
None — the underlying range plumbing (`App.tsx` → `LogTimeline`/`LogPreview`/
|
||||
`Metrics`/`Dashboards`) already exists; this only adds the control surface.
|
||||
@@ -1,39 +0,0 @@
|
||||
# 025 — Seed a default dashboard with 2 default panels
|
||||
|
||||
## Summary
|
||||
|
||||
`ProfileStore::ensure_default` creates the `Default` profile with no dashboards.
|
||||
The wishlist wants every fresh Default profile to come with a starter dashboard:
|
||||
a line chart of "error" vs. non-"error" log volume, and a pie chart of the same
|
||||
ratio.
|
||||
|
||||
## Scope
|
||||
|
||||
- On `ensure_default` creating a brand-new `Default` profile (not on every
|
||||
startup — only when the profile itself is newly created), seed one
|
||||
`Dashboard` with two panels:
|
||||
- Line chart: log message count over time, split into "contains `error`" vs.
|
||||
"does not contain `error`" series.
|
||||
- Pie chart: same split, as a ratio/total-count breakdown.
|
||||
- Express both panels as PromQL-style queries against a counter
|
||||
([022](022-metrics-counters-rethink.md)) — this likely requires an implicit
|
||||
built-in "log matched regex X" counter concept (e.g. auto-declaring a metric
|
||||
for `regex("error")` vs. its complement), or a raw-text regex count primitive
|
||||
in the PromQL surface if one doesn't already exist. Decide and document which.
|
||||
- Seeded dashboard/panels must persist through the normal profile save path (same
|
||||
as any user-created dashboard) so it's editable/deletable like any other.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A fresh install (no existing settings/profile store) starts up with a `Default`
|
||||
profile that already has one dashboard containing the line and pie panels
|
||||
described above, without any user action.
|
||||
- Restarting with an already-existing `Default` profile does not re-seed or
|
||||
duplicate the dashboard.
|
||||
- The seeded panels render real data against a log stream containing a mix of
|
||||
"error" and non-"error" lines.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [022 — Metrics rethink](022-metrics-counters-rethink.md) (defines what a
|
||||
"count of matching logs" counter/query looks like).
|
||||
@@ -1,34 +0,0 @@
|
||||
# 026 — Settings: maximum logs kept in memory
|
||||
|
||||
## Summary
|
||||
|
||||
ottyl currently keeps every ingested log forever in the in-memory `BTreeMap`. The
|
||||
wishlist wants a settings-configurable cap on how many logs are retained, with no
|
||||
cap (unlimited) as the default.
|
||||
|
||||
## Scope
|
||||
|
||||
- New `Settings` field (e.g. `max_logs: Option<usize>`), `None`/unset = unlimited,
|
||||
matching the existing "by default it should be unlimited" requirement.
|
||||
- Enforce the cap in the engine's ingest path: once the `IngestedLog` range
|
||||
exceeds the configured max, evict the oldest entries (lowest `sequence`) to
|
||||
bring it back under the cap.
|
||||
- Decide and document what happens to data that references evicted logs:
|
||||
- Params/metrics already derived into `Bucket` values from an evicted log are
|
||||
**not** retroactively removed (buckets are aggregate data, not raw logs) —
|
||||
state this explicitly so it's not treated as a bug later.
|
||||
- `sequence` numbering does not reset/renumber on eviction.
|
||||
- Expose the setting in the Settings API/NavMenu settings panel (a numeric input,
|
||||
clearable back to "unlimited").
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- With no cap configured, behavior is unchanged (unbounded retention, as today).
|
||||
- Setting a cap and ingesting past it keeps memory usage bounded — oldest logs
|
||||
are evicted, newest `max_logs` remain queryable via `GET /api/logs`.
|
||||
- Changing the cap at runtime (via settings) takes effect on the next ingest
|
||||
without requiring a restart.
|
||||
|
||||
## Depends on
|
||||
|
||||
None — extends the existing `Settings` model.
|
||||
@@ -1,33 +0,0 @@
|
||||
# 027 — Dashboards: remove manual save, autosave everything
|
||||
|
||||
## Summary
|
||||
|
||||
`Dashboards.tsx` currently has an explicit "Save dashboard" button
|
||||
(`saveDashboard` called on click). The wishlist wants dashboards to always
|
||||
autosave — no explicit save step, matching how settings/profile changes already
|
||||
persist immediately elsewhere in the app.
|
||||
|
||||
## Scope
|
||||
|
||||
- Remove the "Save dashboard" button and the `saving`/dirty-draft gating around
|
||||
it in `Dashboards.tsx`.
|
||||
- Persist panel/name edits automatically: debounce edits (e.g. text field
|
||||
keystrokes) so autosave doesn't fire on every keystroke, but don't require any
|
||||
user action to commit a change.
|
||||
- Cover all mutations that currently route through the save button: panel
|
||||
add/remove/edit (title, PromQL, chart type), dashboard rename.
|
||||
- Surface save failures unobtrusively (e.g. a small inline status/error near the
|
||||
dashboard, not a blocking dialog) since there's no longer a discrete "Save"
|
||||
click to attach an error to.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Editing a panel's title, query, or chart type persists without clicking
|
||||
anything, verified by reloading the page and seeing the change retained.
|
||||
- Rapid typing in a text field doesn't spam the save endpoint on every keystroke.
|
||||
- A failed autosave (e.g. simulated API error) surfaces to the user without
|
||||
losing the in-progress edit.
|
||||
|
||||
## Depends on
|
||||
|
||||
None — modifies the existing `Dashboards.tsx` save path.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 028 — Bundle the webapp with fs-embed, drop `--static-dir`
|
||||
|
||||
## Summary
|
||||
|
||||
`fs-embed` is already a `Cargo.toml` dependency but unused (`grep -rn
|
||||
"fs_embed" src/` turns up nothing) — `src/api.rs` still serves the webapp via
|
||||
`ServeDir`/`ServeFile` pointed at a `--static-dir` CLI arg
|
||||
(`src/main.rs:35`), which requires shipping `webapp/dist` alongside the
|
||||
binary. Embed the built webapp into the binary instead so `ottyl` is a single
|
||||
self-contained executable.
|
||||
|
||||
## Scope
|
||||
|
||||
- Use `fs-embed` to embed `webapp/dist` at build time.
|
||||
- Wire the embedded filesystem into `src/api.rs` in place of the
|
||||
`ServeDir::new(static_dir).fallback(ServeFile::new(index_html))` setup
|
||||
(`src/api.rs:100-101`), preserving the SPA fallback-to-`index.html`
|
||||
behavior.
|
||||
- Remove the `--static-dir` clap arg (`src/main.rs:30-35`) and its plumbing
|
||||
through `api::router` (`src/api.rs:91`, `src/main.rs:108`).
|
||||
- Update the test helper that builds a router against a temp static dir
|
||||
(`src/api.rs:729-744`) to match the new embedding approach.
|
||||
- `webapp/dist` must exist at `cargo build` time (i.e. the webapp build step
|
||||
stays a prerequisite) — document that in the README's build instructions if
|
||||
not already covered.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `cargo build` (with `webapp/dist` present) produces a binary that serves
|
||||
the webapp with no `--static-dir` flag and no `webapp/dist` directory
|
||||
needed at runtime.
|
||||
- `--static-dir` no longer appears in `--help` output or `Args`.
|
||||
- Existing API tests pass against the embedded-filesystem router.
|
||||
|
||||
## Depends on
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,29 @@
|
||||
# 029 — Remove unused crates and dependencies
|
||||
|
||||
## Summary
|
||||
|
||||
Audit `Cargo.toml` and `webapp/package.json` for dependencies that are
|
||||
declared but not actually used, and remove them.
|
||||
|
||||
## Scope
|
||||
|
||||
- Check each `[dependencies]`/`[dev-dependencies]` entry in `Cargo.toml`
|
||||
against actual usage (e.g. `cargo +nightly udeps`, or grepping for each
|
||||
crate's root module name), same for `webapp/package.json` (e.g. `npx
|
||||
depcheck`).
|
||||
- Remove anything unused. If a dependency was added ahead of a specific
|
||||
ticket that hasn't landed yet (e.g. `fs-embed` before
|
||||
[028](028-fs-embed-static-bundling.md) wires it in), leave it — this ticket
|
||||
is about dead weight, not blocking in-flight work.
|
||||
- Re-run the full test suite and a production webapp build after pruning to
|
||||
confirm nothing silently depended on a "removed" package.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No dependency remains in either manifest without a corresponding usage in
|
||||
the codebase.
|
||||
- `cargo test` and the webapp build both pass after pruning.
|
||||
|
||||
## Depends on
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 030 — Cut comments down to clear, direct one-liners
|
||||
|
||||
## Summary
|
||||
|
||||
Pass over the Rust codebase, the webapp source, and the webapp's in-app docs,
|
||||
trimming every comment/doc-comment to a single clear, direct line where one is
|
||||
needed at all, and removing it outright where it isn't.
|
||||
|
||||
## Scope
|
||||
|
||||
- `src/*.rs`: many modules currently carry multi-paragraph `///`/`//!` blocks
|
||||
(e.g. `src/settings.rs`'s module doc and per-method docs). Keep only what
|
||||
explains a non-obvious *why* (a constraint, an invariant, a workaround);
|
||||
drop restatements of what the code already says via naming.
|
||||
- `webapp/src/**`: same treatment for inline comments and any JSDoc-style
|
||||
blocks.
|
||||
- The webapp's in-app documentation content (surfaced via the "?" help icons
|
||||
from [021](021-docs-contextual-help.md)) — reduce prose there the same way:
|
||||
clear and direct, not verbose.
|
||||
- This is a mechanical cleanup pass, not a refactor — don't change behavior or
|
||||
restructure code while doing it; keep it reviewable as a comments-only diff
|
||||
where possible.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No multi-line comment blocks remain outside of license/generated headers
|
||||
(if any).
|
||||
- Every remaining comment states a non-obvious reason, not a restatement of
|
||||
the adjacent code.
|
||||
- `cargo test` and the webapp build/tests still pass (confirms nothing beyond
|
||||
comments changed).
|
||||
|
||||
## Depends on
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 031 — Timeline start/end markers, cursor hover, and live drag readout
|
||||
|
||||
## Summary
|
||||
|
||||
`LogTimeline` (`webapp/src/components/LogTimeline.tsx`) shows a bar per
|
||||
bucket with only a per-bar `title` tooltip on native hover, and a drag
|
||||
selection that's silent until `handleMouseUp` fires `onSelectRange`. Add
|
||||
always-visible start/end labels, a hover readout that tracks the cursor, and
|
||||
a live-updating readout while dragging.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Start/end markers**: render the sequence index and timestamp of the
|
||||
first and last visible bucket at the two ends of the timeline (using
|
||||
`domainStart`/`domainEnd`, already computed at
|
||||
`LogTimeline.tsx:52-53`), always visible, not hover-only.
|
||||
- **Cursor hover readout**: on `onMouseMove` (already wired for drag at
|
||||
`handleMouseMove`, `LogTimeline.tsx:65`), when not dragging, compute the
|
||||
bucket at or immediately before the cursor via `xToMs` and show its
|
||||
timestamp/count in a readout near the cursor — replacing reliance on the
|
||||
native per-bar `title` attribute (`LogTimeline.tsx:135`).
|
||||
- **Live drag readout**: while `dragging` is true (`LogTimeline.tsx:78`), show
|
||||
the resolved start/end (`xToMs(dragStartX)`/`xToMs(dragCurrentX)`) updating
|
||||
on every `handleMouseMove`, not just after `handleMouseUp` resolves the
|
||||
final range — the drag rectangle already updates live (`selRect`,
|
||||
`LogTimeline.tsx:79-84`); this adds the numeric readout next to it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The timeline always shows the sequence/timestamp of its first and last
|
||||
bucket, without needing to hover.
|
||||
- Moving the mouse over the timeline (not dragging) shows the timestamp/count
|
||||
of the bucket under (or just before) the cursor, updating as the mouse
|
||||
moves.
|
||||
- While dragging, the displayed start/end timestamps update continuously as
|
||||
the drag extends, matching the already-live selection rectangle.
|
||||
|
||||
## Depends on
|
||||
|
||||
None — builds on the existing `LogTimeline` drag/hover plumbing.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 032 — Rework colour themes as greyscale equivalents with accent-only colour
|
||||
|
||||
## Summary
|
||||
|
||||
Today's colour themes (`webapp/src/index.css`) only vary `--c-accent`/
|
||||
`--c-accent-soft`/`--c-accent-text` per theme (`html.red`, `.green`, `.blue`,
|
||||
`.yellow`, `.pink`, `.orange` at lines 97-130) — every theme shares the same
|
||||
dark `:root` surfaces/borders/text. `html.grey` (lines 75-95) is a Tailwind
|
||||
slate palette, which reads blue-tinted rather than neutral. The wishlist
|
||||
wants: `light`/`dark` as true black-and-white inverses of each other, `grey`
|
||||
as true neutral greyscale, and every other named theme (`red`/`green`/etc.)
|
||||
to desaturate its named hue to the matching greyscale lightness for
|
||||
surfaces/borders/text, reserving actual saturated colour for accents only.
|
||||
|
||||
## Scope
|
||||
|
||||
- `:root` (dark) and `html.light`: convert to a true black/white greyscale
|
||||
scale (surfaces, borders, text all `#000`–`#fff` greys, inverted between
|
||||
the two), replacing the current warm/cool-tinted neutrals.
|
||||
- `html.grey`: replace the slate-derived palette (lines 76-90) with the same
|
||||
neutral greyscale scale as dark/light (i.e. `grey` becomes the canonical
|
||||
greyscale theme other themes derive from), keeping only its own accent
|
||||
distinct if one is still wanted.
|
||||
- `red`/`green`/`blue`/`yellow`/`pink`/`orange`: for each, desaturate that
|
||||
hue to compute a matching greyscale lightness scale for
|
||||
`--c-surface-*`/`--c-border-*`/`--c-text-*` (e.g. convert the hue to HSL,
|
||||
zero the saturation, keep the lightness steps), then keep that theme's
|
||||
actual saturated colour only on `--c-accent*`.
|
||||
- Document the derivation (e.g. a short comment or a small script/table) so a
|
||||
new themed variant can be added consistently later.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `light` and `dark` are literal greyscale inverses of each other (every
|
||||
`--c-*` value in one is the other's lightness mirrored, no hue).
|
||||
- `grey` uses a neutral (zero-saturation) scale, not a blue-tinted one.
|
||||
- Each named colour theme's surfaces/borders/text render as neutral greys at
|
||||
a lightness matching that colour's own luminance, with the named colour
|
||||
visible only via `--c-accent*`.
|
||||
|
||||
## Depends on
|
||||
|
||||
None.
|
||||
|
||||
## Depended on by
|
||||
|
||||
- [033 — Downloadable/uploadable custom themes](033-custom-theme-download-upload.md)
|
||||
@@ -0,0 +1,42 @@
|
||||
# 033 — Downloadable and re-uploadable custom themes
|
||||
|
||||
## Summary
|
||||
|
||||
Let a user export the active theme's `--c-*` variable values as a file, edit
|
||||
it, and load it back in as a custom theme — reusing the CSS import/link
|
||||
machinery `Settings` already has (`CssEntry`, `import_css`/`link_css` in
|
||||
`src/settings.rs`) rather than inventing a parallel storage mechanism.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Download**: given a theme name (one of the built-ins from
|
||||
[032](032-greyscale-theme-rework.md), or a previously-uploaded custom one),
|
||||
produce a small CSS (or JSON) file containing just its `--c-*` variable
|
||||
values, served for download from the webapp's theme picker.
|
||||
- **Upload**: accept such a file back, validate it defines the expected set
|
||||
of `--c-*` variables, and register it as a selectable custom theme —
|
||||
persisted via the existing `import_css`-style flow (a new CSS entry scoped
|
||||
under a `html.<custom-name>` selector, or a dedicated theme entry if that
|
||||
shape doesn't fit `CssEntry` cleanly — decide during implementation which
|
||||
reuses more of the existing settings model).
|
||||
- **Linked** custom themes: since `CssEntry::Linked` already models
|
||||
"reference a URL, don't own the content," a custom theme can also be
|
||||
registered by URL instead of upload, consistent with how linked CSS works
|
||||
today.
|
||||
- Theme picker UI: list built-in themes alongside any uploaded/linked custom
|
||||
ones, each with a download action.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Downloading a theme produces a file that, re-uploaded unmodified, recreates
|
||||
the same theme.
|
||||
- Editing a downloaded theme's variable values and re-uploading it registers
|
||||
a distinct selectable theme without touching the built-ins.
|
||||
- A linked (URL) custom theme behaves like linked CSS today: not deletable
|
||||
via the "remove" action that applies to uploaded ones, per
|
||||
`CssError::NotImported`'s existing semantics.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [032 — Greyscale theme rework](032-greyscale-theme-rework.md)
|
||||
- [008 — Settings model](008-settings-model.md) (`CssEntry` import/link flow)
|
||||
@@ -0,0 +1,41 @@
|
||||
# 034 — Params declaration UI
|
||||
|
||||
## Summary
|
||||
|
||||
`POST /api/params` (ticket 004) already lets a param be declared — name,
|
||||
`message_match_regex`, `capture_group`, `kind` — and `GET /api/params`
|
||||
(`fetchParams`, `webapp/src/lib/api.ts:245`) already feeds LogPreview's
|
||||
"Columns" picker (`webapp/src/components/LogPreview.tsx:63`) so a declared
|
||||
param can be shown as a log table column. But nothing in the webapp can
|
||||
*create* one — the only way to declare a param today is a raw `POST
|
||||
/api/params` call. Add a UI for it, following the same declare-then-list
|
||||
pattern `Metrics` already uses for `POST /api/metrics`
|
||||
(`webapp/src/components/Metrics.tsx`).
|
||||
|
||||
## Scope
|
||||
|
||||
- A "Params" section in the primary UI (alongside Log viewer / Metrics /
|
||||
Dashboards in `App.tsx`), listing currently-declared params (name, regex,
|
||||
capture group, kind) via `fetchParams`.
|
||||
- A declare form: name, `message_match_regex`, `capture_group`, kind
|
||||
(string/number/timestamp — mirror `MetricPrimitiveKind`), submitting via a
|
||||
new `declareParam` call to `POST /api/params`.
|
||||
- Surface `400` validation errors from the backend (bad regex, unknown
|
||||
capture group) inline on the form, matching how `Metrics`' declare form
|
||||
reports its own validation errors.
|
||||
- After a successful declare, refresh the params list so it (and
|
||||
LogPreview's Columns picker) immediately reflects the new param without a
|
||||
page reload.
|
||||
- Contextual "?" help icon on the section, matching [021](021-docs-contextual-help.md)'s pattern.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A param can be declared from the UI and immediately appears in LogPreview's
|
||||
Columns picker without a page reload.
|
||||
- An invalid regex or unknown capture group shows the backend's error message
|
||||
on the form instead of failing silently.
|
||||
- The declared-params list matches `GET /api/params` after a page reload.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [021 — Docs contextual help](021-docs-contextual-help.md)
|
||||
@@ -0,0 +1,34 @@
|
||||
# 035 — Collapsible primary UI sections
|
||||
|
||||
## Summary
|
||||
|
||||
`App.tsx` stacks the Log viewer (`LogTimeline` + `LogPreview`), Params
|
||||
([034](034-params-declaration-ui.md)), `Metrics`, and `Dashboards` vertically
|
||||
with no way to collapse any of them. On a small screen — or just when only
|
||||
one section is being worked on — the others take up space and force
|
||||
scrolling. Make each section collapsible.
|
||||
|
||||
## Scope
|
||||
|
||||
- Each of the four sections (Log viewer, Params, Metrics, Dashboards) gets a
|
||||
collapse/expand toggle in its header, next to its existing "?" help icon.
|
||||
- Collapsed state persists across reloads per-section, via `localStorage`
|
||||
(same pattern as `LogPreview`'s `VISIBLE_PARAM_COLUMNS_STORAGE_KEY` at
|
||||
`webapp/src/components/LogPreview.tsx:13`).
|
||||
- A collapsed section still shows its header (title + toggle), just not its
|
||||
body/content.
|
||||
- Collapsing a section must not tear down state that would be lost/reset on
|
||||
re-expand (e.g. `Dashboards`' in-progress panel edits, `Metrics`' declare
|
||||
form) — hide with CSS/conditional render at the container level, not by
|
||||
unmounting stateful subtrees.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Each section can be independently collapsed and re-expanded.
|
||||
- Reloading the page preserves each section's collapsed/expanded state.
|
||||
- Collapsing a section with unsaved in-progress input (e.g. a half-filled
|
||||
Metrics declare form) and re-expanding it preserves that input.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [034 — Params declaration UI](034-params-declaration-ui.md)
|
||||
@@ -0,0 +1,42 @@
|
||||
# 036 — Open a section in its own tab
|
||||
|
||||
## Summary
|
||||
|
||||
`App.tsx` has no routing at all — `NavMenu`, `FilterBar`, `LogTimeline` +
|
||||
`LogPreview`, Params ([034](034-params-declaration-ui.md)), `Metrics`, and
|
||||
`Dashboards` all render together in one fixed layout
|
||||
(`webapp/src/App.tsx:94-108`). Add a small icon next to each section's
|
||||
collapse toggle ([035](035-collapsible-sections.md)) that opens *just that
|
||||
section* in a new browser tab — no `NavMenu`, no other sections — for
|
||||
pop-out/multi-monitor use.
|
||||
|
||||
## Scope
|
||||
|
||||
- A "pop out" icon button in each of the four section headers (Log viewer,
|
||||
Params, Metrics, Dashboards), alongside the collapse toggle and "?" help
|
||||
icon.
|
||||
- Clicking it opens a new tab (`window.open`, not same-tab navigation) at a
|
||||
URL identifying that section, e.g. `?section=metrics`.
|
||||
- Minimal client-side routing in `App.tsx`/`main.tsx`: when a `section`
|
||||
query param is present, render only that one section full-page — no
|
||||
`NavMenu`, no `FilterBar` unless the Log viewer section needs it, no
|
||||
sibling sections.
|
||||
- The standalone view keeps the section's own functionality intact (e.g. the
|
||||
popped-out Metrics tab can still declare metrics; popped-out Dashboards
|
||||
can still edit/autosave panels) — it's a layout change, not a read-only
|
||||
view.
|
||||
- The popped-out tab reflects the current `range` state (time range) at the
|
||||
moment it's opened, passed via query params, since it has no `LogTimeline`
|
||||
of its own to select one (unless the Log viewer section itself is what's
|
||||
popped out).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Clicking a section's pop-out icon opens a new tab showing only that
|
||||
section, with no nav bar and no other sections.
|
||||
- The popped-out section is fully functional (not a static snapshot).
|
||||
- Closing the popped-out tab has no effect on the original tab's state.
|
||||
|
||||
## Depends on
|
||||
|
||||
- [035 — Collapsible primary UI sections](035-collapsible-sections.md)
|
||||
Reference in New Issue
Block a user