mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
feat: fe caching (#13642)
Co-authored-by: Kirtan Thakkar <kirtan@kiara.tech>
This commit is contained in:
co-authored by
Kirtan Thakkar
parent
9ea170f4c2
commit
6f04af073d
@@ -36,7 +36,9 @@ description: >-
|
||||
side panels become drawers that open from their own row, row actions fold into a
|
||||
kebab, and popups fit the viewport. It also settles the recurring
|
||||
structural decisions: use OTable for any tabular data, follow the
|
||||
view → service → Vuex/local-ref layering for fetching list data, choose the
|
||||
TanStack Query layering for server data (a declared queryOptions() per read
|
||||
on its module's staleTime tier, mutationOptions() writes that invalidate,
|
||||
refresh buttons that force every read on the view, no Vuex copies), choose the
|
||||
right form container (ConfirmDialog vs ODialog vs ODrawer vs a full in-page
|
||||
view) by the weight of the interaction, and build every validated form with
|
||||
OForm + a colocated Zod schema (single-source-of-truth name-bound fields, no
|
||||
@@ -45,7 +47,7 @@ description: >-
|
||||
validate, or restyle any screen, component, header, table, list, dialog,
|
||||
drawer, form, field, or panel in the web frontend, asks to make any of them
|
||||
work on mobile / phone / tablet / small screens (responsive), or asks where a
|
||||
form/table/fetch should live, how to validate a form, how to add a keyboard
|
||||
form/table/fetch should live, how to cache or refresh server data, how to validate a form, how to add a keyboard
|
||||
shortcut, how to build a new reusable/common O2 component when nothing existing
|
||||
fits (create one in web/src/lib instead of assembling divs and classes),
|
||||
whether something belongs in a dialog or a drawer, or where a new page should be
|
||||
@@ -557,13 +559,13 @@ and each domain has its own reference below.
|
||||
|
||||
| Decision | The rule | Detail |
|
||||
| --- | --- | --- |
|
||||
| **Server data (fetch & cache)** | Every read is a declared `queryOptions()` in `services/<domain>.queries.ts` (reuse the existing one if the list is already declared), keyed with `orgKey`, on its **module's** `staleTime` tier from `cachePolicy.ts`; components `useQuery` it (rows as a `computed`) — never `http`/axios, never a Vuex copy of a server list. Writes are `mutationOptions()` with `meta.invalidates`. A user refresh forces **every** read on the view; mount, paging and search read the cache. | [data-fetching](references/data-fetching.md) |
|
||||
| **Tabular data** | `OTable` + `OTableColumnDef[]`; client-side pagination unless the backend paginates a set too large to fetch whole | [core-controls-table](references/core-controls-table.md) |
|
||||
| **Charts / graphs** | **Every data chart renders through the shared dashboard engine — never mount a charting lib in a feature page.** Time-series, category, scatter, geo/map, gauge, pie → **`PanelSchemaRenderer`** (`web/src/components/dashboards/PanelSchemaRenderer.vue`) with a panel schema: it runs the query, applies the app's unit/theme/annotation formatting, and owns the loading/error ladder. **Banned in feature code:** `echarts.init` / a raw `<v-chart>` / ApexCharts / D3 / Chart.js / a hand-rolled `<canvas>` or `<svg>` plot. The low-level **`panels/ChartRenderer.vue`** (raw ECharts option) is the ONLY sanctioned escape hatch, and ONLY when you need chart-`@click` forwarding `PanelSchemaRenderer` doesn't re-emit — annotate the site with why, and convert once the schema renderer forwards clicks. **Not charts** (do NOT force these through the renderer): in-row trend lines are **`OSparkline`**, single-value share bars are **`OProgressBar`**, in-cell data bars are the table's **`ODataBarCell`**, and a decorative topology/diagram is bespoke SVG. | [core-display](references/core-display.md) |
|
||||
| **Whole-page layout** | **Every routed view is a `OPageLayout`.** It's the ONE page component — it owns the full-height column, the header (from `:title`/`:icon`/`:subtitle`/`:back` props + `#actions`/`#header-tabs`, the latter needing **`tabs-below`** to land in row 2 instead of inline), an optional `#subnav` strip, an optional `#sidebar` rail (fixed or `resizable`), and the body's inset. You plug in data; there's no place to hand-roll a padded `<div>`. Body is inset to the page-edge grid by default — pass **`bleed`** for a full-bleed body (an `OTable`, a chart, a `router-view` shell), or **`constrained`** for a centered reading column (forms). The `#header` slot is a rare escape hatch only. | [page-recipes](references/page-recipes.md) |
|
||||
| **Content inset** | `OPageLayout` already insets the body. Anywhere else (a panel, a dialog section, one tab's content) wrap it in **`OContent`** (bakes the one `px-page-edge` grid line, the primitive `OPageLayout` uses internally) instead of hand-picking `px-2`/`px-4`/`p-2.5`; pass `bleed` (or `bleed-x`/`bleed-y`) for full-bleed content that owns its own edge — same escape-hatch idea as `ODrawer`/`ODialog` `bleed`. Never hand-roll a content inset. | [conventions](references/conventions.md) |
|
||||
| **Tab strips** | an `OTabs` strip needs **no** horizontal wrapper padding — the first tab's label self-aligns to the `px-page-edge` grid, so it lines up with the `OContent` body below it. Put the strip's bottom divider on the strip (`border-b`) and give it no `px-*`; wrapping a tab strip in `px-page-edge` double-insets the labels. | [conventions](references/conventions.md) |
|
||||
| **Listing toolbar** | every list carries three affordances — search + filters (`#toolbar`), refresh (`#toolbar-trailing`), and the auto-injected column-visibility toggle; empty state is one `OEmptyState` with `:filtered`, in the `#empty` slot only, plus `:forbidden` on the table so a 403 shows "You don't have access" instead of "create your first…" | [page-recipes](references/page-recipes.md) |
|
||||
| **Data fetching** | view → domain service (`src/services`, via the `http.ts` wrapper) → Vuex (shared/cached) or local `ref` (ephemeral); never call `http`/axios from a component | [conventions](references/conventions.md) |
|
||||
| **Form container** | confirm → `ConfirmDialog`; short form → `ODialog`; tall or contextual form → `ODrawer`; primary multi-section flow → a full in-page view. Use `ODialog` / `ODrawer` for these | [conventions](references/conventions.md) |
|
||||
| **Form validation** | `OForm` + a colocated Zod `<Form>.schema.ts`; fields are `OForm*` bound **only by `name=`** (no `v-model`/`ref` mirror, no `formData`); submit + loading automatic; payload built with explicit keys; field arrays use `:key="index"` | [forms-validation](references/forms-validation.md) |
|
||||
| **New page in nav** | a route **+ exactly one** surface (rail item / flyout child / Settings / IAM sub-page) **+** an env/role gate — the route condition, the nav-entry gate, and the SectionRail `visible` all express the same rule | [navigation-menus](references/navigation-menus.md) |
|
||||
@@ -653,6 +655,15 @@ considering the UI done:
|
||||
(`navGroups.ts` / `linksList` / `settingsItems` / SectionRail). A module
|
||||
showing one glyph in the rail and another in its header reads as two
|
||||
places — see [navigation-menus](references/navigation-menus.md#icon-parity).
|
||||
- [ ] Any data the page reads is a `queryOptions()` in
|
||||
`services/<domain>.queries.ts`, consumed with `useQuery` — not a service
|
||||
call with a hand-rolled `loading` ref. Durations come from
|
||||
`cachePolicy.ts` (the module's tier), never a bare number; no `persister`,
|
||||
no per-query `gcTime`. Writes are `mutationOptions()` declaring
|
||||
`meta.invalidates`; a component never calls `invalidateQueries` itself.
|
||||
Refresh (button, `r`, section icons, Retry) forces **every** read on the
|
||||
view through a named handler. See
|
||||
[references/data-fetching.md](references/data-fetching.md).
|
||||
- [ ] Every interactive control is an O2 component if one exists in
|
||||
`web/src/lib` — no bare HTML controls or third-party primitives with an O2 equivalent.
|
||||
- [ ] A self-contained/repeated UI element with no matching component was
|
||||
@@ -697,8 +708,9 @@ considering the UI done:
|
||||
(`config.isEnterprise` / `config.isCloud` / `zoConfig.*`), with the route,
|
||||
nav-entry gate, and SectionRail `visible` all in sync
|
||||
(see [navigation-menus.md](references/navigation-menus.md)).
|
||||
- [ ] Data fetched through a domain service (`src/services`), not raw `http` in
|
||||
the component; shared data in Vuex, ephemeral data in local refs.
|
||||
- [ ] Server data reaches the component through a declared query (never raw
|
||||
`http`), and no server list is copied into Vuex or a load-once map;
|
||||
ephemeral view state stays in local refs.
|
||||
- [ ] Form container matches weight: confirm → `ConfirmDialog`, short form →
|
||||
`ODialog`, large/contextual form → `ODrawer`, primary multi-section flow →
|
||||
full page.
|
||||
|
||||
@@ -14,15 +14,15 @@ Apply it in three steps.
|
||||
|
||||
## Step 1 — Name the screen's primary signal
|
||||
|
||||
Every screen answers one question. Colour *that*; keep the rest quiet. Which
|
||||
Every screen answers one question. Colour _that_; keep the rest quiet. Which
|
||||
signal earns the colour changes by page type:
|
||||
|
||||
| Archetype | Examples | Primary signal → colour |
|
||||
| --- | --- | --- |
|
||||
| **Monitoring** | Alerts, Incidents, RUM errors, Synthetics | **state / severity / health** — active/paused/failed, firing, recency |
|
||||
| **Catalog** | Dashboards, Streams, Functions, Pipelines | **organise & orient** — category/type, owner, freshness, size/usage, favourites |
|
||||
| **Access** | Users, IAM, Roles, Service accounts | **role / permission** — privilege runs hot→cool, plus active/invited status |
|
||||
| **Forms & settings** | creation flows, editors, org/stream settings | **progress & validity** — where am I, is it right, what still needs me |
|
||||
| Archetype | Examples | Primary signal → colour |
|
||||
| -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **Monitoring** | Alerts, Incidents, RUM errors, Synthetics | **state / severity / health** — active/paused/failed, firing, recency |
|
||||
| **Catalog** | Dashboards, Streams, Functions, Pipelines | **organise & orient** — category/type, owner, freshness, size/usage, favourites |
|
||||
| **Access** | Users, IAM, Roles, Service accounts | **role / permission** — privilege runs hot→cool, plus active/invited status |
|
||||
| **Forms & settings** | creation flows, editors, org/stream settings | **progress & validity** — where am I, is it right, what still needs me |
|
||||
|
||||
Don't bolt "status" onto a screen that has none. A Dashboards list has no
|
||||
firing/paused state — its signal is category + recency + ownership.
|
||||
@@ -61,27 +61,28 @@ All token-backed and dark-mode-safe. Reuse these before inventing anything.
|
||||
exception highlight** via `row-class`. The rail and the wash are two different
|
||||
strengths of the same signal, and the rail is the default:
|
||||
|
||||
| | Rail (`getRowStyle`) | Wash (`row-class`) |
|
||||
| --- | --- | --- |
|
||||
| Cost | a few px at the row edge | ~the whole row |
|
||||
| Use for | **every** state, always | only the two cases below |
|
||||
| | Rail (`getRowStyle`) | Wash (`row-class`) |
|
||||
| ------- | ------------------------ | ------------------------ |
|
||||
| Cost | a few px at the row edge | ~the whole row |
|
||||
| Use for | **every** state, always | only the two cases below |
|
||||
|
||||
A full-row wash is the loudest thing on a list, so it earns its place only when
|
||||
**all three** hold: the state means **act now** (not merely "not green"), it is
|
||||
**rare in a healthy system**, and **the row is the unit of action**. In practice
|
||||
that leaves exactly two washes:
|
||||
- `!bg-status-error-bg` — failing/errored/offline. *Alerts* failed, *Pipelines*
|
||||
errored, *Nodes* offline, *Synthetics* failed.
|
||||
- `!bg-status-error-bg` — failing/errored/offline. _Alerts_ failed, _Pipelines_
|
||||
errored, _Nodes_ offline, _Synthetics_ failed.
|
||||
- `!bg-surface-panel` — paused/disabled. This one is **de-emphasis, not alarm**:
|
||||
the row is deliberately inert, so it recedes rather than shouts.
|
||||
|
||||
Everything else keeps a clean row and reads from the rail — including states
|
||||
that are "bad but not urgent": **degraded/warning** (worth noticing, not worth
|
||||
acting on this second), **stale**, **unknown**, **never-ran**. And whatever the
|
||||
state means, if it is **common** the wash is wrong regardless: *Streams* drops it
|
||||
state means, if it is **common** the wash is wrong regardless: _Streams_ drops it
|
||||
entirely, because "never ingested" and "quiet for a day" describe a large share
|
||||
of rows in a normal org and a table where most rows are tinted signals nothing.
|
||||
Pages with no failure state at all — Users, Roles, Dashboards — never wash.
|
||||
|
||||
- **Recency** — `OTimeCell` `mode="relative"` (`"3 min ago"`) with a hot/warm/
|
||||
cold dot, instead of a raw timestamp column.
|
||||
- **People** — `OUserCell` for owner/author columns.
|
||||
@@ -100,12 +101,12 @@ Once a strip is `selectable`, four rules keep every strip behaving identically
|
||||
|
||||
- **Order attention-first, left → right.** The most critical / attention-worthy
|
||||
tile is leftmost, descending to the calm/inert ones — e.g. `failed → recent →
|
||||
active → paused`, `P1 → P2 → P3 → P4`, `degraded → paused → active → draft →
|
||||
archived`. This mirrors the row rail's "critical colour on the left edge": what
|
||||
active → paused`, `P1 → P2 → P3 → P4`, `degraded → paused → active → draft →
|
||||
archived`. This mirrors the row rail's "critical colour on the left edge": what
|
||||
needs attention lives on the left, everywhere.
|
||||
- **The "All" / "Total" tile is LAST, stays CLICKABLE, and is never highlighted.**
|
||||
It only clears the facet; it is never itself the active tile. Wire `:selected-key`
|
||||
to the *raw filter value* (`null` / `"all"` when unfiltered) so nothing shows the
|
||||
to the _raw filter value_ (`null` / `"all"` when unfiltered) so nothing shows the
|
||||
ring while viewing everything — never fall back to selecting "All" as the default.
|
||||
**Never put `selectable: false` on it** — that is the one wrong way to express
|
||||
"never highlighted": it turns the tile into a plain `<div>`, so clicking it does
|
||||
@@ -120,7 +121,7 @@ Once a strip is `selectable`, four rules keep every strip behaving identically
|
||||
see Step 3.
|
||||
- **A strip in `#subheader` is a CLAIM ABOUT THE ROWS — scope decides position.**
|
||||
Inside the table frame, a strip promises "these numbers describe the list below",
|
||||
so it must be computed from the same filtered set *and* be facet-clickable. Before
|
||||
so it must be computed from the same filtered set _and_ be facet-clickable. Before
|
||||
writing one, check the fetch: **if the list is server-paginated you only hold one
|
||||
page**, so page-local sums are not totals — never sum the visible page and label
|
||||
it "Total". When the numbers genuinely can't meet the promise (server pagination,
|
||||
@@ -135,8 +136,7 @@ Once a strip is `selectable`, four rules keep every strip behaving identically
|
||||
- **`ODataBarCell` needs the whole set — client-paginated tables only.** Its bar is
|
||||
a share of the `max` the caller computes over the rendered rows, so on a
|
||||
**server-paginated** table it silently means "biggest on this page": the scale
|
||||
changes as you page, and the same stream draws a different bar on page 1 and page
|
||||
4. On such a table drop the bars and let the (sortable) numbers rank the rows —
|
||||
changes as you page, and the same stream draws a different bar on page 1 and page 4. On such a table drop the bars and let the (sortable) numbers rank the rows —
|
||||
right-aligned + `tabular-nums` already scans fine, and the stray part-width
|
||||
underlines read as artefacts rather than data. Same test as the strip: can this
|
||||
mark be computed from data you actually hold?
|
||||
@@ -153,7 +153,7 @@ strip feeds a drill-down.
|
||||
|
||||
### Before you colour a state, check how it CLEARS
|
||||
|
||||
A State column is a claim about *now*. Read the write path of whatever field backs
|
||||
A State column is a claim about _now_. Read the write path of whatever field backs
|
||||
it and confirm something resets it — an expiry/retention job, a success write, a
|
||||
status transition. A sticky error field (written on failure, never cleared) pins a
|
||||
row to "Errored" forever and the column becomes noise within a week. Pipelines'
|
||||
@@ -182,20 +182,20 @@ Colour only earns attention if most of the screen stays quiet:
|
||||
each tile to three questions: does the number **vary**, does someone **act** on
|
||||
it, and is it **not already on screen**? Tiles that fail are noise dressed as
|
||||
signal:
|
||||
- *structurally constant* — "System accounts" is 1 in almost every org, so the
|
||||
- _structurally constant_ — "System accounts" is 1 in almost every org, so the
|
||||
tile is a label with a number stuck to it;
|
||||
- *almost always zero* — "New this week" on a list that gains an item a quarter;
|
||||
- *derivable from its neighbours* — "In use" beside "Unused" and "Total";
|
||||
- *already in the footer* — `footerTitle` renders "N Dashboards" under every
|
||||
- _almost always zero_ — "New this week" on a list that gains an item a quarter;
|
||||
- _derivable from its neighbours_ — "In use" beside "Unused" and "Total";
|
||||
- _already in the footer_ — `footerTitle` renders "N Dashboards" under every
|
||||
table, so a Total tile alone is not a reason to have a strip.
|
||||
A page whose only candidates fail these gets **no strip** — keep the per-row
|
||||
signals (relative recency, a state rail, a count column) and stop. Dashboards,
|
||||
Service Accounts and Roles all ended up here: pages where a strip added pixels
|
||||
and no information. Roles is the clearest case — "Unused" is just the member
|
||||
column sorted ascending, so two tiles restated what the rows already said. **A
|
||||
count column plus sorting usually beats a strip**; reach for a strip only when
|
||||
the page has a real distribution to summarise (Users across roles, monitors
|
||||
across health) *and* the tiles double as the facet.
|
||||
A page whose only candidates fail these gets **no strip** — keep the per-row
|
||||
signals (relative recency, a state rail, a count column) and stop. Dashboards,
|
||||
Service Accounts and Roles all ended up here: pages where a strip added pixels
|
||||
and no information. Roles is the clearest case — "Unused" is just the member
|
||||
column sorted ascending, so two tiles restated what the rows already said. **A
|
||||
count column plus sorting usually beats a strip**; reach for a strip only when
|
||||
the page has a real distribution to summarise (Users across roles, monitors
|
||||
across health) _and_ the tiles double as the facet.
|
||||
- **Highlight exceptions, not the norm.** Tint the failed/paused rows; leave the
|
||||
healthy majority clean. A table where every row is coloured signals nothing.
|
||||
And if a page has no true failure state (a catalog list), the calm answer is a
|
||||
@@ -204,19 +204,19 @@ Colour only earns attention if most of the screen stays quiet:
|
||||
data" is a `—`, not a wall of zeros. (`OStatCard` does this.)
|
||||
- **State is border/colour, not fills.** Selected/hover on interactive tiles use
|
||||
an accent **border**, not a grey/blue background wash. No hover shadows.
|
||||
- **Semantic colour ≠ brand accent.** Green/amber/red carry *meaning* (health);
|
||||
the brand teal carries *structure/selection*. Don't mix the two roles.
|
||||
- **Semantic colour ≠ brand accent.** Green/amber/red carry _meaning_ (health);
|
||||
the brand teal carries _structure/selection_. Don't mix the two roles.
|
||||
- **One primary action.** In forms, exactly one brand-coloured primary (Save);
|
||||
destructive in red; everything else neutral.
|
||||
- **No layout shift.** Reserve space for anything that streams in (a stat card
|
||||
renders the same box loaded or not; a proportion-bar *track* is always drawn).
|
||||
renders the same box loaded or not; a proportion-bar _track_ is always drawn).
|
||||
|
||||
---
|
||||
|
||||
## Per-archetype quick recipe
|
||||
|
||||
- **Monitoring** → `OStatStrip` (state counts, filter tiles) in `#subheader`;
|
||||
`OTag` status chips; row rail + exception tint; relative recency. *(Alerts.)*
|
||||
`OTag` status chips; row rail + exception tint; relative recency. _(Alerts.)_
|
||||
- **Catalog** → `OStatStrip` counting totals (Total · Folders · Favourites ·
|
||||
Updated-this-week, `max` optional); category `OTag` (folder/type/language);
|
||||
`OUserCell` owner; relative "updated"; a colour-filled favourite.
|
||||
|
||||
@@ -10,21 +10,21 @@ markup — don't guess a prop name.
|
||||
|
||||
### Reference files
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| [references/core-display.md](core-display.md) | Badge/Tag/DimensionChip, Card, Code, Collapsible, EmptyState, Icon, Separator, Shortcut, **Text (typography)**, VirtualScroll |
|
||||
| [references/core-controls-table.md](core-controls-table.md) | **Button**/ButtonGroup, Navbar, RefreshButton, Splitter, ToggleGroup, **Table** (+ cell renderers) |
|
||||
| [references/forms-inputs.md](forms-inputs.md) | Input/Textarea, Select, Combobox, SearchInput, Checkbox, Radio, Switch, OptionGroup |
|
||||
| [references/forms-specialized.md](forms-specialized.md) | **Form + useOForm**, Color, Date, DateTimeRange, Time, File, Range, Slider |
|
||||
| [references/forms-validation.md](forms-validation.md) | **Validating a form: OForm + Zod schema, binding rules, submit/loading, conditional rendering, field arrays, testing** |
|
||||
| [references/keyboard-shortcuts.md](keyboard-shortcuts.md) | **Keyboard shortcuts: registry, `useShortcut`/`useShortcuts`, display via `OShortcut`/`shortcut-id`, cheatsheet** |
|
||||
| [references/creating-components.md](creating-components.md) | **Building a NEW O2 component: lib vs components, folder contract, families, headless-first (reka-ui), no-UI-prop-leakage, strict TS, tokens, form wrappers, workflow, testing** |
|
||||
| [references/design-tokens.md](design-tokens.md) | **Design tokens: using `--color-*`, the token files, registering a new token, the `--o2-*` ban + full `--o2-*` → `--color-*` migration map** |
|
||||
| [references/page-recipes.md](page-recipes.md) | **Whole-page layouts: the listing/table page (full-height flush skeleton, mandatory search · refresh · column-visibility toggle, filtered-vs-first-run empty state) and the detail/editor page** |
|
||||
| [references/navigation-menus.md](navigation-menus.md) | **Registering a new page in navigation: the route composables, the left-rail item (`linksList`), Settings/IAM sub-pages (`SectionRail`), hover-flyout children (`navGroups.ts`), and cloud/enterprise/RBAC gating** |
|
||||
| [references/overlay-navigation.md](overlay-navigation.md) | Dialog, Drawer, Dropdown, Popover, Tooltip · Pagination, Stepper, Tabs |
|
||||
| [references/responsive.md](responsive.md) | **Phones & tablets with an unchanged laptop view: breakpoints + `useBreakpoint`, one-row header/toolbar/stat chrome, rails → anchored drawers, row-action kebab, popup sizing, clipping and touch gotchas, verification** |
|
||||
| [references/feedback-data.md](feedback-data.md) | Banner, Toast (+ useToast), Spinner, Skeleton, InnerLoading · ProgressBar, Timeline, Tree · FieldList |
|
||||
| File | Covers |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [references/core-display.md](core-display.md) | Badge/Tag/DimensionChip, Card, Code, Collapsible, EmptyState, Icon, Separator, Shortcut, **Text (typography)**, VirtualScroll |
|
||||
| [references/core-controls-table.md](core-controls-table.md) | **Button**/ButtonGroup, Navbar, RefreshButton, Splitter, ToggleGroup, **Table** (+ cell renderers) |
|
||||
| [references/forms-inputs.md](forms-inputs.md) | Input/Textarea, Select, Combobox, SearchInput, Checkbox, Radio, Switch, OptionGroup |
|
||||
| [references/forms-specialized.md](forms-specialized.md) | **Form + useOForm**, Color, Date, DateTimeRange, Time, File, Range, Slider |
|
||||
| [references/forms-validation.md](forms-validation.md) | **Validating a form: OForm + Zod schema, binding rules, submit/loading, conditional rendering, field arrays, testing** |
|
||||
| [references/keyboard-shortcuts.md](keyboard-shortcuts.md) | **Keyboard shortcuts: registry, `useShortcut`/`useShortcuts`, display via `OShortcut`/`shortcut-id`, cheatsheet** |
|
||||
| [references/creating-components.md](creating-components.md) | **Building a NEW O2 component: lib vs components, folder contract, families, headless-first (reka-ui), no-UI-prop-leakage, strict TS, tokens, form wrappers, workflow, testing** |
|
||||
| [references/design-tokens.md](design-tokens.md) | **Design tokens: using `--color-*`, the token files, registering a new token, the `--o2-*` ban + full `--o2-*` → `--color-*` migration map** |
|
||||
| [references/page-recipes.md](page-recipes.md) | **Whole-page layouts: the listing/table page (full-height flush skeleton, mandatory search · refresh · column-visibility toggle, filtered-vs-first-run empty state) and the detail/editor page** |
|
||||
| [references/navigation-menus.md](navigation-menus.md) | **Registering a new page in navigation: the route composables, the left-rail item (`linksList`), Settings/IAM sub-pages (`SectionRail`), hover-flyout children (`navGroups.ts`), and cloud/enterprise/RBAC gating** |
|
||||
| [references/overlay-navigation.md](overlay-navigation.md) | Dialog, Drawer, Dropdown, Popover, Tooltip · Pagination, Stepper, Tabs |
|
||||
| [references/responsive.md](responsive.md) | **Phones & tablets with an unchanged laptop view: breakpoints + `useBreakpoint`, one-row header/toolbar/stat chrome, rails → anchored drawers, row-action kebab, popup sizing, clipping and touch gotchas, verification** |
|
||||
| [references/feedback-data.md](feedback-data.md) | Banner, Toast (+ useToast), Spinner, Skeleton, InnerLoading · ProgressBar, Timeline, Tree · FieldList |
|
||||
|
||||
> **Headless vs form-bound.** Most form controls come as a pair: `OInput`
|
||||
> (headless, `v-model` only) and `OFormInput` (adds label / error / required /
|
||||
@@ -33,57 +33,57 @@ markup — don't guess a prop name.
|
||||
|
||||
### Scenario → component
|
||||
|
||||
| I need to… | Use | Reference |
|
||||
| --- | --- | --- |
|
||||
| Trigger an action (submit, add, open) | `OButton` (`OButtonGroup` for a set) | core-controls-table |
|
||||
| Show a page/module header | `OPageHeader` (rule 1) | — |
|
||||
| Render tabular data | `OTable` + `OTableColumnDef[]` | core-controls-table |
|
||||
| Show a status label / count / tag | `OBadge`, `OTag`, `ODimensionChip` | core-display |
|
||||
| Group content in a surface | `OCard` (+ `OCardSection`, `OCardActions`) | core-display |
|
||||
| Body / heading / muted text | `OText` (pick the `variant`) | core-display |
|
||||
| Show an icon | `OIcon` (`name` from the icon registry) | core-display |
|
||||
| A divider / hairline | `OSeparator` | core-display |
|
||||
| Show code / a code block | `OCode`, `OCodeBlock` | core-display |
|
||||
| Expand/collapse a section | `OCollapsible` | core-display |
|
||||
| Empty "no data" state | `OEmptyState` | core-display |
|
||||
| Render a huge list performantly | `OVirtualScroll` | core-display |
|
||||
| Single-line / multiline text entry | `OInput` / `OTextarea` (`OForm*` in a form) | forms-inputs |
|
||||
| Pick one/many from a fixed list | `OSelect` (+ `OSelectItem`) | forms-inputs |
|
||||
| Typeahead / free-entry combobox | `OCombobox` | forms-inputs |
|
||||
| A search box | `OSearchInput` | forms-inputs |
|
||||
| Boolean checkbox / group | `OCheckbox` / `OCheckboxGroup` | forms-inputs |
|
||||
| Mutually exclusive choice | `ORadioGroup` (few) / `OSelect` (many) | forms-inputs |
|
||||
| Instant on/off toggle | `OSwitch` | forms-inputs |
|
||||
| Segmented control | `OToggleGroup` | core-controls-table |
|
||||
| Filter strip that must fit a phone row | `OToggleGroup mobile-dropdown` | **responsive** |
|
||||
| Side rail / field list on a phone | `ODrawer` with `anchor` (or `OPageLayout #sidebar` / `FolderList`) | **responsive** |
|
||||
| Secondary header actions on a phone | `OPageHeader`/`OPageLayout` `#actions-overflow` | **responsive** |
|
||||
| Build a validated form | `OForm` + Zod schema (binding rules) | **forms-validation** |
|
||||
| Read form state for conditional rendering | `useOForm` (owner) / `inject`+`useStore` (child) | forms-validation |
|
||||
| Add repeatable/dynamic field rows | `z.array` + indexed `name`, `:key="index"` | forms-validation |
|
||||
| Pick a color | `OColor` | forms-specialized |
|
||||
| Pick a date / date-time range / time | `ODate` / `ODateTimeRange` / `OTime` | forms-specialized |
|
||||
| Upload a file | `OFile` | forms-specialized |
|
||||
| Numeric slider / range | `OSlider` / `ORange` | forms-specialized |
|
||||
| Modal task / short form | `ODialog` (see form-container rule) | overlay-navigation |
|
||||
| Side panel / large or contextual form | `ODrawer` | overlay-navigation |
|
||||
| Menu of actions | `ODropdown` (+ `ODropdownItem`) | overlay-navigation |
|
||||
| Freeform floating content | `OPopover` | overlay-navigation |
|
||||
| Hover hint | `OTooltip` | overlay-navigation |
|
||||
| Switch between content sections | `OTabs` (+ `OTab`/`OTabPanel`); `ORouteTab` for route-driven | overlay-navigation |
|
||||
| Multi-step wizard | `OStepper` (+ `OStep`) | overlay-navigation |
|
||||
| Paginate a list | `OPagination` | overlay-navigation |
|
||||
| Persistent inline message | `OBanner` | feedback-data |
|
||||
| Transient notification (replaces `q-notify`) | `useToast()` (+ `OToastProvider` at root) | feedback-data |
|
||||
| Loading state | `OSkeleton` (placeholder) / `OSpinner` (bare) / `OInnerLoading` (over a container) | feedback-data |
|
||||
| Progress indicator | `OProgressBar` | feedback-data |
|
||||
| Event timeline | `OTimeline` (+ `OTimelineItem`) | feedback-data |
|
||||
| Hierarchical / tree data | `OTree` (+ `OTreeNode`) | feedback-data |
|
||||
| Label–value detail rows | `OFieldList` (+ `OFieldRow`, `OFieldLabel`) | feedback-data |
|
||||
| Confirm / destructive prompt | `ConfirmDialog` + `useConfirmDialog` (app-level, `@/components` + `@/composables`) | — |
|
||||
| Add a keyboard shortcut | registry entry + `useShortcuts([{ id, handler }])`; display via `OShortcut`/`shortcut-id` | keyboard-shortcuts |
|
||||
| Build a NEW component (nothing fits) | new `O*` in `web/src/lib` (generic) or named component in `web/src/components` (app-specific) — never inline classes | **creating-components** |
|
||||
| Add a new page to the nav / menu / settings sub-menu | route composable + `linksList` / `navGroups.ts` / `settingsItems`, gated by env/role | **navigation-menus** |
|
||||
| I need to… | Use | Reference |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| Trigger an action (submit, add, open) | `OButton` (`OButtonGroup` for a set) | core-controls-table |
|
||||
| Show a page/module header | `OPageHeader` (rule 1) | — |
|
||||
| Render tabular data | `OTable` + `OTableColumnDef[]` | core-controls-table |
|
||||
| Show a status label / count / tag | `OBadge`, `OTag`, `ODimensionChip` | core-display |
|
||||
| Group content in a surface | `OCard` (+ `OCardSection`, `OCardActions`) | core-display |
|
||||
| Body / heading / muted text | `OText` (pick the `variant`) | core-display |
|
||||
| Show an icon | `OIcon` (`name` from the icon registry) | core-display |
|
||||
| A divider / hairline | `OSeparator` | core-display |
|
||||
| Show code / a code block | `OCode`, `OCodeBlock` | core-display |
|
||||
| Expand/collapse a section | `OCollapsible` | core-display |
|
||||
| Empty "no data" state | `OEmptyState` | core-display |
|
||||
| Render a huge list performantly | `OVirtualScroll` | core-display |
|
||||
| Single-line / multiline text entry | `OInput` / `OTextarea` (`OForm*` in a form) | forms-inputs |
|
||||
| Pick one/many from a fixed list | `OSelect` (+ `OSelectItem`) | forms-inputs |
|
||||
| Typeahead / free-entry combobox | `OCombobox` | forms-inputs |
|
||||
| A search box | `OSearchInput` | forms-inputs |
|
||||
| Boolean checkbox / group | `OCheckbox` / `OCheckboxGroup` | forms-inputs |
|
||||
| Mutually exclusive choice | `ORadioGroup` (few) / `OSelect` (many) | forms-inputs |
|
||||
| Instant on/off toggle | `OSwitch` | forms-inputs |
|
||||
| Segmented control | `OToggleGroup` | core-controls-table |
|
||||
| Filter strip that must fit a phone row | `OToggleGroup mobile-dropdown` | **responsive** |
|
||||
| Side rail / field list on a phone | `ODrawer` with `anchor` (or `OPageLayout #sidebar` / `FolderList`) | **responsive** |
|
||||
| Secondary header actions on a phone | `OPageHeader`/`OPageLayout` `#actions-overflow` | **responsive** |
|
||||
| Build a validated form | `OForm` + Zod schema (binding rules) | **forms-validation** |
|
||||
| Read form state for conditional rendering | `useOForm` (owner) / `inject`+`useStore` (child) | forms-validation |
|
||||
| Add repeatable/dynamic field rows | `z.array` + indexed `name`, `:key="index"` | forms-validation |
|
||||
| Pick a color | `OColor` | forms-specialized |
|
||||
| Pick a date / date-time range / time | `ODate` / `ODateTimeRange` / `OTime` | forms-specialized |
|
||||
| Upload a file | `OFile` | forms-specialized |
|
||||
| Numeric slider / range | `OSlider` / `ORange` | forms-specialized |
|
||||
| Modal task / short form | `ODialog` (see form-container rule) | overlay-navigation |
|
||||
| Side panel / large or contextual form | `ODrawer` | overlay-navigation |
|
||||
| Menu of actions | `ODropdown` (+ `ODropdownItem`) | overlay-navigation |
|
||||
| Freeform floating content | `OPopover` | overlay-navigation |
|
||||
| Hover hint | `OTooltip` | overlay-navigation |
|
||||
| Switch between content sections | `OTabs` (+ `OTab`/`OTabPanel`); `ORouteTab` for route-driven | overlay-navigation |
|
||||
| Multi-step wizard | `OStepper` (+ `OStep`) | overlay-navigation |
|
||||
| Paginate a list | `OPagination` | overlay-navigation |
|
||||
| Persistent inline message | `OBanner` | feedback-data |
|
||||
| Transient notification (replaces `q-notify`) | `useToast()` (+ `OToastProvider` at root) | feedback-data |
|
||||
| Loading state | `OSkeleton` (placeholder) / `OSpinner` (bare) / `OInnerLoading` (over a container) | feedback-data |
|
||||
| Progress indicator | `OProgressBar` | feedback-data |
|
||||
| Event timeline | `OTimeline` (+ `OTimelineItem`) | feedback-data |
|
||||
| Hierarchical / tree data | `OTree` (+ `OTreeNode`) | feedback-data |
|
||||
| Label–value detail rows | `OFieldList` (+ `OFieldRow`, `OFieldLabel`) | feedback-data |
|
||||
| Confirm / destructive prompt | `ConfirmDialog` + `useConfirmDialog` (app-level, `@/components` + `@/composables`) | — |
|
||||
| Add a keyboard shortcut | registry entry + `useShortcuts([{ id, handler }])`; display via `OShortcut`/`shortcut-id` | keyboard-shortcuts |
|
||||
| Build a NEW component (nothing fits) | new `O*` in `web/src/lib` (generic) or named component in `web/src/components` (app-specific) — never inline classes | **creating-components** |
|
||||
| Add a new page to the nav / menu / settings sub-menu | route composable + `linksList` / `navGroups.ts` / `settingsItems`, gated by env/role | **navigation-menus** |
|
||||
|
||||
If a scenario isn't covered by anything above, re-check the reference files
|
||||
before assuming a component is missing — the list is complete as of this skill's
|
||||
|
||||
@@ -33,7 +33,7 @@ and [references/page-recipes.md](page-recipes.md).
|
||||
background, no border). Every card must carry explicit surface classes, e.g.
|
||||
`bg-card-glass-bg rounded-default border border-border-default` (the AI
|
||||
Observability panel family) or `bg-surface-base border border-border-default
|
||||
rounded-default` (synthetics). Copy the class string from the sibling family
|
||||
rounded-default` (synthetics). Copy the class string from the sibling family
|
||||
verbatim.
|
||||
- **A fixed set of stacked panels must not make the page scroll.** When a page
|
||||
is N cards tall (dashboards, signal panels), give it the full-height split:
|
||||
@@ -98,13 +98,14 @@ looking for one to reshape a component, that's the signal you need a new
|
||||
The visual of an O2 component is produced by its own `variant`/`size`, never by
|
||||
patching it from outside. Banned on an O2 component:
|
||||
|
||||
| Banned | Why | Instead |
|
||||
| --- | --- | --- |
|
||||
| `style="color: #fff; padding: 10px"` | hardcoded colors/px, bypasses tokens | use `variant` / `size` / component design |
|
||||
| `class="tw:px-2 tw:text-sm"` (`tw:` prefix) | prefix removed — `tw:*` doesn't resolve | use component `variant`/`size` props |
|
||||
| `<style scoped>` targeting its class | invisible fork of the design system | move the intent into a `variant` |
|
||||
| Banned | Why | Instead |
|
||||
| ------------------------------------------- | --------------------------------------- | ----------------------------------------- |
|
||||
| `style="color: #fff; padding: 10px"` | hardcoded colors/px, bypasses tokens | use `variant` / `size` / component design |
|
||||
| `class="tw:px-2 tw:text-sm"` (`tw:` prefix) | prefix removed — `tw:*` doesn't resolve | use component `variant`/`size` props |
|
||||
| `<style scoped>` targeting its class | invisible fork of the design system | move the intent into a `variant` |
|
||||
|
||||
Decision flow for any visual that differs from the default:
|
||||
|
||||
1. Expressible via `variant` / `size`? → use the prop.
|
||||
2. No variant covers it? → add a new named variant to the component source, then
|
||||
use it.
|
||||
@@ -192,8 +193,8 @@ Decide where it lives:
|
||||
component in **`web/src/components/…`**, itself made of O2 components. Still a
|
||||
real component with a name and props, not inline markup.
|
||||
|
||||
The trigger is simple: *if you're about to write a self-contained UI element —
|
||||
especially one you'd repeat — stop and make it a component.* Inline utility
|
||||
The trigger is simple: _if you're about to write a self-contained UI element —
|
||||
especially one you'd repeat — stop and make it a component._ Inline utility
|
||||
classes are for **layout/composition** of components (flex, grid, gap, spacing),
|
||||
never for reconstructing a component the library should own. This is the same
|
||||
reason a missing **variant** becomes a new variant and a missing **token**
|
||||
@@ -212,8 +213,8 @@ before writing a new `O*` component.
|
||||
|
||||
## Choosing components & structure
|
||||
|
||||
The rules above cover *how* you style. This section covers *what* to reach for
|
||||
and *where the code lives* — the recurring decisions that otherwise get answered
|
||||
The rules above cover _how_ you style. This section covers _what_ to reach for
|
||||
and _where the code lives_ — the recurring decisions that otherwise get answered
|
||||
differently on every screen.
|
||||
|
||||
### Page anatomy & the listing-page recipe
|
||||
@@ -236,8 +237,10 @@ Lay out a whole page from a recipe, not from scratch — full recipes in
|
||||
`:show-global-filter="false"`. Only a search-only list with no other filters
|
||||
uses the built-in `show-global-filter` + `v-model:global-filter`.
|
||||
- **Refresh** — an `OButton variant="outline" size="icon-sm" icon-left="refresh"
|
||||
:loading` in the `#toolbar-trailing` slot, wired to the fetch fn, with a
|
||||
tooltip carrying the `r` shortcut.
|
||||
:loading` in the `#toolbar-trailing` slot, with a tooltip carrying the `r`
|
||||
shortcut. It calls a named handler that **forces** every read on the view
|
||||
(never the plain mount fetch, which serves the cache) — see
|
||||
[data-fetching.md § The refresh rule](data-fetching.md#the-refresh-rule).
|
||||
- **Column show/hide toggle** — `OTable` **auto-injects** it (between `#toolbar`
|
||||
and `#toolbar-trailing`) when `:persist-columns="true"` + a stable `table-id` +
|
||||
at least one non-action column marked `hideable: true` are all present. Omit
|
||||
@@ -281,30 +284,37 @@ of that inconsistently and re-implements pagination you'd then have to retest.
|
||||
For a list/table screen the flow is:
|
||||
|
||||
```
|
||||
view (src/views)
|
||||
→ [optional] composable (src/composables/use*) OR helper (utils/commons.ts)
|
||||
view (src/views) / component
|
||||
→ useQuery / useMutation (or queryClient.fetchQuery for imperative flows)
|
||||
→ declaration (src/services/<domain>.queries.ts + <domain>.querykeys.ts)
|
||||
→ service (src/services/<domain>.ts, via the http.ts wrapper)
|
||||
→ Vuex store (shared/cacheable data) + local refs (ephemeral render data)
|
||||
→ TanStack Query cache (shared server data) + local refs (ephemeral view state)
|
||||
```
|
||||
|
||||
**How.**
|
||||
- **Never call `http`/axios directly from a component.** Fetch through the
|
||||
domain service in `src/services` (e.g. `dashboardService.list(...)`). Get
|
||||
`org_identifier` from `store.state.selectedOrganization` — don't thread it down
|
||||
through props.
|
||||
- **Composables (`use*`)** are optional thin wrappers that inject the org and
|
||||
wrap service calls. Add one when 2+ components share the same fetch/transform;
|
||||
otherwise calling the service straight from the view is the accepted norm here.
|
||||
- **State placement:** genuinely shared/cached org data (streams, dashboards,
|
||||
folders, …) goes in a namespaced Vuex module. Per-screen table rows and column
|
||||
defs stay in local `ref`/`computed` — don't push throwaway view state into
|
||||
Vuex.
|
||||
- **Keep views thin:** fetch + wire only. Heavy transforms belong in the
|
||||
service, composable, or a `utils` helper, not inline in the template's script.
|
||||
**How.** The full contract — keys, tiers, refresh and invalidation rules — is in
|
||||
[data-fetching.md](data-fetching.md). In short:
|
||||
|
||||
**Why.** The layer boundary is what lets a second screen reuse a fetch, lets a
|
||||
test mock a service, and keeps the store from silting up with ephemeral state. A
|
||||
component that calls axios directly can't be reused or tested without a network.
|
||||
- **Never call `http`/axios from a component**, and don't call the domain
|
||||
service for a read either: read it through its declared query
|
||||
(`useQuery(() => thingsQuery(orgId.value))`). Get the org from `useOrgId()`
|
||||
(or `store.state.selectedOrganization` inside an imperative call) — don't
|
||||
thread it down through props.
|
||||
- **Composables (`use*`)** wrap a query when 2+ components share the same
|
||||
read/transform; they read through the declared query too, never a private
|
||||
cache.
|
||||
- **State placement:** shared server data lives in the query cache, **not in
|
||||
Vuex** — a Vuex copy never expires, so the query's freshness tier is never
|
||||
consulted. Per-screen table rows derive from the query with a `computed`;
|
||||
column defs and UI state stay in local `ref`/`computed`.
|
||||
- **Keep views thin:** fetch + wire only. Heavy transforms belong in the
|
||||
query's `queryFn`, a composable, or a `utils` helper, not inline in the
|
||||
template's script.
|
||||
|
||||
**Why.** The layer boundary is what lets a second screen reuse a read (and its
|
||||
cache entry), lets a write expire every screen that shows the data, and lets a
|
||||
test mock a service while exercising the real cache. A component that calls
|
||||
axios directly — or keeps its own copy — can't share, refresh or be tested
|
||||
consistently.
|
||||
|
||||
### The same figure must render identically everywhere
|
||||
|
||||
@@ -321,6 +331,7 @@ English value will diverge the first time someone edits one. Sharing the key is
|
||||
what makes the two surfaces stay in sync.
|
||||
|
||||
**How.**
|
||||
|
||||
- Before adding a formatter, grep `utils/formatters.ts` (and the sibling screen)
|
||||
for one that already exists — `formatEventCount`, `formatSizeFromMB`,
|
||||
`addCommasToNumber`, `formatLargeNumber`, `formatTimeWithSuffix`.
|
||||
@@ -328,7 +339,7 @@ what makes the two surfaces stay in sync.
|
||||
(`t("home.totalDataIngested")` from the Streams page is correct, not a
|
||||
duplicate). A new key is for a genuinely new label.
|
||||
- Match the icon and tone family too when the tiles sit on comparable surfaces —
|
||||
but never copy a *semantic* colour (green/amber/red) onto a non-health number
|
||||
but never copy a _semantic_ colour (green/amber/red) onto a non-health number
|
||||
just to match; that's the one thing worth diverging on.
|
||||
|
||||
### Registering a new page in navigation
|
||||
@@ -342,12 +353,12 @@ shapes, gating expressions, checklists) in
|
||||
|
||||
**How — pick the surface by what the page is:**
|
||||
|
||||
| The page is… | Surface | Edit |
|
||||
| --- | --- | --- |
|
||||
| A top-level product area | plain **left-rail item** | `linksList` in `web/src/layouts/MainLayout.vue` |
|
||||
| One of several related data destinations | **hover-flyout child** under a rail group | `NAV_GROUPS` in `web/src/lib/core/Navbar/navGroups.ts` |
|
||||
| An admin/config screen | **Settings sub-page** (SectionRail) | `settingsItems` in `web/src/components/settings/index.vue` |
|
||||
| An access-control screen | **IAM sub-page** (SectionRail) | `web/src/views/IdentityAccessManagement.vue` |
|
||||
| The page is… | Surface | Edit |
|
||||
| ---------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
|
||||
| A top-level product area | plain **left-rail item** | `linksList` in `web/src/layouts/MainLayout.vue` |
|
||||
| One of several related data destinations | **hover-flyout child** under a rail group | `NAV_GROUPS` in `web/src/lib/core/Navbar/navGroups.ts` |
|
||||
| An admin/config screen | **Settings sub-page** (SectionRail) | `settingsItems` in `web/src/components/settings/index.vue` |
|
||||
| An access-control screen | **IAM sub-page** (SectionRail) | `web/src/views/IdentityAccessManagement.vue` |
|
||||
|
||||
Plus a **route** (lazy `component`, `meta.title`, `beforeEnter: routeGuard`) in the
|
||||
composable that owns the domain, and a **gate** built from `config.isEnterprise` /
|
||||
@@ -370,19 +381,20 @@ without RBAC). A hand-rolled sidebar link bypasses the flyout reshaping, the
|
||||
Match the container to the **weight** of the interaction. This is the
|
||||
established split in the codebase, not a preference:
|
||||
|
||||
| Interaction | Container | Typical size |
|
||||
| --- | --- | --- |
|
||||
| Confirmation / destructive-action prompt | `ConfirmDialog` (`@/components/ConfirmDialog.vue`) + `useConfirmDialog` (`@/composables/useConfirmDialog`) | — |
|
||||
| Short, atomic form — one to a few fields, single decision (rename, clone, add-stream, create-link) | `ODialog` | `sm` / `md` |
|
||||
| Small detail or preview popup | `ODialog` | `sm` / `md` |
|
||||
| Medium-to-large form or config panel needing vertical room, or where seeing page context helps (pipeline node config, schema/regex/query editor) | `ODrawer` (side `right`) | `md` / `lg` or `:width` vw override |
|
||||
| Detail / inspector panel (trace details, job detail) | `ODrawer` | `lg` / `xl` |
|
||||
| Large **primary** multi-section create/edit flow (e.g. Add/Edit Alert) | full **in-page view** swapped with `v-if` — NOT an overlay | — |
|
||||
| Interaction | Container | Typical size |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| Confirmation / destructive-action prompt | `ConfirmDialog` (`@/components/ConfirmDialog.vue`) + `useConfirmDialog` (`@/composables/useConfirmDialog`) | — |
|
||||
| Short, atomic form — one to a few fields, single decision (rename, clone, add-stream, create-link) | `ODialog` | `sm` / `md` |
|
||||
| Small detail or preview popup | `ODialog` | `sm` / `md` |
|
||||
| Medium-to-large form or config panel needing vertical room, or where seeing page context helps (pipeline node config, schema/regex/query editor) | `ODrawer` (side `right`) | `md` / `lg` or `:width` vw override |
|
||||
| Detail / inspector panel (trace details, job detail) | `ODrawer` | `lg` / `xl` |
|
||||
| Large **primary** multi-section create/edit flow (e.g. Add/Edit Alert) | full **in-page view** swapped with `v-if` — NOT an overlay | — |
|
||||
|
||||
Rules of thumb:
|
||||
|
||||
- Reach for `ODialog` first for anything short and modal-by-nature. Reach for
|
||||
`ODrawer` when the form is tall/multi-field or the user benefits from side
|
||||
context. Promote to a full page when it's the screen's *primary* task and has
|
||||
context. Promote to a full page when it's the screen's _primary_ task and has
|
||||
multiple sections.
|
||||
- Don't cram a tall multi-section form into a dialog (it scrolls awkwardly
|
||||
against the viewport), and don't use a drawer for a yes/no confirmation (too
|
||||
@@ -424,13 +436,13 @@ read it before building a form. The non-negotiables:
|
||||
- **Build the payload with explicit keys**, never `{ ...value }` (it leaks
|
||||
`.optional()` helper fields and string-typed numbers — `OFormInput` emits
|
||||
strings, so coerce with `Number(...)` / `z.coerce.number()`).
|
||||
- **Conditional rendering off form state** — the component that *owns* `<OForm>`
|
||||
- **Conditional rendering off form state** — the component that _owns_ `<OForm>`
|
||||
creates it with `useOForm({ defaultValues, schema, onSubmit })` and passes
|
||||
`<OForm :form>`; a child *inside* uses `inject(FORM_CONTEXT_KEY)` +
|
||||
`<OForm :form>`; a child _inside_ uses `inject(FORM_CONTEXT_KEY)` +
|
||||
`form.useStore`. Both read the one form — never mirror it.
|
||||
- **Field arrays** use `z.array` + indexed names (`rows[${i}].key`) and
|
||||
**`:key="index"` — never a `uuid`** (a stable-id key blanks inputs on a mid-list
|
||||
delete). Ship a delete-a-non-last-row test that asserts the *rendered* inputs.
|
||||
delete). Ship a delete-a-non-last-row test that asserts the _rendered_ inputs.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
@@ -450,4 +462,3 @@ The essentials:
|
||||
with a focus check so they don't fire while the user is typing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,10 +19,12 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
---
|
||||
|
||||
### OButton
|
||||
|
||||
**Import:** `@/lib/core/Button/OButton.vue`
|
||||
**Use when:** Any clickable action — form submit, toolbar action, icon button, destructive confirm, CTA. Design is baked into each variant/size; there are no style-override props.
|
||||
**Don't use for:** Navigation between routes — use a router-link/`MenuLink` styled tile. For a segmented on/off choice, use [OToggleGroup](#otogglegroup). For a set of buttons joined into one control, wrap in [OButtonGroup](#obuttongroup).
|
||||
**Key props:**
|
||||
|
||||
- `variant` (default `"primary"`) — one of: `primary`, `secondary`, `outline`, `ghost`, `ghost-primary`, `ghost-muted`, `ghost-subtle`, `ghost-destructive`, `ghost-success`, `ghost-warning`, `ghost-neutral`, `outline-destructive`, `sidebar-toggle`, `panel-collapse`, `sidebar-button`, `destructive`, `ai-gradient`, `on-dark-primary`, `on-dark-ghost`, `warning`, `preview-slack`, `preview-teams`, `preview-email`, `preview-action`, `webinar-dismiss`, `pricing-chip`, `outline-primary`
|
||||
- `size` (default `"md"`) — one of: `xs`, `chip`, `sm`, `sm-action`, `md`, `lg`, `icon`, `icon-xs`, `icon-xs-circle`, `icon-xs-sq`, `icon-chip`, `icon-sm`, `icon-md`, `icon-lg`, `icon-circle`, `icon-circle-sm`, `icon-toolbar`, `icon-panel`, `sidebar-button`, `sm-toolbar`, `chip-12`
|
||||
- `iconLeft` (`IconName`) — renders an OIcon before the label; `#icon-left` slot takes precedence if present
|
||||
@@ -33,31 +35,47 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
- `block` (boolean, default `false`) — full-width flex button
|
||||
- `type` (`"button"` | `"submit"` | `"reset"`, default `"button"`) — only meaningful when `as="button"`
|
||||
- `as` / `asChild` (`PrimitiveProps` from reka-ui) — render as a different element/child
|
||||
**Slots:** `default` (label), `icon-left`, `icon-right`
|
||||
**Emits:** `click` (`MouseEvent`) — suppressed while `disabled` or `loading`
|
||||
**Example:**
|
||||
**Slots:** `default` (label), `icon-left`, `icon-right`
|
||||
**Emits:** `click` (`MouseEvent`) — suppressed while `disabled` or `loading`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OButton variant="primary" size="md" type="submit" :loading="saving" @click="save">
|
||||
<OButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
Save
|
||||
</OButton>
|
||||
|
||||
<OButton variant="ghost-destructive" size="icon-sm" icon-left="delete" @click="remove" />
|
||||
<OButton
|
||||
variant="ghost-destructive"
|
||||
size="icon-sm"
|
||||
icon-left="delete"
|
||||
@click="remove"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** OButtonGroup (joins multiple OButtons into one segmented control)
|
||||
|
||||
---
|
||||
|
||||
### OButtonGroup
|
||||
|
||||
**Import:** `@/lib/core/Button/OButtonGroup.vue`
|
||||
**Use when:** Joining 2+ OButtons into a single control with shared corners and dividers (e.g. a split action, segmented toolbar cluster).
|
||||
**Don't use for:** A single button (use [OButton](#obutton) directly). A mutually-exclusive selector where one option stays "on" — use [OToggleGroup](#otogglegroup) instead.
|
||||
**Key props:**
|
||||
|
||||
- `orientation` (`"horizontal"` | `"vertical"`, default `"horizontal"`)
|
||||
- `align` (`"stretch"` | `"center"` | `"start"` | `"end"`, default `"stretch"`) — cross-axis alignment of children
|
||||
- `radius` (`"sm"` | `"md"` | `"lg"`, default `"md"`) — must match the inner buttons' radius; use `"sm"` when children use `size="chip"` or `size="icon-chip"`
|
||||
**Slots:** `default` (one or more OButton children)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
**Slots:** `default` (one or more OButton children)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OButtonGroup radius="md">
|
||||
<OButton variant="outline" size="sm">Day</OButton>
|
||||
@@ -65,72 +83,96 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
<OButton variant="outline" size="sm">Month</OButton>
|
||||
</OButtonGroup>
|
||||
```
|
||||
|
||||
**Family:** OButton
|
||||
|
||||
---
|
||||
|
||||
### ONavbar
|
||||
|
||||
**Import:** `@/lib/core/Navbar/ONavbar.vue`
|
||||
**Use when:** Rendering the app's left sidebar rail. It reshapes a flat `linksList` into rail entries (plain links, link+flyout groups, pure flyout groups) via `groupNavLinks`, with ArrowUp/ArrowDown/Tab keyboard navigation.
|
||||
**Don't use for:** In-page tab strips or section navs — this is specifically the global left rail. Sub-page flyout rows are rendered by its child [ONavGroup](#onavgroup).
|
||||
**Key props:**
|
||||
|
||||
- `linksList` (`NavItem[]`, required) — flat list of nav items (`{ title, icon, link, name, exact?, display?, hide?, badge? }`)
|
||||
- `miniMode` (boolean, default `false`) — passes `mini` down to each MenuLink
|
||||
- `visible` (boolean, default `true`) — toggles `v-show` on the `<nav>`
|
||||
**Slots:** none (`default?: never`)
|
||||
**Emits:** `menu-hover` (`routePath: string`) — fired on tile mouseenter
|
||||
**Example:**
|
||||
**Slots:** none (`default?: never`)
|
||||
**Emits:** `menu-hover` (`routePath: string`) — fired on tile mouseenter
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ONavbar :links-list="navLinks" :mini-mode="collapsed" @menu-hover="prefetch" />
|
||||
```
|
||||
|
||||
**Family:** ONavGroup (renders the hover flyout tiles inside the rail); `navGroups.ts` supplies `groupNavLinks` + `GATE_PREDICATES`
|
||||
|
||||
---
|
||||
|
||||
### ONavGroup
|
||||
|
||||
**Import:** `@/lib/core/Navbar/ONavGroup.vue`
|
||||
**Use when:** Internally by [ONavbar](#onavbar) to render a rail tile that reveals a hover flyout of sub-pages. Two modes: link+subnav (`parentItem` set — tile navigates AND reveals children on hover) or pure group (no `parentItem` — click pins the flyout). Only one flyout is open at a time (module-scoped coordinator).
|
||||
**Don't use for:** Direct use in feature code — it is an ONavbar building block. Standalone dropdown menus should use the overlay dropdown components.
|
||||
**Key props:**
|
||||
|
||||
- `groupKey` (string, required) — unique key; drives the single-open coordinator
|
||||
- `title` (string, required) / `icon` (string, required)
|
||||
- `children` (`SubnavChild[]`, required) — flyout rows; filtered by `router.hasRoute` and per-item `gate` predicates
|
||||
- `parentItem` (`{ link; title; icon; name }`, optional) — when set, the tile navigates here and the flyout is hover-only
|
||||
**Slots:** none (renders MenuLink tile + teleported flyout internally)
|
||||
**Emits:** none declared (forwards `mouseenter` from ONavbar's binding)
|
||||
**Example:**
|
||||
**Slots:** none (renders MenuLink tile + teleported flyout internally)
|
||||
**Emits:** none declared (forwards `mouseenter` from ONavbar's binding)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<!-- Emitted by ONavbar; not typically hand-authored -->
|
||||
<ONavGroup group-key="data" title="Data" icon="database"
|
||||
:children="dataSubnav" :parent-item="dataItem" />
|
||||
<ONavGroup
|
||||
group-key="data"
|
||||
title="Data"
|
||||
icon="database"
|
||||
:children="dataSubnav"
|
||||
:parent-item="dataItem"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** ONavbar (parent)
|
||||
|
||||
---
|
||||
|
||||
### ORefreshButton
|
||||
|
||||
**Import:** `@/lib/core/RefreshButton/ORefreshButton.vue`
|
||||
**Use when:** A manual "refresh now" control that also shows a staleness dot (green <30s, amber 30s–5min, red >5min) and a live relative-time label ("2m ago") of the last query run.
|
||||
**Don't use for:** A plain icon action with no staleness indicator — use [OButton](#obutton) with `size="icon"`. Auto-refresh interval selection is a separate control.
|
||||
**Key props:**
|
||||
|
||||
- `lastRunAt` (`number | null`, default `null`) — Unix ms timestamp of the last completed query; drives the dot color and relative label
|
||||
- `loading` (boolean, default `false`) — spins the icon, disables the button, forces the idle dot
|
||||
- `disabled` (boolean, default `false`) — disables independently of loading
|
||||
**Slots:** none
|
||||
**Emits:** `click` (`MouseEvent`) — suppressed while `loading` or `disabled`
|
||||
**Example:**
|
||||
**Slots:** none
|
||||
**Emits:** `click` (`MouseEvent`) — suppressed while `loading` or `disabled`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ORefreshButton :last-run-at="lastQueryTs" :loading="isFetching" @click="runQuery" />
|
||||
<ORefreshButton
|
||||
:last-run-at="lastQueryTs"
|
||||
:loading="isFetching"
|
||||
@click="runQuery"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** standalone (wraps OButton internally)
|
||||
|
||||
---
|
||||
|
||||
### OSplitter
|
||||
|
||||
**Import:** `@/lib/core/Splitter/OSplitter.vue`
|
||||
**Use when:** Two resizable panes with a draggable separator (keyboard-accessible: Arrow keys step, Home/End jump to limits). Content goes in the `before` and `after` slots; the split position is `v-model`-bound.
|
||||
**Don't use for:** Static two-column layouts that don't resize (use flex/grid). Note the value unit is configurable via `unit` (`'%'` default or `'px'`), despite the project's general no-`px` rule — this is a measurement value, not a style.
|
||||
**Key props:**
|
||||
|
||||
- `modelValue` (number, required) — split position in `unit`; `v-model`
|
||||
- `horizontal` (boolean, default `false`) — `false` = vertical split (side-by-side, col-resize); `true` = horizontal split (stacked, row-resize)
|
||||
- `unit` (`"px"` | `"%"`, default `"%"`) — unit of `modelValue` and `limits`
|
||||
@@ -139,24 +181,28 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
- `separator` (boolean, default `true`) — set `false` to hide the separator element
|
||||
- `separatorClass` (string) / `separatorStyle` (`StyleValue`) — extra class/style on the separator
|
||||
- `beforeClass` (string) — extra class on the before pane
|
||||
**Slots:** `before`, `after`, `separator` (custom separator content)
|
||||
**Emits:** `update:modelValue` (`value: number`)
|
||||
**Example:**
|
||||
**Slots:** `before`, `after`, `separator` (custom separator content)
|
||||
**Emits:** `update:modelValue` (`value: number`)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSplitter v-model="splitPos" :limits="[20, 80]" class="h-full">
|
||||
<template #before><QueryList /></template>
|
||||
<template #after><ResultPane /></template>
|
||||
</OSplitter>
|
||||
```
|
||||
|
||||
**Family:** standalone (uses the `useResizer` composable)
|
||||
|
||||
---
|
||||
|
||||
### OToggleGroup
|
||||
|
||||
**Import:** `@/lib/core/ToggleGroup/OToggleGroup.vue`
|
||||
**Use when:** A segmented control — a horizontal (or vertical) bar of mutually-exclusive (`type="single"`) or multi-select (`type="multiple"`) pills sharing one track. Ideal for view switches (Logs/Table), density toggles, mode pickers. Built on reka-ui `ToggleGroupRoot`.
|
||||
**Don't use for:** A binary on/off setting — use a switch/checkbox. Joined action buttons that each fire a command — use [OButtonGroup](#obuttongroup). Form-bound toggles — use [OFormToggleGroup](#oformtogglegroup).
|
||||
**Key props:**
|
||||
|
||||
- `type` (`"single"` | `"multiple"`, default `"single"`) — in `single` mode empty/null selections are ignored so one item always stays active
|
||||
- `modelValue` (`AcceptableValue | AcceptableValue[]`) — active value(s); `v-model`
|
||||
- `variant` (`"default"` | `"primary"`, default `"default"`) — use `primary` when the bar sits on a primary-colored surface (e.g. dashboard query bar)
|
||||
@@ -165,49 +211,63 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
- `label` (string) — text label beside the bar; for rich content use the `label` slot
|
||||
- `labelPosition` (`"left"` | `"right"` | `"top"`, default `"left"`)
|
||||
- `mobileDropdown` (boolean, default `false`) — below md a single-select horizontal group renders as a dropdown whose trigger shows the active item. Use it on every toolbar filter strip so it doesn't claim its own phone row. Data-tests: trigger `<group data-test>-dropdown-btn`, items `<item data-test>-item`. `AppTabs` forwards it. See [responsive § Toolbars](responsive.md#toolbars-one-row-filters-become-a-dropdown).
|
||||
**Slots:** `default` (OToggleGroupItem children), `label` (overrides the `label` prop)
|
||||
**Emits:** `update:modelValue` (`AcceptableValue | AcceptableValue[]`)
|
||||
**Example:**
|
||||
**Slots:** `default` (OToggleGroupItem children), `label` (overrides the `label` prop)
|
||||
**Emits:** `update:modelValue` (`AcceptableValue | AcceptableValue[]`)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OToggleGroup v-model="view" label="View">
|
||||
<OToggleGroupItem value="logs" icon-left="list">Logs</OToggleGroupItem>
|
||||
<OToggleGroupItem value="table" icon-left="table">Table</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
```
|
||||
|
||||
**Family:** OToggleGroupItem (children), OFormToggleGroup (form-bound wrapper)
|
||||
|
||||
---
|
||||
|
||||
### OToggleGroupItem
|
||||
|
||||
**Import:** `@/lib/core/ToggleGroup/OToggleGroupItem.vue`
|
||||
**Use when:** Each option inside an [OToggleGroup](#otogglegroup) / [OFormToggleGroup](#oformtogglegroup). Active state shows a white chip with shadow (`data-state=on`).
|
||||
**Don't use for:** Anything outside a ToggleGroup — it relies on reka-ui `ToggleGroupItem` context.
|
||||
**Key props:**
|
||||
|
||||
- `value` (`AcceptableValue`, required) — unique option value
|
||||
- `size` (`"md"` | `"sm"` | `"xs"`, default `"md"`) — `md` full toolbar, `sm` small toolbar, `xs` compact inline
|
||||
- `disabled` (boolean, default `false`) — disables only this item (wrapper keeps `cursor-not-allowed` + tooltip visible)
|
||||
- `tooltip` (string) — hover tooltip (OTooltip); useful to explain a disabled item
|
||||
- `iconLeft` (string) / `iconRight` (string) — OIcon registry names; `icon-left`/`icon-right` slots take precedence
|
||||
**Slots:** `default` (label), `icon-left`, `icon-right`
|
||||
**Emits:** none (selection is handled by the parent ToggleGroupRoot)
|
||||
**Example:**
|
||||
**Slots:** `default` (label), `icon-left`, `icon-right`
|
||||
**Emits:** none (selection is handled by the parent ToggleGroupRoot)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OToggleGroupItem value="json" size="sm" icon-left="code" tooltip="Raw JSON">JSON</OToggleGroupItem>
|
||||
<OToggleGroupItem
|
||||
value="json"
|
||||
size="sm"
|
||||
icon-left="code"
|
||||
tooltip="Raw JSON"
|
||||
>JSON</OToggleGroupItem>
|
||||
```
|
||||
|
||||
**Family:** OToggleGroup (parent)
|
||||
|
||||
---
|
||||
|
||||
### OFormToggleGroup
|
||||
|
||||
**Import:** `@/lib/core/ToggleGroup/OFormToggleGroup.vue`
|
||||
**Use when:** A segmented toggle bar bound to an OForm field. It is the OForm* wrapper around OToggleGroup — auto-binds the active value to a TanStack field by `name` (model-value ← field state, change → `handleChange` + `handleBlur`) and renders field errors below the bar.
|
||||
**Don't use for:** Toggles outside an `<OForm>` (warns in DEV) — use plain [OToggleGroup](#otogglegroup) with your own `v-model`.
|
||||
**Key props:** accepts everything OToggleGroup accepts **except** `modelValue` (auto-bound), plus:
|
||||
|
||||
- `name` (string, required) — field name; must match a key in the parent OForm's `defaultValues`
|
||||
- `type`, `variant`, `orientation`, `disabled`, `label`, `labelPosition` — passed through to OToggleGroup (same defaults)
|
||||
**Slots:** `default` (OToggleGroupItem children), `label`
|
||||
**Emits:** none (changes are pushed into the form field, not emitted)
|
||||
**Example:**
|
||||
**Slots:** `default` (OToggleGroupItem children), `label`
|
||||
**Emits:** none (changes are pushed into the form field, not emitted)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OForm :default-values="{ severity: 'warn' }">
|
||||
<OFormToggleGroup name="severity" label="Severity">
|
||||
@@ -217,11 +277,13 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
</OFormToggleGroup>
|
||||
</OForm>
|
||||
```
|
||||
|
||||
**Family:** OToggleGroup, OToggleGroupItem
|
||||
|
||||
---
|
||||
|
||||
### OTable
|
||||
|
||||
**Import:** `@/lib/core/Table/OTable.vue`
|
||||
**Use when:** Any tabular data grid — CRUD listings, log/trace tables, dashboards. Built on TanStack `@tanstack/vue-table`. Supports client or server pagination/sorting/filtering, selection, row expansion, tree mode, virtual scroll, column resize/reorder/pin/persistence, and prebuilt cell renderers.
|
||||
**Don't use for:** A simple static key/value list or definition list (use plain markup). The `sub-components/` folder (OTableHeader, OTableBodyRow, etc.) is **internal** — never import those directly; drive everything through OTable props/slots.
|
||||
@@ -282,23 +344,31 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
- Highlight: `highlightText`, `highlightFields`
|
||||
|
||||
**`OTableColumnDef` shape:**
|
||||
|
||||
```ts
|
||||
interface OTableColumnDef<TData = any> {
|
||||
id: string; // unique id (also default accessorKey)
|
||||
header: string | Component; // header text or render component
|
||||
accessorKey?: string; // key in the row object
|
||||
accessorFn?: (row: TData) => any; // custom accessor
|
||||
cell?: string | Component; // custom cell renderer
|
||||
size?: number; minSize?: number; maxSize?: number; // width hints (px → rem)
|
||||
sortable?: boolean; filterable?: boolean; resizable?: boolean;
|
||||
pinnable?: boolean; pinned?: "left" | "right" | false;
|
||||
hideable?: boolean; isAction?: boolean; // action col = compact, no data
|
||||
id: string; // unique id (also default accessorKey)
|
||||
header: string | Component; // header text or render component
|
||||
accessorKey?: string; // key in the row object
|
||||
accessorFn?: (row: TData) => any; // custom accessor
|
||||
cell?: string | Component; // custom cell renderer
|
||||
size?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number; // width hints (px → rem)
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
resizable?: boolean;
|
||||
pinnable?: boolean;
|
||||
pinned?: "left" | "right" | false;
|
||||
hideable?: boolean;
|
||||
isAction?: boolean; // action col = compact, no data
|
||||
footer?: string | Component;
|
||||
aggregate?: "sum" | "avg" | "count" | "min" | "max";
|
||||
meta?: {
|
||||
align?: "left" | "center" | "right";
|
||||
headerClass?: string; cellClass?: string;
|
||||
isName?: boolean; // primary name column (weight 500)
|
||||
headerClass?: string;
|
||||
cellClass?: string;
|
||||
isName?: boolean; // primary name column (weight 500)
|
||||
format?: (value: any, row: any) => any;
|
||||
[key: string]: any;
|
||||
};
|
||||
@@ -341,7 +411,7 @@ arrow, the rows re-order, and the order is wrong.
|
||||
silently removes the wrong column as soon as someone inserts one above it. Use
|
||||
`columns.filter((c) => c.id !== "region")`.
|
||||
- **Size a column for its header plus the sort chevron.** The shared `COL.*`
|
||||
presets fit typical *values*, not long labels — `COL.sizeBytes` truncates
|
||||
presets fit typical _values_, not long labels — `COL.sizeBytes` truncates
|
||||
"Compressed Size" to "Compressed Si…". Set an explicit `size` (+ `minSize` floor)
|
||||
when the header is longer than the numbers under it.
|
||||
- **`persistColumns` persists widths as well as visibility**, keyed by `tableId`
|
||||
@@ -353,6 +423,7 @@ arrow, the rows re-order, and the order is wrong.
|
||||
value its own column.
|
||||
|
||||
**Main emits:**
|
||||
|
||||
- Pagination: `update:currentPage`, `update:pageSize`, `pagination-change`
|
||||
- Sorting: `update:sortBy`, `update:sortOrder`, `sort-change`
|
||||
- Filter: `update:globalFilter`, `filter-change`
|
||||
@@ -367,6 +438,7 @@ arrow, the rows re-order, and the order is wrong.
|
||||
**Exposed (template ref):** `table` (TanStack instance), `toggleAllRows`, `clearSelection`, `resetColumnSizes`, `resetColumnOrder`, `resetPersistedColumns`, `scrollToTop`, `getRows`
|
||||
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
@@ -378,9 +450,21 @@ const rows = ref([/* ... */]);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
|
||||
const columns: OTableColumnDef[] = [
|
||||
{ id: "name", header: "Name", accessorKey: "name", sortable: true, meta: { isName: true } },
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
sortable: true,
|
||||
meta: { isName: true },
|
||||
},
|
||||
{ id: "owner", header: "Owner", accessorKey: "owner", cell: OUserCell },
|
||||
{ id: "createdAt", header: "Created", accessorKey: "created_at", cell: OTimeCell, size: 200 },
|
||||
{
|
||||
id: "createdAt",
|
||||
header: "Created",
|
||||
accessorKey: "created_at",
|
||||
cell: OTimeCell,
|
||||
size: 200,
|
||||
},
|
||||
{ id: "actions", header: "", isAction: true, hideable: false },
|
||||
];
|
||||
</script>
|
||||
@@ -398,12 +482,18 @@ const columns: OTableColumnDef[] = [
|
||||
>
|
||||
<template #cell="{ column, row, value }">
|
||||
<template v-if="column.id === 'actions'">
|
||||
<OButton variant="ghost" size="icon-sm" icon-left="edit" @click.stop="edit(row.original)" />
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
icon-left="edit"
|
||||
@click.stop="edit(row.original)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</OTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Family:** cell renderers (below); `OTable.types.ts` exports `OTableColumnDef`, the mode/param types, and `COL`/`TABLE_*` size constants. `sub-components/` is internal.
|
||||
|
||||
---
|
||||
@@ -449,4 +539,4 @@ For renders not covered by prebuilt cells, use a slot template `#cell-{id}`:
|
||||
</OTable>
|
||||
```
|
||||
|
||||
**Important:** Access row data directly (`row.enabled`, `row.status`, `row.id`), not via `row.original`. The `row` object *is* the data record with all properties accessible.
|
||||
**Important:** Access row data directly (`row.enabled`, `row.status`, `row.id`), not via `row.original`. The `row` object _is_ the data record with all properties accessible.
|
||||
|
||||
@@ -23,10 +23,12 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
---
|
||||
|
||||
### OBadge
|
||||
|
||||
**Import:** `@/lib/core/Badge/OBadge.vue`
|
||||
**Use when:** You need the low-level badge renderer with full manual control over variant, size, shape, dot, icon, count, and interactivity. In app code you almost never reach for this directly.
|
||||
**Don't use for:** Application-level badges — use `OTag` instead (it wraps OBadge and adds registry-driven semantic colours/labels). OBadge is used only *inside* OTag.
|
||||
**Don't use for:** Application-level badges — use `OTag` instead (it wraps OBadge and adds registry-driven semantic colours/labels). OBadge is used only _inside_ OTag.
|
||||
**Key props:**
|
||||
|
||||
- `variant` (default `"default"`) — one of: `default`, `primary`, `success`, `warning`, `error`, `default-outline`, `primary-outline`, `success-outline`, `warning-outline`, `error-outline`, `info-outline`, `purple-outline`, `default-soft`, `primary-soft`, `success-soft`, `warning-soft`, `error-soft`, `teal`, `teal-outline`, `teal-soft`, `orange`, `orange-outline`, `orange-soft`, `lime`, `lime-outline`, `lime-soft`, `amber`, `amber-outline`, `amber-soft`, `cyan`, `cyan-outline`, `cyan-soft`, `blue`, `blue-outline`, `blue-soft`, `purple`, `purple-soft`, `indigo`, `indigo-outline`, `indigo-soft`
|
||||
- `size` (`xs` | `sm` | `md` — default `md`)
|
||||
- `shape` (`pill` | `rounded` | `square` — default `pill`).
|
||||
@@ -44,14 +46,17 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** `default` (label), `icon` (custom left content, overrides `icon` prop), `trailing` (custom right content, overrides `count`)
|
||||
**Emits:** `click` (`[e: MouseEvent | KeyboardEvent]`) — only when `clickable`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OBadge variant="success-soft" dot>Active</OBadge>
|
||||
```
|
||||
|
||||
**Family:** Rendered internally by `OTag`. Standalone otherwise.
|
||||
|
||||
---
|
||||
|
||||
### OTag
|
||||
|
||||
**Import:** `@/lib/core/Badge/OTag.vue`
|
||||
**Use when:** This is THE badge for application code. Use it for any status pill, type chip, or label — either semantic (pass `type` + `value` to resolve colour/icon/dot/label from the badge registry) or manual (pass `variant`/slots like OBadge).
|
||||
**Don't use for:** Two-segment `key=value` dimension chips — use `ODimensionChip`. Don't reach past it to `OBadge`.
|
||||
@@ -71,6 +76,7 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
> find yourself writing `<OTag type="x" value="y" size="md" shape="pill">`, the
|
||||
> size/shape belong in group `x` (or a new group), not on the tag.
|
||||
**Key props:**
|
||||
|
||||
- `type` (`BadgeGroupName | string` — registry group e.g. `"alertType"`; omit for a manual badge)
|
||||
- `value` (unknown — raw value resolved against the group)
|
||||
- `size` (`xs` | `sm` | `md` — precedence: prop → registry → `sm`)
|
||||
@@ -91,21 +97,25 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** `default` (label content — wins over `label`), `icon`, `trailing`
|
||||
**Emits:** `click` (`[e: MouseEvent | KeyboardEvent]`)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<!-- semantic: colour + icon resolved from the registry -->
|
||||
<OTag type="alertStatus" value="active" />
|
||||
<!-- manual passthrough -->
|
||||
<OTag variant="primary-soft" :count="12">Steps</OTag>
|
||||
```
|
||||
|
||||
**Family:** Wraps `OBadge`. Sibling `ODimensionChip` for key=value chips.
|
||||
|
||||
---
|
||||
|
||||
### ODimensionChip
|
||||
|
||||
**Import:** `@/lib/core/Badge/ODimensionChip.vue`
|
||||
**Use when:** Displaying a two-segment `key=value` dimension chip (e.g. `service=openobserve`, `k8s-cluster=prod`). Colour is derived from the key via `dimensionVariant()` (exact → substring → stable hash) so the same dimension is the same colour everywhere.
|
||||
**Don't use for:** Single-label status/type chips — use `OTag`. Do not pass `type="dimensionKey"` to OTag for prefixed keys (it only exact-matches).
|
||||
**Key props:**
|
||||
|
||||
- `dimKey` (string, required — drives the colour)
|
||||
- `value` (string | number, required — shown in the bold segment)
|
||||
- `keyLabel` (string — display override for the key segment)
|
||||
@@ -114,14 +124,22 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** none
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ODimensionChip dim-key="k8s-cluster" key-label="cluster" :value="clusterName" tooltip />
|
||||
<ODimensionChip
|
||||
dim-key="k8s-cluster"
|
||||
key-label="cluster"
|
||||
:value="clusterName"
|
||||
tooltip
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Composes `OTag` + `OTooltip`. Standalone at the call site.
|
||||
|
||||
---
|
||||
|
||||
### OCard
|
||||
|
||||
**Import:** `@/lib/core/Card/OCard.vue`
|
||||
**Use when:** You need a flat surface container. Always flat — no elevation/variant props. Compose `OCardSection`, `OSeparator`, and `OCardActions` inside it.
|
||||
**Don't use for:** A raised/modal surface — those are handled by dialog/overlay components. Don't add padding directly; use `OCardSection`.
|
||||
@@ -129,6 +147,7 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** `default` (card content)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCard class="w-full">
|
||||
<OCardSection role="header">Title</OCardSection>
|
||||
@@ -136,55 +155,66 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
<OCardSection role="body">Content</OCardSection>
|
||||
</OCard>
|
||||
```
|
||||
|
||||
**Family:** Use with `OCardSection`, `OCardActions`, and `OSeparator`.
|
||||
|
||||
---
|
||||
|
||||
### OCardSection
|
||||
|
||||
**Import:** `@/lib/core/Card/OCardSection.vue`
|
||||
**Use when:** Defining a semantic zone inside an `OCard`. `role` bundles the correct padding, flex-grow/shrink, and layout for the three standard zones.
|
||||
**Don't use for:** Action button rows — use `OCardActions`. Outside a card, apply your own classes rather than a role.
|
||||
**Key props:**
|
||||
|
||||
- `role` (`header` | `body` | `footer` — omit for a plain unstyled section)
|
||||
- `scrollable` (boolean, default `false` — adds `overflow-y: auto`; only meaningful with `role="body"`)
|
||||
|
||||
**Slots:** `default`
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCardSection role="body" scrollable>
|
||||
<OText variant="body">Scrollable card content…</OText>
|
||||
</OCardSection>
|
||||
```
|
||||
|
||||
**Family:** Child of `OCard`. Sibling `OCardActions`.
|
||||
|
||||
---
|
||||
|
||||
### OCardActions
|
||||
|
||||
**Import:** `@/lib/core/Card/OCardActions.vue`
|
||||
**Use when:** Laying out the action button row (typically `OButton`s) at the bottom of an `OCard`.
|
||||
**Don't use for:** General card content — use `OCardSection`.
|
||||
**Key props:**
|
||||
|
||||
- `align` (`left` | `center` | `right` | `between` — default `right`)
|
||||
|
||||
**Slots:** `default` (action buttons)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCardActions align="between">
|
||||
<OButton variant="ghost">Cancel</OButton>
|
||||
<OButton variant="primary">Save</OButton>
|
||||
</OCardActions>
|
||||
```
|
||||
|
||||
**Family:** Child of `OCard`. Sibling `OCardSection`.
|
||||
|
||||
---
|
||||
|
||||
### OCode
|
||||
|
||||
**Import:** `@/lib/core/Code/OCode.vue`
|
||||
**Use when:** Displaying short monospace code — cron expressions, stream/resource IDs, SQL/PromQL/VRL snippets, config values, paths, terminal commands. Inline chip by default; `block` for a scrollable pre/code.
|
||||
**Don't use for:** Syntax-highlighted multi-line blocks with chrome/masking — use `OCodeBlock`. For non-executable identifiers shown as plain text, prefer `<OText variant="mono">`.
|
||||
**Key props:**
|
||||
|
||||
- `block` (boolean, default `false` — full-width scrollable block vs inline chip)
|
||||
- `copyable` (boolean, default `false` — shows a copy-to-clipboard button)
|
||||
- `truncate` (boolean, default `false` — ellipsis; inline mode only)
|
||||
@@ -192,18 +222,22 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** `default` (code content)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCode copyable>0 0 * * *</OCode>
|
||||
```
|
||||
|
||||
**Family:** Standalone. Sibling `OCodeBlock` for highlighted blocks.
|
||||
|
||||
---
|
||||
|
||||
### OCodeBlock
|
||||
|
||||
**Import:** `@/lib/core/Code/OCodeBlock.vue`
|
||||
**Use when:** Rendering a syntax-highlighted block of code (highlight.js) with a copy button, optional secret masking (Reveal/Hide), and optional window chrome. Copy always copies the raw `code` prop.
|
||||
**Don't use for:** Short inline code or simple non-highlighted blocks — use `OCode`.
|
||||
**Key props:**
|
||||
|
||||
- `code` (string, required — raw code; copy uses this)
|
||||
- `lang` (string — fence language, auto-detected when omitted)
|
||||
- `codeMasked` (string — masked variant shown by default with a Reveal/Hide toggle)
|
||||
@@ -218,18 +252,22 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** `actions` (extra toolbar actions, rendered left of the copy button)
|
||||
**Emits:** `copy` (fired after the raw code is copied)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCodeBlock :code="installCmd" lang="bash" chrome="terminal" @copy="onCopied" />
|
||||
```
|
||||
|
||||
**Family:** Composes `OButton`, `OIcon`, `OTooltip`. Sibling `OCode`.
|
||||
|
||||
---
|
||||
|
||||
### OCollapsible
|
||||
|
||||
**Import:** `@/lib/core/Collapsible/OCollapsible.vue`
|
||||
**Use when:** A show/hide content section with an animated trigger row. Supports uncontrolled (`defaultOpen`), controlled (`v-model`), and accordion (`group`) modes. Two layouts: general content sections and sidebar/config panels.
|
||||
**Don't use for:** Non-collapsing card sections — use `OCardSection`.
|
||||
**Key props:**
|
||||
|
||||
- `label` (string — trigger label when no `#trigger` slot)
|
||||
- `icon` (string — Material or OIcon name before the label)
|
||||
- `caption` (string — secondary text below the label)
|
||||
@@ -242,16 +280,19 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** `default` (body content), `trigger` (custom trigger row, exposes `{ open: boolean }`; hides the built-in chevron)
|
||||
**Emits:** `update:modelValue` (boolean), `open`, `opened`, `close`, `closed`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCollapsible label="Advanced settings" icon="settings" caption="Optional">
|
||||
<OText variant="body">Body content…</OText>
|
||||
</OCollapsible>
|
||||
```
|
||||
|
||||
**Family:** Built on reka-ui `Collapsible*` + `OIcon`; accordion coordination via `useCollapsibleGroup`. Standalone.
|
||||
|
||||
---
|
||||
|
||||
### OEmptyState
|
||||
|
||||
**Import:** `@/lib/core/EmptyState/OEmptyState.vue`
|
||||
**Use when:** The app-wide empty-state primitive for "no data / no results" contexts. Driven by a named `preset` (fills illustration + copy + actions) or by props/slots. Three sizes for the three contexts: full page/section (`hero`), inside a card/panel (`block`), inside a table/dropdown (`inline`).
|
||||
**Don't use for:** Loading/skeleton states, or inline validation errors.
|
||||
@@ -267,6 +308,7 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
> `actionLabel` + `@action` for a simple button. Never a hand-placed button row
|
||||
> or a custom card. So every empty state across the app reads as one system.
|
||||
**Key props:**
|
||||
|
||||
- `preset` (`EmptyStatePresetName` — named scenario from the catalog; see `EmptyState/presets.ts`)
|
||||
- `size` (`hero` | `block` | `inline` — default `block`)
|
||||
- `variant` (`EmptyStateVariant` — tone; defaults from preset, else `neutral`)
|
||||
@@ -285,18 +327,22 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
|
||||
**Slots:** `illustration`, `title`, `description`, `actions`, `extra`
|
||||
**Emits:** `action` (`(id?: string)`), `secondaryAction`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OEmptyState preset="no-search-results" filtered @action="clearFilters" />
|
||||
```
|
||||
|
||||
**Family:** Composes `OButton`, `OIcon`, `EmptyStateActionCard`, illustrations, and presets. Standalone.
|
||||
|
||||
---
|
||||
|
||||
### OIcon
|
||||
|
||||
**Import:** `@/lib/core/Icon/OIcon.vue`
|
||||
**Use when:** Rendering any icon. `name` is an `IconName` from the registry (Material Symbols), or an `img:<path>` string for an external image.
|
||||
**Don't use for:** Decorative illustrations in empty states — those come from `OEmptyState`.
|
||||
**Key props:**
|
||||
|
||||
- `name` (`IconName | string`, required — registry name or `img:<path>`). There are several hundred registry names; discover them in `@/lib/core/Icon/OIcon.icons.ts` (the exported `iconRegistry` / `IconName` type). Do not guess — check the registry.
|
||||
- **A wrong name fails SILENTLY — nothing renders, no error, no warning.** The
|
||||
`| string` in the prop type (needed for `img:<path>`) collapses the union, so
|
||||
@@ -323,36 +369,44 @@ Note: delete/bin icon names render in the destructive (red) colour by default; o
|
||||
**Slots:** `default` (optional — e.g. to co-locate an `OTooltip`)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OIcon name="settings" size="sm" label="Settings" />
|
||||
```
|
||||
|
||||
**Family:** Standalone. Used inside most other core components.
|
||||
|
||||
---
|
||||
|
||||
### OSeparator
|
||||
|
||||
**Import:** `@/lib/core/Separator/OSeparator.vue`
|
||||
**Use when:** Drawing a horizontal or vertical divider line (reka-ui `Separator`, token-coloured).
|
||||
**Don't use for:** Spacing only — use layout/margin utilities.
|
||||
**Key props:**
|
||||
|
||||
- `vertical` (boolean, default `false` — renders a vertical line instead of horizontal)
|
||||
|
||||
**Slots:** none
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSeparator />
|
||||
<OSeparator vertical />
|
||||
```
|
||||
|
||||
**Family:** Commonly used inside `OCard`. Standalone.
|
||||
|
||||
---
|
||||
|
||||
### OShortcut
|
||||
|
||||
**Import:** `@/lib/core/Shortcut/OShortcut.vue`
|
||||
**Use when:** Rendering keyboard shortcut keycaps. Modifier tokens are symbolised and made platform-aware automatically (`ctrl` → `⌘` on Mac / `Ctrl` on Windows, `shift` → `⇧`, `enter` → `↵`, …).
|
||||
**Don't use for:** Displaying arbitrary code/text — use `OCode` or `OText variant="mono"`.
|
||||
**Key props:**
|
||||
|
||||
- `keys` (`string | string[]` — a combo string like `"ctrl+enter"` renders as ONE keycap; an array renders one keycap per element. Optional when `id` is given)
|
||||
- `id` (string — registry shortcut id, resolves keys from `shortcutRegistry.ts`; ignored when `keys` is provided)
|
||||
- `size` (`sm` | `md` — default `sm`)
|
||||
@@ -360,19 +414,23 @@ Note: delete/bin icon names render in the destructive (red) colour by default; o
|
||||
**Slots:** none
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OShortcut keys="ctrl+enter" />
|
||||
<OShortcut :keys="['g', 'l']" size="md" />
|
||||
```
|
||||
|
||||
**Family:** Resolves ids via `shortcutRegistry`. Standalone.
|
||||
|
||||
---
|
||||
|
||||
### OText
|
||||
|
||||
**Import:** `@/lib/core/Typography/OText.vue`
|
||||
**Use when:** The main text component. Pick a `variant` by semantic intent — each maps to a fixed font-size/weight/colour and a default HTML element (overridable via `as`).
|
||||
**Don't use for:** Executable code/query content — use `OCode` (even for the `mono` case where the text is real code).
|
||||
**Key props:**
|
||||
|
||||
- `variant` (default `"body"`) — one of:
|
||||
- `page-title` — 14px medium, page-title colour, `<span>` default (use `as="h1"` for the single page heading)
|
||||
- `section` — ~11.5px medium, section colour, `<h2>` default (group labels/eyebrows)
|
||||
@@ -389,19 +447,23 @@ Note: delete/bin icon names render in the destructive (red) colour by default; o
|
||||
**Slots:** `default`
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OText variant="page-title" as="h1">Dashboards</OText>
|
||||
<OText variant="meta">Updated 2m ago</OText>
|
||||
```
|
||||
|
||||
**Family:** Built on reka-ui `Primitive`. Standalone.
|
||||
|
||||
---
|
||||
|
||||
### OVirtualScroll
|
||||
|
||||
**Import:** `@/lib/core/VirtualScroll/OVirtualScroll.vue`
|
||||
**Use when:** Rendering a long list efficiently by only mounting visible rows. Generic over the item type; supports fixed or dynamic row heights and an internal or external scroll container.
|
||||
**Don't use for:** Short lists that fit on screen — render them directly. For full data tables, use the table component.
|
||||
**Key props:**
|
||||
|
||||
- `items` (`T[]`, required — the full array to virtualize)
|
||||
- `estimateSize` (number, default `40` — estimated per-item height in px)
|
||||
- `overscan` (number, default `5` — extra items rendered above/below the viewport)
|
||||
@@ -413,9 +475,16 @@ Note: delete/bin icon names render in the destructive (red) colour by default; o
|
||||
**Emits:** `virtual-scroll` (`{ startIndex, endIndex, visibleStartIndex, visibleEndIndex }`)
|
||||
**Exposes:** `scrollToIndex`, `scrollToTop`, `measure`, and `measureElement` (when `dynamicRowHeight`)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OVirtualScroll :items="rows" :estimate-size="36" height="24rem" v-slot="{ item }">
|
||||
<OVirtualScroll
|
||||
:items="rows"
|
||||
:estimate-size="36"
|
||||
height="24rem"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<OText variant="body">{{ item.name }}</OText>
|
||||
</OVirtualScroll>
|
||||
```
|
||||
|
||||
**Family:** Backed by `useVirtualScroll`. Standalone.
|
||||
|
||||
@@ -63,12 +63,12 @@ Rules:
|
||||
|
||||
A compound family is one folder with multiple members that share a `provide`/`inject` context. **Build and ship the whole family — never half of one.** Existing families:
|
||||
|
||||
| Family | Members (co-located) |
|
||||
| --- | --- |
|
||||
| Button | `OButton`, `OButtonGroup` |
|
||||
| Tabs | `OTabs`, `OTab`, `ORouteTab`, `OTabPanels`, `OTabPanel` |
|
||||
| Select | `OSelect`, `OSelectItem`, `OSelectGroup` (+ `OFormSelect`) |
|
||||
| Dropdown | `ODropdown` + item/group/separator members |
|
||||
| Family | Members (co-located) |
|
||||
| -------- | ---------------------------------------------------------- |
|
||||
| Button | `OButton`, `OButtonGroup` |
|
||||
| Tabs | `OTabs`, `OTab`, `ORouteTab`, `OTabPanels`, `OTabPanel` |
|
||||
| Select | `OSelect`, `OSelectItem`, `OSelectGroup` (+ `OFormSelect`) |
|
||||
| Dropdown | `ODropdown` + item/group/separator members |
|
||||
|
||||
The shared context (its `InjectionKey`) is defined in the root member's `.types.ts` and provided by the root, injected by children.
|
||||
|
||||
@@ -89,11 +89,11 @@ For ARIA-complex behavior (Dialog, Drawer, Tabs, Select, Popover, Combobox, Drop
|
||||
|
||||
A component bakes its design in. It exposes **intent**, never **appearance**. Only three prop categories are allowed:
|
||||
|
||||
| Category | Examples | Why |
|
||||
| --- | --- | --- |
|
||||
| Semantic **variant** | `variant="primary" \| "ghost" \| "outline"` | communicates intent |
|
||||
| Semantic **size** | `size="sm" \| "md" \| "lg"` | adapts without exposing pixels |
|
||||
| **State** | `disabled`, `loading`, `readonly` | behavioral flags |
|
||||
| Category | Examples | Why |
|
||||
| -------------------- | ------------------------------------------- | ------------------------------ |
|
||||
| Semantic **variant** | `variant="primary" \| "ghost" \| "outline"` | communicates intent |
|
||||
| Semantic **size** | `size="sm" \| "md" \| "lg"` | adapts without exposing pixels |
|
||||
| **State** | `disabled`, `loading`, `readonly` | behavioral flags |
|
||||
|
||||
**Banned props** — anything whose only job is to change a CSS value:
|
||||
|
||||
@@ -109,8 +109,9 @@ Implement variants as a computed class map keyed by the variant/size value (no C
|
||||
|
||||
```ts
|
||||
const variantClasses: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
primary: "bg-button-primary text-button-primary-foreground hover:bg-button-primary-hover",
|
||||
ghost: "bg-transparent text-button-ghost-text hover:bg-button-ghost-hover-bg",
|
||||
primary:
|
||||
"bg-button-primary text-button-primary-foreground hover:bg-button-primary-hover",
|
||||
ghost: "bg-transparent text-button-ghost-text hover:bg-button-ghost-hover-bg",
|
||||
};
|
||||
```
|
||||
|
||||
@@ -127,7 +128,8 @@ const variantClasses: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
|
||||
```ts
|
||||
// OButton.types.ts
|
||||
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "destructive";
|
||||
export type ButtonVariant =
|
||||
"primary" | "secondary" | "outline" | "ghost" | "destructive";
|
||||
export type ButtonSize = "sm" | "md" | "lg" | "icon";
|
||||
|
||||
export interface ButtonProps {
|
||||
@@ -160,12 +162,12 @@ Two-way binding uses the standard `modelValue` prop + `update:modelValue` emit (
|
||||
|
||||
Token files live in `web/src/lib/styles/tokens/`:
|
||||
|
||||
| File | Layer |
|
||||
| --- | --- |
|
||||
| `base.css` | raw palette (`:root`) — the only place literal hex lives; never referenced in components |
|
||||
| `semantic.css` | `@theme inline` — shared meaning (`--color-text-primary`, `--color-surface-panel`, `--color-border-default`) |
|
||||
| `component.css` | `@theme inline` — per-component tokens (`--color-button-primary`) |
|
||||
| `dark.css` | dark overrides under `.dark` |
|
||||
| File | Layer |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `base.css` | raw palette (`:root`) — the only place literal hex lives; never referenced in components |
|
||||
| `semantic.css` | `@theme inline` — shared meaning (`--color-text-primary`, `--color-surface-panel`, `--color-border-default`) |
|
||||
| `component.css` | `@theme inline` — per-component tokens (`--color-button-primary`) |
|
||||
| `dark.css` | dark overrides under `.dark` |
|
||||
|
||||
**Adding a new component token:** define it in `component.css` inside `@theme inline`, referencing a semantic/base token (never a raw value):
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# Data fetching & caching (TanStack Query)
|
||||
|
||||
How a screen reads and writes server data. The app uses **TanStack Query v5**
|
||||
(`@tanstack/vue-query`) with one shared client; every server read a page shows
|
||||
goes through a declared query, and every write declares what it makes stale.
|
||||
Follow this for any new page, panel, dialog or picker that talks to the backend.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [The model](#the-model)
|
||||
- [Where the code goes](#where-the-code-goes)
|
||||
- [Keys](#keys)
|
||||
- [Freshness tiers (by module)](#freshness-tiers-by-module)
|
||||
- [Declaring a read and a write](#declaring-a-read-and-a-write)
|
||||
- [Reading in a component](#reading-in-a-component)
|
||||
- [The refresh rule](#the-refresh-rule)
|
||||
- [Invalidation: what a write must expire](#invalidation-what-a-write-must-expire)
|
||||
- [Anti-patterns (each one shipped as a bug)](#anti-patterns-each-one-shipped-as-a-bug)
|
||||
- [What is never cached](#what-is-never-cached)
|
||||
- [Org switch, logout, errors](#org-switch-logout-errors)
|
||||
- [Testing](#testing)
|
||||
- [Checklist](#checklist)
|
||||
|
||||
---
|
||||
|
||||
## The model
|
||||
|
||||
- **One client:** `web/src/composables/query/queryClient.ts`. Its defaults are
|
||||
the policy: fallback `staleTime` = the live tier, `gcTime` = `GC_TIME` (3 h),
|
||||
**`refetchOnWindowFocus` and `refetchOnReconnect` are off everywhere**,
|
||||
400/401/403/404 and 501 are never retried (other failures retry twice),
|
||||
mutations never retry.
|
||||
- **Memory only.** No declaration has a `persister`. The one disk cache is the
|
||||
dashboard panel-result cache (`usePanelCache` + IndexedDB) and the log
|
||||
field-values database; neither is a pattern to copy.
|
||||
- **Stale time is pull-based.** Nothing fires when an entry turns stale. The next
|
||||
read decides: a fresh entry is served with no request, a stale one is
|
||||
re-fetched. So a screen only sees fresh data if it *asks* on mount — see
|
||||
[Anti-patterns](#anti-patterns-each-one-shipped-as-a-bug).
|
||||
- **Changes made outside this tab never expire this tab's cache** — another tab
|
||||
(every "Create destination/workflow" link opens one), another user, the API,
|
||||
ingestion. The user's recourse is the Refresh button, which is why
|
||||
[the refresh rule](#the-refresh-rule) is strict.
|
||||
|
||||
## Where the code goes
|
||||
|
||||
| File | Holds | Never holds |
|
||||
| --- | --- | --- |
|
||||
| `services/<domain>.ts` | the transport — one function per endpoint, via `http()` | caching, keys, durations |
|
||||
| `services/<domain>.querykeys.ts` | the key factory (`xKeys`) — imports only `orgKey` | the transport (so another domain can import the keys without an import cycle) |
|
||||
| `services/<domain>.queries.ts` | `queryOptions()` reads and `mutationOptions()` writes | component state |
|
||||
| `composables/query/cachePolicy.ts` | **the only durations in the app** | — |
|
||||
| `composables/query/keys.ts` | `orgKey`, `globalKey`, `GLOBAL_SCOPE` | — |
|
||||
| components / composables | `useQuery` / `useMutation` / `queryClient.fetchQuery` calls | `http`/axios calls, bare `staleTime` numbers, hand-made keys |
|
||||
|
||||
Before declaring a new query, **grep `services/*.queries.ts`** — most lists
|
||||
already have one (functions, destinations, templates, folders, streams, users,
|
||||
roles, pipelines, workflows…). Reuse it; a second uncached path to the same list
|
||||
is a bug (it never expires with the rest).
|
||||
|
||||
## Keys
|
||||
|
||||
```ts
|
||||
// services/things.querykeys.ts
|
||||
import { orgKey } from "@/composables/query/keys";
|
||||
|
||||
export const thingKeys = {
|
||||
all: (org: string) => orgKey(org, "things"), // the invalidation scope
|
||||
list: (org: string, folderId: string) => orgKey(org, "things", "list", folderId),
|
||||
detail: (org: string, id: string) => orgKey(org, "things", "detail", id),
|
||||
};
|
||||
```
|
||||
|
||||
- **Every key is rooted at `["org", <id>, …]`** through `orgKey` (or
|
||||
`globalKey` for reads that are not org-scoped, e.g. `/config`). The org-switch
|
||||
and logout purges depend on that shape; a hand-built key opts out silently.
|
||||
- **`all` is what writes drop.** Every other key sits beneath it.
|
||||
- **Put every input that changes the result in the key**: folder, page, sort,
|
||||
filters (`stableFilters(params)` keeps the order stable and drops empty
|
||||
values), search term, time range.
|
||||
- **Time ranges are bucketed:** `quantizeRange(start, end)` in the query
|
||||
factory. A range built from `Date.now()` is new on every mount, so a raw
|
||||
timestamp in a key never hits. Bucket the key; send the exact range in the
|
||||
request when the last minute of data matters (see `workflowRunsQuery`).
|
||||
|
||||
## Freshness tiers (by module)
|
||||
|
||||
A query's `staleTime` comes from the **module** it belongs to, not from how it is
|
||||
read. Every declaration names its tier from `cachePolicy.ts`; never write a
|
||||
number.
|
||||
|
||||
| Tier | Constant | Modules (current declarations) |
|
||||
| --- | --- | --- |
|
||||
| 1 min | `LIVE_STALE_TIME` | alerts (list, detail, history, dependencies), alert sources, anomaly detection, incidents, SLOs, synthetics monitors, enrichment-table job statuses |
|
||||
| 5 min | `MEDIUM_STALE_TIME` | logs & streams (name list, stream page, schema), saved views, traces (DAG, service graph), service correlation (semantic groups, identity config), org summary counts, AI observability (agents, experiments, remote tasks, datasets, queues), online evals |
|
||||
| 1 h | `NORMAL_STALE_TIME` | dashboards (+ annotations), pipelines (+ history), functions, query functions, workflows (folder lists, search, run history), reports, folders, nodes, IAM (users, roles, groups, resources, service accounts), ingestion/RUM tokens, org passcode, cipher keys, org settings, settings KV, alert destinations + templates, regex patterns, model pricing, AI toolsets, synthetics agent tokens |
|
||||
| ∞ | `SESSION_STALE_TIME` | the two `/config` reads |
|
||||
| 0 | `staleTime: 0` | license, org cleanup-task poll (`refetchInterval` while its dialog is open) |
|
||||
|
||||
- A new query joins its module's row. A new module: pick the row whose data
|
||||
changes at the same speed, and name it in the table above.
|
||||
- **Never set `gcTime` on a declaration** — the client owns it. (The panel cache
|
||||
keeps its own `PANEL_GC_TIME`.)
|
||||
- Billing is not cached at all (no declarations; the billing pages call
|
||||
`BillingService` directly).
|
||||
|
||||
## Declaring a read and a write
|
||||
|
||||
```ts
|
||||
// services/things.queries.ts
|
||||
import { mutationOptions, queryOptions } from "@tanstack/vue-query";
|
||||
import things from "./things";
|
||||
import { thingKeys } from "./things.querykeys";
|
||||
import { NORMAL_STALE_TIME } from "@/composables/query/cachePolicy";
|
||||
|
||||
export const thingsQuery = (org: string, folderId: string) =>
|
||||
queryOptions({
|
||||
queryKey: thingKeys.list(org, folderId),
|
||||
queryFn: async (): Promise<Thing[]> => (await things.list(org, folderId)).data?.list ?? [],
|
||||
staleTime: NORMAL_STALE_TIME,
|
||||
});
|
||||
|
||||
export const saveThingMutation = (org: string, isUpdate: () => boolean) =>
|
||||
mutationOptions({
|
||||
mutationFn: (payload: Thing) =>
|
||||
isUpdate() ? things.update(org, payload) : things.create(org, payload),
|
||||
// The form renders its own toasts.
|
||||
meta: { invalidates: [thingKeys.all(org)], silentError: true },
|
||||
});
|
||||
```
|
||||
|
||||
`meta` is applied centrally by the client's `MutationCache` — a component never
|
||||
repeats it:
|
||||
|
||||
| `meta` field | Effect |
|
||||
| --- | --- |
|
||||
| `invalidates: [key…]` | on success, marks those scopes stale (active observers refetch now, the rest on next read). Use the domain's `all` key, plus any **other** domain whose data the write changes. |
|
||||
| `removes: [key…]` | on success, drops those scopes' *inactive* entries — after a delete, so a cached detail can't serve the deleted entity. |
|
||||
| `successMessage` | the success toast. |
|
||||
| `silentError` | the call site renders the failure itself; suppresses the default error toast. 403s are never toasted here (the http interceptor reports them). |
|
||||
|
||||
Unwrap envelopes in `queryFn` (`?.list ?? []`, `Array.isArray(data) ? …`) so every
|
||||
consumer gets the same shape.
|
||||
|
||||
## Reading in a component
|
||||
|
||||
### Preferred: `useQuery`
|
||||
|
||||
```ts
|
||||
const orgId = useOrgId(); // computed org, so the key follows an org switch
|
||||
const thingsList = useQuery(() =>
|
||||
Object.assign(thingsQuery(orgId.value, folderId.value), {
|
||||
// Gate until every key input is resolved, or the first key fires with a placeholder and double-fetches.
|
||||
enabled: !!orgId.value && folderId.value !== undefined,
|
||||
}),
|
||||
);
|
||||
// The rows ARE the query — a computed, never a copy in a ref.
|
||||
const rows = computed(() => thingsList.data.value ?? []);
|
||||
// Top-level consts, because a template only unwraps top-level refs.
|
||||
const loading = thingsList.isPending;
|
||||
const fetching = thingsList.isFetching;
|
||||
const lastUpdatedAt = thingsList.dataUpdatedAt;
|
||||
const forbidden = computed(() => {
|
||||
const e: any = thingsList.error.value;
|
||||
return e?.status === 403 || e?.response?.status === 403;
|
||||
});
|
||||
// Named handler: `@click="thingsList.refetch"` would pass the MouseEvent in.
|
||||
const refreshThings = () => thingsList.refetch();
|
||||
const saveThing = useMutation(() => saveThingMutation(orgId.value, () => isEdit.value));
|
||||
```
|
||||
|
||||
| UI need | Source |
|
||||
| --- | --- |
|
||||
| table skeleton (nothing to show yet) | `isPending` — false whenever cached data exists |
|
||||
| refresh-button spinner | `isFetching` — true for any request, including one with rows on screen |
|
||||
| `ORefreshButton :last-run-at` | `dataUpdatedAt` (the fetch time, not `Date.now()`) |
|
||||
| Refresh click | a named handler calling `refetch()` (always hits the server) **plus** a forced read of every secondary list on the view |
|
||||
| `:forbidden` | the query's `error` status (this list's own request only) |
|
||||
|
||||
`FunctionList.vue` and `TemplateList.vue` are reference list pages.
|
||||
|
||||
If something must react to a reload (rebuild a derived structure), watch
|
||||
`dataUpdatedAt`, not `data`: an unchanged refetch keeps the same object and a
|
||||
value watcher never fires.
|
||||
|
||||
### Imperative: `queryClient.fetchQuery`
|
||||
|
||||
For Options-API components and flows that sequence a read against other work.
|
||||
Same declaration, same cache:
|
||||
|
||||
```ts
|
||||
// Named handler: `@click="load"` would pass the MouseEvent as `force`.
|
||||
const refresh = () => load(true);
|
||||
|
||||
const load = async (force = false) => {
|
||||
const options = thingsQuery(orgId.value, folderId.value);
|
||||
// Stale-while-revalidate: paint what the cache has before the request.
|
||||
const cached = queryClient.getQueryData(options.queryKey);
|
||||
if (cached) rows.value = cached;
|
||||
loading.value = !cached; // skeleton only on a cold read
|
||||
fetching.value = true;
|
||||
try {
|
||||
// Force by invalidating the exact entry — never by passing `staleTime: 0`.
|
||||
if (force) {
|
||||
await queryClient.invalidateQueries({ queryKey: options.queryKey, exact: true, refetchType: "none" });
|
||||
}
|
||||
rows.value = await queryClient.fetchQuery(options);
|
||||
lastUpdatedAt.value = queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
fetching.value = false;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- A list whose key changes under the user (folder switch, cross-folder search)
|
||||
needs a **newest-read guard** (a counter checked after the `await`), so a slow
|
||||
earlier response can't overwrite the current list. `WorkflowsList.vue` is the
|
||||
reference.
|
||||
- Repaint from the cache only when **switching** lists; a reload of the same list
|
||||
keeps the rows on screen (an in-place toggle must not flicker back).
|
||||
- `fetchInto()` (`composables/query/fetchInto.ts`) wraps this pattern for the
|
||||
remaining legacy callers. Don't add new callers — prefer `useQuery`.
|
||||
- Server-paginated tables: page, size, sort and filters go in the key; prefetch
|
||||
the next page with `queryClient.prefetchQuery` (see `LogStream.vue`,
|
||||
`AlertHistory.vue`).
|
||||
|
||||
## The refresh rule
|
||||
|
||||
**A user-initiated refresh reaches the server for everything that view shows.**
|
||||
Everything else reads the cache.
|
||||
|
||||
| Trigger | Reads |
|
||||
| --- | --- |
|
||||
| Refresh button, `r` shortcut, a section/table/picker refresh icon, Retry, an error state's action | **forced** — the list **and every secondary read on the same screen**: counts ("Used by", member counts, usage), summary strips, dropdown options, agent/filter lists |
|
||||
| Page open, revisit, tab switch, route back | cache (`fetchQuery` / `useQuery`) |
|
||||
| Paging, sorting, searching, filtering, date-window change | cache for the new key; the key change is what fetches |
|
||||
| After a write in this tab | the mutation's `invalidates` expires it; a plain read then fetches |
|
||||
|
||||
- **Thread `force` only from the refresh path.** A shared loader that other
|
||||
triggers also call (search, paging, mount) needs a separate flag for the
|
||||
secondary reads, or every page click refetches them (`SessionsList.vue`
|
||||
`forceAgents`).
|
||||
- A child component's refresh icon that emits to a parent must reach a **forced**
|
||||
loader in the parent: `@refresh="load(true)"`, not `@refresh="load"`.
|
||||
- When the page's own list was just re-read, don't force it again for a derived
|
||||
structure — force only the inputs the page doesn't own
|
||||
(`useDependencyGraph().loadGraph(org, ["alerts", "templates"])`).
|
||||
|
||||
## Invalidation: what a write must expire
|
||||
|
||||
- Every write is a `mutationOptions()` with `meta.invalidates` — including
|
||||
writes in dialogs, drawers, import flows and bulk actions.
|
||||
- A write that changes **another** domain's data invalidates that domain too
|
||||
(keys are imported from the other domain's `querykeys` file).
|
||||
- A write whose effect **lands later** (a queued retry, a job, a test run that
|
||||
records history) expires the affected scope again when the page observes it
|
||||
settle, and expires derived lists (run history) at submit.
|
||||
- A side-effect write that isn't a user save but changes rows other screens seed
|
||||
from (e.g. the workflow canvas persisting test badges) still invalidates the
|
||||
domain.
|
||||
- A write still made through a raw service call (not yet a mutation) must call
|
||||
`queryClient.invalidateQueries({ queryKey: xKeys.all(org) })` right after it.
|
||||
|
||||
## Anti-patterns (each one shipped as a bug)
|
||||
|
||||
| Don't | Because | Do |
|
||||
| --- | --- | --- |
|
||||
| `if (!store.state.organizationData.things) await getThings()` — a Vuex copy guarding the fetch | the copy never expires, so the query's `staleTime` is never consulted: a folder another user created never appears | call the loader every time; the cache decides whether it costs a request. No server list lives in Vuex. |
|
||||
| A module-level "load once per org" map | never refreshed until a full reload (a changed destination URL kept showing the old one) | read the shared query (`destinationsQuery(org, "pipeline")`) |
|
||||
| `fetchQuery({ ...opts, staleTime: 0 })` | the passed `staleTime` is stored on the query permanently | `invalidateQueries({ exact: true, refetchType: "none" })`, then `fetchQuery(opts)` |
|
||||
| A raw `Date.now()` range in a key | a new key on every mount — the cache never hits | `quantizeRange()` in the factory |
|
||||
| A reactive key that fires before the folder/org resolves | two requests per mount (placeholder key, then real key) | `enabled` gate on every key input |
|
||||
| Copying `data` into a ref from a watcher without `immediate` | a warm remount paints an empty table | a `computed` over `data` (or `immediate: true`, keyed on `dataUpdatedAt`) |
|
||||
| A `persister:` on a declaration | a restored copy is served without any age check | memory only |
|
||||
| `refetchOnWindowFocus: true` on a query | off globally on purpose (wall displays, expensive endpoints) | a Refresh button |
|
||||
| Calling the service for a list another page already caches | a second, uncached path that never expires with the first | reuse the declared query |
|
||||
| `@click="load"` where `load(force)` / `load(folderId)` | the MouseEvent becomes the argument | a named no-arg handler |
|
||||
| Refresh that forces the main list only | secondary lists stay stale for up to their tier | force every read on the view |
|
||||
|
||||
## What is never cached
|
||||
|
||||
- Log/metric/trace **search** (`search.search`, partitions, streaming/WebSocket
|
||||
search), AI chat streams, single-use URLs and page tokens, GETs that mutate.
|
||||
- **Billing** reads (no declarations exist).
|
||||
- **Read-modify-write dialogs** (e.g. `WorkflowLinkAlertsDialog`) — they must
|
||||
start from the server's copy.
|
||||
- Detail reads that carry a **concurrency token**: `dashboardService.get_Dashboard`
|
||||
(`hash`).
|
||||
- **Editor cold loads** from a deep link (pipeline and workflow editors read
|
||||
fresh before editing). Opening an editor from a list row seeds it from the row.
|
||||
- Single run / execution detail reads (workflow run errors, synthetics run
|
||||
results) and the IAM role editor's per-entity permission lists.
|
||||
- Anything with `staleTime: 0` in the tier table.
|
||||
|
||||
If a new read belongs here, call the service directly and leave a one-line
|
||||
comment saying why it is uncached.
|
||||
|
||||
## Org switch, logout, errors
|
||||
|
||||
- **Org switch** (`MainLayout` → `purgeOrgQueries`): memory is kept — keys are
|
||||
org-rooted, so switching back inside the tier costs nothing — and disk entries
|
||||
for other orgs are dropped. Read the org through `useOrgId()` so reactive keys
|
||||
follow the switch; inside an imperative call `store.state.selectedOrganization.identifier`
|
||||
is fine.
|
||||
- **Logout** (`purgeAllQueries`): everything cleared, memory and disk.
|
||||
- **Errors:** a failed query keeps its previous data. Surface 403 as the table's
|
||||
`:forbidden` from *this* list's own request; mutation errors toast by default
|
||||
unless `silentError`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests run against the **real app `queryClient`** (installed by
|
||||
`web/src/test/unit/helpers/setupTests.ts`, cleared after every test). Mock the
|
||||
**service module** (`overlayServiceMock` from `@/test/unit/helpers/mockService`),
|
||||
never the query.
|
||||
- Assert request counts, not implementation: a revisit/remount → **0** calls; a
|
||||
refresh → **1**; a read after a write → **1**; a secondary list on refresh → **1**.
|
||||
A test for a forced path should fail when the `force` is removed — check it.
|
||||
- Assert write invalidation with
|
||||
`vi.spyOn(queryClient, "invalidateQueries")` and the domain's `all` key.
|
||||
- `ORefreshButton` runs an interval; stub it in specs that call
|
||||
`vi.runAllTimers()`.
|
||||
- **Browser check:** the live client is
|
||||
`document.querySelector("#app").__vue_app__._context.provides.VUE_QUERY_CLIENT`.
|
||||
Record requests with an in-page XHR wrapper, and test a tier boundary by
|
||||
ageing an entry: `qc.setQueryData(key, qc.getQueryData(key), { updatedAt: Date.now() - 61 * 60_000 })`.
|
||||
A plain reload empties the cache — re-install the recorder after one.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Every read the view shows is a declared `queryOptions()` in
|
||||
`services/<domain>.queries.ts` (an existing one if the list is already
|
||||
declared), keyed with `orgKey` and every result-changing input.
|
||||
- [ ] `staleTime` names the module's tier from `cachePolicy.ts`; no `gcTime`,
|
||||
no `persister`, no focus/reconnect refetch.
|
||||
- [ ] Components use `useQuery` (rows as a `computed`, `enabled` gate) or
|
||||
`fetchQuery`; no `http`/axios, no Vuex copy of the list, no load-once map.
|
||||
- [ ] Skeleton from `isPending`, refresh spinner from `isFetching`,
|
||||
`ORefreshButton :last-run-at` from `dataUpdatedAt`.
|
||||
- [ ] Refresh (button, `r`, section icons, Retry) forces **every** read on the
|
||||
view through a named handler; paging/search/mount don't force.
|
||||
- [ ] Every write is a `mutationOptions()` with `meta.invalidates` covering the
|
||||
domain and any other domain it changes; delayed effects expire again when
|
||||
they settle.
|
||||
- [ ] Time ranges in keys go through `quantizeRange`.
|
||||
- [ ] Specs assert 0 requests on revisit and 1 on refresh/after a write.
|
||||
@@ -29,8 +29,13 @@ set. The legacy **`--o2-*`** set is banned (see the bottom of this file).
|
||||
guaranteed to exist and compiles to the **identical** CSS — the arbitrary value
|
||||
is just noisier. The utility name is the token minus the `--color-` prefix:
|
||||
```html
|
||||
<!-- avoid --> <div class="bg-[var(--color-surface-base)] text-[var(--color-text-heading)] border-[var(--color-border-default)]">
|
||||
<!-- prefer --> <div class="bg-surface-base text-text-heading border-border-default">
|
||||
<!-- avoid -->
|
||||
<div
|
||||
class="bg-[var(--color-surface-base)] text-[var(--color-text-heading)] border-[var(--color-border-default)]"
|
||||
></div>
|
||||
|
||||
<!-- prefer -->
|
||||
<div class="bg-surface-base text-text-heading border-border-default"></div>
|
||||
```
|
||||
(`--color-text-heading` → `text-text-heading`, `--color-surface-base` →
|
||||
`bg-surface-base`, `--color-border-default` → `border-border-default`.) This also
|
||||
@@ -65,10 +70,10 @@ set. The legacy **`--o2-*`** set is banned (see the bottom of this file).
|
||||
Two other shapes are banned for the same reason, because each is provably
|
||||
replaceable:
|
||||
|
||||
| banned | write instead |
|
||||
|---|---|
|
||||
| `bg-[var(--color-x)]` · `bg-(--color-x)` | `bg-x` |
|
||||
| `bg-[color-mix(in_srgb,var(--color-x)_12%,transparent)]` | `bg-x/12` — byte-identical |
|
||||
| banned | write instead |
|
||||
| ----------------------------------------------------------- | ------------------------------------------- |
|
||||
| `bg-[var(--color-x)]` · `bg-(--color-x)` | `bg-x` |
|
||||
| `bg-[color-mix(in_srgb,var(--color-x)_12%,transparent)]` | `bg-x/12` — byte-identical |
|
||||
| `bg-[color-mix(in_srgb,var(--color-x)_12%,var(--color-y))]` | name the blend as a token, then `bg-<name>` |
|
||||
|
||||
A mix with `transparent` is only an alpha change, which is exactly what Tailwind's
|
||||
@@ -79,6 +84,7 @@ set. The legacy **`--o2-*`** set is banned (see the bottom of this file).
|
||||
form at all: a gradient stop, a mask stop, a drop-shadow colour, a `calc()`
|
||||
percentage, or a mix whose input is `currentColor` or a runtime-set custom
|
||||
property.
|
||||
|
||||
- **Raw `var(--color-*)` in a `.vue` file is a counted bypass** (`rawVarInComponent`,
|
||||
zero tolerance). It is allowed **only** in the sanctioned residue where no
|
||||
utility can reach: inside `:deep()`, `@keyframes`, `color-mix()`, `calc()`, SVG
|
||||
@@ -106,11 +112,11 @@ set. The legacy **`--o2-*`** set is banned (see the bottom of this file).
|
||||
Radius is a token too, and it has exactly **three** app-facing values. Pick by role;
|
||||
never eyeball an arbitrary radius.
|
||||
|
||||
| Utility | Token | Value | Use for |
|
||||
|---|---|---|---|
|
||||
| `rounded-default` | `--radius-default` | 4px | controls — buttons, inputs, chips, small icon buttons |
|
||||
| `rounded-surface` | `--radius-surface` | 12px | surfaces — dialogs, drawers, cards, panels, the app-shell content area |
|
||||
| `rounded-full` | `--radius-full` | ∞ | pills, avatars, status dots |
|
||||
| Utility | Token | Value | Use for |
|
||||
| ----------------- | ------------------ | ----- | ---------------------------------------------------------------------- |
|
||||
| `rounded-default` | `--radius-default` | 4px | controls — buttons, inputs, chips, small icon buttons |
|
||||
| `rounded-surface` | `--radius-surface` | 12px | surfaces — dialogs, drawers, cards, panels, the app-shell content area |
|
||||
| `rounded-full` | `--radius-full` | ∞ | pills, avatars, status dots |
|
||||
|
||||
Per-corner variants use the same names (`rounded-t-surface`, `rounded-s-default`).
|
||||
**Banned (all CI-enforced):** bare `rounded`, arbitrary `rounded-[10px]`, and the
|
||||
@@ -163,7 +169,7 @@ cd web && node scripts/check-design-consistency.mjs --list
|
||||
- the arbitrary-property form `[background:color-mix(…)]` has no utility prefix, so
|
||||
no category matches it
|
||||
- a `var()` fallback (`var(--color-accent,#3F7994)`) hides the read from the
|
||||
`color-mix` rules *and* the hex rule
|
||||
`color-mix` rules _and_ the hex rule
|
||||
|
||||
A clean run means "no bypasses of the kinds it looks for, in the two file types it
|
||||
walks" — review still matters.
|
||||
@@ -173,12 +179,12 @@ walks" — review still matters.
|
||||
Plain CSS, Tailwind v4, **no SCSS**. They live in
|
||||
`web/src/lib/styles/tokens/` and load in order via `web/src/styles/tailwind.css`:
|
||||
|
||||
| File | Holds |
|
||||
| --- | --- |
|
||||
| `base.css` | raw palette primitives (`--color-grey-*`, `--color-primary-*`, radius, shadow) + `@font-face` — the only place literal hex lives |
|
||||
| `semantic.css` | semantic/intent `--color-*` tokens, light `:root` (e.g. `--color-text-heading`, `--color-surface-base`, `--color-border-default`) |
|
||||
| `component.css` | per-component `--color-*` tokens (e.g. `--color-button-primary`) |
|
||||
| `dark.css` | **all** dark-mode overrides, under `.dark` |
|
||||
| File | Holds |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `base.css` | raw palette primitives (`--color-grey-*`, `--color-primary-*`, radius, shadow) + `@font-face` — the only place literal hex lives |
|
||||
| `semantic.css` | semantic/intent `--color-*` tokens, light `:root` (e.g. `--color-text-heading`, `--color-surface-base`, `--color-border-default`) |
|
||||
| `component.css` | per-component `--color-*` tokens (e.g. `--color-button-primary`) |
|
||||
| `dark.css` | **all** dark-mode overrides, under `.dark` |
|
||||
|
||||
Dark mode binds to the `.dark` class (set by `utils/theme.ts`), via
|
||||
`@custom-variant dark` in `tailwind.css` — **not** the OS media query.
|
||||
@@ -193,20 +199,28 @@ utilities silently break. The new token **must** be a `--color-*` name.
|
||||
base/semantic token, never a raw literal:
|
||||
```css
|
||||
/* semantic.css */
|
||||
:root { --color-surface-raised: var(--color-grey-50); }
|
||||
:root {
|
||||
--color-surface-raised: var(--color-grey-50);
|
||||
}
|
||||
```
|
||||
2. **Register for Tailwind** — re-declare it self-referentially in that same
|
||||
file's `@theme inline { … }` block so Tailwind emits utilities for it. The
|
||||
`inline` keyword is what lets the runtime dark override still win:
|
||||
```css
|
||||
@theme inline { --color-surface-raised: var(--color-surface-raised); }
|
||||
@theme inline {
|
||||
--color-surface-raised: var(--color-surface-raised);
|
||||
}
|
||||
```
|
||||
3. **Dark override** — add the dark value in `dark.css` under the modern selector
|
||||
**only**. A token that already points at a semantic token inherits dark for
|
||||
free and needs no override; add one only for base-palette values that must flip:
|
||||
```css
|
||||
/* dark.css — real selector; NO .dark-theme, NO .body--dark */
|
||||
:root.dark, .dark :root, .dark { --color-surface-raised: var(--color-grey-800); }
|
||||
:root.dark,
|
||||
.dark :root,
|
||||
.dark {
|
||||
--color-surface-raised: var(--color-grey-800);
|
||||
}
|
||||
```
|
||||
|
||||
Then use it as a utility (`bg-surface-raised`) or `var(--color-surface-raised)`.
|
||||
@@ -235,31 +249,31 @@ forbidden:
|
||||
Most legacy tokens map to a same-meaning modern token — the heuristic is simply
|
||||
**`--o2-<meaning>` → `--color-<meaning>`**. Common cases:
|
||||
|
||||
| Legacy `--o2-*` | Modern `--color-*` |
|
||||
| --- | --- |
|
||||
| `--o2-text-heading` | `--color-text-heading` |
|
||||
| `--o2-text-primary` / `--o2-text-4` | `--color-text-heading` |
|
||||
| `--o2-text-body` | `--color-text-body` |
|
||||
| `--o2-text-secondary` / `--o2-text-2` | `--color-text-secondary` |
|
||||
| `--o2-text-caption` / `--o2-text-1` | `--color-text-secondary` |
|
||||
| `--o2-text-label` / `--o2-text-3` | `--color-text-label` |
|
||||
| `--o2-text-muted` | `--color-text-muted` |
|
||||
| `--o2-text-placeholder` | `--color-text-placeholder` |
|
||||
| `--o2-text-link` / `--o2-text-link-hover` | `--color-text-link` / `-hover` |
|
||||
| `--o2-text-inverse` | `--color-text-inverse` |
|
||||
| `--o2-border` / `--o2-border-color` | `--color-border-default` |
|
||||
| `--o2-border-2` | `--color-border-subtle` |
|
||||
| `--o2-border-input` | `--color-input-border` |
|
||||
| `--o2-primary-background` | `--color-surface-base` |
|
||||
| `--o2-secondary-background` | `--color-surface-panel` |
|
||||
| `--o2-muted-background` | `--color-surface-subtle` |
|
||||
| `--o2-card-background` / `--o2-card-bg` | `--color-surface-base` |
|
||||
| `--o2-popover-background` | `--color-surface-overlay` |
|
||||
| `--o2-code-bg` | `--color-code-bg` |
|
||||
| `--o2-primary-btn-bg` | `--color-button-primary` |
|
||||
| `--o2-primary-btn-text` | `--color-button-primary-foreground` |
|
||||
| `--o2-secondary-btn-bg` / `-text` / `-border` | `--color-button-secondary` / `-foreground` / `-border` |
|
||||
| `--o2-hover-accent` / `--o2-interactive-hover` | `--color-interactive-hover-bg` |
|
||||
| Legacy `--o2-*` | Modern `--color-*` |
|
||||
| ---------------------------------------------- | ------------------------------------------------------ |
|
||||
| `--o2-text-heading` | `--color-text-heading` |
|
||||
| `--o2-text-primary` / `--o2-text-4` | `--color-text-heading` |
|
||||
| `--o2-text-body` | `--color-text-body` |
|
||||
| `--o2-text-secondary` / `--o2-text-2` | `--color-text-secondary` |
|
||||
| `--o2-text-caption` / `--o2-text-1` | `--color-text-secondary` |
|
||||
| `--o2-text-label` / `--o2-text-3` | `--color-text-label` |
|
||||
| `--o2-text-muted` | `--color-text-muted` |
|
||||
| `--o2-text-placeholder` | `--color-text-placeholder` |
|
||||
| `--o2-text-link` / `--o2-text-link-hover` | `--color-text-link` / `-hover` |
|
||||
| `--o2-text-inverse` | `--color-text-inverse` |
|
||||
| `--o2-border` / `--o2-border-color` | `--color-border-default` |
|
||||
| `--o2-border-2` | `--color-border-subtle` |
|
||||
| `--o2-border-input` | `--color-input-border` |
|
||||
| `--o2-primary-background` | `--color-surface-base` |
|
||||
| `--o2-secondary-background` | `--color-surface-panel` |
|
||||
| `--o2-muted-background` | `--color-surface-subtle` |
|
||||
| `--o2-card-background` / `--o2-card-bg` | `--color-surface-base` |
|
||||
| `--o2-popover-background` | `--color-surface-overlay` |
|
||||
| `--o2-code-bg` | `--color-code-bg` |
|
||||
| `--o2-primary-btn-bg` | `--color-button-primary` |
|
||||
| `--o2-primary-btn-text` | `--color-button-primary-foreground` |
|
||||
| `--o2-secondary-btn-bg` / `-text` / `-border` | `--color-button-secondary` / `-foreground` / `-border` |
|
||||
| `--o2-hover-accent` / `--o2-interactive-hover` | `--color-interactive-hover-bg` |
|
||||
|
||||
**Authoritative source (always in-tree):** the codemod's machine map is
|
||||
`web/scripts/o2-token-map.json` (`migrate` key — this is what the CI failure message
|
||||
|
||||
@@ -22,15 +22,16 @@ O2 components for loading states, messaging, transient notifications, hierarchic
|
||||
|
||||
Pick by **persistence** and **whether the thing is data that is still loading**:
|
||||
|
||||
| Situation | Use | Why |
|
||||
|---|---|---|
|
||||
| Persistent message that stays inline in the layout (validation summary, config hint, inline error/insight) | **OBanner** | Sits in the flow, does not auto-dismiss, semantic variant colors |
|
||||
| Transient popup confirming an action or reporting a background result ("Saved", "Query failed") | **Toast** (`useToast().toast()`) | Auto-dismisses, floats over app, replaces `q-notify` |
|
||||
| A whole container/panel is loading and you want to dim it with a centered spinner overlay | **OInnerLoading** | Absolute overlay over a `relative` parent; blocks the region |
|
||||
| Content whose *shape* is known but not yet loaded (rows, cards, avatars) | **OSkeleton** | Placeholder that mimics final layout — less jarring than a spinner |
|
||||
| A small, bare inline "working…" indicator (inside a button, next to text, an AI typing state) | **OSpinner** | Just the spinner, no overlay, no layout |
|
||||
| Situation | Use | Why |
|
||||
| ---------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------ |
|
||||
| Persistent message that stays inline in the layout (validation summary, config hint, inline error/insight) | **OBanner** | Sits in the flow, does not auto-dismiss, semantic variant colors |
|
||||
| Transient popup confirming an action or reporting a background result ("Saved", "Query failed") | **Toast** (`useToast().toast()`) | Auto-dismisses, floats over app, replaces `q-notify` |
|
||||
| A whole container/panel is loading and you want to dim it with a centered spinner overlay | **OInnerLoading** | Absolute overlay over a `relative` parent; blocks the region |
|
||||
| Content whose _shape_ is known but not yet loaded (rows, cards, avatars) | **OSkeleton** | Placeholder that mimics final layout — less jarring than a spinner |
|
||||
| A small, bare inline "working…" indicator (inside a button, next to text, an AI typing state) | **OSpinner** | Just the spinner, no overlay, no layout |
|
||||
|
||||
Rules of thumb:
|
||||
|
||||
- **Banner vs Toast**: does the message need to persist and stay tied to a spot in the page? Banner. Is it a fire-and-forget confirmation/error? Toast.
|
||||
- **Skeleton vs Spinner vs InnerLoading**: Skeleton for first-load of structured content; InnerLoading for re-loading an existing container in place; bare Spinner for tiny inline waits.
|
||||
|
||||
@@ -39,10 +40,12 @@ Rules of thumb:
|
||||
## Feedback
|
||||
|
||||
### OBanner
|
||||
|
||||
**Import:** `@/lib/feedback/Banner/OBanner.vue`
|
||||
**Use when:** You need a persistent, inline message block within the page flow — a form-level error summary, an informational note, a success confirmation that stays, or a soft warning/insight.
|
||||
**Don't use for:** Transient "operation succeeded/failed" popups — use [Toast](#toast--otoastprovider--usetoast). Not for loading states.
|
||||
**Key props:**
|
||||
|
||||
- `variant` (`"default" | "info" | "success" | "warning" | "error" | "error-soft"` — default `"default"`). `error` is a solid hard-failure banner; `error-soft` is a tinted, left-accent-bar variant for hints/insights.
|
||||
- `content` (`string`) — message text, used only when the default slot is empty.
|
||||
- `icon` (`string`) — OIcon name shown in the leading icon area.
|
||||
@@ -54,6 +57,7 @@ Rules of thumb:
|
||||
**Slots:** `default` (message body, overrides `content`), `icon` (custom leading icon, overrides `icon` prop), `actions` (buttons/links).
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OBanner
|
||||
variant="error-soft"
|
||||
@@ -66,32 +70,39 @@ Rules of thumb:
|
||||
</template>
|
||||
</OBanner>
|
||||
```
|
||||
|
||||
**Family:** standalone (no sibling parts).
|
||||
|
||||
### OInnerLoading
|
||||
|
||||
**Import:** `@/lib/feedback/InnerLoading/OInnerLoading.vue`
|
||||
**Use when:** An existing container (panel, card, table region) is (re)loading and you want to dim it with a centered spinner + optional label. The overlay is absolutely positioned, so the parent must be `relative`.
|
||||
**Don't use for:** First-time load of content whose skeleton you can mimic — prefer [OSkeleton](#oskeleton). For a bare inline spinner with no overlay, use [OSpinner](#ospinner).
|
||||
**Key props:**
|
||||
|
||||
- `showing` (`boolean`, required) — toggles the overlay (fades in/out over 200ms).
|
||||
- `label` (`string`) — optional text under the spinner; also becomes the `aria-label` (falls back to `"Loading"`).
|
||||
- `size` (`"xs" | "sm" | "md" | "lg" | "xl"` — default `"xs"`) — spinner size (uses the `ring` OSpinner).
|
||||
**Slots:** none.
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
**Slots:** none.
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<div class="relative min-h-40">
|
||||
<OInnerLoading :showing="isFetching" label="Loading results" size="sm" />
|
||||
<ResultsTable :rows="rows" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Family:** wraps [OSpinner](#ospinner) internally.
|
||||
|
||||
### OSkeleton
|
||||
|
||||
**Import:** `@/lib/feedback/Skeleton/OSkeleton.vue`
|
||||
**Use when:** Content shape is known but data hasn't arrived — render one or more skeletons matching the final layout (text lines, avatar circles, card blocks) for first load.
|
||||
**Don't use for:** Re-loading a populated container in place (use [OInnerLoading](#oinnerloading)) or a tiny inline wait (use [OSpinner](#ospinner)).
|
||||
**Key props:**
|
||||
|
||||
- `type` (`"rect" | "circle" | "text"` — default `"rect"`). `rect` = full-width rounded block; `circle` = square aspect, fully rounded (avatars); `text` = full-width line at text height.
|
||||
- `animation` (`"pulse" | "wave" | "none"` — default `"wave"`; note the type's doc-comment says `pulse` but the component default is `wave`). `wave` is a GPU-composited shimmer sweep.
|
||||
|
||||
@@ -99,6 +110,7 @@ Sizing is done with utility classes on the element (it renders a `span`) — set
|
||||
**Slots:** none.
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<div class="flex items-center gap-3">
|
||||
<OSkeleton type="circle" class="size-10" />
|
||||
@@ -108,28 +120,35 @@ Sizing is done with utility classes on the element (it renders a `span`) — set
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Family:** standalone.
|
||||
|
||||
### OSpinner
|
||||
|
||||
**Import:** `@/lib/feedback/Spinner/OSpinner.vue`
|
||||
**Use when:** You need just a spinning indicator — inside a button, beside a line of text, or an AI "typing" state — with no overlay and no layout scaffolding.
|
||||
**Don't use for:** Dimming a whole region (use [OInnerLoading](#oinnerloading)) or placeholdering structured content (use [OSkeleton](#oskeleton)).
|
||||
**Key props:**
|
||||
|
||||
- `variant` (`"ring" | "dots"` — default `"ring"`). `ring` for general loading; `dots` (three bouncing dots) for AI/typing contexts.
|
||||
- `size` (`"xs" | "sm" | "md" | "lg" | "xl"` — default `"md"`).
|
||||
**Slots:** none.
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
**Slots:** none.
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OButton :disabled="saving">
|
||||
<OSpinner v-if="saving" variant="ring" size="xs" />
|
||||
<span>Save</span>
|
||||
</OButton>
|
||||
```
|
||||
|
||||
**Family:** standalone; consumed by [OInnerLoading](#oinnerloading).
|
||||
|
||||
### Toast — OToastProvider + useToast
|
||||
|
||||
**Import:**
|
||||
|
||||
- Provider: `@/lib/feedback/Toast/OToastProvider.vue`
|
||||
- Composable: `import { useToast } from "@/lib/feedback/Toast/useToast"` (also exports a bare `toast` for use outside the Vue tree — services, `main.ts`)
|
||||
|
||||
@@ -137,6 +156,7 @@ Sizing is done with utility classes on the element (it renders a `span`) — set
|
||||
**Don't use for:** Persistent inline messages tied to a spot in the layout — use [OBanner](#obanner).
|
||||
|
||||
**Setup — mount the provider once at the app root.** `OToastProvider` reads a module-level singleton store, so it must appear exactly once near the top of the app; every `toast()` call anywhere renders through it.
|
||||
|
||||
```vue
|
||||
<!-- App.vue (or the root layout), once -->
|
||||
<template>
|
||||
@@ -151,6 +171,7 @@ import OToastProvider from "@/lib/feedback/Toast/OToastProvider.vue";
|
||||
**Firing a toast.** Call `useToast()` inside a component to get `{ toast, toasts, dismissAll }`. `toast(options)` returns a **dismiss function** — call it to close early, or pass a `replacement` toast (used with the `loading` variant to swap in a result).
|
||||
|
||||
**`toast(options)` — key options:**
|
||||
|
||||
- `message` (`string`, required) — plain text only (no HTML). Auto-capitalized.
|
||||
- `variant` (`"success" | "error" | "warning" | "info" | "loading" | "default"` — default `"default"`).
|
||||
- `title` (`string`) — bold line above the message.
|
||||
@@ -166,6 +187,7 @@ import OToastProvider from "@/lib/feedback/Toast/OToastProvider.vue";
|
||||
Duplicate identical open toasts collapse into one record with a count badge (a polling 403 won't stack). `dismissAll()` clears everything (use on org switch / logout).
|
||||
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useToast } from "@/lib/feedback/Toast/useToast";
|
||||
@@ -196,6 +218,7 @@ function copyLink(url: string) {
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Family:** `OToastProvider.vue` (mount once), `OToast.vue` (single toast, rendered internally), `useToast.ts` (public API). You author with the provider + `useToast()`; `OToast` is internal.
|
||||
|
||||
---
|
||||
@@ -203,42 +226,50 @@ function copyLink(url: string) {
|
||||
## Data display
|
||||
|
||||
### OProgressBar
|
||||
|
||||
**Import:** `@/lib/data/ProgressBar/OProgressBar.vue`
|
||||
**Use when:** Showing determinate progress or a proportion (0–1) — upload progress, quota usage, a percentage fill.
|
||||
**Don't use for:** Indeterminate "still working" states — use [OSpinner](#ospinner) or [OInnerLoading](#oinnerloading).
|
||||
**Key props:**
|
||||
|
||||
- `value` (`number`, required) — between 0 and 1; clamped to that range internally.
|
||||
- `variant` (`"default" | "warning" | "danger"` — default `"default"`) — semantic fill color.
|
||||
- `size` (`"xs" | "sm" | "md" | "lg"` — default `"sm"`) — track height.
|
||||
**Slots:** `default` — content rendered inside the filled bar (e.g. a percentage label).
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
**Slots:** `default` — content rendered inside the filled bar (e.g. a percentage label).
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OProgressBar :value="usedRatio" variant="warning" size="md">
|
||||
{{ Math.round(usedRatio * 100) }}%
|
||||
</OProgressBar>
|
||||
```
|
||||
|
||||
**Family:** standalone.
|
||||
|
||||
### OTimeline / OTimelineItem
|
||||
|
||||
**Import:**
|
||||
|
||||
- `@/lib/data/Timeline/OTimeline.vue`
|
||||
- `@/lib/data/Timeline/OTimelineItem.vue`
|
||||
|
||||
**Use when:** Rendering an ordered sequence of events with dots and a connecting vertical line — audit history, deployment steps, activity feeds.
|
||||
**Don't use for:** Hierarchical parent/child data (use [OTree](#otree)) or flat key-value details (use [OFieldList](#fieldlist--ofieldlist--ofieldrow--ofieldlabel)).
|
||||
**Key props:**
|
||||
|
||||
- `OTimeline` — no props; renders an `<ol>` and hides the connector line under the last item automatically.
|
||||
- `OTimelineItem`:
|
||||
- `title` (`string`) — bold header line.
|
||||
- `subtitle` (`string`) — muted secondary line.
|
||||
- `icon` (`string`) — icon inside the dot; resolves an OIcon SVG name if registered, otherwise treated as a `material-icons` ligature (e.g. `"check_circle"`). Omit for a plain filled dot.
|
||||
- `variant` (`"primary" | "success" | "destructive" | "info" | "muted"` — default `"primary"`) — dot color.
|
||||
**Slots:**
|
||||
**Slots:**
|
||||
- `OTimeline`: `default` (the `OTimelineItem`s).
|
||||
- `OTimelineItem`: `default` — extra content below the subtitle row.
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
**Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OTimeline>
|
||||
<OTimelineItem
|
||||
@@ -252,13 +283,16 @@ function copyLink(url: string) {
|
||||
</OTimelineItem>
|
||||
</OTimeline>
|
||||
```
|
||||
|
||||
**Family:** `OTimeline` (list container) + `OTimelineItem` (rows) — always composed together.
|
||||
|
||||
### OTree
|
||||
|
||||
**Import:** `@/lib/data/Tree/OTree.vue`
|
||||
**Use when:** Rendering selectable hierarchical data — a nested folder/field tree with tick checkboxes and expand/collapse, with optional filtering.
|
||||
**Don't use for:** A time-ordered event sequence (use [OTimeline](#otimeline--otimelineitem)) or flat detail rows (use [OFieldList](#fieldlist--ofieldlist--ofieldrow--ofieldlabel)).
|
||||
**Key props:**
|
||||
|
||||
- `nodes` (`TreeNode[]`, required) — each node: `{ label, children?, disabled?, [extra] }`.
|
||||
- `nodeKey` (`string` — default `"label"`) — field used as each node's unique key.
|
||||
- `tickStrategy` (`"leaf"` — default `"leaf"`) — only leaves are tickable; parents show indeterminate when partially ticked and act as tick-all shortcuts.
|
||||
@@ -267,9 +301,10 @@ function copyLink(url: string) {
|
||||
- `filter` (`string` — default `""`) — hides non-matching nodes; parents stay visible if any descendant matches.
|
||||
- `filterMethod` (`(node, filter) => boolean`) — custom predicate; defaults to case-insensitive label substring match.
|
||||
- `defaultExpandAll` (`boolean` — default `false`) — expand every parent on first render.
|
||||
**Slots:** none (renders from `nodes`).
|
||||
**Emits:** `update:ticked` (`TreeNodeKey[]`), `update:expanded` (`TreeNodeKey[]`).
|
||||
**Example:**
|
||||
**Slots:** none (renders from `nodes`).
|
||||
**Emits:** `update:ticked` (`TreeNodeKey[]`), `update:expanded` (`TreeNodeKey[]`).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OTree
|
||||
:nodes="streamFields"
|
||||
@@ -280,6 +315,7 @@ function copyLink(url: string) {
|
||||
@update:ticked="selectedIds = $event"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** `OTree` (public) + `OTreeNode.vue` (internal recursive renderer, not imported directly).
|
||||
|
||||
---
|
||||
@@ -287,7 +323,9 @@ function copyLink(url: string) {
|
||||
## Lists
|
||||
|
||||
### FieldList — OFieldList / OFieldRow / OFieldLabel
|
||||
|
||||
**Import:**
|
||||
|
||||
- `@/lib/lists/FieldList/OFieldList.vue`
|
||||
- `@/lib/lists/FieldList/OFieldRow.vue`
|
||||
- `@/lib/lists/FieldList/OFieldLabel.vue`
|
||||
@@ -296,23 +334,26 @@ function copyLink(url: string) {
|
||||
**Don't use for:** Hierarchical selection (use [OTree](#otree)) or a small fixed set of label-value details where you don't need search/pagination — there you can use `OFieldRow` + `OFieldLabel` on their own.
|
||||
|
||||
**How the three compose:**
|
||||
|
||||
- **`OFieldList`** is the container: search box, scroll body, pagination, group headers, drag/expand plumbing. For each field it renders a default row consisting of **`OFieldRow`** wrapping **`OFieldLabel`** — but you'll usually override the `field-row` slot to compose your own row.
|
||||
- **`OFieldRow`** is a single hover-highlight row shell that reveals an `actions` slot on hover.
|
||||
- **`OFieldLabel`** renders one field's type icon + truncated label with a tooltip when truncated.
|
||||
|
||||
**Key props:**
|
||||
|
||||
- `OFieldList` — `fields` (`FieldItem[]`, required; `FieldItem = { name, type?, isGroup?, groupName?, ... }`), `search` (default `""`), `searchPlaceholder` (default `"Search fields"`), `loading` (default `false`), `currentPage` (default `1`), `pageSize` (default `50`), `pageSizeOptions` (default `[50, 100, 250]`), `rowKey` (default `"name"`), `showSearch` (default `true`), `showPagination` (default `true`), `expandedIds` (default `[]`), `draggable` (default `false`), `dragEnabledFn` (`(row, index) => boolean`), `sortFn` (`(a, b) => number`).
|
||||
- `OFieldRow` — `highlight` (`boolean`) — force the hover-highlight background on.
|
||||
- `OFieldLabel` — `field` (`{ name, label?, type? }`, required), `showTypeIcon` (`boolean` — default `false`).
|
||||
**Slots:**
|
||||
**Slots:**
|
||||
- `OFieldList`: `before-list`, `after-list` (receives pagination helpers: `currentPage, pageSize, totalPages, totalRows, isFirstPage, isLastPage, setPageSize, firstPage, prevPage, nextPage, lastPage`), `field-row` (`{ row, index, draggable, isDragEnabled }`), `field-actions` (`{ row, index }`), `group-header` (`{ row, groupName }`), `expansion` (`{ row }`), `empty`, `loading`.
|
||||
- `OFieldRow`: `default` (row content), `actions` (revealed on hover).
|
||||
- `OFieldLabel`: none.
|
||||
**Emits:**
|
||||
**Emits:**
|
||||
- `OFieldList`: `update:search`, `update:currentPage`, `update:expandedIds`, `row-click` (`row, MouseEvent`), `row-dblclick` (`row, MouseEvent`), `scroll-end` (`scrollInfo`), `drag-start` (`row, DragEvent`), `drag-end` (`row, DragEvent`).
|
||||
- `OFieldRow` / `OFieldLabel`: none.
|
||||
`OFieldList` exposes `scrollToTop()` via template ref.
|
||||
**Example:**
|
||||
`OFieldList` exposes `scrollToTop()` via template ref.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFieldList
|
||||
:fields="fields"
|
||||
@@ -333,4 +374,5 @@ function copyLink(url: string) {
|
||||
<template #empty>No fields match your search</template>
|
||||
</OFieldList>
|
||||
```
|
||||
|
||||
**Family:** `OFieldList` (container) + `OFieldRow` (row shell) + `OFieldLabel` (icon + label) — ship and compose together.
|
||||
|
||||
@@ -37,6 +37,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
## Input family
|
||||
|
||||
### OInput
|
||||
|
||||
**Import:** `@/lib/forms/Input/OInput.vue`
|
||||
**Use when:** A single-line (or, via `type="textarea"`, multi-line) free-text field — text, password, email, number, url, tel, search — with optional prefix/suffix, icons, clear button, mask, debounce, and character counter.
|
||||
**Don't use for:** Choosing from a fixed option list (use `OSelect`); typeahead over a suggestion list where free text is also allowed (use `OCombobox`); a pure filter/search box in a toolbar (use `OSearchInput`, which wraps this with a search icon).
|
||||
@@ -44,6 +45,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `icon-left`, `icon-right`, `prefix`, `suffix`, `tooltip` (put a tooltip element inside — renders an info icon in the label). (`append` is also consumed in template.)
|
||||
**Emits:** `update:modelValue`, `clear`, `blur`, `focus`, `keydown`, `keyup`, `keypress`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OInput
|
||||
v-model="host"
|
||||
@@ -55,9 +57,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
:error-message="hostError"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Siblings `OTextarea` (dedicated multi-line), `OFormInput` (form wrapper). Headless — pair with `OFormInput` inside `<OForm>`.
|
||||
|
||||
### OTextarea
|
||||
|
||||
**Import:** `@/lib/forms/Input/OTextarea.vue`
|
||||
**Use when:** A dedicated multi-line text field where you want first-class textarea props like `rows`, `autogrow`, or `fill` (stretch to parent height in a bounded flex container).
|
||||
**Don't use for:** Single-line input (use `OInput`). Note `OInput` can also render a textarea via `type="textarea"`; prefer `OTextarea` when you need `fill` or a textarea-only API surface.
|
||||
@@ -65,12 +69,15 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `append` (block below the border), `tooltip` (info icon in label)
|
||||
**Emits:** `update:modelValue`, `blur`, `focus`, `keydown`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OTextarea v-model="description" label="Description" :rows="5" autogrow />
|
||||
```
|
||||
|
||||
**Family:** Siblings `OInput`, `OFormTextarea` (form wrapper). Headless.
|
||||
|
||||
### OFormInput
|
||||
|
||||
**Import:** `@/lib/forms/Input/OFormInput.vue`
|
||||
**Use when:** An `OInput` field inside `<OForm>` — value and validation errors bind automatically from the form field named by `name`.
|
||||
**Don't use for:** Standalone inputs with no `<OForm>` ancestor (use `OInput` + your own `v-model`).
|
||||
@@ -78,14 +85,17 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** forwards `icon-left`, `icon-right`, `prefix`, `suffix`, `tooltip`, `append` to `OInput`.
|
||||
**Emits:** none you wire yourself — internally emits `update:modelValue`/`blur` into the form field.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OForm :default-values="{ email: '' }" ...>
|
||||
<OFormInput name="email" type="email" label="Email" required />
|
||||
</OForm>
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `OInput`. Sibling wrapper `OFormTextarea`.
|
||||
|
||||
### OFormTextarea
|
||||
|
||||
**Import:** `@/lib/forms/Input/OFormTextarea.vue`
|
||||
**Use when:** An `OTextarea` field inside `<OForm>` with auto-bound value and validation.
|
||||
**Don't use for:** Standalone multi-line input (use `OTextarea`).
|
||||
@@ -93,9 +103,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** none forwarded (wraps `OTextarea` directly).
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormTextarea name="notes" label="Notes" :rows="4" autogrow />
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `OTextarea`.
|
||||
|
||||
---
|
||||
@@ -103,6 +115,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
## Select family
|
||||
|
||||
### OSelect
|
||||
|
||||
**Import:** `@/lib/forms/Select/OSelect.vue`
|
||||
**Use when:** Choosing one or many values from a **known option list** — single or `multiple`, optionally `searchable` (built-in filter), `creatable`, grouped, virtualized, with chips, "Select All", and per-option icons.
|
||||
**Don't use for:** Free-text entry with suggestions where the typed text itself is the value (use `OCombobox`); a plain text field (use `OInput`); a small set of always-visible mutually-exclusive/multi options laid out inline (use `OOptionGroup` / `ORadioGroup` / `OCheckboxGroup`).
|
||||
@@ -110,6 +123,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `default` (render `OSelectItem`/`OSelectGroup`), `trigger` (`{ value }`), `chip` (`{ label, value }`), `empty`, `before-options`, `after-options`, `icon-left`, `tooltip` (OSelect has `icon-left` only — no `icon-right`)
|
||||
**Emits:** `update:modelValue`, `clear`, `search`, `create`, `open`, `close`, `blur`, `change`, `keydown`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSelect
|
||||
v-model="stream"
|
||||
@@ -120,9 +134,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
clearable
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Siblings `OSelectItem`, `OSelectGroup` (slot children), `OFormSelect` (form wrapper). Headless.
|
||||
|
||||
### OSelectItem
|
||||
|
||||
**Import:** `@/lib/forms/Select/OSelectItem.vue`
|
||||
**Use when:** Declaring a single custom option inside `OSelect`'s default slot — when you need custom label markup instead of the flat `options` array.
|
||||
**Don't use for:** Simple flat option lists (pass `options` to `OSelect` instead); grouping headers (wrap items in `OSelectGroup`).
|
||||
@@ -130,15 +146,18 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `default` (custom option content — overrides `label`)
|
||||
**Emits:** none (selection is driven by parent `OSelect`)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSelect v-model="pick">
|
||||
<OSelectItem :value="1" label="One" />
|
||||
<OSelectItem :value="2"><b>Two</b></OSelectItem>
|
||||
</OSelect>
|
||||
```
|
||||
|
||||
**Family:** Child of `OSelect`; sibling `OSelectGroup`. Headless building block (no form wrapper of its own).
|
||||
|
||||
### OSelectGroup
|
||||
|
||||
**Import:** `@/lib/forms/Select/OSelectGroup.vue`
|
||||
**Use when:** Grouping `OSelectItem`s under a visible heading inside `OSelect`'s default slot.
|
||||
**Don't use for:** Flat lists (use `options`); a single option (use `OSelectItem`).
|
||||
@@ -146,6 +165,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `default` (the `OSelectItem` nodes)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSelect v-model="pick">
|
||||
<OSelectGroup label="Fruit">
|
||||
@@ -153,9 +173,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
</OSelectGroup>
|
||||
</OSelect>
|
||||
```
|
||||
|
||||
**Family:** Child of `OSelect`; sibling `OSelectItem`. Headless building block.
|
||||
|
||||
### OFormSelect
|
||||
|
||||
**Import:** `@/lib/forms/Select/OFormSelect.vue`
|
||||
**Use when:** An `OSelect` inside `<OForm>` — value and validation bind automatically.
|
||||
**Don't use for:** Standalone selects (use `OSelect`).
|
||||
@@ -163,9 +185,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** forwards `default`, `empty`, `tooltip` to `OSelect`.
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormSelect name="stream" :options="streamOptions" label="Stream" required />
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `OSelect`.
|
||||
|
||||
---
|
||||
@@ -173,6 +197,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
## Combobox family
|
||||
|
||||
### OCombobox
|
||||
|
||||
**Import:** `@/lib/forms/Combobox/OCombobox.vue`
|
||||
**Use when:** A **typeahead text input with a suggestion dropdown** where the bound value is the **typed string** (free entry allowed) and picking a suggestion fills/transforms the text. Supports a `searchRegex` to extract a needle from the input and a `valueReplaceFn` to transform the chosen option before emit. This is the O2 replacement for `CommonAutoComplete`.
|
||||
**Don't use for:** Selecting from a fixed set where only listed values are valid (use `OSelect` — it emits the option's value, not free text, and supports multiple/groups/chips). Difference from `OSelect`: **OCombobox's model is the input string and free text is allowed**; **OSelect's model is a chosen option value from the list** and there is no free-text value (its `searchable`/`creatable` still commit list values or explicit created entries).
|
||||
@@ -180,6 +205,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label` (replaces `label` prop), `tooltip` (info icon next to label)
|
||||
**Emits:** `update:modelValue` (`string`), `select` (`string` — fired when an option is chosen)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCombobox
|
||||
v-model="query"
|
||||
@@ -188,9 +214,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
placeholder="Type or pick a field"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Sibling `OFormCombobox` (form wrapper). Headless.
|
||||
|
||||
### OFormCombobox
|
||||
|
||||
**Import:** `@/lib/forms/Combobox/OFormCombobox.vue`
|
||||
**Use when:** An `OCombobox` inside `<OForm>` with auto-bound value and validation.
|
||||
**Don't use for:** Standalone typeahead (use `OCombobox`); fixed-list form selection (use `OFormSelect`).
|
||||
@@ -198,9 +226,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label`, `tooltip` (forwarded).
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormCombobox name="field" :items="fieldSuggestions" label="Field" />
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `OCombobox`.
|
||||
|
||||
---
|
||||
@@ -208,6 +238,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
## SearchInput
|
||||
|
||||
### OSearchInput
|
||||
|
||||
**Import:** `@/lib/forms/SearchInput/OSearchInput.vue`
|
||||
**Use when:** A compact filter/search box (list filters, toolbar search) — an `OInput` preset with a search icon, clearable by default, and an extra `xs` size. Value is a plain string, no label/error surface.
|
||||
**Don't use for:** A general text field with label/validation (use `OInput`); selecting from options (use `OSelect`); typeahead suggestions (use `OCombobox`).
|
||||
@@ -215,9 +246,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** none
|
||||
**Emits:** `update:modelValue`, `clear`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSearchInput v-model="filter" placeholder="Filter streams..." />
|
||||
```
|
||||
|
||||
**Family:** No form wrapper — it wraps `OInput` internally. Standalone/headless only.
|
||||
|
||||
---
|
||||
@@ -225,6 +258,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
## Checkbox family
|
||||
|
||||
### OCheckbox
|
||||
|
||||
**Import:** `@/lib/forms/Checkbox/OCheckbox.vue`
|
||||
**Use when:** A single boolean (or tri-state `indeterminate`) checkbox, or a member of an `OCheckboxGroup` (set `value`/`val` and it reads the group's checked array). Supports custom `trueValue`/`falseValue`/`indeterminateValue`.
|
||||
**Don't use for:** A set of related checkboxes with a shared array model (wrap them in `OCheckboxGroup`, or use `OOptionGroup type="checkbox"` for a list-driven layout); an on/off toggle switch (use `OSwitch`).
|
||||
@@ -232,12 +266,15 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label` (custom label content — overrides `label` prop)
|
||||
**Emits:** `update:modelValue`, `change` (emits the item value when inside a group)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCheckbox v-model="agree" label="I agree to the terms" />
|
||||
```
|
||||
|
||||
**Family:** Siblings `OCheckboxGroup`, `OFormCheckbox`, `OFormCheckboxGroup`. Headless.
|
||||
|
||||
### OCheckboxGroup
|
||||
|
||||
**Import:** `@/lib/forms/Checkbox/OCheckboxGroup.vue`
|
||||
**Use when:** Managing several `OCheckbox` children as one array-valued selection — it `provide()`s a group context so each child with a `value` toggles into/out of the shared `modelValue` array.
|
||||
**Don't use for:** A single boolean (use `OCheckbox`); a compact list-driven multi-select laid out from an options array (use `OOptionGroup type="checkbox"`).
|
||||
@@ -245,15 +282,18 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `default` (the `OCheckbox` children)
|
||||
**Emits:** `update:modelValue` (the new checked-values array)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OCheckboxGroup v-model="selected">
|
||||
<OCheckbox value="logs" label="Logs" />
|
||||
<OCheckbox value="metrics" label="Metrics" />
|
||||
</OCheckboxGroup>
|
||||
```
|
||||
|
||||
**Family:** Parent of `OCheckbox`; siblings `OFormCheckbox`, `OFormCheckboxGroup`. Headless.
|
||||
|
||||
### OFormCheckbox
|
||||
|
||||
**Import:** `@/lib/forms/Checkbox/OFormCheckbox.vue`
|
||||
**Use when:** A single boolean checkbox bound to one `<OForm>` field.
|
||||
**Don't use for:** A multi-value checkbox set in a form (use `OFormCheckboxGroup`); standalone (use `OCheckbox`).
|
||||
@@ -261,12 +301,15 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label` (per `OCheckbox`).
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormCheckbox name="acceptTos" label="Accept terms" />
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `OCheckbox`.
|
||||
|
||||
### OFormCheckboxGroup
|
||||
|
||||
**Import:** `@/lib/forms/Checkbox/OFormCheckboxGroup.vue`
|
||||
**Use when:** An array-valued checkbox group bound to one `<OForm>` field (unlike the other OForm wrappers this extends the full `CheckboxGroupProps` — `modelValue` still comes from the form).
|
||||
**Don't use for:** A single boolean (use `OFormCheckbox`).
|
||||
@@ -274,12 +317,14 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `default` (the `OCheckbox` children).
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormCheckboxGroup name="channels">
|
||||
<OCheckbox value="email" label="Email" />
|
||||
<OCheckbox value="slack" label="Slack" />
|
||||
</OFormCheckboxGroup>
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `OCheckboxGroup`.
|
||||
|
||||
---
|
||||
@@ -287,6 +332,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
## Radio family
|
||||
|
||||
### ORadio
|
||||
|
||||
**Import:** `@/lib/forms/Radio/ORadio.vue`
|
||||
**Use when:** A single radio button representing one `value` — **must** be used inside `ORadioGroup` (the group provides the RadioGroupRoot context and value map).
|
||||
**Don't use for:** Standalone use outside a group (it has no own model); a multi-select (use checkboxes); a compact options-array layout (use `OOptionGroup type="radio"`).
|
||||
@@ -294,15 +340,18 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label` (custom label content)
|
||||
**Emits:** none (selection flows through the parent `ORadioGroup`)
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ORadioGroup v-model="mode">
|
||||
<ORadio value="auto" label="Auto" />
|
||||
<ORadio value="manual" label="Manual" />
|
||||
</ORadioGroup>
|
||||
```
|
||||
|
||||
**Family:** Child of `ORadioGroup`. Headless building block.
|
||||
|
||||
### ORadioGroup
|
||||
|
||||
**Import:** `@/lib/forms/Radio/ORadioGroup.vue`
|
||||
**Use when:** A single-choice group of `ORadio` children with one selected value; controls orientation and shared name.
|
||||
**Don't use for:** Multi-select (use `OCheckboxGroup`); a list-driven layout from an options array (use `OOptionGroup type="radio"`).
|
||||
@@ -310,15 +359,18 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `default` (the `ORadio` children)
|
||||
**Emits:** `update:modelValue`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ORadioGroup v-model="mode" orientation="horizontal" label="Mode">
|
||||
<ORadio value="auto" label="Auto" />
|
||||
<ORadio value="manual" label="Manual" />
|
||||
</ORadioGroup>
|
||||
```
|
||||
|
||||
**Family:** Parent of `ORadio`; sibling `OFormRadioGroup`. Headless.
|
||||
|
||||
### OFormRadioGroup
|
||||
|
||||
**Import:** `@/lib/forms/Radio/OFormRadioGroup.vue`
|
||||
**Use when:** A single-choice radio group bound to one `<OForm>` field.
|
||||
**Don't use for:** Standalone radios (use `ORadioGroup`).
|
||||
@@ -326,12 +378,14 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `default` (the `ORadio` children).
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormRadioGroup name="mode" label="Mode">
|
||||
<ORadio value="auto" label="Auto" />
|
||||
<ORadio value="manual" label="Manual" />
|
||||
</OFormRadioGroup>
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `ORadioGroup`.
|
||||
|
||||
---
|
||||
@@ -339,6 +393,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
## Switch family
|
||||
|
||||
### OSwitch
|
||||
|
||||
**Import:** `@/lib/forms/Switch/OSwitch.vue`
|
||||
**Use when:** An on/off toggle — instant enable/disable of a setting. Supports custom `checkedValue`/`uncheckedValue` and label placement.
|
||||
**Don't use for:** A checkbox in a list/form validation context (use `OCheckbox`); choosing among 3+ options (use `ORadioGroup`/`OOptionGroup`).
|
||||
@@ -346,12 +401,15 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label` (custom label content — overrides prop), `tooltip` (info icon inline in label)
|
||||
**Emits:** `update:modelValue`, `change`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSwitch v-model="enabled" label="Enable alerts" />
|
||||
```
|
||||
|
||||
**Family:** Sibling `OFormSwitch`. Headless.
|
||||
|
||||
### OFormSwitch
|
||||
|
||||
**Import:** `@/lib/forms/Switch/OFormSwitch.vue`
|
||||
**Use when:** An on/off toggle bound to one `<OForm>` field.
|
||||
**Don't use for:** Standalone toggles (use `OSwitch`).
|
||||
@@ -359,9 +417,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label`, `tooltip` (per `OSwitch`).
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormSwitch name="notifications" label="Email notifications" />
|
||||
```
|
||||
|
||||
**Family:** Form wrapper for `OSwitch`.
|
||||
|
||||
---
|
||||
@@ -371,6 +431,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**What OptionGroup is:** `OOptionGroup` is a **single component that renders an inline group of radios OR checkboxes from an `options` array** (switch via `type`), with a shared label, help text, and error surface. Contrast with `ORadioGroup`/`OCheckboxGroup`, which are containers you fill with individual `ORadio`/`OCheckbox` **child elements** in the slot. Use `OOptionGroup` when the choices come from a data array and you want a compact, list-driven layout with built-in label/help/error; use the Radio/Checkbox groups when you need custom per-item markup as slot children.
|
||||
|
||||
### OOptionGroup
|
||||
|
||||
**Import:** `@/lib/forms/OptionGroup/OOptionGroup.vue`
|
||||
**Use when:** Rendering a set of mutually-exclusive (`type="radio"`) or multi-select (`type="checkbox"`) choices from an options array, inline (horizontal/vertical), with label + help text + error styling.
|
||||
**Don't use for:** Choices needing custom child markup (use `ORadioGroup`/`OCheckboxGroup` with slot children); a dropdown of many options (use `OSelect`); a single toggle (use `OSwitch`/`OCheckbox`).
|
||||
@@ -378,6 +439,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label` (custom label content), `tooltip` (info icon in label row)
|
||||
**Emits:** `update:modelValue`, `change`
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OOptionGroup
|
||||
v-model="severity"
|
||||
@@ -389,9 +451,11 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
label="Severity"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Sibling `OFormOptionGroup`. Headless.
|
||||
|
||||
### OFormOptionGroup
|
||||
|
||||
**Import:** `@/lib/forms/OptionGroup/OFormOptionGroup.vue`
|
||||
**Use when:** An option group (radio or checkbox) bound to one `<OForm>` field.
|
||||
**Don't use for:** Standalone option groups (use `OOptionGroup`).
|
||||
@@ -399,6 +463,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
|
||||
**Slots:** `label`, `tooltip` (per `OOptionGroup`).
|
||||
**Emits:** none consumer-facing (binds into the form field).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormOptionGroup
|
||||
name="severity"
|
||||
@@ -418,7 +483,7 @@ When no O2 component fits your use case, **build a reusable control** instead of
|
||||
2. **Build the headless version first** — a plain component that owns its value via `v-model` (props `modelValue`, emits `update:modelValue`). No form knowledge.
|
||||
3. **Add the form wrapper** if it will go inside `<OForm>` — a sibling component that `inject(FORM_CONTEXT_KEY)`, renders the headless version inside a `<Field :name="...">`, and wires validation/value/blur to the field.
|
||||
|
||||
**Illustrative example — a `SecretInput` control** *(hypothetical: there is no `SecretInput` in this repo; it only shows the shape of a headless control you'd build)*
|
||||
**Illustrative example — a `SecretInput` control** _(hypothetical: there is no `SecretInput` in this repo; it only shows the shape of a headless control you'd build)_
|
||||
|
||||
Say you need a field that accepts a secret token with reveal/copy controls and nothing in O2 fits. The headless version owns its value via `v-model` and has no form knowledge:
|
||||
|
||||
@@ -495,6 +560,7 @@ const copyToClipboard = async () => {
|
||||
```
|
||||
|
||||
**To use outside a form:**
|
||||
|
||||
```vue
|
||||
<SecretInput
|
||||
:model-value="authToken"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
The `OForm` container + `useOForm` composable own field registration, validation timing, and submit; each specialized control ships as a headless `OX.vue` (bind `v-model` yourself) and a form-bound `OFormX.vue` wrapper (reads/writes the field by `name`, renders label/error/required for you).
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [OForm + useOForm](#oform--useoform) — container & validation (read first)
|
||||
- [OColor](#ocolor) — hex color picker
|
||||
- [OEmojiPicker](#oemojipicker) — curated emoji picker
|
||||
@@ -26,10 +27,12 @@ The `OForm` container + `useOForm` composable own field registration, validation
|
||||
**Submit:** `OForm` **awaits** your `@submit` handler, so TanStack's `isSubmitting` stays true for the whole save. That drives the Save spinner automatically (an `ODialog`/`ODrawer` footer button linked by `form-id`, via the injected `FORM_SUBMIT_STATE_KEY`; or an inline button through the default slot's `isSubmitting`/`canSubmit` scope). Double-submit (e.g. Enter while saving) is guarded.
|
||||
|
||||
### OForm
|
||||
|
||||
**Import:** `@/lib/forms/Form/OForm.vue`
|
||||
**Use when:** Wrapping any set of `OForm*` fields that validate and submit together.
|
||||
**Don't use for:** A single unmanaged input with no validation/submit — use the headless control (`OInput`, `OColor`, …) with your own `v-model` instead.
|
||||
**Key props:**
|
||||
|
||||
- `defaultValues` (`T` — required on the normal path; supplied via `useOForm()` on the headless `:form` path) — initial values for every field; keys must match each field's `name`
|
||||
- `schema` (`unknown` — Zod / Standard Schema; optional) — validates the whole form, mapped to fields by `name`
|
||||
- `onSubmit` (`(values: T) => unknown | Promise<unknown>`) — written `@submit="handler"` in templates; awaited
|
||||
@@ -42,12 +45,14 @@ The `OForm` container + `useOForm` composable own field registration, validation
|
||||
**Family:** Container for all `OForm*` field wrappers; pairs with `useOForm`.
|
||||
|
||||
### useOForm
|
||||
|
||||
**Import:** `import { useOForm } from "@/lib/forms/Form/useOForm"`
|
||||
**Use when:** The component that **owns** `<OForm>` needs the form state reactively at `setup()` — e.g. a discriminated form whose visible sections depend on a `kind` field (`v-if` / `v-for` / `OStepper`). Create the form in setup, read it with `form.useStore(selector)`, write it with `form.setFieldValue(...)`, then hand it to `<OForm :form="form">`.
|
||||
**Don't use for:** A field **inside** `<OForm>` (a descendant) — it should `inject(FORM_CONTEXT_KEY)` instead. Simple forms with no parent-side conditional rendering need only `<OForm :default-values :schema @submit>` (OForm calls `useOForm` internally with the identical config).
|
||||
**Signature:** `useOForm<T>({ defaultValues: T, schema?: unknown, onSubmit?: (values: T) => unknown | Promise<unknown> })` → a TanStack form instance (`OFormInstance`). The config is guaranteed identical to OForm's internal fallback (same submit-then-change timing, single `onDynamic` source, awaited `onSubmit`).
|
||||
|
||||
**End-to-end example (simple path — OForm owns the form):**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { z } from "zod";
|
||||
@@ -89,6 +94,7 @@ async function onSubmit(values: { name: string; email: string }) {
|
||||
```
|
||||
|
||||
**Headless path (owner needs reactive state at setup):**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useOForm } from "@/lib/forms/Form/useOForm";
|
||||
@@ -96,7 +102,9 @@ import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
|
||||
const form = useOForm({
|
||||
defaultValues: { kind: "http", url: "" },
|
||||
onSubmit: async (v) => { await save(v); },
|
||||
onSubmit: async (v) => {
|
||||
await save(v);
|
||||
},
|
||||
});
|
||||
// read reactively to drive parent-side v-if
|
||||
const kind = form.useStore((s) => s.values.kind);
|
||||
@@ -115,6 +123,7 @@ const kind = form.useStore((s) => s.values.kind);
|
||||
## OColor
|
||||
|
||||
### OColor
|
||||
|
||||
**Import:** `@/lib/forms/Color/OColor.vue`
|
||||
**Use when:** Picking a single `#RRGGBB` hex color — a swatch opens a Reka saturation/brightness + hue popover, with an always-visible hex text input.
|
||||
**Don't use for:** A choice among a fixed palette of named colors — use `OSelect`/`OOptionGroup`. Inside an `OForm`, use `OFormColor`.
|
||||
@@ -122,17 +131,21 @@ const kind = form.useStore((s) => s.values.kind);
|
||||
**Slots:** `label`, `tooltip` (renders an info icon in the label row).
|
||||
**Emits:** `update:modelValue` (string), `change` (string), `clear`, `blur` (FocusEvent), `focus` (FocusEvent).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OColor v-model="brandColor" label="Brand color" clearable />
|
||||
```
|
||||
|
||||
**Family:** Headless sibling `OColor`; form-bound wrapper `OFormColor` (`@/lib/forms/Color/OFormColor.vue`).
|
||||
|
||||
### OFormColor
|
||||
|
||||
**Import:** `@/lib/forms/Color/OFormColor.vue`
|
||||
**Use when:** A hex color field inside `<OForm>`.
|
||||
**Key props:** `name` (`string`, required — matches a `defaultValues` key) plus every `OColor` prop except `modelValue` (value + error come from the form).
|
||||
**Slots:** `label`. **Emits:** none (writes through `field.handleChange` / `field.handleBlur`).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormColor name="color" label="Series color" required />
|
||||
```
|
||||
@@ -179,6 +192,7 @@ The picker's value is an **icon token**, not plain text — either a Unicode emo
|
||||
## ODate
|
||||
|
||||
### ODate
|
||||
|
||||
**Import:** `@/lib/forms/Date/ODate.vue`
|
||||
**Use when:** Selecting a single calendar date as `YYYY-MM-DD`.
|
||||
**Don't use for:** A start/end date span — use [ODateRangeCalendar](#odaterangecalendar) or [ODateTimeRange](#odatetimerange). A date **and** time-of-day range — use [ODateTimeRange](#odatetimerange). Inside an `OForm`, use `OFormDate`.
|
||||
@@ -186,17 +200,21 @@ The picker's value is an **icon token**, not plain text — either a Unicode emo
|
||||
**Slots:** `label`, `tooltip`.
|
||||
**Emits:** `update:modelValue` (string), `change` (string), `clear`, `blur`, `focus`.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ODate v-model="startDate" label="Start date" :max="today" clearable />
|
||||
```
|
||||
|
||||
**Family:** Headless sibling `ODate`; form-bound wrapper `OFormDate`. For a range, see the DateTimeRange family below.
|
||||
|
||||
### OFormDate
|
||||
|
||||
**Import:** `@/lib/forms/Date/OFormDate.vue`
|
||||
**Use when:** A single-date field inside `<OForm>`.
|
||||
**Key props:** `name` (`string`, required) plus every `ODate` prop except `modelValue`.
|
||||
**Slots:** `label`. **Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormDate name="expiresOn" label="Expires on" :min="today" required />
|
||||
```
|
||||
@@ -206,6 +224,7 @@ The picker's value is an **icon token**, not plain text — either a Unicode emo
|
||||
## OTime
|
||||
|
||||
### OTime
|
||||
|
||||
**Import:** `@/lib/forms/Time/OTime.vue`
|
||||
**Use when:** Selecting a time of day as `HH:MM` (or `HH:MM:SS` with `withSeconds`).
|
||||
**Don't use for:** A full date+time — pair with `ODate` or use `ODateTimeRange`. Inside an `OForm`, use `OFormTime`.
|
||||
@@ -213,17 +232,21 @@ The picker's value is an **icon token**, not plain text — either a Unicode emo
|
||||
**Slots:** `label`, `tooltip`.
|
||||
**Emits:** `update:modelValue` (string), `change` (string), `clear`, `blur`, `focus`.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OTime v-model="runAt" label="Run at" with-seconds />
|
||||
```
|
||||
|
||||
**Family:** Headless sibling `OTime`; form-bound wrapper `OFormTime`.
|
||||
|
||||
### OFormTime
|
||||
|
||||
**Import:** `@/lib/forms/Time/OFormTime.vue`
|
||||
**Use when:** A time field inside `<OForm>`.
|
||||
**Key props:** `name` (`string`, required) plus every `OTime` prop except `modelValue`.
|
||||
**Slots:** `label`. **Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormTime name="startTime" label="Start time" required />
|
||||
```
|
||||
@@ -239,6 +262,7 @@ Three distinct components — do not confuse them:
|
||||
- **`OFormDateTimeRange`** — the form-bound wrapper. **It does not wrap `ODateTimeRange`;** it wraps the legacy composite `@/components/DateTime.vue` (which takes its value via once-read `default-*` props and emits `on:date-change`). The wrapper seeds those props from the field, translates each change into the form's `{ type, period, from, to }` timerange object via `field.handleChange`, and remounts the inner picker (via `:key`) only when the field value changes from an external source (form reset / async prefill).
|
||||
|
||||
### ODateTimeRange
|
||||
|
||||
**Import:** `@/lib/forms/DateTimeRange/ODateTimeRange.vue`
|
||||
**Use when:** A standalone date-time range picker offering both relative ("last 30 minutes") and absolute (start/end date+time) selection, outside a form.
|
||||
**Don't use for:** A single date (`ODate`) or a date-only range with no time/relative modes (`ODateRangeCalendar`). Inside an `OForm`, use `OFormDateTimeRange`.
|
||||
@@ -246,6 +270,7 @@ Three distinct components — do not confuse them:
|
||||
**Slots:** `label`, `tooltip`.
|
||||
**Emits:** `update:startDate`, `update:startTime`, `update:endDate`, `update:endTime`, `update:mode`, `update:timezone`, `update:relativeUnit`, `update:relativeAmount`, and `change` (the consolidated `DateTimeRangeValue` union). Note: no single `v-model` — bind the individual `update:*` events (or use `v-model:startDate` etc.).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ODateTimeRange
|
||||
v-model:start-date="startDate"
|
||||
@@ -258,9 +283,11 @@ Three distinct components — do not confuse them:
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Standalone headless picker; date-only sibling `ODateRangeCalendar`; form-bound wrapper `OFormDateTimeRange` (wraps legacy DateTime, not this component).
|
||||
|
||||
### ODateRangeCalendar
|
||||
|
||||
**Import:** `@/lib/forms/DateTimeRange/ODateRangeCalendar.vue`
|
||||
**Use when:** Embedding a bare dual-month date-range calendar (no time, no popover) — e.g. inline in a custom filter panel.
|
||||
**Don't use for:** Anything needing time-of-day, relative ranges, or a popover trigger — use `ODateTimeRange`. A single date — use `ODate`.
|
||||
@@ -268,6 +295,7 @@ Three distinct components — do not confuse them:
|
||||
**Slots:** none.
|
||||
**Emits:** `update:startDate` (`YYYY/MM/DD`), `update:endDate` (`YYYY/MM/DD` — fired when the range is completed).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ODateRangeCalendar
|
||||
v-model:start-date="from"
|
||||
@@ -275,15 +303,18 @@ Three distinct components — do not confuse them:
|
||||
:max-date="today"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Low-level calendar; the full picker is `ODateTimeRange`.
|
||||
|
||||
### OFormDateTimeRange
|
||||
|
||||
**Import:** `@/lib/forms/DateTime/OFormDateTimeRange.vue`
|
||||
**Use when:** A timerange field inside `<OForm>` whose value is the app's `{ type, period, from, to }` shape (backed by the legacy `@/components/DateTime.vue` picker).
|
||||
**Don't use for:** A standalone (non-form) range picker — use `ODateTimeRange`. Note the path lives under `forms/DateTime/`, not `forms/DateTimeRange/`.
|
||||
**Key props:** `name` (`string`, required — supports nested/indexed paths like `dashboards[0].timerange`), `label` (`string` — DateTime has no label of its own), `required` (`boolean` — renders the `*`), `description` (`string` — helper line under the label). All other DateTime attrs pass through via `$attrs`.
|
||||
**Slots:** none. **Emits:** none (writes the timerange object through `field.handleChange`). Errors are validated cross-field in the form schema, not rendered inline.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormDateTimeRange name="timerange" label="Time range" required />
|
||||
```
|
||||
@@ -298,6 +329,7 @@ Both are numeric track controls with identical `min`/`max`/`step` semantics —
|
||||
- **`ORange`** — a **pair** (`modelValue: { min: number; max: number }`), two thumbs (dual-thumb). Use when the user selects a span, not a point.
|
||||
|
||||
### ORange
|
||||
|
||||
**Import:** `@/lib/forms/Range/ORange.vue`
|
||||
**Use when:** Selecting a numeric span (a low/high pair) with two draggable thumbs.
|
||||
**Don't use for:** A single value — use `OSlider`. Inside an `OForm`, use `OFormRange`.
|
||||
@@ -305,6 +337,7 @@ Both are numeric track controls with identical `min`/`max`/`step` semantics —
|
||||
**Slots:** `label`, `tooltip`.
|
||||
**Emits:** `update:modelValue` (`RangeValue`), `change` (`RangeValue`), `blur`, `focus`.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ORange
|
||||
v-model="priceRange"
|
||||
@@ -315,19 +348,23 @@ Both are numeric track controls with identical `min`/`max`/`step` semantics —
|
||||
label="Price range"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Headless sibling `ORange`; form-bound wrapper `OFormRange`. Single-value counterpart: `OSlider`.
|
||||
|
||||
### OFormRange
|
||||
|
||||
**Import:** `@/lib/forms/Range/OFormRange.vue`
|
||||
**Use when:** A dual-thumb range field inside `<OForm>`.
|
||||
**Key props:** `name` (`string`, required) plus every `ORange` prop except `modelValue`.
|
||||
**Slots:** `label`. **Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormRange name="scoreRange" :min="0" :max="100" label="Score range" />
|
||||
```
|
||||
|
||||
### OSlider
|
||||
|
||||
**Import:** `@/lib/forms/Slider/OSlider.vue`
|
||||
**Use when:** Selecting a single numeric value on a track (one thumb).
|
||||
**Don't use for:** A low/high span — use `ORange`. Inside an `OForm`, use `OFormSlider`.
|
||||
@@ -335,19 +372,36 @@ Both are numeric track controls with identical `min`/`max`/`step` semantics —
|
||||
**Slots:** `label`, `tooltip`.
|
||||
**Emits:** `update:modelValue` (number), `change` (number), `blur`, `focus`.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OSlider v-model="opacity" :min="0" :max="1" :step="0.1" show-value label="Opacity" />
|
||||
<OSlider
|
||||
v-model="opacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
show-value
|
||||
label="Opacity"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Headless sibling `OSlider`; form-bound wrapper `OFormSlider`. Dual-thumb counterpart: `ORange`.
|
||||
|
||||
### OFormSlider
|
||||
|
||||
**Import:** `@/lib/forms/Slider/OFormSlider.vue`
|
||||
**Use when:** A single-value slider field inside `<OForm>`.
|
||||
**Key props:** `name` (`string`, required) plus every `OSlider` prop except `modelValue`.
|
||||
**Slots:** `label`. **Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormSlider name="threshold" :min="0" :max="100" show-value label="Threshold" />
|
||||
<OFormSlider
|
||||
name="threshold"
|
||||
:min="0"
|
||||
:max="100"
|
||||
show-value
|
||||
label="Threshold"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
@@ -355,6 +409,7 @@ Both are numeric track controls with identical `min`/`max`/`step` semantics —
|
||||
## OFile
|
||||
|
||||
### OFile
|
||||
|
||||
**Import:** `@/lib/forms/File/OFile.vue`
|
||||
**Use when:** Selecting one or many files, optionally via a drag-and-drop drop zone, with `accept` and max-size validation.
|
||||
**Don't use for:** A field inside `<OForm>` — use `OFormFile`.
|
||||
@@ -362,6 +417,7 @@ Both are numeric track controls with identical `min`/`max`/`step` semantics —
|
||||
**Slots:** `label`, `tooltip`, `hint` (custom drop-zone hint when `dropZone` is on).
|
||||
**Emits:** `update:modelValue` (`FileValue`), `change` (`FileValue`), `clear`, `size-error` (`File[]` — files over `maxFileSize`), `type-error` (`File[]` — files rejected by `accept`).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFile
|
||||
v-model="upload"
|
||||
@@ -372,14 +428,17 @@ Both are numeric track controls with identical `min`/`max`/`step` semantics —
|
||||
@size-error="notifyTooLarge"
|
||||
/>
|
||||
```
|
||||
|
||||
**Family:** Headless sibling `OFile`; form-bound wrapper `OFormFile`.
|
||||
|
||||
### OFormFile
|
||||
|
||||
**Import:** `@/lib/forms/File/OFormFile.vue`
|
||||
**Use when:** A file field inside `<OForm>`.
|
||||
**Key props:** `name` (`string`, required) plus every `OFile` prop except `modelValue`.
|
||||
**Slots:** `label`. **Emits:** none.
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OFormFile name="avatar" accept="image/*" label="Avatar" required />
|
||||
```
|
||||
|
||||
@@ -57,7 +57,10 @@ const schema = makeStreamSchema(t);
|
||||
|
||||
async function onSubmit(value: StreamForm) {
|
||||
// value is validated + typed. Build the request with explicit keys.
|
||||
await streamService.create({ name: value.name, retention: Number(value.retention) });
|
||||
await streamService.create({
|
||||
name: value.name,
|
||||
retention: Number(value.retention),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -133,8 +136,10 @@ binding.
|
||||
<OFormInput name="host" :label="t('dest.host')" required />
|
||||
|
||||
<!-- banned: a parallel ref is a second source of truth -->
|
||||
<OFormInput name="host" v-model="hostRef" /> <!-- ❌ -->
|
||||
<OInput v-model="hostRef" /> <!-- ❌ bare, unbound -->
|
||||
<OFormInput name="host" v-model="hostRef" />
|
||||
<!-- ❌ -->
|
||||
<OInput v-model="hostRef" />
|
||||
<!-- ❌ bare, unbound -->
|
||||
```
|
||||
|
||||
- **No `OForm*` wrapper exists for a control you need?** Author a thin one
|
||||
@@ -147,7 +152,7 @@ binding.
|
||||
widgets — a Monaco code editor, `<query-editor>`. If such a widget needs
|
||||
validation, bridge its value into the form **once** with
|
||||
`form.setFieldValue(name, value)` so the schema covers it; then it's already in
|
||||
`value` at submit. That single bridge is the *only* sanctioned `setFieldValue`
|
||||
`value` at submit. That single bridge is the _only_ sanctioned `setFieldValue`
|
||||
from outside a field.
|
||||
- **Data that isn't a form control** (org id from the store, a constant, the
|
||||
Monaco content) lives outside the form and is merged at submit — see
|
||||
@@ -160,7 +165,7 @@ Validation timing is **submit-then-change**, owned by `OForm` (via TanStack's
|
||||
`revalidateLogic`): nothing validates while the user types or on blur, so errors
|
||||
stay hidden during first entry; the first submit reveals all errors at once; then
|
||||
each field re-validates live on every change. You don't configure this — it's
|
||||
baked into `OForm`. The schema file only describes *what* is valid, never *when*.
|
||||
baked into `OForm`. The schema file only describes _what_ is valid, never _when_.
|
||||
|
||||
Mark required fields with the **`required` prop** on the `OForm*` component — it
|
||||
renders the required affordance and is the accessible signal. **Never hardcode a
|
||||
@@ -168,7 +173,8 @@ renders the required affordance and is the accessible signal. **Never hardcode a
|
||||
refine; the prop and the schema rule go together.
|
||||
|
||||
```vue
|
||||
<OFormInput name="email" :label="t('user.email')" required /> <!-- prop, not "Email *" -->
|
||||
<OFormInput name="email" :label="t('user.email')" required />
|
||||
<!-- prop, not "Email *" -->
|
||||
```
|
||||
|
||||
## 4. Submitting
|
||||
@@ -178,7 +184,7 @@ Save button lives:
|
||||
|
||||
- **Inline form** (button inside `<OForm>`): the Save button is
|
||||
`type="submit"`. Enter also submits.
|
||||
- **Overlay form** (button in an `ODialog`/`ODrawer` footer, *outside* the
|
||||
- **Overlay form** (button in an `ODialog`/`ODrawer` footer, _outside_ the
|
||||
`<form>`): give `<OForm id="myForm">` an `id` (it falls through to the inner
|
||||
`<form>`) and set the overlay's `form-id="myForm"`. The footer's built-in Save
|
||||
button renders as `<button form="myForm" type="submit">`, so it submits the
|
||||
@@ -237,6 +243,7 @@ When a form lives inside an `ODialog` or `ODrawer`, always use the overlay's
|
||||
```
|
||||
|
||||
**Why:** The built-in buttons automatically:
|
||||
|
||||
- Render with correct styling (outline/primary per the standard)
|
||||
- Wire `:form="formId"` so the primary button's type="submit" submits the form
|
||||
- Show the spinner automatically during `@submit` (no manual `:loading`)
|
||||
@@ -244,6 +251,7 @@ When a form lives inside an `ODialog` or `ODrawer`, always use the overlay's
|
||||
- Cancel button fires `@click:secondary` so you control the close action
|
||||
|
||||
**What the props do:**
|
||||
|
||||
- `:form-id="FORM_ID"` — wires the footer buttons to the `<OForm id="FORM_ID">`
|
||||
- `:primary-button-label` — text on the Save button (localized, via `t()`)
|
||||
- `:secondary-button-label` — text on the Cancel button
|
||||
@@ -260,17 +268,18 @@ async function onSubmit(value: DestForm) {
|
||||
await service.create({
|
||||
name: value.name,
|
||||
url: value.url,
|
||||
retries: Number(value.retries), // OFormInput emits a string
|
||||
org: store.state.selectedOrganization.identifier, // context, merged here
|
||||
retries: Number(value.retries), // OFormInput emits a string
|
||||
org: store.state.selectedOrganization.identifier, // context, merged here
|
||||
});
|
||||
}
|
||||
|
||||
// banned: spread leaks schema-only .optional() helper fields into the body,
|
||||
// and ships string-typed numbers the API rejects
|
||||
await service.create({ ...value }); // ❌
|
||||
await service.create({ ...value }); // ❌
|
||||
```
|
||||
|
||||
Two concrete traps this avoids:
|
||||
|
||||
- **Schema-only fields leak.** `.optional()` helper fields (a UI toggle, a
|
||||
discriminator) spread straight into the request and backends reject the extra
|
||||
keys. Name the keys you send.
|
||||
@@ -284,7 +293,7 @@ Two concrete traps this avoids:
|
||||
Sometimes visible sections depend on form state (`v-if` on a `kind` field, an
|
||||
`OStepper`, a `v-for` over a repeatable group). `form.useStore(...)` is reactive
|
||||
**only for components rendered inside `<OForm>`** (Vue `provide` flows downward).
|
||||
Pick the pattern by *who* needs the state — and in both, everyone reads the
|
||||
Pick the pattern by _who_ needs the state — and in both, everyone reads the
|
||||
**same one form**; nobody keeps a copy.
|
||||
|
||||
- **A descendant inside `<OForm>` needs it** → `inject(FORM_CONTEXT_KEY)` then
|
||||
@@ -292,7 +301,7 @@ Pick the pattern by *who* needs the state — and in both, everyone reads the
|
||||
child and bind its fields by nested `name` (`config.akeyless.base_url`); the
|
||||
parent owns the single schema.
|
||||
|
||||
- **The component that *owns* `<OForm>` needs it** (it renders `<OForm>`, so it
|
||||
- **The component that _owns_ `<OForm>` needs it** (it renders `<OForm>`, so it
|
||||
sits above the provide and can't inject) → create the form in `setup()` with
|
||||
`useOForm({ defaultValues, schema, onSubmit })`, read it with
|
||||
`form.useStore((s) => s.values.kind)`, write with `form.setFieldValue(...)`,
|
||||
@@ -303,8 +312,9 @@ Pick the pattern by *who* needs the state — and in both, everyone reads the
|
||||
// owner setup()
|
||||
import { useOForm } from "@/lib/forms/Form/useOForm";
|
||||
const form = useOForm({ defaultValues: defaults(), schema, onSubmit });
|
||||
const kind = form.useStore((s) => s.values.kind); // reactive, drives v-if
|
||||
const kind = form.useStore((s) => s.values.kind); // reactive, drives v-if
|
||||
```
|
||||
|
||||
```vue
|
||||
<OForm :form="form">
|
||||
<OFormSelect name="kind" :options="kinds" />
|
||||
@@ -312,7 +322,7 @@ const kind = form.useStore((s) => s.values.kind); // reactive, drives v-if
|
||||
</OForm>
|
||||
```
|
||||
|
||||
Rule of thumb: *owns* `<OForm>` → `useOForm()`; *rendered inside* it → `inject` +
|
||||
Rule of thumb: _owns_ `<OForm>` → `useOForm()`; _rendered inside_ it → `inject` +
|
||||
`useStore`. **Never** mirror form state into a parallel `ref`/`reactive` synced
|
||||
by a `watch → setFieldValue` bridge, and never copy `form.state.values` into
|
||||
local state via a subscription — that's the two-sources-of-truth bug the whole
|
||||
@@ -393,25 +403,42 @@ A complex form with non-schema fields (custom headers, reusable secret input) wi
|
||||
export const makeNotificationChannelSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1, { message: t("notificationChannels.validation.nameRequired") }).trim(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t("notificationChannels.validation.nameRequired") })
|
||||
.trim(),
|
||||
type: z.enum(["webhook", "slack", "email"]),
|
||||
destination_url: z.string().min(1).url({ message: t("notificationChannels.validation.invalidUrl") }),
|
||||
destination_url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.url({ message: t("notificationChannels.validation.invalidUrl") }),
|
||||
auth_token: z.string().optional(),
|
||||
headers: z.record(z.string()).optional().default({}), // map; convert rows ↔ map at submit
|
||||
headers: z.record(z.string()).optional().default({}), // map; convert rows ↔ map at submit
|
||||
retry_count: z.coerce.number().int().min(0).max(10),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type NotificationChannelForm = z.infer<ReturnType<typeof makeNotificationChannelSchema>>;
|
||||
export type NotificationChannelForm = z.infer<
|
||||
ReturnType<typeof makeNotificationChannelSchema>
|
||||
>;
|
||||
export const notificationChannelDefaults = (): NotificationChannelForm => ({
|
||||
name: "", type: "webhook", destination_url: "", auth_token: "", headers: {},
|
||||
retry_count: 3, enabled: true,
|
||||
name: "",
|
||||
type: "webhook",
|
||||
destination_url: "",
|
||||
auth_token: "",
|
||||
headers: {},
|
||||
retry_count: 3,
|
||||
enabled: true,
|
||||
});
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ODialog :form-id="FORM_ID" primary-button-label="Save" secondary-button-label="Cancel">
|
||||
<ODialog
|
||||
:form-id="FORM_ID"
|
||||
primary-button-label="Save"
|
||||
secondary-button-label="Cancel"
|
||||
>
|
||||
<OForm
|
||||
:id="FORM_ID"
|
||||
:schema="notificationChannelSchema"
|
||||
@@ -419,11 +446,32 @@ export const notificationChannelDefaults = (): NotificationChannelForm => ({
|
||||
@submit="saveChannel"
|
||||
>
|
||||
<!-- Schema fields: OForm* components with name= -->
|
||||
<OFormInput name="name" :label="t('notificationChannels.name')" required />
|
||||
<OFormSelect name="type" :label="t('notificationChannels.type')" :options="typeOptions" required />
|
||||
<OFormInput name="destination_url" :label="t('notificationChannels.destination')" required />
|
||||
<OFormInput name="retry_count" type="number" :label="t('notificationChannels.retryCount')" required />
|
||||
<OFormCheckbox name="enabled" :label="t('notificationChannels.enabled')" />
|
||||
<OFormInput
|
||||
name="name"
|
||||
:label="t('notificationChannels.name')"
|
||||
required
|
||||
/>
|
||||
<OFormSelect
|
||||
name="type"
|
||||
:label="t('notificationChannels.type')"
|
||||
:options="typeOptions"
|
||||
required
|
||||
/>
|
||||
<OFormInput
|
||||
name="destination_url"
|
||||
:label="t('notificationChannels.destination')"
|
||||
required
|
||||
/>
|
||||
<OFormInput
|
||||
name="retry_count"
|
||||
type="number"
|
||||
:label="t('notificationChannels.retryCount')"
|
||||
required
|
||||
/>
|
||||
<OFormCheckbox
|
||||
name="enabled"
|
||||
:label="t('notificationChannels.enabled')"
|
||||
/>
|
||||
|
||||
<!-- Non-schema field bridged by hand — SecretInput here is an illustrative
|
||||
custom control (not a real repo component); if it could be a form
|
||||
@@ -431,7 +479,9 @@ export const notificationChannelDefaults = (): NotificationChannelForm => ({
|
||||
auth_token stays schema-owned; the control is value-dumb -->
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">{{ t('notificationChannels.authToken') }}</label>
|
||||
<label class="text-sm font-medium">{{
|
||||
t("notificationChannels.authToken")
|
||||
}}</label>
|
||||
<SecretInput
|
||||
:model-value="formInputs.auth_token"
|
||||
@update:model-value="formInputs.auth_token = $event"
|
||||
@@ -442,16 +492,36 @@ export const notificationChannelDefaults = (): NotificationChannelForm => ({
|
||||
<!-- Dynamic field array: custom headers (map ↔ array conversion at edit/submit) -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-sm font-medium">{{ t("notificationChannels.headers") }}</label>
|
||||
<OButton variant="ghost" size="sm" icon-left="add" type="button" @click="addHeader">
|
||||
<label class="text-sm font-medium">{{
|
||||
t("notificationChannels.headers")
|
||||
}}</label>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-left="add"
|
||||
type="button"
|
||||
@click="addHeader"
|
||||
>
|
||||
{{ t("notificationChannels.addHeader") }}
|
||||
</OButton>
|
||||
</div>
|
||||
<!-- Rendered as array of {key, value} rows; converted to map for schema at submit -->
|
||||
<div v-for="(header, idx) in headers" :key="idx" class="flex gap-2">
|
||||
<OInput v-model="header.key" :placeholder="t('notificationChannels.headerKey')" />
|
||||
<OInput v-model="header.value" :placeholder="t('notificationChannels.headerValue')" />
|
||||
<OButton variant="ghost" size="sm" icon-left="delete" type="button" @click="removeHeader(idx)" />
|
||||
<OInput
|
||||
v-model="header.key"
|
||||
:placeholder="t('notificationChannels.headerKey')"
|
||||
/>
|
||||
<OInput
|
||||
v-model="header.value"
|
||||
:placeholder="t('notificationChannels.headerValue')"
|
||||
/>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-left="delete"
|
||||
type="button"
|
||||
@click="removeHeader(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OForm>
|
||||
@@ -461,13 +531,19 @@ export const notificationChannelDefaults = (): NotificationChannelForm => ({
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { makeNotificationChannelSchema, notificationChannelDefaults, type NotificationChannelForm } from "./NotificationChannels.schema";
|
||||
import {
|
||||
makeNotificationChannelSchema,
|
||||
notificationChannelDefaults,
|
||||
type NotificationChannelForm,
|
||||
} from "./NotificationChannels.schema";
|
||||
|
||||
const { t } = useI18n();
|
||||
const FORM_ID = "notification-channel-form";
|
||||
|
||||
// Schema is computed because it needs t() at every render (translations can change)
|
||||
const notificationChannelSchema = computed(() => makeNotificationChannelSchema(t));
|
||||
const notificationChannelSchema = computed(() =>
|
||||
makeNotificationChannelSchema(t),
|
||||
);
|
||||
const formDefaults = notificationChannelDefaults();
|
||||
const formInputs = ref<NotificationChannelForm>(structuredClone(formDefaults));
|
||||
|
||||
@@ -496,20 +572,21 @@ const openEditForm = (channel: any) => {
|
||||
// On submit: the validated schema value, + non-schema rows converted back to map
|
||||
async function saveChannel(values: NotificationChannelForm) {
|
||||
const headersObj = Object.fromEntries(
|
||||
headers.value.filter((h) => h.key && h.value).map((h) => [h.key, h.value])
|
||||
headers.value.filter((h) => h.key && h.value).map((h) => [h.key, h.value]),
|
||||
);
|
||||
|
||||
|
||||
const data = {
|
||||
...values,
|
||||
headers: headersObj, // merge the constructed map into the payload
|
||||
headers: headersObj, // merge the constructed map into the payload
|
||||
};
|
||||
|
||||
|
||||
await service.create(data);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Key patterns here:**
|
||||
|
||||
- **Computed schema:** `makeNotificationChannelSchema(t)` wrapped in `computed()` because translations are reactive
|
||||
- **Non-schema fields don't go in Zod.** Custom headers, SecretInput are UI-only (converted at edit/submit).
|
||||
- **The split:** `formInputs` (schema-owned refs) + `headers` (ephemeral array rows) live side-by-side.
|
||||
@@ -520,16 +597,16 @@ async function saveChannel(values: NotificationChannelForm) {
|
||||
|
||||
## Anti-patterns (banned)
|
||||
|
||||
| Anti-pattern | Why it's wrong | Do instead |
|
||||
| --- | --- | --- |
|
||||
| `OFormInput` + `v-model="ref"` | two sources of truth → drift | `name=` only; read `value` at submit |
|
||||
| A `formData` object the `@submit` reads | same | read the validated `value` arg |
|
||||
| Mirror `ref` synced by `watch → setFieldValue` | drift, post-save "required" flash | owner: `useOForm` + `:form`; descendant: `inject` + `useStore` |
|
||||
| Per-field `:rules` / hand-rolled `validate()` / `xError` refs | forks validation off the schema | one Zod schema on `<OForm :schema>` |
|
||||
| Hardcoded `*` in a label | not accessible, drifts from the rule | `required` prop + schema rule |
|
||||
| `:primary-button-disabled` on invalid | dead-end UX; Save must explain itself | leave Save enabled (submit reveals errors) |
|
||||
| `useLoading` / `:loading` for submit | redundant | `OForm` awaits `@submit` → auto spinner |
|
||||
| `{ ...value }` as the request body | leaks `.optional()` keys, ships string numbers | explicit keys + `Number()`/`z.coerce.number()` |
|
||||
| `uuid` `:key` on a field-array row | mid-list delete shifts/blanks inputs | `:key="index"` + delete test |
|
||||
| bare control with no `OForm*` wrapper inside `<OForm>` | unvalidated, unbound | author the `OForm*` wrapper |
|
||||
| Schema tied to static `t` | translations don't react | `computed(() => makeSchema(t))` for reactive updates |
|
||||
| Anti-pattern | Why it's wrong | Do instead |
|
||||
| ------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `OFormInput` + `v-model="ref"` | two sources of truth → drift | `name=` only; read `value` at submit |
|
||||
| A `formData` object the `@submit` reads | same | read the validated `value` arg |
|
||||
| Mirror `ref` synced by `watch → setFieldValue` | drift, post-save "required" flash | owner: `useOForm` + `:form`; descendant: `inject` + `useStore` |
|
||||
| Per-field `:rules` / hand-rolled `validate()` / `xError` refs | forks validation off the schema | one Zod schema on `<OForm :schema>` |
|
||||
| Hardcoded `*` in a label | not accessible, drifts from the rule | `required` prop + schema rule |
|
||||
| `:primary-button-disabled` on invalid | dead-end UX; Save must explain itself | leave Save enabled (submit reveals errors) |
|
||||
| `useLoading` / `:loading` for submit | redundant | `OForm` awaits `@submit` → auto spinner |
|
||||
| `{ ...value }` as the request body | leaks `.optional()` keys, ships string numbers | explicit keys + `Number()`/`z.coerce.number()` |
|
||||
| `uuid` `:key` on a field-array row | mid-list delete shifts/blanks inputs | `:key="index"` + delete test |
|
||||
| bare control with no `OForm*` wrapper inside `<OForm>` | unvalidated, unbound | author the `OForm*` wrapper |
|
||||
| Schema tied to static `t` | translations don't react | `computed(() => makeSchema(t))` for reactive updates |
|
||||
|
||||
@@ -4,12 +4,12 @@ Keyboard shortcuts are **registry-driven**. There is one system with three
|
||||
coordinated parts — don't hand-roll `window.addEventListener("keydown", …)` or
|
||||
scatter raw key combos through templates:
|
||||
|
||||
| Part | Where | Job |
|
||||
| --- | --- | --- |
|
||||
| **Registry** | `@/lib/vue-shortcut-manager` → `shortcutRegistry.ts` | Declares every shortcut once by `id`: its key(s), platform variants, i18n description, grouped by module. |
|
||||
| **Binding** | `useShortcut` / `useShortcuts` composables | Attach behavior to a registered `id` in a component's `setup()`. |
|
||||
| **Display** | `OShortcut`, and `shortcut-id` on `ODropdownItem` / `OTooltip` | Render the keycaps, resolved from the same registry (platform-aware). |
|
||||
| **Cheatsheet** | `ShortcutCheatsheet` (mounted once in `MainLayout`) | The global "?" overlay listing all shortcuts. New registry entries appear automatically. |
|
||||
| Part | Where | Job |
|
||||
| -------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| **Registry** | `@/lib/vue-shortcut-manager` → `shortcutRegistry.ts` | Declares every shortcut once by `id`: its key(s), platform variants, i18n description, grouped by module. |
|
||||
| **Binding** | `useShortcut` / `useShortcuts` composables | Attach behavior to a registered `id` in a component's `setup()`. |
|
||||
| **Display** | `OShortcut`, and `shortcut-id` on `ODropdownItem` / `OTooltip` | Render the keycaps, resolved from the same registry (platform-aware). |
|
||||
| **Cheatsheet** | `ShortcutCheatsheet` (mounted once in `MainLayout`) | The global "?" overlay listing all shortcuts. New registry entries appear automatically. |
|
||||
|
||||
Because all four read the one registry, a shortcut's key, its label, its keycap
|
||||
display, and its cheatsheet row never drift apart.
|
||||
@@ -39,8 +39,18 @@ supply the handler:
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
|
||||
useShortcuts([
|
||||
{ id: "streamsListAdd", handler: () => { if (!isInputFocused()) addStream(); } },
|
||||
{ id: "streamsListRefresh", handler: () => { if (!isInputFocused()) refresh(); } },
|
||||
{
|
||||
id: "streamsListAdd",
|
||||
handler: () => {
|
||||
if (!isInputFocused()) addStream();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "streamsListRefresh",
|
||||
handler: () => {
|
||||
if (!isInputFocused()) refresh();
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
@@ -61,8 +71,12 @@ Never hardcode `⌘N` text — render from the registry so it stays platform-awa
|
||||
and in sync:
|
||||
|
||||
```vue
|
||||
<OShortcut id="streamsListAdd" /> <!-- resolves keys from the registry -->
|
||||
<ODropdownItem shortcut-id="streamsListAdd" @select="addStream">Add stream</ODropdownItem>
|
||||
<OShortcut id="streamsListAdd" />
|
||||
<!-- resolves keys from the registry -->
|
||||
<ODropdownItem
|
||||
shortcut-id="streamsListAdd"
|
||||
@select="addStream"
|
||||
>Add stream</ODropdownItem>
|
||||
<OTooltip content="Refresh" shortcut-id="streamsListRefresh">…</OTooltip>
|
||||
```
|
||||
|
||||
|
||||
@@ -30,22 +30,22 @@ grouping layer then reshapes into three visual shapes:
|
||||
(Home, Logs, Metrics, Traces, Alerts, Incidents, Actions, AI, IAM,
|
||||
Management).
|
||||
2. **Rail group with a hover flyout** — one tile that navigates to a primary page
|
||||
on click *and* reveals a submenu of related pages on hover (currently **Data**
|
||||
on click _and_ reveals a submenu of related pages on hover (currently **Data**
|
||||
→ Streams/Pipelines/Functions/Enrichment/Ingestion, and **Dashboards** →
|
||||
Dashboards/Reports).
|
||||
3. **In-page section sub-nav (`SectionRail`)** — a secondary left sidebar *inside*
|
||||
3. **In-page section sub-nav (`SectionRail`)** — a secondary left sidebar _inside_
|
||||
a page that switches between sub-sections via `<router-view>` (used by
|
||||
**Settings/Management** and **IAM**).
|
||||
|
||||
These are produced by three files — learn which one owns each surface:
|
||||
|
||||
| Surface | Owner file |
|
||||
| --- | --- |
|
||||
| Flat top-level list (the rail items) | `web/src/layouts/MainLayout.vue` → `linksList` ref |
|
||||
| Reshaping flat list → groups + flyouts | `web/src/lib/core/Navbar/navGroups.ts` → `NAV_GROUPS` |
|
||||
| Settings sub-nav | `web/src/components/settings/index.vue` → `settingsItems` |
|
||||
| IAM sub-nav | `web/src/views/IdentityAccessManagement.vue` → sections array |
|
||||
| Routes (by domain) | the router composables in [§1](#1-register-the-route) |
|
||||
| Surface | Owner file |
|
||||
| -------------------------------------- | ------------------------------------------------------------- |
|
||||
| Flat top-level list (the rail items) | `web/src/layouts/MainLayout.vue` → `linksList` ref |
|
||||
| Reshaping flat list → groups + flyouts | `web/src/lib/core/Navbar/navGroups.ts` → `NAV_GROUPS` |
|
||||
| Settings sub-nav | `web/src/components/settings/index.vue` → `settingsItems` |
|
||||
| IAM sub-nav | `web/src/views/IdentityAccessManagement.vue` → sections array |
|
||||
| Routes (by domain) | the router composables in [§1](#1-register-the-route) |
|
||||
|
||||
> The rail renderer is `web/src/lib/core/Navbar/ONavbar.vue` (+ `ONavGroup.vue` in
|
||||
> the same folder, and `MenuLink.vue` at `web/src/components/MenuLink.vue`) and the
|
||||
@@ -58,14 +58,14 @@ These are produced by three files — learn which one owns each surface:
|
||||
## Deciding where a new page goes
|
||||
|
||||
Same spatial grammar as the dialog/drawer/page decision — match the surface to
|
||||
what the page *is*:
|
||||
what the page _is_:
|
||||
|
||||
| The page is… | Put it… | Edit |
|
||||
| --- | --- | --- |
|
||||
| A top-level product area (its own concern, reached often) | a **plain rail item** | `MainLayout.vue` `linksList` + a route |
|
||||
| One of several related data destinations | a **flyout child** under an existing rail group | `navGroups.ts` `NAV_GROUPS` + a route |
|
||||
| An admin / configuration screen | a **sub-page under Settings** | `settings/index.vue` `settingsItems` + a route |
|
||||
| An access-control screen | a **sub-page under IAM** | `IdentityAccessManagement.vue` + a route |
|
||||
| The page is… | Put it… | Edit |
|
||||
| --------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------- |
|
||||
| A top-level product area (its own concern, reached often) | a **plain rail item** | `MainLayout.vue` `linksList` + a route |
|
||||
| One of several related data destinations | a **flyout child** under an existing rail group | `navGroups.ts` `NAV_GROUPS` + a route |
|
||||
| An admin / configuration screen | a **sub-page under Settings** | `settings/index.vue` `settingsItems` + a route |
|
||||
| An access-control screen | a **sub-page under IAM** | `IdentityAccessManagement.vue` + a route |
|
||||
|
||||
A new page **always** needs step 1 (a route). It then needs **exactly one** of
|
||||
2a / 2b / 2c to be reachable from navigation. Don't register it in two surfaces.
|
||||
@@ -79,13 +79,13 @@ Routes are split by domain across composables, merged in
|
||||
and installs the global `beforeEach` that sets `document.title` from
|
||||
`meta.title`). Add your route to the composable that owns its domain:
|
||||
|
||||
| Domain | Composable file | Owns |
|
||||
| --- | --- | --- |
|
||||
| Core product (logs, metrics, traces, dashboards, streams, alerts, pipeline…) | `web/src/composables/shared/router.ts` (`useRoutes()` → `homeChildRoutes`) | most in-app pages |
|
||||
| Settings / Management | `web/src/composables/shared/useManagementRoutes.ts` | `/settings/*` |
|
||||
| IAM | `web/src/composables/shared/useEnterpriseRoutes.ts` | `/iam/*`, incidents, actions |
|
||||
| Ingestion | `web/src/composables/shared/useIngestionRoutes.ts` | `/ingestion/*` |
|
||||
| Cloud/enterprise-only (AI, billings, marketplace) | `web/src/enterprise/composables/router.ts` (`useEnvRoutes()`) | env-specific pages |
|
||||
| Domain | Composable file | Owns |
|
||||
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------- |
|
||||
| Core product (logs, metrics, traces, dashboards, streams, alerts, pipeline…) | `web/src/composables/shared/router.ts` (`useRoutes()` → `homeChildRoutes`) | most in-app pages |
|
||||
| Settings / Management | `web/src/composables/shared/useManagementRoutes.ts` | `/settings/*` |
|
||||
| IAM | `web/src/composables/shared/useEnterpriseRoutes.ts` | `/iam/*`, incidents, actions |
|
||||
| Ingestion | `web/src/composables/shared/useIngestionRoutes.ts` | `/ingestion/*` |
|
||||
| Cloud/enterprise-only (AI, billings, marketplace) | `web/src/enterprise/composables/router.ts` (`useEnvRoutes()`) | env-specific pages |
|
||||
|
||||
**Route shape** (from `shared/router.ts`):
|
||||
|
||||
@@ -120,11 +120,11 @@ is a `NavItem`:
|
||||
|
||||
```ts
|
||||
interface NavItem {
|
||||
title: string; // display label — pass t("menu.xxx") (translated at build time)
|
||||
icon: string; // OIcon registry name
|
||||
link: string; // route path — navigation + active-state matching
|
||||
name: string; // route name — ordering, gating, hasRoute checks
|
||||
exact?: boolean; // only "home" uses this
|
||||
title: string; // display label — pass t("menu.xxx") (translated at build time)
|
||||
icon: string; // OIcon registry name
|
||||
link: string; // route path — navigation + active-state matching
|
||||
name: string; // route name — ordering, gating, hasRoute checks
|
||||
exact?: boolean; // only "home" uses this
|
||||
display?: boolean; // per-item show/hide (e.g. admin-only)
|
||||
hide?: boolean;
|
||||
badge?: number;
|
||||
@@ -179,9 +179,10 @@ setting `visible` to the same predicate the route uses.
|
||||
`SectionRail` pattern (users, service accounts, tokens, groups, roles, quota, …).
|
||||
|
||||
**To add a Settings sub-page:**
|
||||
|
||||
1. Add a route child in `useManagementRoutes.ts` (IAM → `useEnterpriseRoutes.ts`).
|
||||
2. Add an item to `settingsItems` with `key / label / description / icon / to /
|
||||
group` (+ `visible` if gated).
|
||||
group` (+ `visible` if gated).
|
||||
3. Add the `settings.*` i18n keys.
|
||||
|
||||
Settings is intentionally a **plain rail link** (no hover flyout) — its in-page
|
||||
@@ -228,10 +229,11 @@ and gate — so the two never disagree. Edit only this one file to move an item.
|
||||
|
||||
## 3. Cloud / enterprise / RBAC gating (mandatory)
|
||||
|
||||
Every new nav entry must decide *where it shows*. There are two gating inputs:
|
||||
Every new nav entry must decide _where it shows_. There are two gating inputs:
|
||||
|
||||
**Build-time flags** — `web/src/aws-exports.ts` default export `config`, string
|
||||
`"true"`/`"false"`:
|
||||
|
||||
- `config.isCloud` (`VITE_OPENOBSERVE_CLOUD`)
|
||||
- `config.isEnterprise` (`VITE_OPENOBSERVE_ENTERPRISE`)
|
||||
- `config.showLLMUI` (`VITE_OPENOBSERVE_LLM_UI`)
|
||||
@@ -249,9 +251,11 @@ Every new nav entry must decide *where it shows*. There are two gating inputs:
|
||||
|
||||
- Enterprise **or** cloud + a runtime flag (most common):
|
||||
```js
|
||||
const isFeatureEnabled = computed(() =>
|
||||
(config.isEnterprise == "true" || config.isCloud == "true") &&
|
||||
Boolean(store.state.zoConfig?.online_evals_enabled));
|
||||
const isFeatureEnabled = computed(
|
||||
() =>
|
||||
(config.isEnterprise == "true" || config.isCloud == "true") &&
|
||||
Boolean(store.state.zoConfig?.online_evals_enabled),
|
||||
);
|
||||
```
|
||||
- Enterprise only: `config.isEnterprise == "true"`
|
||||
- Cloud only: `config.isCloud == "true"`
|
||||
@@ -259,12 +263,12 @@ Every new nav entry must decide *where it shows*. There are two gating inputs:
|
||||
|
||||
**How each surface applies the gate:**
|
||||
|
||||
| Surface | Mechanism |
|
||||
| --- | --- |
|
||||
| Rail top-level item | splice into `linksList` from a computed gate; `filterMenus()` also drops any `name` listed in `zoConfig.custom_hide_menus`, or with `hide`/`display:false` |
|
||||
| Route | conditional `push`/guarded block in the composable (e.g. `useManagementRoutes.ts` pushes enterprise-only routes only when `isEnterprise`), or a `beforeEnter` redirect |
|
||||
| Flyout child | a `gate` key resolved by `GATE_PREDICATES` in `navGroups.ts` (e.g. `rbac`, `enterprise`, `enterpriseMeta`, `storage`, `streamPipelines`) |
|
||||
| SectionRail sub-page | a `visible:` expression on the item (e.g. `visible: isEnt && meta`) |
|
||||
| Surface | Mechanism |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Rail top-level item | splice into `linksList` from a computed gate; `filterMenus()` also drops any `name` listed in `zoConfig.custom_hide_menus`, or with `hide`/`display:false` |
|
||||
| Route | conditional `push`/guarded block in the composable (e.g. `useManagementRoutes.ts` pushes enterprise-only routes only when `isEnterprise`), or a `beforeEnter` redirect |
|
||||
| Flyout child | a `gate` key resolved by `GATE_PREDICATES` in `navGroups.ts` (e.g. `rbac`, `enterprise`, `enterpriseMeta`, `storage`, `streamPipelines`) |
|
||||
| SectionRail sub-page | a `visible:` expression on the item (e.g. `visible: isEnt && meta`) |
|
||||
|
||||
**Keep the three in sync.** A flyout `gate`, the route's push condition, and the
|
||||
page's SectionRail `visible` must express the **same** rule — otherwise the rail
|
||||
@@ -288,6 +292,7 @@ strings in `navGroups.ts` (flyout children may also use feature namespaces like
|
||||
## Add-a-page checklists
|
||||
|
||||
**New top-level rail item**
|
||||
|
||||
- [ ] Route added to the right composable ([§1](#1-register-the-route)), lazy
|
||||
`component`, `meta.title`.
|
||||
- [ ] `NavItem` in `MainLayout.vue` `linksList` (spliced from a computed gate if
|
||||
@@ -297,18 +302,21 @@ strings in `navGroups.ts` (flyout children may also use feature namespaces like
|
||||
hideable via config, `name` respected by `custom_hide_menus`.
|
||||
|
||||
**New Settings / IAM sub-page**
|
||||
|
||||
- [ ] Route child in `useManagementRoutes.ts` (IAM → `useEnterpriseRoutes.ts`).
|
||||
- [ ] Item in `settingsItems` / IAM sections: `key / label / description / icon /
|
||||
to / group` (+ `visible` if gated).
|
||||
to / group` (+ `visible` if gated).
|
||||
- [ ] i18n keys added.
|
||||
- [ ] Not also added to `navGroups.ts` (Settings/IAM are plain rail links).
|
||||
|
||||
**New flyout child under Data / Dashboards**
|
||||
|
||||
- [ ] Route `name` exists.
|
||||
- [ ] `SubnavChild` in the `NAV_GROUPS` entry (`titleKey / icon / name /
|
||||
requires` + `gate` if gated), mirroring the page's own SectionRail.
|
||||
requires` + `gate` if gated), mirroring the page's own SectionRail.
|
||||
|
||||
**Every case**
|
||||
|
||||
- [ ] Reachable from exactly **one** nav surface.
|
||||
- [ ] Gate expressed identically across route + nav entry (+ SectionRail
|
||||
`visible` / flyout `gate`).
|
||||
|
||||
@@ -20,13 +20,13 @@ O2 overlay primitives (Dialog, Drawer, Dropdown, Popover, Tooltip) and navigatio
|
||||
|
||||
## Overlay decision guide
|
||||
|
||||
| If you need… | Use | Not |
|
||||
|---|---|---|
|
||||
| A hover/focus hint describing an element (no interaction inside) | **OTooltip** | Popover — a tooltip is dismissable-free and passive |
|
||||
| A menu of **actions** (Edit, Duplicate, Delete) triggered by a button | **ODropdown** + items | Popover — dropdown gives menu semantics (arrow-key nav, typeahead, `role="menu"`) |
|
||||
| **Freeform** floating content (filter form, color picker, mini info card) | **OPopover** | Dropdown — popover keeps normal Tab order and frees arrow keys for the content |
|
||||
| A focused **modal task** centred on screen (create/edit form, confirm) | **ODialog** | Drawer — dialog is for short, self-contained tasks |
|
||||
| A **side panel** task (details inspector, long form, contextual editor) | **ODrawer** | Dialog — drawer slides from an edge and can stay `seamless` (page still usable) |
|
||||
| If you need… | Use | Not |
|
||||
| ------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------- |
|
||||
| A hover/focus hint describing an element (no interaction inside) | **OTooltip** | Popover — a tooltip is dismissable-free and passive |
|
||||
| A menu of **actions** (Edit, Duplicate, Delete) triggered by a button | **ODropdown** + items | Popover — dropdown gives menu semantics (arrow-key nav, typeahead, `role="menu"`) |
|
||||
| **Freeform** floating content (filter form, color picker, mini info card) | **OPopover** | Dropdown — popover keeps normal Tab order and frees arrow keys for the content |
|
||||
| A focused **modal task** centred on screen (create/edit form, confirm) | **ODialog** | Drawer — dialog is for short, self-contained tasks |
|
||||
| A **side panel** task (details inspector, long form, contextual editor) | **ODrawer** | Dialog — drawer slides from an edge and can stay `seamless` (page still usable) |
|
||||
|
||||
Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything freeform, Dialog/Drawer = modal task.** Dialog and Drawer share the exact same surface, header/footer, built-in footer buttons, and `formId` auto-loading — choose by placement (center vs edge), not capability.
|
||||
|
||||
@@ -35,10 +35,12 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
## Overlay
|
||||
|
||||
### ODialog
|
||||
|
||||
**Import:** `@/lib/overlay/Dialog/ODialog.vue`
|
||||
**Use when:** A user must complete a short, self-contained task without leaving the page — create/edit forms, confirmations, focused settings.
|
||||
**Don't use for:** Long or contextual side-panel work (use **ODrawer**); a menu of actions (use **ODropdown**); a passive hint (use **OTooltip**).
|
||||
**Key props:**
|
||||
|
||||
- `open` (boolean — controlled, use `v-model:open`)
|
||||
- `size` (`"xs"|"sm"|"md"|"lg"|"xl"|"full"` — default `"md"`; xs=320px, sm=480px, md=640px, lg=800px, xl=1024px, full=screen)
|
||||
- `width` (number 1–100 → `vw`; overrides `size` width entirely)
|
||||
@@ -77,10 +79,12 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
---
|
||||
|
||||
### ODrawer
|
||||
|
||||
**Import:** `@/lib/overlay/Drawer/ODrawer.vue`
|
||||
**Use when:** A contextual or longer task belongs in a panel sliding from a viewport edge — details inspectors, long forms, side editors. Can stay `seamless` so the page underneath remains interactive.
|
||||
**Don't use for:** A short centred task or confirmation (use **ODialog**); a compact action menu (use **ODropdown**).
|
||||
**Key props:** (superset of ODialog — same header/footer/formId/built-in buttons) plus:
|
||||
|
||||
- `side` (`"right"|"left"` — default `"right"`)
|
||||
- `size` (`"sm"|"md"|"lg"|"xl"|"full"` — default `"md"`; sm=360px, md=480px, lg=640px, xl=800px, full=100vw)
|
||||
- `width` (number 1–100 → `vw`, or `%` when `portalTarget` set; overrides `size`)
|
||||
@@ -119,6 +123,7 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
---
|
||||
|
||||
### ODropdown
|
||||
|
||||
**Import:** `@/lib/overlay/Dropdown/ODropdown.vue`
|
||||
**Use when:** A trigger (icon button, "•••", "Actions") opens a menu of discrete actions. Gives full menu semantics — arrow-key navigation, typeahead, `role="menu"`.
|
||||
**Don't use for:** Freeform content like forms or pickers (use **OPopover**); a persistent tab strip (use **OTabs**).
|
||||
@@ -144,6 +149,7 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
---
|
||||
|
||||
### ODropdownItem
|
||||
|
||||
**Import:** `@/lib/overlay/Dropdown/ODropdownItem.vue`
|
||||
**Use when:** A single selectable action row inside an ODropdown.
|
||||
**Don't use for:** A section heading (use **ODropdownGroup**'s `label`); a visual divider (use **ODropdownSeparator**).
|
||||
@@ -153,7 +159,11 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<ODropdownItem icon-left="download" shortcut-id="export.csv" @select="exportCsv">
|
||||
<ODropdownItem
|
||||
icon-left="download"
|
||||
shortcut-id="export.csv"
|
||||
@select="exportCsv"
|
||||
>
|
||||
Export CSV
|
||||
</ODropdownItem>
|
||||
```
|
||||
@@ -163,6 +173,7 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
---
|
||||
|
||||
### ODropdownGroup
|
||||
|
||||
**Import:** `@/lib/overlay/Dropdown/ODropdownGroup.vue`
|
||||
**Use when:** Grouping related items under an optional label inside an ODropdown.
|
||||
**Don't use for:** A plain divider without a heading (use **ODropdownSeparator**).
|
||||
@@ -183,6 +194,7 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
---
|
||||
|
||||
### ODropdownSeparator
|
||||
|
||||
**Import:** `@/lib/overlay/Dropdown/ODropdownSeparator.vue`
|
||||
**Use when:** A thin visual divider between groups of dropdown items.
|
||||
**Don't use for:** A labelled section (use **ODropdownGroup**).
|
||||
@@ -200,6 +212,7 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
|
||||
---
|
||||
|
||||
### OPopover
|
||||
|
||||
**Import:** `@/lib/overlay/Popover/OPopover.vue`
|
||||
**Use when:** Floating content that isn't a list of actions — filter forms, pickers, info cards, mini editors. Keeps normal Tab order; arrow keys belong to the content. Non-modal by default. Descendant overlays (OSelect / OCombobox / nested dropdowns) opened inside won't dismiss it.
|
||||
**Don't use for:** A menu of actions (use **ODropdown** for menu semantics); a passive text hint (use **OTooltip**); a large modal task (use **ODialog**/**ODrawer**).
|
||||
@@ -264,6 +277,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### OTooltip
|
||||
|
||||
**Import:** `@/lib/overlay/Tooltip/OTooltip.vue`
|
||||
**Use when:** A passive hover/focus hint describing an element — icon-button labels, truncated text, disabled-state explanations. Two modes: **wrapper** (default slot wraps the trigger) or **child** (no default slot).
|
||||
**Child mode binds to the nearest previous VISIBLE ELEMENT SIBLING, and only falls back to the parent when there is none** — it is not simply "the parent". So put a child-mode `OTooltip` **first** inside the element you want as the trigger; placed after an `<OIcon>` it silently binds to that icon, and only the icon becomes hoverable. Text nodes do not count as siblings, so `<OIcon/> {{ count }} <OTooltip/>` binds to the icon and leaves the count dead. This shipped in the alert-dependency count chips, where the tooltip fired on a 12px glyph inside a 52px chip.
|
||||
@@ -293,6 +307,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
## Navigation
|
||||
|
||||
### OPagination
|
||||
|
||||
**Import:** `@/lib/navigation/Pagination/OPagination.vue`
|
||||
**Use when:** Navigating a paged list/table — a centred window of page-number buttons with prev/next.
|
||||
**Don't use for:** Switching between content sections (use **OTabs**); a linear multi-step flow (use **OStepper**).
|
||||
@@ -310,6 +325,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### OStepper
|
||||
|
||||
**Import:** `@/lib/navigation/Stepper/OStepper.vue`
|
||||
**Use when:** A guided linear flow across multiple steps — a wizard, or (with `expanded`) a progress checklist/timeline. Root container owns the active-step state and only the active step's panel shows by default.
|
||||
**Don't use for:** Peer content sections switched in any order (use **OTabs**); paging a list (use **OPagination**).
|
||||
@@ -337,6 +353,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### OStep
|
||||
|
||||
**Import:** `@/lib/navigation/Stepper/OStep.vue`
|
||||
**Use when:** One step within an OStepper — registers itself and renders its content when active.
|
||||
**Don't use for:** Standalone use — must be a child of OStepper.
|
||||
@@ -346,7 +363,13 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<OStep :name="2" title="Transform" description="Optional" :done="step > 2" :error="hasError">
|
||||
<OStep
|
||||
:name="2"
|
||||
title="Transform"
|
||||
description="Optional"
|
||||
:done="step > 2"
|
||||
:error="hasError"
|
||||
>
|
||||
<TransformConfig />
|
||||
</OStep>
|
||||
```
|
||||
@@ -356,6 +379,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### OTabs
|
||||
|
||||
**Import:** `@/lib/navigation/Tabs/OTabs.vue`
|
||||
**Use when:** The tab-bar container for switching between peer content sections. Owns the active-tab state; pair with OTabPanels/OTabPanel for the content area.
|
||||
**Don't use for:** A linear wizard (use **OStepper**); route-per-tab navigation without a shared panel area — use **ORouteTab** children instead of OTab.
|
||||
@@ -368,6 +392,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### OTab
|
||||
|
||||
**Import:** `@/lib/navigation/Tabs/OTab.vue`
|
||||
**Use when:** A single **content-switching** tab trigger inside OTabs — clicking it just changes the active tab (via OTabs' `v-model`), no routing.
|
||||
**Don't use for:** A tab that should navigate to a route (use **ORouteTab**).
|
||||
@@ -385,6 +410,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### ORouteTab
|
||||
|
||||
**Import:** `@/lib/navigation/Tabs/ORouteTab.vue`
|
||||
**Use when:** A tab that is **route-driven** — clicking navigates via Vue Router (`to`), and the active tab auto-syncs when the route matches `name`. Use for URL-addressable sections (deep links, back-button support).
|
||||
**Don't use for:** In-place content switching with no URL change (use **OTab**).
|
||||
@@ -405,6 +431,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### OTabPanels
|
||||
|
||||
**Import:** `@/lib/navigation/Tabs/OTabPanels.vue`
|
||||
**Use when:** The container that shows the correct OTabPanel for the active tab. Keep its `modelValue` in sync with OTabs' `v-model`.
|
||||
**Don't use for:** The tab strip itself (use **OTabs**).
|
||||
@@ -417,6 +444,7 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
---
|
||||
|
||||
### OTabPanel
|
||||
|
||||
**Import:** `@/lib/navigation/Tabs/OTabPanel.vue`
|
||||
**Use when:** The content area for one tab, shown when its `name` matches the active tab.
|
||||
**Don't use for:** Standalone use — must be a child of OTabPanels.
|
||||
@@ -427,8 +455,8 @@ unfiltered page spends one button and a filtered one shows its scope as chips).
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
const activeTab = ref('logs')
|
||||
import { ref } from "vue";
|
||||
const activeTab = ref("logs");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -175,9 +175,9 @@ for every list — don't ship a listing page without them.
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:loading="fetching"
|
||||
data-test="channels-refresh"
|
||||
@click="fetchChannels"
|
||||
@click="refreshChannels"
|
||||
>
|
||||
<OTooltip side="bottom" :content="t('channels.reload')" shortcut-id="channelsRefresh" />
|
||||
</OButton>
|
||||
@@ -214,9 +214,30 @@ search-only lists with no other filters).
|
||||
needs extra controls (scope toggles, etc.). Set `:show-global-filter="false"`
|
||||
and drive the query yourself.
|
||||
|
||||
**Refresh.** An `OButton size="icon-sm" icon-left="refresh" :loading` in the
|
||||
`#toolbar-trailing` slot, wired to the same fetch function the page uses on
|
||||
mount. Attach an `OTooltip` with `shortcut-id` so it advertises the `r` shortcut.
|
||||
**Refresh.** An `OButton size="icon-sm" icon-left="refresh"` in the
|
||||
`#toolbar-trailing` slot, with `:loading` bound to the query's `isFetching` (the
|
||||
table's `:loading` is `isPending`, the cold-read skeleton). Attach an `OTooltip`
|
||||
with `shortcut-id` so it advertises the `r` shortcut.
|
||||
|
||||
The click goes to a **named handler that forces every read the page shows** —
|
||||
the list and any secondary read (counts, "Used by", dropdown options). Mount,
|
||||
paging and search read the cache; only a refresh is guaranteed to reach the
|
||||
server. Full rule: [data-fetching.md § The refresh rule](data-fetching.md#the-refresh-rule).
|
||||
|
||||
```ts
|
||||
const orgId = useOrgId();
|
||||
const channelsList = useQuery(() =>
|
||||
Object.assign(channelsQuery(orgId.value), { enabled: !!orgId.value }),
|
||||
);
|
||||
const rows = computed(() => channelsList.data.value ?? []);
|
||||
const loading = channelsList.isPending;
|
||||
const fetching = channelsList.isFetching;
|
||||
// Named handler: `@click="channelsList.refetch"` would pass the MouseEvent in.
|
||||
const refreshChannels = () => {
|
||||
void channelsList.refetch();
|
||||
void loadChannelUsage(true); // a secondary read on the same view is forced too
|
||||
};
|
||||
```
|
||||
|
||||
**Column show/hide toggle.** `OTable` renders the column-visibility button
|
||||
**automatically** — but only when **all three** hold:
|
||||
@@ -309,14 +330,31 @@ apart:
|
||||
<OTable :loading="loading" :forbidden="forbidden" ... />
|
||||
```
|
||||
|
||||
With `useQuery`, derive it from the query's own error — it follows every
|
||||
load path by construction:
|
||||
|
||||
```ts
|
||||
const forbidden = computed(() => {
|
||||
const e: any = channelsList.error.value;
|
||||
return e?.status === 403 || e?.response?.status === 403;
|
||||
});
|
||||
```
|
||||
|
||||
With an imperative `queryClient.fetchQuery` loader, keep a ref and clear it at
|
||||
the start of every load:
|
||||
|
||||
```ts
|
||||
const forbidden = ref(false);
|
||||
|
||||
const getAlerts = async () => {
|
||||
const getAlerts = async (folderId: string, force = false) => {
|
||||
loading.value = true;
|
||||
forbidden.value = false; // clear at the START of every load
|
||||
try {
|
||||
rows.value = (await alertsService.list(org)).data.list;
|
||||
const options = alertsListQuery(orgId.value, folderId);
|
||||
if (force) {
|
||||
await queryClient.invalidateQueries({ queryKey: options.queryKey, exact: true, refetchType: "none" });
|
||||
}
|
||||
rows.value = await queryClient.fetchQuery(options);
|
||||
} catch (err: any) {
|
||||
forbidden.value = err?.response?.status === 403;
|
||||
// the grouped access toast already reports a 403; a second red toast adds nothing
|
||||
@@ -369,9 +407,11 @@ visible data.
|
||||
[keyboard-shortcuts.md](keyboard-shortcuts.md)): `n` → create, `/` → focus
|
||||
search, `r` → refresh. Advertise `r` via the refresh button's `OTooltip
|
||||
shortcut-id`.
|
||||
- **Data flow** (see [SKILL.md § Where code goes](../SKILL.md)): fetch through a
|
||||
domain service, org from `store.state.selectedOrganization`; hold the rows in a
|
||||
local `ref` (or Vuex if shared); keep column defs local.
|
||||
- **Data flow** (see [data-fetching.md](data-fetching.md)): read the rows
|
||||
through the domain's declared query (`services/<domain>.queries.ts`, reuse
|
||||
one if it exists), org from `useOrgId()`; the rows are a `computed` over the
|
||||
query's data — never a Vuex copy; writes are `mutationOptions()` that
|
||||
invalidate the domain; keep column defs local.
|
||||
|
||||
---
|
||||
|
||||
@@ -403,8 +443,11 @@ form — those go in an `ODialog`/`ODrawer`; see SKILL.md § Forms):
|
||||
(`:show-global-filter="false"`), or the built-in global filter for a
|
||||
search-only list.
|
||||
- [ ] **Refresh** button in `#toolbar-trailing` (`variant="outline"
|
||||
size="icon-sm" icon-left="refresh"`), wired to the fetch fn, with an
|
||||
`OTooltip shortcut-id`.
|
||||
size="icon-sm" icon-left="refresh"`, `:loading` from `isFetching`), with an
|
||||
`OTooltip shortcut-id`; its named handler **forces** the list and every
|
||||
secondary read on the page.
|
||||
- [ ] Rows come from a declared query (`useQuery` or `fetchQuery`) — no raw
|
||||
service/`http` call, no Vuex copy; writes invalidate the domain.
|
||||
- [ ] **Column show/hide toggle** present — i.e. `:persist-columns="true"` +
|
||||
`table-id` + at least one `hideable` column.
|
||||
- [ ] Non-essential columns **hidden by default** via `:column-visibility`.
|
||||
|
||||
Generated
+128
@@ -17,7 +17,9 @@
|
||||
"@openobserve/node-sql-parser": "^0.1.5",
|
||||
"@openobserve/rrweb-player": "^0.2.1",
|
||||
"@rudderstack/analytics-js": "3.6.0",
|
||||
"@tanstack/query-persist-client-core": "^5.101.4",
|
||||
"@tanstack/vue-form": "^1.33.3",
|
||||
"@tanstack/vue-query": "^5.101.4",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.35",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@@ -82,6 +84,7 @@
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/vue-query-devtools": "^6.1.38",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
@@ -4182,6 +4185,22 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/match-sorter-utils": {
|
||||
"version": "8.19.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
|
||||
"integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"remove-accents": "0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/pacer-lite": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz",
|
||||
@@ -4195,6 +4214,40 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz",
|
||||
"integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-persist-client-core": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.101.4.tgz",
|
||||
"integrity": "sha512-Bmu+RfWhwYEyYZEwSeKC0gX4+KTkiEB3yO8oz9BfAY/Jfe3ndz9EnYVhihIZPmWIr8NXayF7JuraNQcrXQQCCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
|
||||
@@ -4245,6 +4298,75 @@
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-query": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.101.4.tgz",
|
||||
"integrity": "sha512-UYjkUZhnWQIFGNb7SdgjCitAftNyYQfOIVUh6vBUQAG4SQKNMSBKeQERFDubpxLAMpIBJTaOrE8fM4c1kZcIGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/query-core": "5.101.4",
|
||||
"@vue/devtools-api": "^6.6.3",
|
||||
"vue-demi": "^0.14.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.1.2",
|
||||
"vue": "^2.6.0 || ^3.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-query-devtools": {
|
||||
"version": "6.1.38",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-query-devtools/-/vue-query-devtools-6.1.38.tgz",
|
||||
"integrity": "sha512-JrZR8p+0v+eRXANudvTzfj5robfr6xh2rjZEqIiHbbxFgtdoTnQAJpoB7sQBw4xfvO8VCoG/xFaSnpPX1AjihA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.101.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/vue-query": "^5.101.4",
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-query/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-store/-/vue-store-0.11.1.tgz",
|
||||
@@ -15018,6 +15140,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/remove-accents": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
|
||||
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/renderkid": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
"@openobserve/node-sql-parser": "^0.1.5",
|
||||
"@openobserve/rrweb-player": "^0.2.1",
|
||||
"@rudderstack/analytics-js": "3.6.0",
|
||||
"@tanstack/query-persist-client-core": "^5.101.4",
|
||||
"@tanstack/vue-form": "^1.33.3",
|
||||
"@tanstack/vue-query": "^5.101.4",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.35",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -30,11 +30,14 @@ vi.mock("@/aws-exports", () => ({
|
||||
|
||||
// Mock license_server service.
|
||||
const mockGetLicense = vi.fn();
|
||||
vi.mock("@/services/license_server", () => ({
|
||||
default: {
|
||||
get_license: (...args: any[]) => mockGetLicense(...args),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/license_server", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
get_license: (...args: any[]) => mockGetLicense(...args),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the lazy chart renderer so the component does not try to load echarts.
|
||||
vi.mock("@/components/dashboards/panels/ChartRenderer.vue", () => ({
|
||||
|
||||
@@ -378,13 +378,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { licenseQuery } from "@/services/license_server.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { defineComponent, ref, computed, watch, defineAsyncComponent } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useRouter } from "vue-router";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import config from "@/aws-exports";
|
||||
import licenseServer from "@/services/license_server";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
@@ -477,8 +478,7 @@ export default defineComponent({
|
||||
if (isEnterprise && hasLicense && !isCloud) {
|
||||
isLoadingLicense.value = true;
|
||||
try {
|
||||
const response = await licenseServer.get_license();
|
||||
licenseData.value = response.data;
|
||||
licenseData.value = await queryClient.fetchQuery(licenseQuery());
|
||||
// Generate dashboard after license data is fetched
|
||||
generateUsageDashboard();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1386,8 +1386,7 @@ import { useTypewriterPlaceholder } from "@/components/ai-assistant/welcome/useT
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/github.css";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { marked } from "marked";
|
||||
import { MarkedOptions } from "marked";
|
||||
import { marked, MarkedOptions } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useStore } from "vuex";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
@@ -37,15 +37,18 @@ vi.mock("vue-router", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/services/ai_toolsets", () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/ai_toolsets", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/lib/feedback/Toast/useToast", () => ({
|
||||
toast: vi.fn(() => vi.fn()),
|
||||
|
||||
@@ -421,7 +421,8 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import OStep from "@/lib/navigation/Stepper/OStep.vue";
|
||||
import OStepper from "@/lib/navigation/Stepper/OStepper.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import alertsService from "@/services/alerts";
|
||||
import { saveAlertMutation } from "@/services/alerts.queries";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import type { AlertLibraryEntry, AlertLibraryFile } from "@/types/alertLibrary";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
@@ -757,6 +758,9 @@ const setResult = (id: string, patch: Partial<InstallResult>) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Each create drops the scope itself, so nothing refreshes after the batch.
|
||||
const installAlert = useMutation(() => saveAlertMutation(orgIdentifier(), () => false));
|
||||
|
||||
const install = async (ids: string[]) => {
|
||||
if (isInstalling.value) return;
|
||||
// Defensive: every path to step 5 sets this, but installing against an empty
|
||||
@@ -814,7 +818,7 @@ const install = async (ids: string[]) => {
|
||||
overrides: batchOverrides,
|
||||
});
|
||||
|
||||
await alertsService.create_by_alert_id(orgIdentifier(), payload, folderId.value);
|
||||
await installAlert.mutateAsync({ payload, folderId: folderId.value });
|
||||
setResult(entry.id, { status: "installed" });
|
||||
installedNow.push(entry.id);
|
||||
} catch (error) {
|
||||
|
||||
@@ -47,7 +47,10 @@ vi.mock("@/composables/useFunctions", () => ({
|
||||
default: () => ({ getAllFunctions: vi.fn().mockResolvedValue({ functions: [] }) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/search", () => ({ default: { search: vi.fn() } }));
|
||||
vi.mock("@/services/search", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), { default: { search: vi.fn() } });
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useParser", () => ({
|
||||
default: () => ({
|
||||
@@ -77,38 +80,44 @@ vi.mock("@/utils/zincutils", async () => {
|
||||
// Toast returns a dismiss fn (loading toast). No-op in tests.
|
||||
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn(() => vi.fn()) }));
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
create_by_alert_id: vi.fn(() =>
|
||||
Promise.resolve({ data: { code: 200, message: "Alert saved" } }),
|
||||
),
|
||||
update_by_alert_id: vi.fn(() => Promise.resolve({ data: { success: true } })),
|
||||
generate_sql: vi.fn(() => Promise.resolve({ data: { sql: "SELECT * FROM test" } })),
|
||||
validateComposite: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
valid: true,
|
||||
canonical_expression: "({id-a} && {id-b})",
|
||||
children: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
result: true,
|
||||
result_level: "critical",
|
||||
},
|
||||
}),
|
||||
),
|
||||
listByFolderId: vi.fn(() => Promise.resolve({ data: { list: [] } })),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
create_by_alert_id: vi.fn(() =>
|
||||
Promise.resolve({ data: { code: 200, message: "Alert saved" } }),
|
||||
),
|
||||
update_by_alert_id: vi.fn(() => Promise.resolve({ data: { success: true } })),
|
||||
generate_sql: vi.fn(() => Promise.resolve({ data: { sql: "SELECT * FROM test" } })),
|
||||
validateComposite: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
valid: true,
|
||||
canonical_expression: "({id-a} && {id-b})",
|
||||
children: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
result: true,
|
||||
result_level: "critical",
|
||||
},
|
||||
}),
|
||||
),
|
||||
listByFolderId: vi.fn(() => Promise.resolve({ data: { list: [] } })),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/anomaly_detection", () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
create: vi.fn(() => Promise.resolve({ data: { id: "anom-1" } })),
|
||||
update: vi.fn(() => Promise.resolve({ data: { id: "anom-1" } })),
|
||||
triggerTraining: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/anomaly_detection", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
create: vi.fn(() => Promise.resolve({ data: { id: "anom-1" } })),
|
||||
update: vi.fn(() => Promise.resolve({ data: { id: "anom-1" } })),
|
||||
triggerTraining: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/segment_analytics", () => ({ default: { track: vi.fn() } }));
|
||||
vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) }));
|
||||
|
||||
@@ -43,13 +43,16 @@ vi.mock("vue-router", () => ({
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: {
|
||||
create: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
update: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
test: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
create: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
update: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
test: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/users", () => ({
|
||||
default: {
|
||||
|
||||
@@ -472,10 +472,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OPageLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { saveDestinationMutation } from "@/services/alert_destination.queries";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { ref, computed, onBeforeMount, onActivated, watch } from "vue";
|
||||
import type { PropType } from "vue";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import { useStore } from "vuex";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
@@ -654,6 +656,13 @@ const slackManifestReady = ref(false);
|
||||
const originalSlackWebhookUrl = ref("");
|
||||
|
||||
const isUpdatingDestination = ref(false);
|
||||
|
||||
const orgId = useOrgId();
|
||||
// One mutation for both branches: the caller picks create vs update, the
|
||||
// declaration owns the cache consequence.
|
||||
const saveDestinationWrite = useMutation(() =>
|
||||
saveDestinationMutation(orgId.value, () => isUpdatingDestination.value),
|
||||
);
|
||||
const router = useRouter();
|
||||
|
||||
// Prebuilt destinations composable
|
||||
@@ -1294,6 +1303,8 @@ async function handlePrebuiltSave(value: AddDestinationForm) {
|
||||
);
|
||||
}
|
||||
|
||||
// `createDestination`/`updateDestination` above come from usePrebuiltDestinations,
|
||||
// which invalidates the scope itself.
|
||||
emit("get:destinations");
|
||||
emit("cancel:hideform");
|
||||
} catch {
|
||||
@@ -1341,12 +1352,8 @@ function saveCustomDestination(value: AddDestinationForm) {
|
||||
button: "Update Destination",
|
||||
page: "Add Destination",
|
||||
});
|
||||
return destinationService
|
||||
.update({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
destination_name: value.name,
|
||||
data: payload,
|
||||
})
|
||||
return saveDestinationWrite
|
||||
.mutateAsync({ destination_name: value.name, data: payload })
|
||||
.then(() => {
|
||||
dismiss();
|
||||
emit("get:destinations");
|
||||
@@ -1371,12 +1378,8 @@ function saveCustomDestination(value: AddDestinationForm) {
|
||||
button: "Create Destination",
|
||||
page: "Add Destination",
|
||||
});
|
||||
return destinationService
|
||||
.create({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
destination_name: value.name,
|
||||
data: payload,
|
||||
})
|
||||
return saveDestinationWrite
|
||||
.mutateAsync({ destination_name: value.name, data: payload })
|
||||
.then(() => {
|
||||
dismiss();
|
||||
emit("get:destinations");
|
||||
|
||||
@@ -7,13 +7,19 @@ import AddExternalAlertSource from "./AddExternalAlertSource.vue";
|
||||
import alertSources from "@/services/alert_sources";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
|
||||
vi.mock("@/services/alert_sources", () => ({
|
||||
default: { create: vi.fn(), listSenders: vi.fn(), update: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/alert_sources", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: { create: vi.fn(), listSenders: vi.fn(), update: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: { list: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: { list: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
// ODrawer teleports its body, which wrapper.find()/html() can't traverse.
|
||||
// Same stub the codebase uses in AddRegexPattern.spec.ts.
|
||||
|
||||
@@ -24,12 +24,15 @@ vi.mock("vue-router", () => ({
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alert_templates", () => ({
|
||||
default: {
|
||||
create: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
update: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_templates", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
create: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
update: vi.fn().mockResolvedValue({ data: { code: 200 } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/reodotdev_analytics", () => ({
|
||||
useReo: () => ({ track: vi.fn() }),
|
||||
|
||||
@@ -283,12 +283,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OPageLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { saveTemplateMutation } from "@/services/alert_templates.queries";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { ref, onActivated, computed, watch, defineAsyncComponent } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useI18nTyped, raw } from "@/types/i18n";
|
||||
|
||||
import templateService from "@/services/alert_templates";
|
||||
import { useStore } from "vuex";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
@@ -343,9 +344,13 @@ const QueryEditor = defineAsyncComponent(() => import("@/components/CodeQueryEdi
|
||||
const { t } = useI18nTyped();
|
||||
const splitterModel: Ref<number> = ref(55);
|
||||
const { isMobile } = useBreakpoint();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const isUpdatingTemplate = ref(false);
|
||||
|
||||
const orgIdForWrites = useOrgId();
|
||||
const saveTemplateWrite = useMutation(() =>
|
||||
saveTemplateMutation(orgIdForWrites.value, () => isUpdatingTemplate.value),
|
||||
);
|
||||
const { track } = useReo();
|
||||
|
||||
// Owner pattern (Rule ③): AddTemplate OWNS <OForm>, and it needs to read `type`
|
||||
@@ -751,12 +756,8 @@ async function saveTemplate(value: AddTemplateForm) {
|
||||
};
|
||||
|
||||
if (isUpdatingTemplate.value) {
|
||||
const request = templateService
|
||||
.update({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
template_name: value.name,
|
||||
data,
|
||||
})
|
||||
const request = saveTemplateWrite
|
||||
.mutateAsync({ template_name: value.name, data })
|
||||
.then(onSuccess)
|
||||
.catch(onError);
|
||||
track("Button Click", {
|
||||
@@ -766,12 +767,8 @@ async function saveTemplate(value: AddTemplateForm) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const request = templateService
|
||||
.create({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
template_name: value.name,
|
||||
data,
|
||||
})
|
||||
const request = saveTemplateWrite
|
||||
.mutateAsync({ template_name: value.name, data })
|
||||
.then(onSuccess)
|
||||
.catch(onError);
|
||||
track("Button Click", {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, flushPromises, type VueWrapper } from "@vue/test-utils";
|
||||
import { defineComponent } from "vue";
|
||||
import { createStore } from "vuex";
|
||||
import i18n from "@/locales";
|
||||
import AlertDestinationsField from "./AlertDestinationsField.vue";
|
||||
|
||||
const listWorkflowsMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
data: [
|
||||
{ id: "wf-1", name: "Escalate", is_draft: false },
|
||||
{ id: "wf-2", name: "Unfinished", is_draft: true },
|
||||
],
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@/aws-exports", () => ({
|
||||
default: { isEnterprise: "true", isCloud: "false" },
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ resolve: () => ({ href: "" }) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/workflows", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
listWorkflows: (...args: any[]) => listWorkflowsMock(...args),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const TargetsStub = defineComponent({
|
||||
name: "AlertTargetsSelect",
|
||||
props: ["workflowOptions", "workflowsEnabled"],
|
||||
emits: ["refresh"],
|
||||
template: "<div />",
|
||||
});
|
||||
|
||||
function mountField() {
|
||||
const store = createStore({
|
||||
state: {
|
||||
selectedOrganization: { identifier: "test-org" },
|
||||
zoConfig: { workflows_enabled: true },
|
||||
},
|
||||
});
|
||||
return mount(AlertDestinationsField, {
|
||||
props: { destinations: [], workflows: [], destinationOptions: [] },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { AlertTargetsSelect: TargetsStub, OIcon: true, OTooltip: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("AlertDestinationsField workflows", () => {
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
listWorkflowsMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
});
|
||||
|
||||
it("offers only published workflows", async () => {
|
||||
wrapper = mountField();
|
||||
await flushPromises();
|
||||
|
||||
const options = wrapper.findComponent(TargetsStub).props("workflowOptions") as any[];
|
||||
expect(options.map((o) => o.value)).toEqual(["wf-1"]);
|
||||
});
|
||||
|
||||
it("serves a reopened form from the cache", async () => {
|
||||
wrapper = mountField();
|
||||
await flushPromises();
|
||||
wrapper.unmount();
|
||||
|
||||
wrapper = mountField();
|
||||
await flushPromises();
|
||||
|
||||
expect(listWorkflowsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A workflow made in the new tab never expires this tab's cache, so only a forced read shows it.
|
||||
it("re-reads workflows from the server on refresh and asks the parent for destinations", async () => {
|
||||
wrapper = mountField();
|
||||
await flushPromises();
|
||||
expect(listWorkflowsMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.findComponent(TargetsStub).vm.$emit("refresh");
|
||||
await flushPromises();
|
||||
|
||||
expect(listWorkflowsMock).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.emitted("refresh")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,8 @@ import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import AlertTargetsSelect from "@/components/alerts/AlertTargetsSelect.vue";
|
||||
import workflowService from "@/services/workflows";
|
||||
import { workflowsQuery } from "@/services/workflows.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import config from "@/aws-exports";
|
||||
|
||||
type RawOption = string | { label: I18nText; value: string };
|
||||
@@ -129,11 +130,19 @@ const workflowsEnabled = computed(
|
||||
);
|
||||
|
||||
const workflowOptions = ref<{ label: I18nText; value: string }[]>([]);
|
||||
const fetchWorkflows = async () => {
|
||||
const fetchWorkflows = async (force = false) => {
|
||||
if (!workflowsEnabled.value) return;
|
||||
try {
|
||||
const res = await workflowService.listWorkflows(store.state.selectedOrganization.identifier);
|
||||
const list = Array.isArray(res.data) ? res.data : (res.data?.list ?? []);
|
||||
const options = workflowsQuery(store.state.selectedOrganization.identifier);
|
||||
// A workflow created in the new tab never expires this tab's cache, so refresh must force.
|
||||
if (force) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
const list = await queryClient.fetchQuery(options);
|
||||
// Drafts aren't runnable/published, so they can't be linked to an alert —
|
||||
// only offer non-draft (published) workflows.
|
||||
workflowOptions.value = list
|
||||
@@ -143,12 +152,12 @@ const fetchWorkflows = async () => {
|
||||
workflowOptions.value = [];
|
||||
}
|
||||
};
|
||||
onMounted(fetchWorkflows);
|
||||
onMounted(() => fetchWorkflows());
|
||||
|
||||
// The combined field's single refresh reloads both lists.
|
||||
const refreshTargets = () => {
|
||||
emit("refresh");
|
||||
fetchWorkflows();
|
||||
fetchWorkflows(true);
|
||||
};
|
||||
|
||||
// New tab, not navigation: leaving the form here would discard the alert
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
getHistory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
getHistory: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import AlertEvaluationHistory from "./AlertEvaluationHistory.vue";
|
||||
import i18n from "@/locales";
|
||||
|
||||
@@ -61,16 +61,14 @@
|
||||
</template>
|
||||
|
||||
<template #toolbar-trailing>
|
||||
<OButton
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:last-run-at="lastUpdatedAt"
|
||||
:loading="loading"
|
||||
data-test="alerts-alertevaluationhistory-refresh"
|
||||
@click="fetchHistory"
|
||||
>
|
||||
<OTooltip side="bottom" :content="t('alerts.groups.refresh')" />
|
||||
</OButton>
|
||||
@click="refreshHistory"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-timestamp="{ row }">
|
||||
@@ -157,12 +155,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { alertHistoryQuery } from "@/services/alerts.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
|
||||
@@ -170,7 +170,6 @@ import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import alertsService from "@/services/alerts";
|
||||
import { conditionSummary } from "@/utils/alerts/runOutcome";
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -183,6 +182,7 @@ const store = useStore();
|
||||
|
||||
const history = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const totalCount = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(25);
|
||||
@@ -220,14 +220,28 @@ const conditionText = (row: Record<string, unknown>): string => {
|
||||
return conditionSummary(row);
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
// Named handler: binding fetchHistory straight to @click would put the
|
||||
// MouseEvent in `force`.
|
||||
const refreshHistory = () => fetchHistory(true);
|
||||
|
||||
const fetchHistory = async (force = false) => {
|
||||
const orgId = store.state.selectedOrganization?.identifier;
|
||||
if (!orgId || !props.alertId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const endTime = Date.now() * 1000;
|
||||
const startTime = endTime - (RANGE_MS[range.value] ?? RANGE_MS["1h"]) * 1000;
|
||||
const res = await alertsService.getHistory(orgId, {
|
||||
const read = <T,>(options: any): Promise<T> => {
|
||||
if (force) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
return queryClient.fetchQuery(options);
|
||||
};
|
||||
const opts = alertHistoryQuery(orgId, {
|
||||
// An anomaly id fails the endpoint's `alert_id` existence check outright.
|
||||
...(props.isAnomaly ? { anomaly_id: props.alertId } : { alert_id: props.alertId }),
|
||||
start_time: startTime,
|
||||
@@ -235,8 +249,11 @@ const fetchHistory = async () => {
|
||||
from: (currentPage.value - 1) * pageSize.value,
|
||||
size: pageSize.value,
|
||||
});
|
||||
history.value = res.data?.hits || [];
|
||||
totalCount.value = res.data?.total || 0;
|
||||
const data = await read<any>(opts);
|
||||
// The cache records the fetch time; fetchQuery does not hand it back, so read it here.
|
||||
lastUpdatedAt.value = queryClient.getQueryState(opts.queryKey)?.dataUpdatedAt ?? Date.now();
|
||||
history.value = data?.hits || [];
|
||||
totalCount.value = data?.total || 0;
|
||||
} catch {
|
||||
history.value = [];
|
||||
totalCount.value = 0;
|
||||
|
||||
@@ -24,12 +24,15 @@ import router from "@/test/unit/helpers/router";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
listByFolderId: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
listByFolderId: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock formatDate from utils/date to prevent the local shadowing recursion
|
||||
// (AlertHistory.vue defines its own formatDate that calls the imported one,
|
||||
@@ -207,6 +210,8 @@ describe("AlertHistory.vue", () => {
|
||||
expect.any(String),
|
||||
"",
|
||||
"",
|
||||
// alertType — part of the list key, so it rides along on every call.
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
icon-left="refresh"
|
||||
@click="refreshData"
|
||||
data-test="alert-history-refresh-btn"
|
||||
:loading="loading"
|
||||
:loading="fetching"
|
||||
>
|
||||
<OTooltip :content="t('common.refresh')" />
|
||||
</OButton>
|
||||
@@ -537,7 +537,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import type { AlertHistoryQuery } from "@/services/alerts";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { alertHistoryQuery, alertsListQuery } from "@/services/alerts.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { ref, onMounted, watch, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
@@ -546,7 +551,6 @@ import DateTime from "@/components/DateTime.vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import alertsService from "@/services/alerts";
|
||||
import NoData from "@/components/shared/grid/NoData.vue";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
@@ -567,7 +571,33 @@ const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Data
|
||||
const loading = ref(false);
|
||||
// The query shape this page is currently reading (range + page + sort +
|
||||
// filter). Held reactively so the key forks per shape — paging back to a page
|
||||
// already seen renders from cache instead of blanking the table.
|
||||
const orgIdForHistory = useOrgId();
|
||||
const readQuery = ref<AlertHistoryQuery | null>(null);
|
||||
const hasRequestedHistory = ref(false);
|
||||
|
||||
// `enabled: false` does not stop the options factory from running, and the key
|
||||
// builder reads fields off the query — so a placeholder stands in until the page
|
||||
// has actually asked for a page of history.
|
||||
const EMPTY_HISTORY_QUERY = {
|
||||
start_time: 0,
|
||||
end_time: 0,
|
||||
from: 0,
|
||||
size: 0,
|
||||
} as unknown as AlertHistoryQuery;
|
||||
|
||||
const historyList = useQuery(() =>
|
||||
Object.assign(alertHistoryQuery(orgIdForHistory.value, readQuery.value ?? EMPTY_HISTORY_QUERY), {
|
||||
enabled: hasRequestedHistory.value && !!readQuery.value && !!orgIdForHistory.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const loading = historyList.isLoading;
|
||||
// A request in flight while rows stay on screen — the refresh button's
|
||||
// spinner. `loading` is the skeleton, which only a cold read wants.
|
||||
const fetching = historyList.isFetching;
|
||||
const rows = ref<any[]>([]);
|
||||
const searchQuery = ref("");
|
||||
const selectedAlert = ref<any>(null);
|
||||
@@ -736,28 +766,18 @@ const fetchAlertsList = async () => {
|
||||
try {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
|
||||
// Fetch all alerts for the organization
|
||||
const res = await alertsService.listByFolderId(
|
||||
1,
|
||||
1000,
|
||||
"name",
|
||||
false,
|
||||
"",
|
||||
org,
|
||||
"", // all folders
|
||||
"", // no query filter
|
||||
);
|
||||
// All folders ("") — through the cached list read, so revisits share the
|
||||
// entry instead of re-downloading every alert for the name filter.
|
||||
const list = await queryClient.fetchQuery(alertsListQuery(org, ""));
|
||||
|
||||
if (res.data && res.data.list) {
|
||||
// Store complete alert objects and sort by name
|
||||
allAlerts.value = res.data.list
|
||||
.map((alert: any) => ({
|
||||
label: alert.name,
|
||||
value: alert.alert_id,
|
||||
}))
|
||||
.sort((a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label));
|
||||
filteredAlertOptions.value = [...allAlerts.value];
|
||||
}
|
||||
// Store complete alert objects and sort by name
|
||||
allAlerts.value = (list ?? [])
|
||||
.map((alert: any) => ({
|
||||
label: alert.name,
|
||||
value: alert.alert_id,
|
||||
}))
|
||||
.sort((a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label));
|
||||
filteredAlertOptions.value = [...allAlerts.value];
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching alerts list:", error);
|
||||
// Silently fail - user can still type alert names manually
|
||||
@@ -782,11 +802,12 @@ const clearSearch = () => {
|
||||
|
||||
const manualSearch = () => {
|
||||
currentPage.value = 1;
|
||||
fetchAlertHistory();
|
||||
// The user explicitly asked for results, so this always hits the server even
|
||||
// when the filter shape is unchanged.
|
||||
fetchAlertHistory(true);
|
||||
};
|
||||
|
||||
const fetchAlertHistory = async () => {
|
||||
loading.value = true;
|
||||
const fetchAlertHistory = async (force = false) => {
|
||||
try {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
|
||||
@@ -809,16 +830,40 @@ const fetchAlertHistory = async () => {
|
||||
query.sort_order = sortOrder.value;
|
||||
}
|
||||
|
||||
const response = await alertsService.getHistory(org, query);
|
||||
if (response.data) {
|
||||
const historyData = response.data;
|
||||
|
||||
rows.value = (historyData.hits || []).map((hit: any, index: number) => ({
|
||||
const applyHistory = (data: any) => {
|
||||
rows.value = (data.hits || []).map((hit: any, index: number) => ({
|
||||
...hit,
|
||||
id: `${hit.timestamp}_${index}`,
|
||||
}));
|
||||
totalCount.value = data.total || 0;
|
||||
};
|
||||
|
||||
totalCount.value = historyData.total || 0;
|
||||
// Cached per query shape (range + page + sort + filter), so paging back to
|
||||
// a page already seen renders from cache instead of blanking the table.
|
||||
// Stale-while-revalidate otherwise: the page keeps its rows while the
|
||||
// refetch runs.
|
||||
readQuery.value = query;
|
||||
// Let the key pick up the new query shape before asking for the data.
|
||||
await nextTick();
|
||||
|
||||
if (!hasRequestedHistory.value) {
|
||||
hasRequestedHistory.value = true;
|
||||
await nextTick();
|
||||
await historyList.suspense();
|
||||
} else if (force) {
|
||||
await historyList.refetch();
|
||||
} else {
|
||||
await historyList.suspense();
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
{
|
||||
if (historyList.data.value) applyHistory(historyList.data.value);
|
||||
|
||||
const nextFrom = currentPage.value * pageSize.value;
|
||||
if (nextFrom < totalCount.value) {
|
||||
queryClient.prefetchQuery(alertHistoryQuery(org, { ...query, from: nextFrom.toString() }));
|
||||
}
|
||||
|
||||
if (rows.value.length === 0) {
|
||||
console.warn("No alert history found for the selected time range");
|
||||
@@ -831,8 +876,6 @@ const fetchAlertHistory = async () => {
|
||||
variant: "error",
|
||||
message: error.response?.data?.message || error.message || t("alerts.failedToFetchHistory"),
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -872,7 +915,7 @@ const onSortChange = (params: { column: string; order: "asc" | "desc" }) => {
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
fetchAlertHistory();
|
||||
fetchAlertHistory(true);
|
||||
};
|
||||
|
||||
const formatHistoryDate = (timestamp: number) => {
|
||||
|
||||
@@ -22,17 +22,23 @@ import store from "@/test/unit/helpers/store";
|
||||
import router from "@/test/unit/helpers/router";
|
||||
|
||||
// Mock services
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
getHistory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
getHistory: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/anomaly_detection", () => ({
|
||||
default: {
|
||||
getConfig: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/anomaly_detection", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
getConfig: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/utils/alerts/anomalySqlBuilder", () => ({
|
||||
buildAnomalyPreviewSql: vi.fn(() => "SELECT * FROM anomalies"),
|
||||
|
||||
@@ -380,6 +380,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { alertHistoryQuery } from "@/services/alerts.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import OTabPanels from "@/lib/navigation/Tabs/OTabPanels.vue";
|
||||
import OTabPanel from "@/lib/navigation/Tabs/OTabPanel.vue";
|
||||
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
|
||||
@@ -397,7 +399,7 @@ import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import DateTime from "@/components/DateTime.vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import { COL } from "@/lib/core/Table/OTable.types";
|
||||
import alertsService from "@/services/alerts";
|
||||
import type { AlertHistoryQuery } from "@/services/alerts";
|
||||
import anomalyDetectionService from "@/services/anomaly_detection";
|
||||
import { buildAnomalyPreviewSql } from "@/utils/alerts/anomalySqlBuilder";
|
||||
import type { Ref } from "vue";
|
||||
@@ -780,7 +782,7 @@ const fetchAlertHistory = async (alertId: string) => {
|
||||
const endTime = dateTimeValues.value.endTime;
|
||||
const from = (currentPage.value - 1) * selectedPerPage.value;
|
||||
|
||||
const historyParams: Record<string, any> = {
|
||||
const historyParams: AlertHistoryQuery = {
|
||||
size: selectedPerPage.value,
|
||||
from: from,
|
||||
start_time: startTime,
|
||||
@@ -791,12 +793,13 @@ const fetchAlertHistory = async (alertId: string) => {
|
||||
} else {
|
||||
historyParams.alert_id = alertId;
|
||||
}
|
||||
const response = await alertsService.getHistory(
|
||||
store?.state?.selectedOrganization?.identifier,
|
||||
historyParams,
|
||||
// Same cached page query as the Alert History page — paging back to a page
|
||||
// already fetched keeps its rows instead of blanking.
|
||||
const data = await queryClient.fetchQuery(
|
||||
alertHistoryQuery(store?.state?.selectedOrganization?.identifier, historyParams),
|
||||
);
|
||||
alertHistory.value = response.data?.hits || [];
|
||||
resultTotal.value = response.data?.total || 0;
|
||||
alertHistory.value = data?.hits || [];
|
||||
resultTotal.value = data?.total || 0;
|
||||
} catch (error: any) {
|
||||
alertHistory.value = [];
|
||||
resultTotal.value = 0;
|
||||
|
||||
@@ -24,18 +24,21 @@ vi.mock("vue-router", () => ({
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
listByFolderId: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
list: [
|
||||
{ alert_id: "a1", name: "TestAlert", folder_id: "default" },
|
||||
{ alert_id: "a2", name: "OtherAlert", folder_id: "default" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
listByFolderId: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
list: [
|
||||
{ alert_id: "a1", name: "TestAlert", folder_id: "default" },
|
||||
{ alert_id: "a2", name: "OtherAlert", folder_id: "default" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useAlertInsights", async () => {
|
||||
const { ref } = await import("vue");
|
||||
|
||||
@@ -29,31 +29,40 @@ vi.mock("@/services/oncall", () => ({
|
||||
default: { listTeams: vi.fn().mockResolvedValue({ data: [{ id: "t1", name: "Payments" }] }) },
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
listByFolderId: vi.fn(),
|
||||
get_by_alert_id: vi.fn(),
|
||||
toggle_state_by_alert_id: vi.fn(),
|
||||
delete_by_alert_id: vi.fn(),
|
||||
create_by_alert_id: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
export_by_id: vi.fn(),
|
||||
retrain_by_id: vi.fn(),
|
||||
bulkDelete: vi.fn(),
|
||||
bulkToggleState: vi.fn(),
|
||||
getCompositeReferences: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_templates", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
listByFolderId: vi.fn(),
|
||||
get_by_alert_id: vi.fn(),
|
||||
toggle_state_by_alert_id: vi.fn(),
|
||||
delete_by_alert_id: vi.fn(),
|
||||
create_by_alert_id: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
export_by_id: vi.fn(),
|
||||
retrain_by_id: vi.fn(),
|
||||
bulkDelete: vi.fn(),
|
||||
bulkToggleState: vi.fn(),
|
||||
getCompositeReferences: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.mock("@/services/alert_templates", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import AlertList from "@/components/alerts/AlertList.vue";
|
||||
import config from "@/aws-exports";
|
||||
@@ -504,6 +513,20 @@ describe("AlertList - data fetching and columns", () => {
|
||||
expect(wrapper.vm.filteredResults.length).toBe(alertsDB.length);
|
||||
});
|
||||
|
||||
// A destination made in the form's new tab never expires this tab's cache, so only a forced read shows it.
|
||||
it("re-reads destinations from the server when the form asks for a refresh", async () => {
|
||||
const wrapper: any = await mountAlertList();
|
||||
await waitData(wrapper);
|
||||
await flushPromises();
|
||||
const afterMount = destinationsSvc.list.mock.calls.length;
|
||||
expect(afterMount).toBeGreaterThan(0);
|
||||
|
||||
await wrapper.vm.refreshDestination();
|
||||
await flushPromises();
|
||||
|
||||
expect(destinationsSvc.list).toHaveBeenCalledTimes(afterMount + 1);
|
||||
});
|
||||
|
||||
// period, state, level, last_trained_at are intentionally absent (config
|
||||
// detail, or duplicate a neighbouring column); frequency remains — it is
|
||||
// the list's only visible cadence signal for non-realtime alerts.
|
||||
|
||||
@@ -216,20 +216,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-trailing>
|
||||
<OButton
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:last-run-at="lastUpdatedAt"
|
||||
:loading="fetching"
|
||||
shortcut-id="alertsRefresh"
|
||||
data-test="alert-list-refresh-btn"
|
||||
@click="refreshAlerts"
|
||||
>
|
||||
<OTooltip
|
||||
side="bottom"
|
||||
:content="t('alerts.reloadAlertsTooltip')"
|
||||
shortcut-id="alertsRefresh"
|
||||
/>
|
||||
</OButton>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
@@ -814,7 +809,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@update:list="refreshList"
|
||||
@cancel:hideform="hideForm"
|
||||
@refresh:destinations="refreshDestination"
|
||||
@refresh:templates="getTemplates"
|
||||
@refresh:templates="refreshTemplates"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -926,6 +921,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { alertDetailQuery } from "@/services/alerts.queries";
|
||||
import { alertsListQuery } from "@/services/alerts.queries";
|
||||
import { alertKeys } from "@/services/alerts.querykeys";
|
||||
import { anomalyKeys } from "@/services/anomaly_detection.querykeys";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import {
|
||||
cloneAnomalyAlertMutation,
|
||||
retrainAnomalyMutation,
|
||||
toggleAlertStateMutation,
|
||||
toggleAnomalyAlertStateMutation,
|
||||
} from "@/services/alerts.queries";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { useOrgId } from "@/composables/query";
|
||||
import { destinationsQuery } from "@/services/alert_destination.queries";
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
@@ -937,6 +946,7 @@ import {
|
||||
onMounted,
|
||||
computed,
|
||||
reactive,
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import type { Ref } from "vue";
|
||||
@@ -958,8 +968,7 @@ import {
|
||||
sloIdOf,
|
||||
} from "@/utils/alerts/sloAlertRouting";
|
||||
import sloService from "@/services/slos";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import templateService from "@/services/alert_templates";
|
||||
import { templatesQuery } from "@/services/alert_templates.queries";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import segment from "@/services/segment_analytics";
|
||||
@@ -975,7 +984,6 @@ import FolderList from "../common/sidebar/FolderList.vue";
|
||||
|
||||
import MoveAcrossFolders from "../common/sidebar/MoveAcrossFolders.vue";
|
||||
import { invalidateDependencyGraphCache } from "@/composables/alerts/useDependencyGraph";
|
||||
import { nextTick } from "vue";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import SelectFolderDropDown from "../common/sidebar/SelectFolderDropDown.vue";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
@@ -983,6 +991,7 @@ import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
|
||||
@@ -1007,6 +1016,7 @@ import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { COL } from "@/lib/core/Table/OTable.types";
|
||||
|
||||
// import alertList from "./alerts";
|
||||
|
||||
export default defineComponent({
|
||||
@@ -1025,6 +1035,7 @@ export default defineComponent({
|
||||
OTooltip,
|
||||
SelectFolderDropDown,
|
||||
OButton,
|
||||
ORefreshButton,
|
||||
OIcon,
|
||||
ODialog,
|
||||
ODropdown,
|
||||
@@ -1107,6 +1118,10 @@ export default defineComponent({
|
||||
// Start in the loading state so the table shows the skeleton on first
|
||||
// render instead of briefly flashing the empty state before the fetch.
|
||||
const loading = ref(true);
|
||||
// A request in flight while rows stay on screen — the refresh button's
|
||||
// spinner. `loading` is the skeleton, which only a cold read wants.
|
||||
const fetching = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const forbidden = ref(false);
|
||||
const oTableRef: any = ref(null);
|
||||
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
|
||||
@@ -1775,6 +1790,19 @@ export default defineComponent({
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalizes an anomaly-detection item returned by the merged alerts list API
|
||||
// (alert_type === "anomaly_detection") into the standard alert row shape.
|
||||
// A row's uuid keys `alertStateLoadingMap` and the enable/disable match, so
|
||||
// it has to survive a re-paint of the same list — a fresh getUUID() per map
|
||||
// would hand the same alert a new identity on every revalidation.
|
||||
const rowUuids = new Map<string, string>();
|
||||
const stableUuid = (identity: string) => {
|
||||
let uuid = rowUuids.get(identity);
|
||||
if (!uuid) {
|
||||
uuid = getUUID();
|
||||
rowUuids.set(identity, uuid);
|
||||
}
|
||||
return uuid;
|
||||
};
|
||||
|
||||
const normalizeAnomalyToAlertRow = (anomaly: any, _num?: number): any => ({
|
||||
alert_id: anomaly.alert_id || anomaly.anomaly_id || anomaly.id,
|
||||
anomaly_id: anomaly.alert_id || anomaly.anomaly_id || anomaly.id,
|
||||
@@ -1791,7 +1819,7 @@ export default defineComponent({
|
||||
filters: anomaly.filters || [],
|
||||
histogram_interval: anomaly.histogram_interval || "",
|
||||
description: anomaly.description || "",
|
||||
uuid: getUUID(),
|
||||
uuid: stableUuid(anomaly.alert_id || anomaly.anomaly_id || anomaly.id || anomaly.name),
|
||||
owner: anomaly.owner || "",
|
||||
period: anomaly.trigger_condition?.period ?? "",
|
||||
frequency: anomaly.trigger_condition?.frequency ?? "",
|
||||
@@ -1848,20 +1876,10 @@ export default defineComponent({
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The folder is part of the query key, so a revisit inside the tier's
|
||||
// staleTime resolves from cache with no request and no special loading path.
|
||||
const getAlertsByFolderId = async (store: any, folderId: any) => {
|
||||
//this is the condition where we are fetching the alerts from the server
|
||||
// assigning it to the allAlertsListByFolderId in the store
|
||||
if (!store.state.organizationData.allAlertsListByFolderId[folderId]) {
|
||||
await getAlertsFn(store, folderId);
|
||||
} else {
|
||||
//this is the condition where we are assigning the alerts to the filteredResults so whenever
|
||||
// we are not fetching the alerts again, we are just assigning the alerts to the filteredResults
|
||||
allAlerts.value = store.state.organizationData.allAlertsListByFolderId[folderId];
|
||||
// Data is served synchronously from cache — clear the loading flag
|
||||
// (it starts true to avoid the empty-state flash) so the table renders
|
||||
// the cached rows instead of staying stuck on the skeleton.
|
||||
loading.value = false;
|
||||
}
|
||||
await getAlertsFn(store, folderId);
|
||||
};
|
||||
const getAlertsFn = async (
|
||||
store: any,
|
||||
@@ -1869,6 +1887,7 @@ export default defineComponent({
|
||||
query = "",
|
||||
refreshResults = true,
|
||||
alertType = "",
|
||||
force = false,
|
||||
) => {
|
||||
//why refreshResults flag is used
|
||||
// this is the only used for one edge case when we move alerts from one folder to another folder
|
||||
@@ -1890,35 +1909,21 @@ export default defineComponent({
|
||||
//here we reset the filteredResults before fetching the filtered alerts
|
||||
filteredResults.value = [];
|
||||
}
|
||||
const dismiss = toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingAlerts"),
|
||||
timeout: 0,
|
||||
});
|
||||
if (query) {
|
||||
folderId = "";
|
||||
}
|
||||
loading.value = true;
|
||||
forbidden.value = false;
|
||||
try {
|
||||
const res = await alertsService.listByFolderId(
|
||||
1,
|
||||
1000,
|
||||
"name",
|
||||
false,
|
||||
"",
|
||||
store?.state?.selectedOrganization?.identifier,
|
||||
folderId,
|
||||
query,
|
||||
alertType,
|
||||
);
|
||||
|
||||
// Painting the same rows twice is safe: uuids are stable per alert, and
|
||||
// the route-driven dialogs below run once, on the fresh result. Returns
|
||||
// false when the folder changed mid-flight and the render was skipped.
|
||||
const renderAlerts = (rows: any[]): boolean => {
|
||||
var counter = 1;
|
||||
let localAllAlerts = [];
|
||||
//this is the alerts that we use to store
|
||||
localAllAlerts = res.data.list.map((alert: any) => {
|
||||
localAllAlerts = rows.map((alert: any) => {
|
||||
return {
|
||||
...alert,
|
||||
uuid: getUUID(),
|
||||
uuid: stableUuid(alert.alert_id || alert.name),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2059,8 +2064,7 @@ export default defineComponent({
|
||||
//if it is not equal then we are returning and if is not search across folders then we are returning as well as in previous step we are anyways storing in the store for future use
|
||||
//this will prevent the side effects of allAlerts are overriding the actual alerts if users are rapidly moving from one folder to another folder
|
||||
if (folderId != activeFolderId.value && !query) {
|
||||
dismiss();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
//here we are actually assigning the localAllAlerts to the allAlerts to avoid the side effects of allAlerts are overriding the actual alerts if users are rapidly moving from one folder to another folder
|
||||
allAlerts.value = localAllAlerts;
|
||||
@@ -2080,25 +2084,53 @@ export default defineComponent({
|
||||
//here we are filtering the alerts by the activeTab
|
||||
//why we are passing the refreshResults flag as false because we dont need to show the alerts in the table
|
||||
filterAlertsByTab(refreshResults);
|
||||
if (router.currentRoute.value.query.action == "import") {
|
||||
showImportAlertDialog.value = true;
|
||||
}
|
||||
if (router.currentRoute.value.query.action == "add") {
|
||||
showAddUpdateFn({ row: undefined });
|
||||
}
|
||||
if (router.currentRoute.value.query.action == "update") {
|
||||
const alertId = router.currentRoute.value.query.alert_id as string;
|
||||
const alert = await getAlertById(alertId);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Same diversion as the edit button. This path is reached by a hard
|
||||
// reload, a bookmark or the back button, so guarding only the button
|
||||
// would leave the generic editor reachable for an SLO alert.
|
||||
if (!(await divertSloAlert(alert))) {
|
||||
showAddUpdateFn({
|
||||
row: alert,
|
||||
});
|
||||
}
|
||||
const org = store?.state?.selectedOrganization?.identifier;
|
||||
|
||||
// Stale-while-revalidate: the rows already on screen stay put while the
|
||||
// folder revalidates, so only a cold cache spins and toasts.
|
||||
// renderAlerts reports whether it rendered — the folder can change
|
||||
// mid-flight — so the paint is driven here rather than through `apply`.
|
||||
// Painted even on a manual refresh: the rows stay while the request runs.
|
||||
const cachedRows = queryClient.getQueryData<any[]>(
|
||||
alertKeys.list(org, folderId, query, alertType),
|
||||
);
|
||||
const painted = cachedRows ? renderAlerts(cachedRows) : false;
|
||||
const opts = alertsListQuery(org, folderId, query, alertType);
|
||||
const pending = force
|
||||
? queryClient
|
||||
.invalidateQueries({
|
||||
queryKey: opts.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
})
|
||||
.then(() => queryClient.fetchQuery(opts))
|
||||
: queryClient.fetchQuery(opts);
|
||||
|
||||
loading.value = !painted;
|
||||
fetching.value = true;
|
||||
forbidden.value = false;
|
||||
const dismiss = painted
|
||||
? () => {}
|
||||
: toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingAlerts"),
|
||||
timeout: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!renderAlerts(await pending)) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
// The cache records the fetch time; fetchQuery does not hand it back, so read it here.
|
||||
lastUpdatedAt.value = queryClient.getQueryState(opts.queryKey)?.dataUpdatedAt ?? Date.now();
|
||||
// `?action=…` deep links are handled by the immediate watcher on
|
||||
// `query.action` below. Handling them here too re-opened a blank form
|
||||
// after every save: the post-save refresh reads the route before
|
||||
// hideForm's cleanup push lands, so it still sees the stale `action`.
|
||||
dismiss();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@@ -2113,6 +2145,7 @@ export default defineComponent({
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
fetching.value = false;
|
||||
}
|
||||
};
|
||||
/** Send an SLO alert to its SLO page instead of the generic editor.
|
||||
@@ -2157,12 +2190,14 @@ export default defineComponent({
|
||||
timeout: 0,
|
||||
});
|
||||
try {
|
||||
const res = await alertsService.get_by_alert_id(
|
||||
store.state.selectedOrganization.identifier,
|
||||
id,
|
||||
// Cached for the editor-open path only. The read-modify-write in
|
||||
// WorkflowLinkAlertsDialog deliberately still goes straight to the
|
||||
// service — a stale alert there would overwrite someone else's edit.
|
||||
const data = await queryClient.fetchQuery(
|
||||
alertDetailQuery(store.state.selectedOrganization.identifier, id),
|
||||
);
|
||||
dismiss();
|
||||
return res.data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
dismiss();
|
||||
throw error;
|
||||
@@ -2215,11 +2250,17 @@ export default defineComponent({
|
||||
|
||||
const refreshAlerts = async () => {
|
||||
if (searchAcrossFolders.value && searchQuery.value) {
|
||||
await getAlertsFn(store, activeFolderId.value, searchQuery.value);
|
||||
await getAlertsFn(store, activeFolderId.value, searchQuery.value, true, "", true);
|
||||
} else {
|
||||
await getAlertsFn(store, activeFolderId.value);
|
||||
await getAlertsFn(store, activeFolderId.value, "", true, "", true);
|
||||
}
|
||||
filterAlertsByTab();
|
||||
// Re-apply the in-folder search too. Without it a refresh handed back the
|
||||
// whole folder while the search box still showed the term — the same
|
||||
// shape every other reload path here already guards against.
|
||||
if (filterQuery.value) {
|
||||
filterAlertsByQuery(filterQuery.value);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -2342,52 +2383,61 @@ export default defineComponent({
|
||||
console.error("Navigation failed:", error);
|
||||
}
|
||||
};
|
||||
// The one handler for `?action=…` deep links (hard reload, bookmark, Back).
|
||||
// Named so tests can drive it without racing the router.
|
||||
const handleActionQuery = async (action: unknown) => {
|
||||
if (!action) {
|
||||
showAddAlertDialog.value = false;
|
||||
showImportAlertDialog.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle update action
|
||||
if (action === "update" && router.currentRoute.value.query.alert_id) {
|
||||
const alertId = router.currentRoute.value.query.alert_id as string;
|
||||
try {
|
||||
const alert = await getAlertById(alertId);
|
||||
if (!(await divertSloAlert(alert))) {
|
||||
showAddUpdateFn({ row: alert });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AlertList: Failed to load alert", error);
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("toastMessages.alerts.failedToLoadAlertForEditing"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle add action
|
||||
if (action === "add") {
|
||||
showAddUpdateFn({ row: undefined });
|
||||
}
|
||||
|
||||
// Handle import action
|
||||
if (action === "import") {
|
||||
showImportAlertDialog.value = true;
|
||||
}
|
||||
};
|
||||
watch(
|
||||
() => router.currentRoute.value.query.action,
|
||||
async (action) => {
|
||||
if (!action) {
|
||||
showAddAlertDialog.value = false;
|
||||
showImportAlertDialog.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle update action
|
||||
if (action === "update" && router.currentRoute.value.query.alert_id) {
|
||||
const alertId = router.currentRoute.value.query.alert_id as string;
|
||||
try {
|
||||
const alert = await getAlertById(alertId);
|
||||
if (!(await divertSloAlert(alert))) {
|
||||
showAddUpdateFn({ row: alert });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AlertList: Failed to load alert", error);
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("toastMessages.alerts.failedToLoadAlertForEditing"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle add action
|
||||
if (action === "add") {
|
||||
showAddUpdateFn({ row: undefined });
|
||||
}
|
||||
|
||||
// Handle import action
|
||||
if (action === "import") {
|
||||
showImportAlertDialog.value = true;
|
||||
}
|
||||
},
|
||||
handleActionQuery,
|
||||
{ immediate: true }, // Run immediately to handle direct navigation
|
||||
);
|
||||
const getDestinations = async () => {
|
||||
destinationService
|
||||
.list({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
module: "alert",
|
||||
})
|
||||
.then((res) => {
|
||||
destinations.value = res.data;
|
||||
const getDestinations = async (force = false) => {
|
||||
const options = destinationsQuery(store.state.selectedOrganization.identifier, "alert");
|
||||
// A destination created in the form's new tab never expires this tab's cache, so refresh forces.
|
||||
const ready = force
|
||||
? queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
})
|
||||
: Promise.resolve();
|
||||
void ready
|
||||
.then(() => queryClient.fetchQuery(options))
|
||||
.then((list: any) => {
|
||||
destinations.value = list as any;
|
||||
})
|
||||
.catch(() =>
|
||||
toast({
|
||||
@@ -2397,13 +2447,20 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
const getTemplates = () => {
|
||||
templateService
|
||||
.list({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
})
|
||||
.then((res) => {
|
||||
templates.value = res.data;
|
||||
const getTemplates = (force = false) => {
|
||||
const options = templatesQuery(store.state.selectedOrganization.identifier);
|
||||
// The mount read stays cache-first; only the form's refresh button forces a server read.
|
||||
const ready = force
|
||||
? queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
})
|
||||
: Promise.resolve();
|
||||
void ready
|
||||
.then(() => queryClient.fetchQuery(options))
|
||||
.then((list: any) => {
|
||||
templates.value = list;
|
||||
})
|
||||
.catch(() =>
|
||||
toast({
|
||||
@@ -2412,6 +2469,8 @@ export default defineComponent({
|
||||
}),
|
||||
);
|
||||
};
|
||||
// The form's refresh event carries no payload, so a bare `getTemplates` handler would never see `force`.
|
||||
const refreshTemplates = () => getTemplates(true);
|
||||
const pageSize = ref<number>(savedAlertListFilters.perPage || 20);
|
||||
const pageSizeOptions = [20, 50, 100, 250, 500];
|
||||
// Restored the same way as pageSize above, so returning from add/edit/detail lands back on the page the user was viewing instead of resetting to page 1.
|
||||
@@ -2478,24 +2537,23 @@ export default defineComponent({
|
||||
timeout: 0,
|
||||
});
|
||||
try {
|
||||
await alertsService.clone_by_id(
|
||||
store.state.selectedOrganization.identifier,
|
||||
toBeClonedID.value,
|
||||
{
|
||||
await cloneAnomalyAlert.mutateAsync({
|
||||
alertId: toBeClonedID.value,
|
||||
data: {
|
||||
name: toBeCloneAlertName.value,
|
||||
folder_id: (folderIdToBeCloned.value as string) || "default",
|
||||
stream_type: toBeClonestreamType.value,
|
||||
stream_name: toBeClonestreamName.value,
|
||||
},
|
||||
folderIdToBeCloned.value,
|
||||
);
|
||||
folderId: folderIdToBeCloned.value,
|
||||
});
|
||||
dismiss();
|
||||
toast({
|
||||
variant: "success",
|
||||
message: t("toastMessages.alerts.anomalyDetectionClonedSuccessfully"),
|
||||
});
|
||||
showForm.value = false;
|
||||
await getAlertsFn(store, folderIdToBeCloned.value);
|
||||
await getAlertsFn(store, folderIdToBeCloned.value, "", true, "", true);
|
||||
activeFolderId.value = folderIdToBeCloned.value;
|
||||
} catch (e: any) {
|
||||
dismiss();
|
||||
@@ -2605,7 +2663,7 @@ export default defineComponent({
|
||||
message: t("toastMessages.alerts.alertClonedSuccessfully"),
|
||||
});
|
||||
showForm.value = false;
|
||||
await getAlertsFn(store, folderIdToBeCloned.value);
|
||||
await getAlertsFn(store, folderIdToBeCloned.value, "", true, "", true);
|
||||
activeFolderId.value = folderIdToBeCloned.value;
|
||||
} else {
|
||||
toast({
|
||||
@@ -2645,7 +2703,7 @@ export default defineComponent({
|
||||
//and we dont need to fetch the alerts again because we are already fetching the alerts in the getAlertsFn
|
||||
const resolvedFolderId = folderId || activeFolderId.value || "default";
|
||||
// Always fetch the latest alerts for the folder from backend
|
||||
await getAlertsFn(store, resolvedFolderId);
|
||||
await getAlertsFn(store, resolvedFolderId, "", true, "", true);
|
||||
// Re-apply active search/filter on the freshly fetched data
|
||||
if (filterQuery.value) {
|
||||
filterAlertsByQuery(filterQuery.value);
|
||||
@@ -2677,6 +2735,46 @@ export default defineComponent({
|
||||
page: "Add Alert",
|
||||
});
|
||||
};
|
||||
const alertWriteOrgId = useOrgId();
|
||||
const toggleAlertStateWrite = useMutation(() =>
|
||||
toggleAlertStateMutation(alertWriteOrgId.value),
|
||||
);
|
||||
const toggleAnomalyState = useMutation(() =>
|
||||
toggleAnomalyAlertStateMutation(alertWriteOrgId.value),
|
||||
);
|
||||
const cloneAnomalyAlert = useMutation(() => cloneAnomalyAlertMutation(alertWriteOrgId.value));
|
||||
const retrainAnomalyAlert = useMutation(() => retrainAnomalyMutation(alertWriteOrgId.value));
|
||||
|
||||
// Only the entries that hold alert rows: the same scope also holds templates, destinations, sources and history, which a patch would re-stamp as fresh.
|
||||
const ALERT_ROW_KEYS = ["list", "search", "dependencies"];
|
||||
const alertRowEntries = (org: string) => ({
|
||||
queryKey: alertKeys.all(org),
|
||||
predicate: (q: any) => ALERT_ROW_KEYS.includes(q.queryKey[3]),
|
||||
});
|
||||
// Table rows are mapped copies, so a local field flip must also reach the cached raw rows every folder revisit re-renders from.
|
||||
const patchCachedAlert = (alertId: any, patch: Record<string, any>) => {
|
||||
if (!alertId) return;
|
||||
const id = String(alertId);
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
queryClient.setQueriesData(alertRowEntries(org), (cached: any) => {
|
||||
if (!Array.isArray(cached)) return undefined;
|
||||
let hit = false;
|
||||
const next = cached.map((r: any) => {
|
||||
if (String(r?.alert_id ?? r?.anomaly_id ?? r?.id) !== id) return r;
|
||||
hit = true;
|
||||
return { ...r, ...patch };
|
||||
});
|
||||
// `undefined` leaves an entry without this alert untouched instead of re-stamping it as fresh.
|
||||
return hit ? next : undefined;
|
||||
});
|
||||
// The editor reads the detail entry cache-first and a row patch cannot reach it; invalidated rather than removed so an in-flight editor read is not cancelled.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: alertKeys.detail(org, id),
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
};
|
||||
|
||||
// A delete is one row leaving a list the server has already confirmed. Splice
|
||||
// it out — of the rows, the filtered view, the selection AND the per-folder
|
||||
// cache a folder revisit is served from — instead of refetching every alert in
|
||||
@@ -2687,6 +2785,10 @@ export default defineComponent({
|
||||
if (!gone.size) return;
|
||||
|
||||
const keep = (row: any) => !gone.has(String(row?.alert_id));
|
||||
// Read before the splice below removes the rows this has to inspect.
|
||||
const droppedAnomaly = allAlerts.value.some(
|
||||
(row: any) => !keep(row) && row?.type === "anomaly",
|
||||
);
|
||||
allAlerts.value = allAlerts.value.filter(keep);
|
||||
filteredResults.value = filteredResults.value.filter(keep);
|
||||
// Anything that failed to delete stays selected, so a bulk retry is one click.
|
||||
@@ -2707,6 +2809,23 @@ export default defineComponent({
|
||||
store.dispatch("setAllAlertsListByFolderId", pruned);
|
||||
}
|
||||
|
||||
// The store copy above is not the only cache the list is served from: the
|
||||
// per-folder query entries are what a revisit inside staleTime reads, so
|
||||
// splice the rows out of every one of them too, and drop each deleted
|
||||
// alert's detail entry outright.
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
queryClient.setQueriesData(alertRowEntries(org), (cached: any) => {
|
||||
if (!Array.isArray(cached)) return undefined;
|
||||
const next = cached.filter(keep);
|
||||
return next.length === cached.length ? undefined : next;
|
||||
});
|
||||
gone.forEach((id: string) =>
|
||||
queryClient.removeQueries({ queryKey: alertKeys.detail(org, id), exact: true }),
|
||||
);
|
||||
|
||||
// The deletes that call this are direct service calls, so the anomaly scope has no mutation to drop it from.
|
||||
if (droppedAnomaly) queryClient.invalidateQueries({ queryKey: anomalyKeys.all(org) });
|
||||
|
||||
// The alert is gone from every destination and template that referenced it.
|
||||
invalidateDependencyGraphCache();
|
||||
};
|
||||
@@ -2814,18 +2933,21 @@ export default defineComponent({
|
||||
const toggleAlertState = (row: any) => {
|
||||
alertStateLoadingMap.value[row.uuid] = true;
|
||||
|
||||
alertsService
|
||||
.toggle_state_by_alert_id(
|
||||
store.state.selectedOrganization.identifier,
|
||||
row.alert_id,
|
||||
!row?.enabled,
|
||||
activeFolderId.value,
|
||||
)
|
||||
// Two mutations, not one conditional invalidation: an anomaly row is also a config Overview renders.
|
||||
const toggle = row.type === "anomaly" ? toggleAnomalyState : toggleAlertStateWrite;
|
||||
|
||||
toggle
|
||||
.mutateAsync({
|
||||
alertId: row.alert_id,
|
||||
enabled: !row?.enabled,
|
||||
folderId: activeFolderId.value,
|
||||
})
|
||||
.then((res: any) => {
|
||||
const isEnabled = res.data.enabled;
|
||||
filteredResults.value.forEach((alert: any) => {
|
||||
alert.uuid === row.uuid ? (alert.enabled = isEnabled) : null;
|
||||
});
|
||||
patchCachedAlert(row.alert_id, { enabled: isEnabled });
|
||||
toast({
|
||||
variant: "success",
|
||||
message: isEnabled
|
||||
@@ -2833,6 +2955,9 @@ export default defineComponent({
|
||||
: t("toastMessages.alerts.alertPausedSuccessfully"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* the mutation handler already toasts the failure; this only keeps the rejection handled */
|
||||
})
|
||||
.finally(() => {
|
||||
alertStateLoadingMap.value[row.uuid] = false;
|
||||
});
|
||||
@@ -2850,7 +2975,7 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
const refreshDestination = async () => {
|
||||
await getDestinations();
|
||||
await getDestinations(true);
|
||||
};
|
||||
|
||||
const importAlert = () => {
|
||||
@@ -2952,7 +3077,7 @@ export default defineComponent({
|
||||
message: t("alerts.alertTriggeredSuccess"),
|
||||
});
|
||||
if (row.type === "anomaly") {
|
||||
await getAlertsFn(store, activeFolderId.value);
|
||||
await getAlertsFn(store, activeFolderId.value, "", true, "", true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
@@ -2964,11 +3089,10 @@ export default defineComponent({
|
||||
|
||||
const retrainAnomaly = async (row: any) => {
|
||||
try {
|
||||
await alertsService.retrain_by_id(
|
||||
store.state.selectedOrganization.identifier,
|
||||
row.alert_id,
|
||||
);
|
||||
await retrainAnomalyAlert.mutateAsync(row.alert_id);
|
||||
row.status = "training";
|
||||
// Patched, not invalidated: refetching the whole folder for one cell would take the scroll with it.
|
||||
patchCachedAlert(row.alert_id, { status: "training" });
|
||||
toast({
|
||||
variant: "success",
|
||||
message: t("toastMessages.alerts.retrainingTriggered"),
|
||||
@@ -3088,15 +3212,17 @@ export default defineComponent({
|
||||
|
||||
// A delete inside the dependency popover — reload the current folder's alerts
|
||||
// so the table drops the removed row.
|
||||
const onDependencyDeleted = () => getAlertsFn(store, activeFolderId.value);
|
||||
// Forced: a cached reload would paint the deleted alert straight back.
|
||||
const onDependencyDeleted = () => getAlertsFn(store, activeFolderId.value, "", true, "", true);
|
||||
|
||||
const updateAcrossFolders = async (activeFolderId: any, selectedFolderId: any) => {
|
||||
//here we are fetching the alerts of the selected folder first and then fetching the alerts of the active folder
|
||||
// A post-write reload, so both folders force past staleTime.
|
||||
if (selectedFolderId === activeFolderId) {
|
||||
await getAlertsFn(store, activeFolderId);
|
||||
await getAlertsFn(store, activeFolderId, "", true, "", true);
|
||||
} else {
|
||||
await getAlertsFn(store, selectedFolderId, "", false);
|
||||
await getAlertsFn(store, activeFolderId);
|
||||
await getAlertsFn(store, selectedFolderId, "", false, "", true);
|
||||
await getAlertsFn(store, activeFolderId, "", true, "", true);
|
||||
}
|
||||
showMoveAlertDialog.value = false;
|
||||
selectedAlertToMove.value = [];
|
||||
@@ -3266,7 +3392,7 @@ export default defineComponent({
|
||||
|
||||
//this function is used to refresh the imported alerts
|
||||
const refreshImportedAlerts = async (store: any, folderId: any) => {
|
||||
await getAlertsFn(store, folderId);
|
||||
await getAlertsFn(store, folderId, "", true, "", true);
|
||||
};
|
||||
const updateFolderIdToBeCloned = (folderId: any) => {
|
||||
folderIdToBeCloned.value = folderId.value;
|
||||
@@ -3371,7 +3497,7 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
// Refresh alerts
|
||||
await getAlertsFn(store, activeFolderId.value);
|
||||
await getAlertsFn(store, activeFolderId.value, "", true, "", true);
|
||||
|
||||
if (filterQuery.value) {
|
||||
filterAlertsByQuery(filterQuery.value);
|
||||
@@ -3481,7 +3607,9 @@ export default defineComponent({
|
||||
);
|
||||
} else {
|
||||
selectedAlerts.value = [];
|
||||
await getAlertsFn(store, activeFolderId.value);
|
||||
// Forced: the point of this branch is to hear it from the server, and an
|
||||
// unforced read inside staleTime answers from cache without asking.
|
||||
await getAlertsFn(store, activeFolderId.value, "", true, "", true);
|
||||
if (filterQuery.value) filterAlertsByQuery(filterQuery.value);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -3589,6 +3717,7 @@ export default defineComponent({
|
||||
addAlert,
|
||||
isUpdated,
|
||||
showAddUpdateFn,
|
||||
handleActionQuery,
|
||||
showDeleteDialogFn,
|
||||
duplicateAlert,
|
||||
showAddAlertDialog,
|
||||
@@ -3608,6 +3737,8 @@ export default defineComponent({
|
||||
streams,
|
||||
isFetchingStreams,
|
||||
loading,
|
||||
fetching,
|
||||
lastUpdatedAt,
|
||||
forbidden,
|
||||
isSubmitting,
|
||||
filterQuery,
|
||||
@@ -3629,6 +3760,7 @@ export default defineComponent({
|
||||
goToAlertInsights,
|
||||
goToAlertHistory,
|
||||
getTemplates,
|
||||
refreshTemplates,
|
||||
exportAlert,
|
||||
showExportDialog,
|
||||
alertsToExport,
|
||||
|
||||
@@ -40,20 +40,25 @@ vi.mock("@/aws-exports", () => ({
|
||||
default: { isCloud: "false", isEnterprise: "false" },
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
listByFolderId: vi.fn(),
|
||||
get_by_alert_id: vi.fn(),
|
||||
toggle_state_by_alert_id: vi.fn(),
|
||||
delete_by_alert_id: vi.fn(),
|
||||
create_by_alert_id: vi.fn(),
|
||||
clone_by_id: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
export_by_id: vi.fn(),
|
||||
retrain_by_id: vi.fn(),
|
||||
trigger_alert: vi.fn(),
|
||||
},
|
||||
}));
|
||||
// Overlay, not replace: this module also exports the co-located queries the
|
||||
// component reads, and a wholesale mock strips them.
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
listByFolderId: vi.fn(),
|
||||
get_by_alert_id: vi.fn(),
|
||||
toggle_state_by_alert_id: vi.fn(),
|
||||
delete_by_alert_id: vi.fn(),
|
||||
create_by_alert_id: vi.fn(),
|
||||
clone_by_id: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
export_by_id: vi.fn(),
|
||||
retrain_by_id: vi.fn(),
|
||||
trigger_alert: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.mock("@/services/alert_templates", () => ({ default: { list: vi.fn() } }));
|
||||
vi.mock("@/services/alert_destination", () => ({ default: { list: vi.fn() } }));
|
||||
vi.mock("@/services/slos", () => ({ default: { list: vi.fn() } }));
|
||||
@@ -566,7 +571,10 @@ describe("AlertList — SLO alert rows", () => {
|
||||
folder: "default",
|
||||
org_identifier: "default",
|
||||
};
|
||||
await (w.vm as any).getAlertsFn(store, "default");
|
||||
// The route object is not reactive in tests, so the `query.action`
|
||||
// watcher cannot be fired by the assignment above — drive its named
|
||||
// handler directly instead.
|
||||
await (w.vm as any).handleActionQuery("update");
|
||||
await settle();
|
||||
return w;
|
||||
};
|
||||
|
||||
@@ -14,18 +14,24 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// Service mocks must be hoisted above all imports
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/alert_templates", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_templates", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/utils/zincutils", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
|
||||
@@ -110,20 +110,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-trailing>
|
||||
<OButton
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:last-run-at="lastUpdatedAt"
|
||||
:loading="fetching"
|
||||
shortcut-id="alertDestinationsRefresh"
|
||||
data-test="alert-destinations-list-refresh-btn"
|
||||
@click="getDestinations"
|
||||
>
|
||||
<OTooltip
|
||||
side="bottom"
|
||||
:content="t('common.refresh')"
|
||||
shortcut-id="alertDestinationsRefresh"
|
||||
/>
|
||||
</OButton>
|
||||
@click="refreshDestinations"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #bottom="{ totalRows }">
|
||||
@@ -312,14 +307,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:destination="editingDestination"
|
||||
:templates="templates"
|
||||
@cancel:hideform="toggleDestinationEditor"
|
||||
@get:destinations="getDestinations"
|
||||
@get:destinations="refreshDestinations"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1">
|
||||
<ImportDestination
|
||||
:destinations="destinations"
|
||||
:templates="templates"
|
||||
@update:destinations="getDestinations"
|
||||
@update:destinations="refreshDestinations"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -341,6 +336,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { bulkDeleteDestinationsMutation } from "@/services/alert_destination.queries";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { destinationsQuery } from "@/services/alert_destination.queries";
|
||||
import { destinationKeys } from "@/services/alert_destination.querykeys";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { ref, onBeforeMount, onActivated, watch, defineComponent, onMounted, computed } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
@@ -348,7 +349,7 @@ import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import { getImageURL } from "@/utils/zincutils";
|
||||
import AddDestination from "./AddDestination.vue";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import templateService from "@/services/alert_templates";
|
||||
import { templatesQuery } from "@/services/alert_templates.queries";
|
||||
import { useStore } from "vuex";
|
||||
import ConfirmDialog from "../ConfirmDialog.vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
@@ -367,7 +368,7 @@ import type { DepNodeKind } from "@/composables/alerts/useDependencyGraph";
|
||||
import { useReo } from "@/services/reodotdev_analytics";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
|
||||
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
@@ -391,12 +392,12 @@ export default defineComponent({
|
||||
name: "PageAlerts",
|
||||
components: {
|
||||
OIcon,
|
||||
ORefreshButton,
|
||||
AddDestination,
|
||||
OEmptyState,
|
||||
ConfirmDialog,
|
||||
ImportDestination,
|
||||
OButton,
|
||||
OTooltip,
|
||||
ODropdown,
|
||||
ODropdownItem,
|
||||
OSearchInput,
|
||||
@@ -562,6 +563,12 @@ export default defineComponent({
|
||||
// Anything that failed to delete stays selected, so a bulk retry is one click.
|
||||
selectedDestinations.value = selectedDestinations.value.filter((d: any) => !gone.has(d.name));
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
// The rows above are a render of the cached list, not the cache itself —
|
||||
// splice the names out of every cached module list too, or the row returns
|
||||
// on the next visit inside staleTime.
|
||||
queryClient.setQueriesData({ queryKey: destinationKeys.all(org) }, (list: any) =>
|
||||
Array.isArray(list) ? list.filter((d: any) => !gone.has(d.name)) : list,
|
||||
);
|
||||
for (const name of gone)
|
||||
depGraph.value = applyDependencyDeletion(
|
||||
org,
|
||||
@@ -591,37 +598,67 @@ export default defineComponent({
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const getDestinations = () => {
|
||||
const dismiss = toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingDestinations"),
|
||||
timeout: 0,
|
||||
});
|
||||
loading.value = true;
|
||||
// Request in flight with rows still on screen — the refresh button's
|
||||
// spinner. `loading` is the skeleton, for a cold read only.
|
||||
const fetching = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
// Bound to refresh / post-write reloads: always reaches the server.
|
||||
const refreshDestinations = () => getDestinations(true);
|
||||
|
||||
const getDestinations = (force = false) => {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
// Only a cold read spins and toasts — the rows stay put on a refresh.
|
||||
const warm = queryClient.getQueryData(destinationKeys.list(org, "alert")) !== undefined;
|
||||
const dismiss = warm
|
||||
? () => {}
|
||||
: toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingDestinations"),
|
||||
timeout: 0,
|
||||
});
|
||||
|
||||
const options = destinationsQuery(org, "alert");
|
||||
const applyRows = (list: any[]) => {
|
||||
const rows = list.filter(
|
||||
(destination: any) => destination.type == "http" || destination.type == "email",
|
||||
);
|
||||
resultTotal.value = rows.length;
|
||||
destinations.value = rows;
|
||||
updateRoute();
|
||||
};
|
||||
|
||||
// Paint the cached rows before the request goes out, so a refresh never
|
||||
// blanks the table.
|
||||
const cached = queryClient.getQueryData<any[]>(options.queryKey);
|
||||
if (cached !== undefined) applyRows(cached);
|
||||
loading.value = cached === undefined;
|
||||
fetching.value = true;
|
||||
forbidden.value = false;
|
||||
destinationService
|
||||
.list({
|
||||
page_num: 1,
|
||||
page_size: 100000,
|
||||
sort_by: "name",
|
||||
desc: false,
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
module: "alert",
|
||||
|
||||
// TODO: fold into `useQuery` — kept imperative for now because the
|
||||
// surrounding toast/dependency-graph flow is sequenced by hand.
|
||||
if (force) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
return queryClient
|
||||
.fetchQuery(options)
|
||||
.then((list: any[]) => {
|
||||
applyRows(list);
|
||||
lastUpdatedAt.value =
|
||||
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
|
||||
// Kept out of `apply`, which runs again for the cached paint:
|
||||
// rebuilding the graph is three more list calls. Only a forced read
|
||||
// can have changed it — every add/edit/delete reloads with force — so
|
||||
// a plain mount leaves the graph's own cache to answer, and the
|
||||
// "Used by" counts still follow every write.
|
||||
if (force) invalidateDependencyGraphCache();
|
||||
loadDepGraph(org, force ? ["alerts", "templates"] : []);
|
||||
})
|
||||
.then((res) => {
|
||||
res.data = res.data.filter(
|
||||
(destination: any) => destination.type == "http" || destination.type == "email",
|
||||
);
|
||||
resultTotal.value = res.data.length;
|
||||
destinations.value = res.data;
|
||||
// Any destination add/edit/delete lands here on refresh — drop the
|
||||
// shared dependency-graph cache and rebuild it so the "Used by" counts
|
||||
// (and the impact dialog) are current.
|
||||
invalidateDependencyGraphCache();
|
||||
loadDepGraph(store.state.selectedOrganization.identifier);
|
||||
updateRoute();
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch((err: any) => {
|
||||
forbidden.value = err?.response?.status === 403;
|
||||
if (!forbidden.value) {
|
||||
toast({
|
||||
@@ -634,14 +671,13 @@ export default defineComponent({
|
||||
.finally(() => {
|
||||
dismiss();
|
||||
loading.value = false;
|
||||
fetching.value = false;
|
||||
});
|
||||
};
|
||||
const getTemplates = () => {
|
||||
templateService
|
||||
.list({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
})
|
||||
.then((res) => (templates.value = res.data));
|
||||
queryClient
|
||||
.fetchQuery(templatesQuery(store.state.selectedOrganization.identifier))
|
||||
.then((list: any) => (templates.value = list));
|
||||
};
|
||||
const updateRoute = () => {
|
||||
if (router.currentRoute.value.query.action === "add") editDestination(null);
|
||||
@@ -829,6 +865,9 @@ export default defineComponent({
|
||||
return filterData(byTab, filterQuery.value);
|
||||
});
|
||||
|
||||
const orgIdForWrites = useOrgId();
|
||||
const bulkDeleteWrite = useMutation(() => bulkDeleteDestinationsMutation(orgIdForWrites.value));
|
||||
|
||||
const openBulkDeleteDialog = () => {
|
||||
confirmBulkDelete.value = true;
|
||||
};
|
||||
@@ -855,10 +894,7 @@ export default defineComponent({
|
||||
ids: selectedDestinations.value.map((d: any) => d.name),
|
||||
};
|
||||
|
||||
const response = await destinationService.bulkDelete(
|
||||
store.state.selectedOrganization.identifier,
|
||||
payload,
|
||||
);
|
||||
const response = await bulkDeleteWrite.mutateAsync(payload.ids);
|
||||
|
||||
dismiss();
|
||||
|
||||
@@ -904,7 +940,9 @@ export default defineComponent({
|
||||
dropDestinations(response.data.successful.map((entry: any) => entry?.name ?? entry));
|
||||
} else {
|
||||
selectedDestinations.value = [];
|
||||
getDestinations();
|
||||
// Forced: this branch exists to hear it from the server, and an unforced
|
||||
// read inside staleTime answers from cache without asking.
|
||||
getDestinations(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
dismiss();
|
||||
@@ -944,7 +982,7 @@ export default defineComponent({
|
||||
{
|
||||
id: "alertDestinationsRefresh",
|
||||
handler: () => {
|
||||
if (!isInputFocused()) getDestinations();
|
||||
if (!isInputFocused()) refreshDestinations();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -956,6 +994,7 @@ export default defineComponent({
|
||||
]);
|
||||
return {
|
||||
t,
|
||||
lastUpdatedAt,
|
||||
depGraph,
|
||||
showDestinationEditor,
|
||||
destinations,
|
||||
@@ -963,6 +1002,8 @@ export default defineComponent({
|
||||
editDestination,
|
||||
getImageURL,
|
||||
loading,
|
||||
fetching,
|
||||
refreshDestinations,
|
||||
forbidden,
|
||||
conformDeleteDestination,
|
||||
filterQuery,
|
||||
|
||||
@@ -255,6 +255,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { alertKeys } from "@/services/alerts.querykeys";
|
||||
import { destinationKeys } from "@/services/alert_destination.querykeys";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { templateKeys } from "@/services/alert_templates.querykeys";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -454,9 +458,26 @@ const performDelete = async () => {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Fold the delete into the graph in place. Refetching instead would blank the
|
||||
// whole dialog behind its loading spinner and re-run three list calls, which
|
||||
// reads as a page reload for what is one row leaving a lane.
|
||||
// This dialog deletes across all three domains, so it is the only place that
|
||||
// reaches every one of their caches — `deleted` reloads just the list it was
|
||||
// opened from. Removed as well as invalidated: the row is gone, and a merely
|
||||
// stale entry can still be served to the next reader.
|
||||
const deletedFrom =
|
||||
n.kind === "destination"
|
||||
? destinationKeys.all(org_identifier)
|
||||
: n.kind === "template"
|
||||
? templateKeys.all(org_identifier)
|
||||
: alertKeys.all(org_identifier);
|
||||
// Picking a *scope* rather than a query object: the keys module is the seam
|
||||
// that makes this polymorphic drop possible without importing three
|
||||
// transports.
|
||||
void queryClient.invalidateQueries({ queryKey: deletedFrom });
|
||||
queryClient.removeQueries({ queryKey: deletedFrom, type: "inactive" });
|
||||
|
||||
// Fold the delete into the graph in place rather than dropping it. Refetching
|
||||
// instead would blank the whole dialog behind its loading spinner and re-run
|
||||
// three list calls, which reads as a page reload for what is one row leaving
|
||||
// a lane.
|
||||
graph.value = applyDependencyDeletion(org_identifier, n.id, graph.value);
|
||||
toast({ variant: "success", message: t("alert_dependencies.deletedToast", { name: n.name }) });
|
||||
emit("deleted", n.kind);
|
||||
|
||||
@@ -6,21 +6,27 @@ import ExternalAlertSourcesList from "./ExternalAlertSourcesList.vue";
|
||||
import alertSources from "@/services/alert_sources";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
|
||||
vi.mock("@/services/alert_sources", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
listSenders: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_sources", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
listSenders: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: { list: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: { list: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/utils/clipboard", () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
|
||||
@@ -31,8 +31,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<AddExternalAlertSource
|
||||
v-model:open="showAddDrawer"
|
||||
:editing-integration="editTargetIntegration"
|
||||
@created="fetchAll"
|
||||
@updated="fetchAll"
|
||||
@created="fetchAll(true)"
|
||||
@updated="fetchAll(true)"
|
||||
/>
|
||||
|
||||
<div class="min-h-0 w-full flex-1 overflow-hidden">
|
||||
@@ -67,20 +67,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
</template>
|
||||
<template #toolbar-trailing>
|
||||
<OButton
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:last-run-at="lastUpdatedAt"
|
||||
:loading="fetching"
|
||||
shortcut-id="alertSourcesRefresh"
|
||||
data-test="alert-sources-refresh-btn"
|
||||
@click="fetchAll"
|
||||
>
|
||||
<OTooltip
|
||||
side="bottom"
|
||||
:content="t('alert_sources.refresh')"
|
||||
shortcut-id="alertSourcesRefresh"
|
||||
/>
|
||||
</OButton>
|
||||
@click="refreshAll"
|
||||
/>
|
||||
</template>
|
||||
<template #empty>
|
||||
<OEmptyState
|
||||
@@ -330,6 +325,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { alertSourcesQuery } from "@/services/alert_sources.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { defineComponent, getCurrentInstance } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
@@ -341,6 +338,7 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
@@ -384,6 +382,7 @@ export default defineComponent({
|
||||
components: {
|
||||
OPageLayout,
|
||||
OButton,
|
||||
ORefreshButton,
|
||||
OTag,
|
||||
OTable,
|
||||
OIcon,
|
||||
@@ -413,7 +412,7 @@ export default defineComponent({
|
||||
{
|
||||
id: "alertSourcesRefresh",
|
||||
handler: () => {
|
||||
if (!isInputFocused()) vm()?.fetchAll();
|
||||
if (!isInputFocused()) vm()?.refreshAll();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -427,7 +426,11 @@ export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
// Request in flight with rows still on screen — the refresh button's
|
||||
// spinner. `loading` is the skeleton, for a cold read only.
|
||||
fetching: false,
|
||||
forbidden: false,
|
||||
lastUpdatedAt: null as number | null,
|
||||
filterQuery: "",
|
||||
showAddDrawer: false,
|
||||
editTargetIntegration: undefined as AlertSourceIntegration | undefined,
|
||||
@@ -585,8 +588,13 @@ export default defineComponent({
|
||||
this.fetchAll();
|
||||
},
|
||||
methods: {
|
||||
async fetchAll() {
|
||||
await this.fetchIntegrations();
|
||||
// Named handler: binding fetchAll straight to @click puts the MouseEvent in
|
||||
// `force`.
|
||||
refreshAll() {
|
||||
return this.fetchAll(true);
|
||||
},
|
||||
async fetchAll(force = false) {
|
||||
await this.fetchIntegrations(force);
|
||||
const fetches: Promise<void>[] = [];
|
||||
if (this.defaultSource) {
|
||||
fetches.push(this.fetchSenders(this.defaultSource.id));
|
||||
@@ -596,12 +604,29 @@ export default defineComponent({
|
||||
}
|
||||
await Promise.all(fetches);
|
||||
},
|
||||
async fetchIntegrations() {
|
||||
this.loading = true;
|
||||
this.forbidden = false;
|
||||
async fetchIntegrations(force = false) {
|
||||
try {
|
||||
const res = await alertSources.list(this.orgIdentifier);
|
||||
this.integrations = res.data.integrations;
|
||||
// `force` only bypasses staleTime — the rows on screen stay either way,
|
||||
// and the skeleton is reserved for a genuinely cold read.
|
||||
const options = alertSourcesQuery(this.orgIdentifier);
|
||||
// Paint what is already cached before the request goes out.
|
||||
const cached = queryClient.getQueryData<any>(options.queryKey);
|
||||
if (cached !== undefined) this.integrations = cached;
|
||||
this.loading = cached === undefined;
|
||||
this.fetching = true;
|
||||
this.forbidden = false;
|
||||
// Options API, so this reads imperatively rather than through useQuery.
|
||||
// TODO: move to `useQuery` when this component moves to `setup()`.
|
||||
if (force) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
this.integrations = await queryClient.fetchQuery(options);
|
||||
this.lastUpdatedAt =
|
||||
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
|
||||
} catch (e: any) {
|
||||
this.forbidden = e?.response?.status === 403;
|
||||
// The grouped access toast already reports a 403; a second red toast adds nothing.
|
||||
@@ -610,6 +635,7 @@ export default defineComponent({
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.fetching = false;
|
||||
}
|
||||
},
|
||||
async fetchSenders(integrationId: string) {
|
||||
@@ -701,7 +727,7 @@ export default defineComponent({
|
||||
toast({ variant: "success", message: this.t("alert_sources.rotatedSuccess") });
|
||||
this.revealedIds = this.revealedIds.filter((id) => id !== this.rotateTarget?.id);
|
||||
this.rotateTarget = undefined;
|
||||
await this.fetchAll();
|
||||
await this.fetchAll(true);
|
||||
} catch (e) {
|
||||
toast({ variant: "error", message: this.t("alert_sources.error") });
|
||||
}
|
||||
@@ -717,7 +743,7 @@ export default defineComponent({
|
||||
toast({ variant: "success", message: this.t("alert_sources.deletedSuccess") });
|
||||
this.revealedIds = this.revealedIds.filter((id) => id !== this.deleteTarget?.id);
|
||||
this.deleteTarget = undefined;
|
||||
await this.fetchAll();
|
||||
await this.fetchAll(true);
|
||||
} catch (e) {
|
||||
toast({ variant: "error", message: this.t("alert_sources.error") });
|
||||
}
|
||||
@@ -731,7 +757,7 @@ export default defineComponent({
|
||||
? this.t("alert_sources.disabledSuccess")
|
||||
: this.t("alert_sources.enabledSuccess"),
|
||||
});
|
||||
await this.fetchIntegrations();
|
||||
await this.fetchIntegrations(true);
|
||||
} catch (e) {
|
||||
toast({ variant: "error", message: this.t("alert_sources.error") });
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ import { ref } from "vue";
|
||||
import ImportAlert from "./ImportAlert.vue";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
create_by_alert_id: vi.fn(),
|
||||
listByFolderId: vi.fn().mockResolvedValue({
|
||||
data: { list: [] },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
create_by_alert_id: vi.fn(),
|
||||
listByFolderId: vi.fn().mockResolvedValue({
|
||||
data: { list: [] },
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useStreams", () => ({
|
||||
default: () => ({
|
||||
@@ -46,8 +49,14 @@ vi.mock("@/router", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alert_templates", () => ({ default: {} }));
|
||||
vi.mock("@/services/alert_destination", () => ({ default: {} }));
|
||||
vi.mock("@/services/alert_templates", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), { default: {} });
|
||||
});
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), { default: {} });
|
||||
});
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -298,7 +298,9 @@ import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import alertsService from "../../services/alerts";
|
||||
import anomalyDetectionService from "../../services/anomaly_detection";
|
||||
import { saveAnomalyConfigMutation } from "@/services/anomaly_detection.queries";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { useOrgId } from "@/composables/query";
|
||||
import useStreams from "@/composables/useStreams";
|
||||
import BaseImport from "../common/BaseImport.vue";
|
||||
import SelectFolderDropDown from "../common/sidebar/SelectFolderDropDown.vue";
|
||||
@@ -387,6 +389,12 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
const streamTypes = ["logs", "metrics", "traces"];
|
||||
const anomalyOrgId = useOrgId();
|
||||
// The anomaly import branch is a create; the mutation drops the anomaly scope.
|
||||
const createAnomalyConfig = useMutation(() =>
|
||||
saveAnomalyConfigMutation(anomalyOrgId.value, () => undefined),
|
||||
);
|
||||
|
||||
const selectedFolderId = ref<any>(
|
||||
props.folderId || router.currentRoute.value.query.folder || "default",
|
||||
);
|
||||
@@ -576,11 +584,17 @@ export default defineComponent({
|
||||
alert_enabled: jsonObj.alert_enabled ?? true,
|
||||
},
|
||||
};
|
||||
await anomalyDetectionService.create(org, payload, selectedFolderId.value || "default");
|
||||
// This branch returns before the regular path's refresh below, so it emits its own.
|
||||
await createAnomalyConfig.mutateAsync({
|
||||
payload,
|
||||
folderId: selectedFolderId.value || "default",
|
||||
});
|
||||
alertCreators.value.push({
|
||||
message: t("alerts.import.anomalyImportSuccess", { index, name: jsonObj.name }),
|
||||
success: true,
|
||||
});
|
||||
emit("update:alerts", store, selectedFolderId.value);
|
||||
getActiveFolderAlerts(selectedFolderId.value);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
alertCreators.value.push({
|
||||
|
||||
@@ -22,11 +22,14 @@ import enMessages from "@/locales/languages/en-US.json";
|
||||
import { ref } from "vue";
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: { get: vi.fn() },
|
||||
|
||||
@@ -266,11 +266,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { saveDestinationMutation } from "@/services/alert_destination.queries";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { defineComponent, ref, computed, reactive } from "vue";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import BaseImport from "../common/BaseImport.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
@@ -664,10 +666,15 @@ export default defineComponent({
|
||||
return true;
|
||||
};
|
||||
|
||||
const orgIdForWrites = useOrgId();
|
||||
const createDestinationWrite = useMutation(() =>
|
||||
saveDestinationMutation(orgIdForWrites.value, () => false),
|
||||
);
|
||||
|
||||
const createDestination = async (input: any, index: number) => {
|
||||
try {
|
||||
await destinationService.create({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
// Was: invalidate, then create — the refetch raced the write.
|
||||
await createDestinationWrite.mutateAsync({
|
||||
destination_name: input.name,
|
||||
data: input,
|
||||
});
|
||||
|
||||
@@ -43,12 +43,15 @@ const mockDiffData = vi.hoisted(() => ({
|
||||
unchanged: [{ id: "unch-1", display: "Stable Group", fields: ["level"], normalize: false }],
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
previewSemanticGroupsDiff: vi.fn().mockResolvedValue({ data: mockDiffData }),
|
||||
saveSemanticGroups: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
previewSemanticGroupsDiff: vi.fn().mockResolvedValue({ data: mockDiffData }),
|
||||
saveSemanticGroups: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import ImportSemanticGroups from "@/components/alerts/ImportSemanticGroups.vue";
|
||||
import alertsService from "@/services/alerts";
|
||||
|
||||
@@ -90,11 +90,14 @@ const mockDiffData = vi.hoisted(() => ({
|
||||
unchanged: [{ id: "unch-1", display: "Stable Group", fields: ["level"], normalize: false }],
|
||||
}));
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
previewSemanticGroupsDiff: vi.fn().mockResolvedValue({ data: mockDiffData }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
previewSemanticGroupsDiff: vi.fn().mockResolvedValue({ data: mockDiffData }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import ImportSemanticGroupsDrawer from "@/components/alerts/ImportSemanticGroupsDrawer.vue";
|
||||
import alertsService from "@/services/alerts";
|
||||
|
||||
@@ -20,17 +20,26 @@ import { createStore } from "vuex";
|
||||
import { ref } from "vue";
|
||||
|
||||
// ─── Service mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock("@/services/alert_templates", () => ({
|
||||
default: { create: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/alert_templates", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: { create: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: { create: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: { create: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: { create: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: { create: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useStreams", () => ({
|
||||
default: vi.fn(() => ({ streams: { value: [] } })),
|
||||
|
||||
@@ -183,11 +183,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { saveTemplateMutation } from "@/services/alert_templates.queries";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { defineComponent, ref, computed } from "vue";
|
||||
import { useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import templateService from "@/services/alert_templates";
|
||||
import BaseImport from "../common/BaseImport.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
@@ -429,10 +431,15 @@ export default defineComponent({
|
||||
return templatesList.includes(templateName);
|
||||
};
|
||||
|
||||
const orgIdForWrites = useOrgId();
|
||||
const createTemplateWrite = useMutation(() =>
|
||||
saveTemplateMutation(orgIdForWrites.value, () => false),
|
||||
);
|
||||
|
||||
const createTemplate = async (input: any, index: number) => {
|
||||
try {
|
||||
await templateService.create({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
// Was: invalidate, then create — the refetch raced the write.
|
||||
await createTemplateWrite.mutateAsync({
|
||||
template_name: input.name,
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
|
||||
@@ -19,11 +19,14 @@ import { createStore } from "vuex";
|
||||
import IncidentAlertTriggersTable from "./IncidentAlertTriggersTable.vue";
|
||||
import incidentsService from "@/services/incidents";
|
||||
|
||||
vi.mock("@/services/incidents", () => ({
|
||||
default: {
|
||||
getExternalAlertPayload: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/incidents", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
getExternalAlertPayload: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers & factories
|
||||
|
||||
@@ -42,16 +42,19 @@ vi.mock("@/composables/useConfirmDialog", () => ({
|
||||
}));
|
||||
|
||||
// Mock incidents service
|
||||
vi.mock("@/services/incidents", () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
triggerRca: vi.fn(),
|
||||
getCorrelatedStreams: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
updateIncident: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/incidents", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
triggerRca: vi.fn(),
|
||||
getCorrelatedStreams: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
updateIncident: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// The panel is absent unless on-call answers, which is also the OSS and
|
||||
// feature-off case — so the default here is a rejection, and only the tests
|
||||
@@ -342,7 +345,10 @@ describe("IncidentDetailDrawer.vue", () => {
|
||||
// Resolve the promise
|
||||
resolvePromise!({ data: createIncidentWithAlerts({ id: "test-123" }) });
|
||||
await flushPromises();
|
||||
await nextTick(); // Give Vue time to update reactive state
|
||||
// The query layer defers its fetch a microtask, so the loader settles a
|
||||
// tick after the service promise does.
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
// Now loading should be false
|
||||
expect(incidentsService.get).toHaveBeenCalled();
|
||||
|
||||
@@ -1497,7 +1497,11 @@ import incidentsService, {
|
||||
} from "@/services/incidents";
|
||||
import oncallService from "@/services/oncall";
|
||||
import type { OnCallResponse } from "@/ts/interfaces/oncall";
|
||||
import streamService from "@/services/stream";
|
||||
import { streamSchemaQuery } from "@/services/stream.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { updateIncidentMutation, updateIncidentStatusMutation } from "@/services/incidents.queries";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { useOrgId } from "@/composables/query";
|
||||
import serviceStreamsApi, {
|
||||
buildChipDimensionsFromFilters,
|
||||
type CorrelationResponse,
|
||||
@@ -1558,6 +1562,12 @@ export default defineComponent({
|
||||
const route = useRoute();
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
const incidentOrgId = useOrgId();
|
||||
const updateIncidentStatus = useMutation(() =>
|
||||
updateIncidentStatusMutation(incidentOrgId.value),
|
||||
);
|
||||
const updateIncident = useMutation(() => updateIncidentMutation(incidentOrgId.value));
|
||||
|
||||
// Copy to clipboard state
|
||||
const copiedField = ref<string | null>(null);
|
||||
|
||||
@@ -2122,7 +2132,7 @@ export default defineComponent({
|
||||
!correlationData.value?.traceStreams?.length
|
||||
) {
|
||||
// No correlation data found - try building fallback correlation
|
||||
await buildFallbackCorrelation(org, incidentDetails.value);
|
||||
await buildFallbackCorrelation(org, incidentDetails.value, force);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load correlated streams:", error);
|
||||
@@ -2134,7 +2144,7 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
// Build fallback correlation using first alert's stream schema
|
||||
const buildFallbackCorrelation = async (org: string, incident: Incident) => {
|
||||
const buildFallbackCorrelation = async (org: string, incident: Incident, force = false) => {
|
||||
try {
|
||||
const groupValues: Record<string, string> = incident.group_values ?? {};
|
||||
// Get first alert to determine source stream
|
||||
@@ -2149,8 +2159,16 @@ export default defineComponent({
|
||||
const streamName = firstAlert.stream_name || "default";
|
||||
|
||||
// Step 1: Get stream schema (like logs page does)
|
||||
const schemaResponse = await streamService.schema(org, streamName, streamType);
|
||||
const schema = schemaResponse.data;
|
||||
const schemaOptions = streamSchemaQuery(org, streamName, streamType);
|
||||
// Retry is the user asking again, so a field added since the last read must show up.
|
||||
if (force) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: schemaOptions.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
const schema = await queryClient.fetchQuery(schemaOptions);
|
||||
|
||||
// Step 2: Extract schema fields (like logs page does)
|
||||
// CRITICAL FIX: Use uds_schema (user-defined schema) if available!
|
||||
@@ -2255,9 +2273,8 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
// Refresh correlation data
|
||||
const refreshCorrelation = () => {
|
||||
fetchCorrelatedStreams(true);
|
||||
};
|
||||
// Returns the promise so callers (and specs) can await the refresh.
|
||||
const refreshCorrelation = () => fetchCorrelatedStreams(true);
|
||||
|
||||
// Lazy load correlation when user clicks telemetry tab for the first time
|
||||
watch(activeTab, (newTab) => {
|
||||
@@ -2690,12 +2707,10 @@ export default defineComponent({
|
||||
if (!incidentDetails.value) return;
|
||||
updating.value = true;
|
||||
try {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
const response = await incidentsService.updateStatus(
|
||||
org,
|
||||
incidentDetails.value.id,
|
||||
newStatus,
|
||||
);
|
||||
const response = await updateIncidentStatus.mutateAsync({
|
||||
id: incidentDetails.value.id,
|
||||
status: newStatus,
|
||||
});
|
||||
// Update local state with the actual status from the API response
|
||||
incidentDetails.value.status = response.data.status;
|
||||
incidentDetails.value.acknowledged_by = response.data.acknowledged_by;
|
||||
@@ -2739,7 +2754,8 @@ export default defineComponent({
|
||||
persistent: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
updateStatus("acknowledged");
|
||||
// The caller's promise must cover the update, not just the confirm dialog.
|
||||
return updateStatus("acknowledged");
|
||||
};
|
||||
const resolveIncident = () => updateStatus("resolved");
|
||||
const reopenIncident = () => updateStatus("open");
|
||||
@@ -2764,9 +2780,9 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
try {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
const response = await incidentsService.updateIncident(org, incidentDetails.value.id, {
|
||||
title: nextTitle,
|
||||
const response = await updateIncident.mutateAsync({
|
||||
id: incidentDetails.value.id,
|
||||
updates: { title: nextTitle },
|
||||
});
|
||||
|
||||
// Update local state with the actual title from the API response
|
||||
@@ -2896,12 +2912,10 @@ export default defineComponent({
|
||||
|
||||
updating.value = true;
|
||||
try {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
const response = await incidentsService.updateStatus(
|
||||
org,
|
||||
incidentDetails.value.id,
|
||||
newStatus,
|
||||
);
|
||||
const response = await updateIncidentStatus.mutateAsync({
|
||||
id: incidentDetails.value.id,
|
||||
status: newStatus,
|
||||
});
|
||||
|
||||
// Update local state with the actual status from the API response
|
||||
incidentDetails.value.status = response.data.status;
|
||||
@@ -2949,8 +2963,9 @@ export default defineComponent({
|
||||
try {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
const incidentId = incidentDetails.value.id;
|
||||
const response = await incidentsService.updateIncident(org, incidentId, {
|
||||
severity: newSeverity,
|
||||
const response = await updateIncident.mutateAsync({
|
||||
id: incidentId,
|
||||
updates: { severity: newSeverity },
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
|
||||
@@ -18,12 +18,15 @@ vi.mock("@/aws-exports", () => ({
|
||||
default: { isEnterprise: "true", isCloud: "false" },
|
||||
}));
|
||||
|
||||
vi.mock("@/services/incidents", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/incidents", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/utils/date", () => ({
|
||||
formatToReadable: vi.fn((ts: number) => `ts-${ts}`),
|
||||
@@ -244,7 +247,9 @@ describe("IncidentList.vue", () => {
|
||||
it("calls list with correct org identifier", async () => {
|
||||
wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(incidentsService.list).toHaveBeenCalledWith("default", undefined, 1000, 0, undefined);
|
||||
// Reads through incidentsQuery now, which passes the four arguments its key
|
||||
// is built from — the fifth was always undefined.
|
||||
expect(incidentsService.list).toHaveBeenCalledWith("default", undefined, 1000, 0);
|
||||
});
|
||||
|
||||
it("populates allIncidents after successful load", async () => {
|
||||
@@ -660,7 +665,8 @@ describe("IncidentList.vue", () => {
|
||||
(wrapper.vm as any).qTableRef.table = { setPageIndex };
|
||||
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
// Pending only: the refresh button's age interval would make runAllTimers loop forever.
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(setPageIndex).toHaveBeenCalledWith(2);
|
||||
store.state.incidents = { incidents: {}, isInitialized: false };
|
||||
|
||||
@@ -103,20 +103,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-trailing>
|
||||
<OButton
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:last-run-at="lastUpdatedAt"
|
||||
:loading="fetching"
|
||||
shortcut-id="alertIncidentsRefresh"
|
||||
data-test="incident-list-refresh-btn"
|
||||
@click="refreshIncidents"
|
||||
>
|
||||
<OTooltip
|
||||
side="bottom"
|
||||
:content="t('common.refresh')"
|
||||
shortcut-id="alertIncidentsRefresh"
|
||||
/>
|
||||
</OButton>
|
||||
/>
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<OTag
|
||||
@@ -305,6 +300,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { incidentsQuery } from "@/services/incidents.queries";
|
||||
import { defineComponent, ref, shallowRef, computed, onMounted, watch, nextTick } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
@@ -314,6 +312,7 @@ import incidentsService, { Incident } from "@/services/incidents";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
@@ -340,6 +339,7 @@ export default defineComponent({
|
||||
OPageLayout,
|
||||
OEmptyState,
|
||||
OButton,
|
||||
ORefreshButton,
|
||||
OSearchInput,
|
||||
OTooltip,
|
||||
OIcon,
|
||||
@@ -361,9 +361,25 @@ export default defineComponent({
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
const qTableRef: any = ref(null);
|
||||
// Starts true so the skeleton shows on first render and the once-off page-restore watch below fires on the real true→false transition.
|
||||
const loading = ref(true);
|
||||
const forbidden = ref(false);
|
||||
const orgIdForList = useOrgId();
|
||||
const incidentsList = useQuery(() =>
|
||||
Object.assign(incidentsQuery(orgIdForList.value, undefined as unknown as string, 1000, 0), {
|
||||
enabled: !!orgIdForList.value,
|
||||
}),
|
||||
);
|
||||
|
||||
// `isPending` also starts true on a cold read, so the page-restore watch below still sees a real true→false transition.
|
||||
const loading = incidentsList.isPending;
|
||||
// Request in flight with rows still on screen — the refresh button's
|
||||
// spinner. `loading` is the skeleton, for a cold read only.
|
||||
const fetching = incidentsList.isFetching;
|
||||
// Epoch ms of the last successful read — drives the button's "1m ago" label.
|
||||
const lastUpdatedAt = incidentsList.dataUpdatedAt;
|
||||
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
|
||||
const forbidden = computed(() => {
|
||||
const e: any = incidentsList.error.value;
|
||||
return e?.status === 403 || e?.response?.status === 403;
|
||||
});
|
||||
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
|
||||
watch(
|
||||
loading,
|
||||
@@ -623,36 +639,37 @@ export default defineComponent({
|
||||
return { boxShadow: `var(--shadow-rail-geom) ${color}` };
|
||||
};
|
||||
|
||||
const loadIncidents = async () => {
|
||||
loading.value = true;
|
||||
forbidden.value = false;
|
||||
try {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
const limit = 1000;
|
||||
const offset = 0;
|
||||
const keyword = undefined;
|
||||
// Freezing and the Vuex dispatch are both idempotent, so this is safe to
|
||||
// run twice — the cached rows paint, then the server's.
|
||||
const applyIncidents = (data: any) => {
|
||||
const items: Incident[] = data?.incidents || [];
|
||||
// Frozen objects are never made reactive, so Vue leaves the hundreds of
|
||||
// rows raw both here and in the Vuex cache below.
|
||||
for (const it of items) Object.freeze(it);
|
||||
allIncidents.value = items;
|
||||
store.dispatch("incidents/setCachedData", items);
|
||||
};
|
||||
|
||||
const response = await incidentsService.list(org, undefined, limit, offset, keyword);
|
||||
// The list is the query now: anything that invalidates the incidents scope
|
||||
// repaints these rows without this component asking.
|
||||
watch(
|
||||
incidentsList.data,
|
||||
(data: any) => {
|
||||
if (data) applyIncidents(data);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(incidentsList.error, (error: any) => {
|
||||
// The grouped access toast already reports a 403; a second red toast adds nothing.
|
||||
if (!error || forbidden.value) return;
|
||||
toast({ variant: "error", message: t("alerts.incidents.errorLoading") });
|
||||
console.error("Failed to load incidents:", error);
|
||||
});
|
||||
|
||||
// Freeze each row so Vue leaves it raw (frozen objects are never made
|
||||
// reactive), both here and once it lands in the Vuex cache below.
|
||||
const items: Incident[] = response.data.incidents || [];
|
||||
for (const it of items) Object.freeze(it);
|
||||
allIncidents.value = items;
|
||||
store.dispatch("incidents/setCachedData", items);
|
||||
} catch (error: any) {
|
||||
forbidden.value = error?.response?.status === 403;
|
||||
// The grouped access toast already reports a 403; a second red toast adds nothing.
|
||||
if (!forbidden.value) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("alerts.incidents.errorLoading"),
|
||||
});
|
||||
}
|
||||
console.error("Failed to load incidents:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// Only an explicit call reads: refresh, post-write reload, search. Mount and
|
||||
// invalidation-driven repaints come from the query itself.
|
||||
const loadIncidents = async (force = false) => {
|
||||
if (force) await incidentsList.refetch();
|
||||
};
|
||||
|
||||
const viewIncident = (incident: Incident) => {
|
||||
@@ -706,7 +723,8 @@ export default defineComponent({
|
||||
variant: "success",
|
||||
message: t("alerts.incidents.statusUpdated"),
|
||||
});
|
||||
loadIncidents();
|
||||
// Post-write reload: must reach the server.
|
||||
loadIncidents(true);
|
||||
store.dispatch("incidents/setShouldRefresh", true);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
@@ -866,6 +884,7 @@ export default defineComponent({
|
||||
const shouldRefresh = store.state.incidents?.shouldRefresh || false;
|
||||
|
||||
if (allIncidents.value.length === 0 || shouldRefresh) {
|
||||
// Not forced: the drawer's writes drop the cached list, so the observer refetches on its own and forcing here would repeat it.
|
||||
await loadIncidents();
|
||||
if (shouldRefresh) {
|
||||
store.dispatch("incidents/setShouldRefresh", false);
|
||||
@@ -895,7 +914,8 @@ export default defineComponent({
|
||||
watch(() => searchQuery.value, savePageState);
|
||||
|
||||
const refreshIncidents = async () => {
|
||||
await loadIncidents();
|
||||
// Explicit refresh: must reach the server.
|
||||
await loadIncidents(true);
|
||||
toast({
|
||||
variant: "success",
|
||||
message: t("toastMessages.alerts.incidentsRefreshed"),
|
||||
@@ -916,6 +936,8 @@ export default defineComponent({
|
||||
raw,
|
||||
t,
|
||||
loading,
|
||||
fetching,
|
||||
lastUpdatedAt,
|
||||
forbidden,
|
||||
allIncidents,
|
||||
visibleIncidents,
|
||||
|
||||
@@ -17,12 +17,15 @@ vi.mock("@/aws-exports", () => ({
|
||||
default: { isEnterprise: "true", isCloud: "false" },
|
||||
}));
|
||||
|
||||
vi.mock("@/services/incidents", () => ({
|
||||
default: {
|
||||
getEvents: vi.fn(),
|
||||
postComment: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/incidents", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
getEvents: vi.fn(),
|
||||
postComment: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
|
||||
@@ -18,25 +18,28 @@ import { mount, flushPromises } from "@vue/test-utils";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
|
||||
vi.mock("@/services/alerts", () => ({
|
||||
default: {
|
||||
getOrganizationDeduplicationConfig: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
enabled: true,
|
||||
alert_dedup_enabled: false,
|
||||
alert_fingerprint_groups: [],
|
||||
time_window_minutes: 30,
|
||||
},
|
||||
}),
|
||||
setOrganizationDeduplicationConfig: vi.fn().mockResolvedValue({ data: {} }),
|
||||
getSemanticGroups: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: "group1", display: "Group 1", fields: ["host"], normalize: false },
|
||||
{ id: "group2", display: "Group 2", fields: ["service"], normalize: false },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alerts", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
getOrganizationDeduplicationConfig: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
enabled: true,
|
||||
alert_dedup_enabled: false,
|
||||
alert_fingerprint_groups: [],
|
||||
time_window_minutes: 30,
|
||||
},
|
||||
}),
|
||||
setOrganizationDeduplicationConfig: vi.fn().mockResolvedValue({ data: {} }),
|
||||
getSemanticGroups: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: "group1", display: "Group 1", fields: ["host"], normalize: false },
|
||||
{ id: "group2", display: "Group 2", fields: ["service"], normalize: false },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import OrganizationDeduplicationSettings from "@/components/alerts/OrganizationDeduplicationSettings.vue";
|
||||
import alertsService from "@/services/alerts";
|
||||
|
||||
@@ -14,18 +14,24 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// Service mocks must be hoisted
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/alert_templates", () => ({
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/alert_templates", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/utils/zincutils", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
@@ -562,9 +568,40 @@ describe("PipelinesDestinationList", () => {
|
||||
(destinationService.list as any).mockResolvedValue({
|
||||
data: [makeDestination(1), makeDestination(2)],
|
||||
});
|
||||
await (wrapper.vm as any).getDestinations();
|
||||
// The mount already warmed the cache, so the new mock is only reached by
|
||||
// a forced reload — the same thing the refresh button does.
|
||||
await (wrapper.vm as any).getDestinations(true);
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).destinations).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── editor lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
describe("after saving from the editor", () => {
|
||||
it("leaves the editor closed and re-reads the list from the server", async () => {
|
||||
// The page's own route is registered lazily in the app; the component pushes to it by name.
|
||||
if (!(router as any).hasRoute("pipelineDestinations")) {
|
||||
(router as any).addRoute({
|
||||
name: "pipelineDestinations",
|
||||
path: "/settings/pipeline_destinations",
|
||||
component: { template: "<div />" },
|
||||
});
|
||||
}
|
||||
// The editor is reached with `?action=add`, which `updateRoute()` reopens from.
|
||||
(router as any).currentRoute.value.query = { action: "add" };
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).showDestinationEditor).toBe(true);
|
||||
|
||||
(destinationService.list as any).mockClear();
|
||||
await (wrapper.vm as any).handleDestinationCreated("destination-new");
|
||||
await flushPromises();
|
||||
|
||||
// A warm list reads the route synchronously, so the query must be gone first.
|
||||
expect((wrapper.vm as any).showDestinationEditor).toBe(false);
|
||||
// Forced: creating a destination does not expire the cached scope.
|
||||
expect(destinationService.list).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,20 +64,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
</template>
|
||||
<template #toolbar-trailing>
|
||||
<OButton
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:last-run-at="lastUpdatedAt"
|
||||
:loading="fetching"
|
||||
shortcut-id="pipelineDestinationsRefresh"
|
||||
data-test="pipeline-destination-list-refresh-btn"
|
||||
@click="getDestinations"
|
||||
>
|
||||
<OTooltip
|
||||
side="bottom"
|
||||
:content="t('common.refresh')"
|
||||
shortcut-id="pipelineDestinationsRefresh"
|
||||
/>
|
||||
</OButton>
|
||||
@click="refreshDestinations"
|
||||
/>
|
||||
</template>
|
||||
<template #empty>
|
||||
<OEmptyState
|
||||
@@ -204,13 +199,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { bulkDeleteDestinationsMutation } from "@/services/alert_destination.queries";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { destinationsQuery } from "@/services/alert_destination.queries";
|
||||
import { destinationKeys } from "@/services/alert_destination.querykeys";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { ref, onBeforeMount, onActivated, watch, defineComponent, onMounted, computed } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { getImageURL } from "@/utils/zincutils";
|
||||
import PipelineDestinationEditor from "../pipeline/PipelineDestinationEditor.vue";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import templateService from "@/services/alert_templates";
|
||||
import { templatesQuery } from "@/services/alert_templates.queries";
|
||||
import { useStore } from "vuex";
|
||||
import ConfirmDialog from "../ConfirmDialog.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -219,7 +220,7 @@ import type { Template } from "@/ts/interfaces/index";
|
||||
|
||||
import { useReo } from "@/services/reodotdev_analytics";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
@@ -250,11 +251,11 @@ export default defineComponent({
|
||||
name: "PageAlerts",
|
||||
components: {
|
||||
OPageLayout,
|
||||
ORefreshButton,
|
||||
PipelineDestinationEditor,
|
||||
OEmptyState,
|
||||
ConfirmDialog,
|
||||
OButton,
|
||||
OTooltip,
|
||||
OIcon,
|
||||
OTag,
|
||||
OSearchInput,
|
||||
@@ -371,30 +372,54 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
// Request in flight with rows still on screen — the refresh button's
|
||||
// spinner. `loading` is the skeleton, for a cold read only.
|
||||
const fetching = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const forbidden = ref(false);
|
||||
const getDestinations = () => {
|
||||
const dismiss = toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingDestinations"),
|
||||
timeout: 0,
|
||||
});
|
||||
loading.value = true;
|
||||
// Bound to refresh / post-write reloads: always reaches the server.
|
||||
const refreshDestinations = () => getDestinations(true);
|
||||
|
||||
const getDestinations = (force = false) => {
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
// Only a cold read spins and toasts — the rows stay put on a refresh.
|
||||
const warm = queryClient.getQueryData(destinationKeys.list(org, "pipeline")) !== undefined;
|
||||
const dismiss = warm
|
||||
? () => {}
|
||||
: toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingDestinations"),
|
||||
timeout: 0,
|
||||
});
|
||||
|
||||
const options = destinationsQuery(org, "pipeline");
|
||||
const applyRows = (list: any[]) => {
|
||||
resultTotal.value = list.length;
|
||||
destinations.value = list;
|
||||
updateRoute();
|
||||
};
|
||||
const cached = queryClient.getQueryData<any[]>(options.queryKey);
|
||||
if (cached !== undefined) applyRows(cached);
|
||||
loading.value = cached === undefined;
|
||||
fetching.value = true;
|
||||
forbidden.value = false;
|
||||
destinationService
|
||||
.list({
|
||||
page_num: 1,
|
||||
page_size: 100000,
|
||||
sort_by: "name",
|
||||
desc: false,
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
module: "pipeline",
|
||||
|
||||
// TODO: fold into `useQuery` when this list drops its imperative refresh.
|
||||
if (force) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
return queryClient
|
||||
.fetchQuery(options)
|
||||
.then((list: any[]) => {
|
||||
applyRows(list);
|
||||
lastUpdatedAt.value =
|
||||
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
|
||||
})
|
||||
.then((res) => {
|
||||
resultTotal.value = res.data.length;
|
||||
destinations.value = res.data;
|
||||
updateRoute();
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch((err: any) => {
|
||||
forbidden.value = err?.response?.status === 403;
|
||||
if (!forbidden.value) {
|
||||
toast({
|
||||
@@ -407,14 +432,13 @@ export default defineComponent({
|
||||
.finally(() => {
|
||||
dismiss();
|
||||
loading.value = false;
|
||||
fetching.value = false;
|
||||
});
|
||||
};
|
||||
const getTemplates = () => {
|
||||
templateService
|
||||
.list({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
})
|
||||
.then((res) => (templates.value = res.data));
|
||||
queryClient
|
||||
.fetchQuery(templatesQuery(store.state.selectedOrganization.identifier))
|
||||
.then((list: any) => (templates.value = list));
|
||||
};
|
||||
const updateRoute = () => {
|
||||
const action = router.currentRoute.value.query.action;
|
||||
@@ -478,7 +502,8 @@ export default defineComponent({
|
||||
name: confirmDelete.value.data.name,
|
||||
}),
|
||||
});
|
||||
getDestinations();
|
||||
// Forced: nothing invalidates this scope, and an unforced read inside staleTime keeps the deleted row.
|
||||
getDestinations(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.data.code === 409) {
|
||||
@@ -502,20 +527,24 @@ export default defineComponent({
|
||||
confirmDelete.value.visible = false;
|
||||
confirmDelete.value.data = null;
|
||||
};
|
||||
// Returns the navigation so callers can await it: `updateRoute()` reopens the
|
||||
// editor while `?action=` is still on the URL, and a warm list reads it synchronously.
|
||||
const toggleDestinationEditor = () => {
|
||||
showDestinationEditor.value = !showDestinationEditor.value;
|
||||
if (!showDestinationEditor.value)
|
||||
router.push({
|
||||
return router.push({
|
||||
name: "pipelineDestinations",
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
},
|
||||
});
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const handleDestinationCreated = (destinationName: string) => {
|
||||
toggleDestinationEditor();
|
||||
getDestinations();
|
||||
const handleDestinationCreated = async (destinationName: string) => {
|
||||
await toggleDestinationEditor();
|
||||
// Forced: the create does not expire this scope, so a cached read would drop the new row.
|
||||
getDestinations(true);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
@@ -525,9 +554,10 @@ export default defineComponent({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDestinationUpdated = (destinationName: string) => {
|
||||
toggleDestinationEditor();
|
||||
getDestinations();
|
||||
const handleDestinationUpdated = async (destinationName: string) => {
|
||||
await toggleDestinationEditor();
|
||||
// Forced: the update does not expire this scope, so a cached read would show the old row.
|
||||
getDestinations(true);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
@@ -589,6 +619,9 @@ export default defineComponent({
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const orgIdForWrites = useOrgId();
|
||||
const bulkDeleteWrite = useMutation(() => bulkDeleteDestinationsMutation(orgIdForWrites.value));
|
||||
|
||||
const openBulkDeleteDialog = () => {
|
||||
confirmBulkDelete.value = true;
|
||||
};
|
||||
@@ -615,10 +648,7 @@ export default defineComponent({
|
||||
ids: selectedDestinations.value.map((d: any) => d.name),
|
||||
};
|
||||
|
||||
const response = await destinationService.bulkDelete(
|
||||
store.state.selectedOrganization.identifier,
|
||||
payload,
|
||||
);
|
||||
const response = await bulkDeleteWrite.mutateAsync(payload.ids);
|
||||
|
||||
dismiss();
|
||||
|
||||
@@ -659,7 +689,8 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
selectedDestinations.value = [];
|
||||
getDestinations();
|
||||
// Forced, same reason as the single delete above.
|
||||
getDestinations(true);
|
||||
} catch (error: any) {
|
||||
dismiss();
|
||||
console.error("Error deleting destinations:", error);
|
||||
@@ -685,13 +716,14 @@ export default defineComponent({
|
||||
{
|
||||
id: "pipelineDestinationsRefresh",
|
||||
handler: () => {
|
||||
if (!isInputFocused()) getDestinations();
|
||||
if (!isInputFocused()) refreshDestinations();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
t,
|
||||
lastUpdatedAt,
|
||||
showDestinationEditor,
|
||||
destinations,
|
||||
columns,
|
||||
@@ -699,6 +731,8 @@ export default defineComponent({
|
||||
getImageURL,
|
||||
conformDeleteDestination,
|
||||
loading,
|
||||
fetching,
|
||||
refreshDestinations,
|
||||
forbidden,
|
||||
filterQuery,
|
||||
filterData,
|
||||
|
||||
@@ -37,14 +37,17 @@ vi.mock("../dashboards/PanelSchemaRenderer.vue", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/services/search", () => ({
|
||||
default: {
|
||||
result_schema: vi.fn().mockResolvedValue({
|
||||
data: { group_by: [], projections: [], timeseries_field: null },
|
||||
}),
|
||||
search: vi.fn().mockResolvedValue({ data: { hits: [], total: 0 } }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/search", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
result_schema: vi.fn().mockResolvedValue({
|
||||
data: { group_by: [], projections: [], timeseries_field: null },
|
||||
}),
|
||||
search: vi.fn().mockResolvedValue({ data: { hits: [], total: 0 } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const baseFormData = () => ({
|
||||
stream_name: "test-stream",
|
||||
|
||||
@@ -44,15 +44,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed, nextTick } from "vue";
|
||||
import { ref, watch, onMounted, computed, nextTick, reactive, onBeforeMount } from "vue";
|
||||
import { buildThresholdMarkLines, thresholdAxisBounds } from "@/utils/alerts/thresholdMarkLines";
|
||||
import {
|
||||
cleanAggregationQuery,
|
||||
getDefaultDashboardPanelData,
|
||||
} from "@/utils/alerts/aggregationPreviewQuery";
|
||||
import PanelSchemaRenderer from "../dashboards/PanelSchemaRenderer.vue";
|
||||
import { reactive } from "vue";
|
||||
import { onBeforeMount } from "vue";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
|
||||
@@ -18,11 +18,14 @@ import { mount, flushPromises } from "@vue/test-utils";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
|
||||
vi.mock("@/services/search", () => ({
|
||||
default: {
|
||||
search: vi.fn().mockResolvedValue({ data: { hits: [] } }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/search", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
search: vi.fn().mockResolvedValue({ data: { hits: [] } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useQuery", () => ({
|
||||
default: () => ({
|
||||
@@ -656,6 +659,9 @@ vi.mock("@/composables/fieldValueStore", () => ({
|
||||
// so this suite makes no HTTP call and the awaited chain settles inside
|
||||
// flushPromises(); the fetch itself is covered in
|
||||
// useSuggestions.serverCatalog.spec.ts.
|
||||
vi.mock("@/services/query_functions", () => ({
|
||||
default: { list: vi.fn().mockResolvedValue({ data: { list: [] } }) },
|
||||
}));
|
||||
vi.mock("@/services/query_functions", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: { list: vi.fn().mockResolvedValue({ data: { list: [] } }) },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,7 +513,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, type PropType, onMounted, inject, type Ref } from "vue";
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
watch,
|
||||
type PropType,
|
||||
onMounted,
|
||||
inject,
|
||||
type Ref,
|
||||
defineAsyncComponent,
|
||||
} from "vue";
|
||||
import { type SqlErrorRange } from "@/utils/query/sqlDiagnostics";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
@@ -524,7 +533,6 @@ import { debounce } from "lodash-es";
|
||||
import { b64EncodeUnicode, getImageURL } from "@/utils/zincutils";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import searchService from "@/services/search";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
const QueryEditor = defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue"));
|
||||
import UnifiedQueryEditor from "@/components/QueryEditor.vue";
|
||||
import O2AIChat from "@/components/O2AIChat.vue";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mount, flushPromises, DOMWrapper } from "@vue/test-utils";
|
||||
import TemplateList from "./TemplateList.vue";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import templateService from "@/services/alert_templates";
|
||||
import alertsFixture from "@/test/unit/mockData/alerts";
|
||||
import router from "@/test/unit/helpers/router";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import i18n from "@/locales";
|
||||
@@ -105,12 +106,16 @@ describe("Alert List", async () => {
|
||||
const listTemplates = vi.spyOn(templateService, "list");
|
||||
let listCallsBeforeDelete = 0;
|
||||
beforeEach(async () => {
|
||||
const templatesUrl = `${store.state.API_ENDPOINT}/api/${store.state.selectedOrganization.identifier}/alerts/templates`;
|
||||
global.server.use(
|
||||
http.delete(
|
||||
`${store.state.API_ENDPOINT}/api/${store.state.selectedOrganization.identifier}/alerts/templates/${template_name}`,
|
||||
() => {
|
||||
return HttpResponse.json({ code: 200 });
|
||||
},
|
||||
http.delete(`${templatesUrl}/${template_name}`, () => {
|
||||
return HttpResponse.json({ code: 200 });
|
||||
}),
|
||||
// The delete's invalidation refetches the list; the shared fixture would put the row back.
|
||||
http.get(templatesUrl, () =>
|
||||
HttpResponse.json(
|
||||
alertsFixture.templates.get.filter((tpl: any) => tpl.name !== template_name),
|
||||
),
|
||||
),
|
||||
);
|
||||
listCallsBeforeDelete = listTemplates.mock.calls.length;
|
||||
@@ -132,9 +137,8 @@ describe("Alert List", async () => {
|
||||
});
|
||||
|
||||
it("drops the deleted row in place, leaving the rest of the list alone", () => {
|
||||
// No refetch: reloading the list would blank the table behind its skeleton
|
||||
// and a loading toast for a row the server already confirmed gone.
|
||||
expect(listTemplates.mock.calls.length).toBe(listCallsBeforeDelete);
|
||||
// The mutation's invalidation refetches the mounted list in the background; the splice keeps the row gone meanwhile.
|
||||
expect(listTemplates.mock.calls.length).toBe(listCallsBeforeDelete + 1);
|
||||
const body = wrapper
|
||||
.find('[data-test="alert-templates-list-table"]')
|
||||
.find('[data-test="o2-table-body"]');
|
||||
|
||||
@@ -108,20 +108,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-trailing>
|
||||
<OButton
|
||||
<ORefreshButton
|
||||
layout="inline"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:last-run-at="lastUpdatedAt"
|
||||
:loading="fetching"
|
||||
shortcut-id="alertTemplatesRefresh"
|
||||
data-test="template-list-refresh-btn"
|
||||
@click="getTemplates"
|
||||
>
|
||||
<OTooltip
|
||||
side="bottom"
|
||||
:content="t('common.refresh')"
|
||||
shortcut-id="alertTemplatesRefresh"
|
||||
/>
|
||||
</OButton>
|
||||
@click="refreshTemplates"
|
||||
/>
|
||||
</template>
|
||||
<template #empty>
|
||||
<OEmptyState
|
||||
@@ -311,11 +306,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:template="editingTemplate"
|
||||
:is-clone="cloningTemplate"
|
||||
@cancel:hideform="toggleTemplateEditor"
|
||||
@get:templates="getTemplates"
|
||||
@get:templates="refreshTemplates"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1">
|
||||
<ImportTemplate :templates="templates" @update:templates="getTemplates" />
|
||||
<ImportTemplate :templates="templates" @update:templates="refreshTemplates" />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -336,18 +331,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, onActivated, onMounted, watch, defineAsyncComponent, computed } from "vue";
|
||||
import {
|
||||
bulkDeleteTemplatesMutation,
|
||||
deleteTemplateMutation,
|
||||
templatesQuery,
|
||||
} from "@/services/alert_templates.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { templateKeys } from "@/services/alert_templates.querykeys";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useMutation, useQuery } from "@tanstack/vue-query";
|
||||
import { ref, onActivated, watch, defineAsyncComponent, computed } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useI18nTyped, raw } from "@/types/i18n";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import templateService from "@/services/alert_templates";
|
||||
import ConfirmDialog from "../ConfirmDialog.vue";
|
||||
import type { TemplateData, Template } from "@/ts/interfaces";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
@@ -378,7 +381,12 @@ const { t } = useI18nTyped();
|
||||
const router = useRouter();
|
||||
const { track } = useReo();
|
||||
const { graph: depGraph, loadGraph: loadDepGraph } = useDependencyGraph();
|
||||
const templates: Ref<Template[]> = ref([]);
|
||||
const orgIdForList = useOrgId();
|
||||
const templatesList = useQuery(() =>
|
||||
Object.assign(templatesQuery(orgIdForList.value), { enabled: !!orgIdForList.value }),
|
||||
);
|
||||
// The rows are the query, not a copy: only an observer applies `staleTime` and revalidates on mount.
|
||||
const templates = computed<Template[]>(() => (templatesList.data.value ?? []) as Template[]);
|
||||
const columns: OTableColumnDef[] = [
|
||||
{
|
||||
id: "name",
|
||||
@@ -455,10 +463,6 @@ const isTemplateRowSelectable = (row: any) => !row?.isPrebuilt;
|
||||
onActivated(() => {
|
||||
if (!templates.value.length) updateRoute();
|
||||
});
|
||||
onMounted(() => {
|
||||
getTemplates();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.query.action,
|
||||
(action) => {
|
||||
@@ -469,8 +473,19 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
const loading = ref(false);
|
||||
const forbidden = ref(false);
|
||||
const loading = templatesList.isPending;
|
||||
// Request in flight with rows still on screen — the refresh button's spinner.
|
||||
// `loading` is the skeleton, for a cold read only.
|
||||
const fetching = templatesList.isFetching;
|
||||
// Epoch ms of the last successful read — drives the button's "1m ago" label.
|
||||
const lastUpdatedAt = templatesList.dataUpdatedAt;
|
||||
// Bound to refresh / post-write reloads: always reaches the server.
|
||||
const refreshTemplates = () => getTemplates(true);
|
||||
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
|
||||
const forbidden = computed(() => {
|
||||
const e: any = templatesList.error.value;
|
||||
return e?.status === 403 || e?.response?.status === 403;
|
||||
});
|
||||
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
|
||||
watch(
|
||||
loading,
|
||||
@@ -482,65 +497,61 @@ watch(
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const getTemplates = () => {
|
||||
const dismiss = toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingTemplates"),
|
||||
timeout: 0,
|
||||
});
|
||||
|
||||
loading.value = true;
|
||||
forbidden.value = false;
|
||||
templateService
|
||||
.list({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
})
|
||||
.then((res) => {
|
||||
resultTotal.value = res.data.length;
|
||||
templates.value = res.data;
|
||||
// Any template add/edit/delete lands here on refresh — drop the shared
|
||||
// dependency-graph cache and rebuild it so the "Used by" counts (and the
|
||||
// impact dialog) reflect the change.
|
||||
invalidateDependencyGraphCache();
|
||||
loadDepGraph(store.state.selectedOrganization.identifier);
|
||||
updateRoute();
|
||||
})
|
||||
.catch((err) => {
|
||||
dismiss();
|
||||
forbidden.value = err?.response?.status === 403;
|
||||
if (!forbidden.value) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("toastMessages.alerts.errorWhilePullingTemplates"),
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
dismiss();
|
||||
loading.value = false;
|
||||
});
|
||||
// Read by the watcher below: the rebuild after a forced read must also re-read the lists this page does not own.
|
||||
let graphInputsStale = false;
|
||||
|
||||
const getTemplates = async (force = false) => {
|
||||
if (!force) return;
|
||||
// Rebuilding the graph costs three more list calls, so only a forced read pays for it.
|
||||
invalidateDependencyGraphCache();
|
||||
graphInputsStale = true;
|
||||
await templatesList.refetch();
|
||||
};
|
||||
// A delete is one row leaving a list the server has already confirmed. Splice it
|
||||
// out and prune the shared dependency graph rather than refetching: a refetch
|
||||
// blanks the table behind its spinner and a "please wait" toast, which reads as a
|
||||
// page reload, and drops the user's scroll and page position with it.
|
||||
|
||||
let dismissLoading: (() => void) | null = null;
|
||||
watch(
|
||||
loading,
|
||||
(pending) => {
|
||||
if (pending && !dismissLoading) {
|
||||
dismissLoading = toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.alerts.pleaseWaitWhileLoadingTemplates"),
|
||||
timeout: 0,
|
||||
});
|
||||
} else if (!pending && dismissLoading) {
|
||||
dismissLoading();
|
||||
dismissLoading = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(templatesList.error, (err: any) => {
|
||||
if (!err || err?.response?.status === 403) return;
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("toastMessages.alerts.errorWhilePullingTemplates"),
|
||||
});
|
||||
});
|
||||
// Splice the confirmed rows out at once; the mutation's background refetch only spins the refresh button.
|
||||
const dropTemplates = (names: string[]) => {
|
||||
if (!names.length) return;
|
||||
const gone = new Set(names);
|
||||
templates.value = templates.value.filter((tpl: any) => !gone.has(tpl.name));
|
||||
// Anything that failed to delete stays selected, so a bulk retry is one click.
|
||||
selectedTemplates.value = selectedTemplates.value.filter((tpl: any) => !gone.has(tpl.name));
|
||||
const org = store.state.selectedOrganization.identifier;
|
||||
// The rows render straight from the cache, so pruning it is what removes them.
|
||||
queryClient.setQueriesData({ queryKey: templateKeys.all(org) }, (list: any) =>
|
||||
Array.isArray(list) ? list.filter((tpl: any) => !gone.has(tpl.name)) : list,
|
||||
);
|
||||
for (const name of gone)
|
||||
depGraph.value = applyDependencyDeletion(org, depNodeId("template", name), depGraph.value);
|
||||
};
|
||||
|
||||
// Deleting an alert or a destination in the impact dialog leaves this list's rows
|
||||
// untouched, so re-read the (already pruned) shared graph for the Used by counts
|
||||
// instead of reloading the page's data.
|
||||
const onDependencyDeleted = (kind: DepNodeKind) => {
|
||||
if (kind === "template") getTemplates();
|
||||
else loadDepGraph(store.state.selectedOrganization.identifier);
|
||||
// Both kinds need only the shared graph now: the rows re-render from the cache.
|
||||
const onDependencyDeleted = (_kind: DepNodeKind) => {
|
||||
loadDepGraph(store.state.selectedOrganization.identifier);
|
||||
};
|
||||
const updateRoute = () => {
|
||||
if (router.currentRoute.value.query.action === "add") editTemplate();
|
||||
@@ -618,15 +629,19 @@ const cloneTemplate = (template: any) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
// Mutations rather than direct service calls: their scope reaches every cache the list feeds, on disk too.
|
||||
const deleteTemplateWrite = useMutation(() =>
|
||||
deleteTemplateMutation(store.state.selectedOrganization.identifier),
|
||||
);
|
||||
const bulkDeleteTemplatesWrite = useMutation(() =>
|
||||
bulkDeleteTemplatesMutation(store.state.selectedOrganization.identifier),
|
||||
);
|
||||
const deleteTemplate = () => {
|
||||
const name = confirmDelete.value?.data?.name;
|
||||
if (!name) return;
|
||||
deletingTemplates.value.add(name);
|
||||
templateService
|
||||
.delete({
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
template_name: name,
|
||||
})
|
||||
deleteTemplateWrite
|
||||
.mutateAsync(name)
|
||||
.then(() => {
|
||||
toast({
|
||||
variant: "success",
|
||||
@@ -712,6 +727,18 @@ const exportTemplate = (row: any) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Keyed on `dataUpdatedAt`: an unchanged refetch shares its object and would never wake a value watcher.
|
||||
watch(
|
||||
() => templatesList.dataUpdatedAt.value,
|
||||
() => {
|
||||
if (!templatesList.data.value) return;
|
||||
updateRoute();
|
||||
loadDepGraph(orgIdForList.value, graphInputsStale ? ["alerts", "destinations"] : []);
|
||||
graphInputsStale = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const base = templates.value || [];
|
||||
const byTab =
|
||||
@@ -740,10 +767,8 @@ const bulkDeleteTemplates = () => {
|
||||
bulkDeleteLoading.value = true;
|
||||
const templateNames = selectedTemplates.value.map((template: any) => template.name);
|
||||
|
||||
templateService
|
||||
.bulkDelete(store.state.selectedOrganization.identifier, {
|
||||
ids: templateNames,
|
||||
})
|
||||
bulkDeleteTemplatesWrite
|
||||
.mutateAsync(templateNames)
|
||||
.then((res) => {
|
||||
const { successful, unsuccessful } = res.data;
|
||||
|
||||
@@ -801,7 +826,7 @@ useShortcuts([
|
||||
{
|
||||
id: "alertTemplatesRefresh",
|
||||
handler: () => {
|
||||
if (!isInputFocused()) getTemplates();
|
||||
if (!isInputFocused()) refreshTemplates();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -50,11 +50,14 @@ vi.mock("vue-router", () => ({
|
||||
const listWorkflowsMock = vi.fn(async () => ({
|
||||
data: { list: [{ id: "wf-1", name: "Escalate to PagerDuty" }] },
|
||||
}));
|
||||
vi.mock("@/services/workflows", () => ({
|
||||
default: {
|
||||
listWorkflows: (...args: any[]) => listWorkflowsMock(...args),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/workflows", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
listWorkflows: (...args: any[]) => listWorkflowsMock(...args),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function makeStore() {
|
||||
return createStore({
|
||||
|
||||
@@ -255,6 +255,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { destinationsQuery } from "@/services/alert_destination.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18nTyped, raw, type I18nText } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
@@ -489,15 +491,12 @@ const isTestSending = ref(false);
|
||||
|
||||
const loadDestinations = async () => {
|
||||
try {
|
||||
const response = await destinationService.list({
|
||||
org_identifier: store.state.selectedOrganization?.identifier,
|
||||
page_num: 1,
|
||||
page_size: 100000,
|
||||
sort_by: "name",
|
||||
desc: false,
|
||||
module: "alert",
|
||||
});
|
||||
destinationOptions.value = (response.data ?? []).map((dest: { name: string }) => ({
|
||||
// Same shared query the alert form and destination lists read, so opening
|
||||
// this panel reuses their cached list instead of issuing its own request.
|
||||
const destinations = await queryClient.fetchQuery(
|
||||
destinationsQuery(store.state.selectedOrganization?.identifier, "alert"),
|
||||
);
|
||||
destinationOptions.value = destinations.map((dest: { name: string }) => ({
|
||||
// Destination names are user data, not prose — nothing to translate.
|
||||
label: raw(dest.name),
|
||||
value: dest.name,
|
||||
|
||||
@@ -38,11 +38,14 @@ import { firstFieldError } from "@/lib/forms/Form/fieldError";
|
||||
import streamService from "@/services/stream";
|
||||
|
||||
// vi.mock must be hoisted — declared before component import
|
||||
vi.mock("@/services/stream", () => ({
|
||||
default: {
|
||||
schema: vi.fn().mockResolvedValue({ data: { schema: [] } }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/stream", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
schema: vi.fn().mockResolvedValue({ data: { schema: [] } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// The stored-value lookup the field-value resolver ends at. Stubbed so the
|
||||
// resolver tests can assert the composite key it was asked for without an
|
||||
|
||||
@@ -660,7 +660,8 @@ import useSqlSuggestions from "@/composables/useSuggestions";
|
||||
import { computed, defineComponent, ref, watch, type PropType } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import streamService from "@/services/stream";
|
||||
import { streamSchemaQuery } from "@/services/stream.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import {
|
||||
ANOMALY_FILTER_OPERATORS,
|
||||
operatorNeedsValue,
|
||||
@@ -999,12 +1000,9 @@ export default defineComponent({
|
||||
}
|
||||
loadingFields.value = true;
|
||||
try {
|
||||
const res = await streamService.schema(
|
||||
store.state.selectedOrganization.identifier,
|
||||
streamName,
|
||||
streamType,
|
||||
const schema = await queryClient.fetchQuery(
|
||||
streamSchemaQuery(store.state.selectedOrganization.identifier, streamName, streamType),
|
||||
);
|
||||
const schema = res.data;
|
||||
const fieldsArray =
|
||||
schema.uds_schema && schema.uds_schema.length > 0
|
||||
? schema.uds_schema
|
||||
|
||||
@@ -28,15 +28,18 @@ import i18n from "@/locales";
|
||||
// exercise the actual schema wiring — an empty required field must block submit
|
||||
// and NOT call the service (the gap that let the AddToDashboard bug ship).
|
||||
|
||||
vi.mock("@/services/cipher_keys", () => ({
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
get_by_name: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/cipher_keys", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
get_by_name: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// toast returns a dismiss() fn — keep it a no-op so the loading toast teardown
|
||||
// in the save handlers doesn't blow up.
|
||||
|
||||
@@ -68,10 +68,14 @@ const ConfirmDialogStub = {
|
||||
|
||||
// Mock the external dependencies
|
||||
vi.mock("vue-router");
|
||||
vi.mock("@/services/dashboards");
|
||||
vi.mock("@/services/dashboards", async (importOriginal) => {
|
||||
const { automockService } = await import("@/test/unit/helpers/mockService");
|
||||
return automockService(await importOriginal());
|
||||
});
|
||||
vi.mock("@/utils/commons", () => ({
|
||||
getFoldersListByType: vi.fn(),
|
||||
deleteFolderByIdByType: vi.fn(),
|
||||
// Both are `async` in the real module, so the doubles must be thenable too.
|
||||
getFoldersListByType: vi.fn(() => Promise.resolve([])),
|
||||
deleteFolderByIdByType: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
// Create persistent mocks that can be accessed across tests
|
||||
const showPositiveNotificationMock = vi.fn();
|
||||
@@ -755,7 +759,8 @@ describe("FolderList.vue", () => {
|
||||
props: { type: "alerts" },
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
// The folder fetch on mount is awaited, so the selection lands a microtask later.
|
||||
await flushPromises();
|
||||
expect(newWrapper.vm.activeFolderId).toBe("folder1");
|
||||
|
||||
newWrapper.unmount();
|
||||
@@ -788,7 +793,8 @@ describe("FolderList.vue", () => {
|
||||
props: { type: "alerts" },
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
// The folder fetch on mount is awaited, so the selection lands a microtask later.
|
||||
await flushPromises();
|
||||
expect(newWrapper.vm.activeFolderId).toBe("default");
|
||||
|
||||
newWrapper.unmount();
|
||||
|
||||
@@ -271,9 +271,8 @@ export default defineComponent({
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.state.organizationData.foldersByType?.[props.type]) {
|
||||
await getFoldersListByType(store, props.type);
|
||||
}
|
||||
// A folder-list 403 must not strand the folder selection below.
|
||||
await getFoldersListByType(store, props.type).catch(() => null);
|
||||
if (router.currentRoute.value.query.folder) {
|
||||
activeFolderId.value = router.currentRoute.value.query.folder as string;
|
||||
} else if (!props.showFavorites) {
|
||||
|
||||
@@ -9,11 +9,14 @@ import streamService from "@/services/stream";
|
||||
import { b64EncodeUnicode } from "@/utils/zincutils";
|
||||
|
||||
// Mock streamService
|
||||
vi.mock("@/services/stream", () => ({
|
||||
default: {
|
||||
fieldValues: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/stream", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
fieldValues: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Shared mocks accessible in both the vi.mock factory and test callbacks.
|
||||
// vi.hoisted ensures they exist before vi.mock factories run.
|
||||
|
||||
@@ -67,12 +67,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onActivated, ref, watch } from "vue";
|
||||
import { defineComponent, onActivated, ref, watch, computed } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import AddFolder from "./AddFolder.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed } from "vue";
|
||||
import { getFoldersListByType } from "@/utils/commons";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
|
||||
@@ -16,12 +16,15 @@
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
// Mock dashboard service
|
||||
vi.mock("@/services/dashboards", () => ({
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/dashboards", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock commons utility
|
||||
vi.mock("@/utils/commons", () => ({
|
||||
|
||||
@@ -154,7 +154,7 @@ export default defineComponent({
|
||||
const data = convertDashboardSchemaVersion(res?.data["v" + res?.data?.version]);
|
||||
|
||||
//update store
|
||||
await getAllDashboards(store, selectedFolder.value.value);
|
||||
await getAllDashboards(store, selectedFolder.value.value, true);
|
||||
emit("updated", data.dashboardId, selectedFolder.value.value);
|
||||
|
||||
showPositiveNotification(t("dashboard.addDashboardPage.addedSuccessfully"));
|
||||
|
||||
@@ -5,14 +5,17 @@ import store from "@/test/unit/helpers/store";
|
||||
import i18n from "@/locales";
|
||||
|
||||
// Mock dashboards service
|
||||
vi.mock("@/services/dashboards", () => ({
|
||||
default: {
|
||||
list_Folders: vi.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/dashboards", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list_Folders: vi.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock AddFolder component
|
||||
vi.mock("@/components/dashboards/AddFolder.vue", () => ({
|
||||
|
||||
@@ -597,7 +597,8 @@ export default defineComponent({
|
||||
|
||||
show.value = false;
|
||||
showFolderSelection.value = false;
|
||||
emit("added");
|
||||
// The parent binds this straight to `getDashboards(force)`, so a bare emit reloads from the cache the import just made stale.
|
||||
emit("added", true);
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "error",
|
||||
|
||||
@@ -463,7 +463,7 @@ import ConfirmDialog from "../ConfirmDialog.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import PanelBar from "@/components/common/PanelBar.vue";
|
||||
import SinglePanelMove from "@/components/dashboards/settings/SinglePanelMove.vue";
|
||||
import { getUUID, processQueryMetadataErrors } from "@/utils/zincutils";
|
||||
import { getUUID, processQueryMetadataErrors, b64EncodeUnicode } from "@/utils/zincutils";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
@@ -471,7 +471,6 @@ import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { b64EncodeUnicode } from "@/utils/zincutils";
|
||||
import shortURL from "@/services/short_url";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
|
||||
@@ -217,6 +217,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:dashboardId="dashboardId"
|
||||
:annotation="annotationToAddEdit"
|
||||
@close="closeAddAnnotation"
|
||||
@saved="reloadAnnotations"
|
||||
:panelsList="panelsList"
|
||||
/>
|
||||
<!-- Alert Context Menu -->
|
||||
@@ -695,6 +696,7 @@ export default defineComponent({
|
||||
searchRequestTraceIds,
|
||||
loadingProgressPercentage,
|
||||
isPartialData,
|
||||
reloadAnnotations,
|
||||
} = usePanelDataLoader(
|
||||
panelSchema,
|
||||
selectedTimeObj,
|
||||
@@ -1821,6 +1823,7 @@ export default defineComponent({
|
||||
validatePanelData,
|
||||
isAddAnnotationDialogVisible,
|
||||
closeAddAnnotation,
|
||||
reloadAnnotations,
|
||||
isAddAnnotationMode,
|
||||
toggleAddAnnotationMode,
|
||||
annotationToAddEdit,
|
||||
|
||||
@@ -19,17 +19,23 @@ import SelectDashboardDropdown from "./SelectDashboardDropdown.vue";
|
||||
import i18n from "@/locales";
|
||||
import { createStore } from "vuex";
|
||||
|
||||
// Mock the utils functions
|
||||
vi.mock("@/utils/commons", () => ({
|
||||
getAllDashboardsByFolderId: vi.fn().mockResolvedValue([
|
||||
{ title: "Dashboard 1", dashboardId: "dash1" },
|
||||
{ title: "Dashboard 2", dashboardId: "dash2" },
|
||||
]),
|
||||
getDashboard: vi.fn().mockResolvedValue({
|
||||
title: "Dashboard 1",
|
||||
dashboardId: "dash1",
|
||||
}),
|
||||
}));
|
||||
// The component reads through useDashboards → dashboardsByFolderQuery, so the
|
||||
// stub goes on the service the query calls, overlaid onto the real module.
|
||||
vi.mock("@/services/dashboards", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
dashboards: [
|
||||
{ title: "Dashboard 1", dashboard_id: "dash1", created: "2024-01-02T00:00:00Z" },
|
||||
{ title: "Dashboard 2", dashboard_id: "dash2", created: "2024-01-01T00:00:00Z" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Stub ODialog so tests are deterministic (no Portal/Reka teleport) and so we
|
||||
// can assert on the props the component forwards + emit the click events
|
||||
@@ -321,8 +327,8 @@ describe("SelectDashboardDropdown", () => {
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should set selectedDashboard to null when folder has no dashboards", async () => {
|
||||
const commons = await import("@/utils/commons");
|
||||
(commons.getAllDashboardsByFolderId as any).mockResolvedValueOnce([]);
|
||||
const dashboards = (await import("@/services/dashboards")).default;
|
||||
vi.mocked(dashboards.list).mockResolvedValueOnce({ data: { dashboards: [] } } as any);
|
||||
|
||||
const wrapper = mountComponent({}, store);
|
||||
await flushPromises();
|
||||
@@ -332,26 +338,36 @@ describe("SelectDashboardDropdown", () => {
|
||||
});
|
||||
|
||||
it("should not load dashboards when folderId is null", async () => {
|
||||
const commons = await import("@/utils/commons");
|
||||
(commons.getAllDashboardsByFolderId as any).mockClear();
|
||||
const dashboards = (await import("@/services/dashboards")).default;
|
||||
vi.mocked(dashboards.list).mockClear();
|
||||
|
||||
const wrapper = mountComponent({ folderId: null }, store);
|
||||
await flushPromises();
|
||||
|
||||
expect(commons.getAllDashboardsByFolderId).not.toHaveBeenCalled();
|
||||
expect(dashboards.list).not.toHaveBeenCalled();
|
||||
expect(wrapper.vm.dashboardList).toEqual([]);
|
||||
});
|
||||
|
||||
it("should reload dashboards when folderId prop changes", async () => {
|
||||
const commons = await import("@/utils/commons");
|
||||
const dashboards = (await import("@/services/dashboards")).default;
|
||||
const wrapper = mountComponent({}, store);
|
||||
await flushPromises();
|
||||
|
||||
(commons.getAllDashboardsByFolderId as any).mockClear();
|
||||
vi.mocked(dashboards.list).mockClear();
|
||||
await wrapper.setProps({ folderId: "default" });
|
||||
await flushPromises();
|
||||
|
||||
expect(commons.getAllDashboardsByFolderId).toHaveBeenCalledWith(expect.anything(), "default");
|
||||
// folderId is the 7th argument of the list builder.
|
||||
expect(dashboards.list).toHaveBeenCalledWith(
|
||||
0,
|
||||
1000,
|
||||
"name",
|
||||
false,
|
||||
"",
|
||||
"org123",
|
||||
"default",
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,16 +62,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onActivated, ref, watch } from "vue";
|
||||
import { computed, defineComponent, ref, watch } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import AddDashboard from "@/components/dashboards/AddDashboard.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import { getAllDashboardsByFolderId } from "@/utils/commons";
|
||||
import { onMounted } from "vue";
|
||||
import { useLoading } from "@/composables/useLoading";
|
||||
import { useDashboards } from "@/services/dashboards.queries";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SelectDashboardDropdown",
|
||||
@@ -89,54 +87,44 @@ export default defineComponent({
|
||||
const store: any = useStore();
|
||||
const showAddDashboardDialog: any = ref(false);
|
||||
const addDashboardRef = ref<InstanceType<typeof AddDashboard> | null>(null);
|
||||
const dashboardList: any = ref([]);
|
||||
|
||||
//dropdown selected dashboard id (primitive string for OSelect)
|
||||
const selectedDashboard = ref<string | null>(null);
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
// Reactive read: mount, folderId change and refetch all flow from the key.
|
||||
const dashboardsQuery = useDashboards(() => props.folderId as string | null);
|
||||
|
||||
// Newest first, matching the Vuex bridge's ordering.
|
||||
const dashboardList = computed(() =>
|
||||
[...(dashboardsQuery.data.value ?? [])]
|
||||
.sort((a: any, b: any) => (b.created ?? "").localeCompare(a.created ?? ""))
|
||||
.map((dashboard: any) => ({
|
||||
label: dashboard.title,
|
||||
value: dashboard.dashboard_id,
|
||||
})),
|
||||
);
|
||||
|
||||
// on add dashboard, select added dashboard
|
||||
const updateDashboardList = async (dashboardId: any) => {
|
||||
showAddDashboardDialog.value = false;
|
||||
await getDashboardList.execute();
|
||||
await dashboardsQuery.refetch();
|
||||
selectedDashboard.value = dashboardId ?? null;
|
||||
};
|
||||
|
||||
// get all dashboards based on selected folderId
|
||||
const getDashboardList = useLoading(async () => {
|
||||
if (!props.folderId) return;
|
||||
|
||||
const allDashboardDataByFolderId = await getAllDashboardsByFolderId(store, props.folderId);
|
||||
|
||||
dashboardList.value = allDashboardDataByFolderId?.map((dashboard: any) => ({
|
||||
label: dashboard.title,
|
||||
value: dashboard.dashboardId,
|
||||
}));
|
||||
|
||||
// select first dashboard
|
||||
if (dashboardList.value.length > 0) {
|
||||
selectedDashboard.value = dashboardList?.value[0]?.value ?? null;
|
||||
} else {
|
||||
selectedDashboard.value = null;
|
||||
}
|
||||
|
||||
emit("dashboard-list-updated");
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getDashboardList.execute();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
getDashboardList.execute();
|
||||
});
|
||||
|
||||
// get all dashboards based on selected folderId
|
||||
// Each (re)load: keep a still-valid selection, else fall back to the first
|
||||
// row — covers mount, folder switch and background revalidation alike.
|
||||
watch(
|
||||
() => [props.folderId],
|
||||
() => {
|
||||
getDashboardList.execute();
|
||||
() => dashboardsQuery.data.value,
|
||||
(data) => {
|
||||
if (data === undefined) return; // pending, or disabled by a null folder
|
||||
const list = dashboardList.value;
|
||||
if (!list.some((d: any) => d.value === selectedDashboard.value)) {
|
||||
selectedDashboard.value = list[0]?.value ?? null;
|
||||
}
|
||||
emit("dashboard-list-updated");
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -159,7 +147,7 @@ export default defineComponent({
|
||||
showAddDashboardDialog,
|
||||
addDashboardRef,
|
||||
dashboardList,
|
||||
getDashboardList,
|
||||
isPending: dashboardsQuery.isPending,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,14 +52,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onActivated, ref, watch } from "vue";
|
||||
import { defineComponent, onActivated, ref, watch, onMounted } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import AddTab from "../../components/dashboards/tabs/AddTab.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import { getDashboard } from "@/utils/commons";
|
||||
import { onMounted } from "vue";
|
||||
import { useLoading } from "@/composables/useLoading";
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -39,11 +39,14 @@ vi.mock("vuex", () => ({
|
||||
}));
|
||||
|
||||
// Mock stream service
|
||||
vi.mock("../../services/stream", () => ({
|
||||
default: {
|
||||
fieldValues: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../services/stream", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
fieldValues: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock child components
|
||||
vi.mock("./settings/VariableQueryValueSelector.vue", () => ({
|
||||
|
||||
@@ -110,8 +110,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch, PropType, inject, computed, nextTick } from "vue";
|
||||
import { defineComponent, reactive } from "vue";
|
||||
import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
PropType,
|
||||
inject,
|
||||
computed,
|
||||
nextTick,
|
||||
defineComponent,
|
||||
reactive,
|
||||
} from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import VariableQueryValueSelector from "./settings/VariableQueryValueSelector.vue";
|
||||
|
||||
@@ -21,13 +21,16 @@ import router from "@/test/unit/helpers/router";
|
||||
import { annotationService } from "@/services/dashboard_annotations";
|
||||
|
||||
// Mock the annotation service
|
||||
vi.mock("@/services/dashboard_annotations", () => ({
|
||||
annotationService: {
|
||||
create_timed_annotations: vi.fn(),
|
||||
update_timed_annotations: vi.fn(),
|
||||
delete_timed_annotations: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/services/dashboard_annotations", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
annotationService: {
|
||||
create_timed_annotations: vi.fn(),
|
||||
update_timed_annotations: vi.fn(),
|
||||
delete_timed_annotations: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const node = document.createElement("div");
|
||||
node.setAttribute("id", "app");
|
||||
|
||||
@@ -77,12 +77,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
createAnnotationMutation,
|
||||
updateAnnotationMutation,
|
||||
deleteAnnotationMutation,
|
||||
} from "@/services/dashboard_annotations.queries";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import type { PropType } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18nTyped, raw, type I18nText } from "@/types/i18n";
|
||||
import { useLoading } from "@/composables/useLoading";
|
||||
import { annotationService } from "@/services/dashboard_annotations";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
@@ -125,6 +130,7 @@ const props = defineProps({
|
||||
const emit = defineEmits<{
|
||||
(e: "remove"): void;
|
||||
(e: "close"): void;
|
||||
(e: "saved"): void;
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
@@ -235,12 +241,11 @@ const handleSave = async () => {
|
||||
tags: annotationData.value.tags,
|
||||
};
|
||||
const annotationId = annotationData.value.annotation_id ?? "";
|
||||
await annotationService.update_timed_annotations(
|
||||
organization,
|
||||
props.dashboardId,
|
||||
// Was: invalidate, then write — the refetch raced the request.
|
||||
await updateAnnotation.mutateAsync({
|
||||
annotationId,
|
||||
annotationToUpdate,
|
||||
);
|
||||
annotation: annotationToUpdate,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification(
|
||||
raw(errorMessage(error)) ||
|
||||
@@ -251,9 +256,7 @@ const handleSave = async () => {
|
||||
} else {
|
||||
try {
|
||||
// create annotation
|
||||
await annotationService.create_timed_annotations(organization, props.dashboardId, [
|
||||
annotationData.value,
|
||||
]);
|
||||
await createAnnotation.mutateAsync([annotationData.value]);
|
||||
} catch (error) {
|
||||
showErrorNotification(
|
||||
raw(errorMessage(error)) ||
|
||||
@@ -263,10 +266,21 @@ const handleSave = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
emit("saved");
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const createAnnotation = useMutation(() =>
|
||||
createAnnotationMutation(organization, props.dashboardId),
|
||||
);
|
||||
const updateAnnotation = useMutation(() =>
|
||||
updateAnnotationMutation(organization, props.dashboardId),
|
||||
);
|
||||
const deleteAnnotationWrite = useMutation(() =>
|
||||
deleteAnnotationMutation(organization, props.dashboardId),
|
||||
);
|
||||
|
||||
const handleDeleteWithConfirm = () => {
|
||||
showDeleteConfirm.value = true;
|
||||
};
|
||||
@@ -274,8 +288,9 @@ const handleDeleteWithConfirm = () => {
|
||||
const confirmDelete = async () => {
|
||||
// Delete is reachable only for a persisted annotation, so `annotation_id` is a string.
|
||||
const annotationId = annotationData.value.annotation_id ?? "";
|
||||
await annotationService.delete_timed_annotations(organization, props.dashboardId, [annotationId]);
|
||||
await deleteAnnotationWrite.mutateAsync([annotationId]);
|
||||
|
||||
emit("saved");
|
||||
handleClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -120,12 +120,9 @@
|
||||
</ODialog>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { computed, ref, nextTick } from "vue";
|
||||
import { defineComponent } from "vue";
|
||||
import { computed, ref, nextTick, defineComponent, onMounted, watch } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { onMounted } from "vue";
|
||||
import { VueDraggableNext } from "vue-draggable-next";
|
||||
import { watch } from "vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
|
||||
@@ -1773,7 +1773,18 @@ import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
|
||||
import { type SwitchValue } from "@/lib/forms/Switch/OSwitch.types";
|
||||
import useDashboardPanelData from "@/composables/dashboard/useDashboardPanel";
|
||||
import { getUnitOptions } from "@/composables/dashboard/useColumnFormatting";
|
||||
import { computed, defineComponent, inject, nextTick, onBeforeMount, onMounted, ref } from "vue";
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
inject,
|
||||
nextTick,
|
||||
onBeforeMount,
|
||||
onMounted,
|
||||
ref,
|
||||
markRaw,
|
||||
watchEffect,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import Drilldown from "./Drilldown.vue";
|
||||
import ValueMapping from "./ValueMapping.vue";
|
||||
@@ -1800,7 +1811,6 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import { markRaw, watchEffect, watch } from "vue";
|
||||
import {
|
||||
convertPanelTimeRangeToPicker,
|
||||
buildPanelTimeRange,
|
||||
|
||||
@@ -35,8 +35,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from "vue";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import { defineComponent, ref, watch, defineAsyncComponent } from "vue";
|
||||
const QueryEditor = defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue"));
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
@@ -395,7 +395,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, reactive, watch, computed } from "vue";
|
||||
import { defineComponent, ref, reactive, watch, computed, inject } from "vue";
|
||||
import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
@@ -405,7 +405,6 @@ import { useI18nTyped, raw } from "@/types/i18n";
|
||||
import useDashboardPanelData from "../../../composables/dashboard/useDashboardPanel";
|
||||
import useDragHandle from "@/composables/useDragHandle";
|
||||
import { getImageURL } from "../../../utils/zincutils";
|
||||
import { inject } from "vue";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
import DashboardFiltersOption from "@/views/Dashboards/addPanel/DashboardFiltersOption.vue";
|
||||
import DynamicFunctionPopUp from "@/components/dashboards/addPanel/dynamicFunction/DynamicFunctionPopUp.vue";
|
||||
|
||||
@@ -311,14 +311,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
|
||||
import OTab from "@/lib/navigation/Tabs/OTab.vue";
|
||||
// @ts-nocheck
|
||||
import { defineComponent, ref, watch, computed, onMounted, nextTick, onUnmounted } from "vue";
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
watch,
|
||||
computed,
|
||||
onMounted,
|
||||
nextTick,
|
||||
onUnmounted,
|
||||
inject,
|
||||
type Ref,
|
||||
onBeforeMount,
|
||||
} from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import useDashboardPanelData from "../../../composables/dashboard/useDashboardPanel";
|
||||
import QueryTypeSelector from "../addPanel/QueryTypeSelector.vue";
|
||||
import usePromqlSuggestions from "@/composables/usePromqlSuggestions";
|
||||
import { inject, type Ref } from "vue";
|
||||
import { onBeforeMount } from "vue";
|
||||
import { getImageURL } from "@/utils/zincutils";
|
||||
import { type SqlErrorRange } from "@/utils/query/sqlDiagnostics";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
@@ -393,9 +402,7 @@ export default defineComponent({
|
||||
|
||||
const getFunctions = async () => {
|
||||
try {
|
||||
if (store.state.organizationData.functions.length == 0) {
|
||||
await getAllFunctions();
|
||||
}
|
||||
await getAllFunctions();
|
||||
|
||||
store.state.organizationData.functions.map((data: any) => {
|
||||
functionList.value.push({
|
||||
|
||||
@@ -57,14 +57,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject, ref } from "vue";
|
||||
import { defineComponent, inject, ref, onBeforeMount } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import DrilldownPopUp from "./DrilldownPopUp.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import { useStore } from "vuex";
|
||||
import useDashboardPanelData from "../../../composables/dashboard/useDashboardPanel";
|
||||
import { onBeforeMount } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Drilldown",
|
||||
|
||||
@@ -237,17 +237,23 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineAsyncComponent, inject, nextTick, reactive, ref } from "vue";
|
||||
import {
|
||||
defineAsyncComponent,
|
||||
inject,
|
||||
nextTick,
|
||||
reactive,
|
||||
ref,
|
||||
defineComponent,
|
||||
watch,
|
||||
computed,
|
||||
onMounted,
|
||||
} from "vue";
|
||||
import OFormToggleGroup from "@/lib/core/ToggleGroup/OFormToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import { defineComponent } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed } from "vue";
|
||||
import { getAllDashboardsByFolderId, getDashboard, getFoldersList } from "../../../utils/commons";
|
||||
import { onMounted } from "vue";
|
||||
import useDashboardPanelData from "../../../composables/dashboard/useDashboardPanel";
|
||||
import DrilldownUserGuide from "@/components/dashboards/addPanel/DrilldownUserGuide.vue";
|
||||
import OFormCombobox from "@/lib/forms/Combobox/OFormCombobox.vue";
|
||||
@@ -408,15 +414,7 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// if no folders in organization, get folders
|
||||
if (
|
||||
!store.state.organizationData.folders ||
|
||||
(Array.isArray(store.state.organizationData.folders) &&
|
||||
store.state.organizationData.folders.length === 0)
|
||||
) {
|
||||
// get folders(will be api call)
|
||||
await getFoldersListLoading.execute();
|
||||
}
|
||||
await getFoldersListLoading.execute();
|
||||
|
||||
// get dashboard list
|
||||
// get tab list
|
||||
|
||||
@@ -59,11 +59,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject } from "vue";
|
||||
import { defineComponent, inject, onBeforeMount } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import useDashboardPanelData from "../../../composables/dashboard/useDashboardPanel";
|
||||
import { onBeforeMount } from "vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user