feat: prebuilt template protection, per-destination templates, and correlation settings fixes (#12423)

## Summary

Combines #12414 (prebuilt template protection) and #12421 (correlation
settings + AI i18n + traces fixes).

### Prebuilt template protection + per-destination template selection
- Lets users attach a custom template to a prebuilt destination so
multiple destinations of the same type (e.g. three Slack channels) can
deliver different message bodies.
- Protects system-managed \`prebuilt_<type>\` templates from accidental
deletion (403) while still allowing edits; create with a reserved name
blocked (400).
- Adds Prebuilt/Custom badges, All/Prebuilt/Custom toggle on both
Templates and Destinations lists.
- Per-row Clone action on templates (\`Copy_of_<name>\` prefilled);
Template column on destinations list.
- Cross-type prebuilt template mismatches blocked in the dropdown (e.g.
\`prebuilt_servicenow\` cannot be selected for a Slack destination).
- Select-All fix in the shared OTable composable —
\`toggleAllRows\`/\`isAllSelected\`/\`isIndeterminate\` now respect
\`isRowSelectable\`.

Design at:
https://github.com/openobserve/designs/blob/main/alerts/prebuilt-template-protection/design.md

### Correlation settings visibility + AI i18n + traces tab fixes
- **Correlation Settings tab** always visible for enterprise: removed
unnecessary \`service_streams_enabled\` guard — route was always
registered, only the nav item was incorrectly gated.
- **Cross-alert fingerprint groups** hidden when org-level dedup is
disabled; save payload sanitises \`alert_fingerprint_groups\` to \`[]\`
when cross-alert dedup is off.
- **AI suggestion cards** use i18n prompt keys
(\`t('aiAssistant.capabilities.{id}.prompt')\`) instead of hardcoded
English — prompt keys added to all 12 locale files.
- **LLM Insights / Sessions tabs** always shown when \`showLLMUI\` is
enabled; org-switch re-mounts both components via \`:key\`.
- **LLMInsightsDashboard** layout, background, and empty-state alignment
fixed.

Fixes openobserve/o2-enterprise#1850, openobserve/o2-enterprise#1851,
openobserve/o2-enterprise#1852

## Changes

| Layer | File | What |
|---|---|---|
| Backend | \`src/service/alerts/templates.rs\` |
\`is_prebuilt_template_name()\` helper; block create with reserved name;
block delete of prebuilt; edit allowed. |
| Backend | \`src/service/db/alerts/templates.rs\` | New error variants:
\`PrebuiltReadOnly\` (403), \`ReservedName\` (400). |
| Backend | \`src/handler/http/request/alerts/templates.rs\` | HTTP
error mapping + two unit tests. |
| Backend | \`src/handler/http/models/destinations.rs\` | Public
Template DTO carries \`isPrebuilt: bool\`. |
| Frontend | \`web/src/components/alerts/AddDestination.vue\` | Template
dropdown for prebuilt destinations with sentinel-backed Default option;
edit-mode remap; i18n for all label strings. |
| Frontend | \`web/src/composables/usePrebuiltDestinations.ts\` |
\`templateOverride\` param on create/update; empty → backend default. |
| Frontend | \`web/src/components/alerts/TemplateList.vue\` |
Prebuilt/Custom badges, Clone action, delete-blocked for prebuilts,
All/Prebuilt/Custom toggle, prebuilts excluded from Select All. |
| Frontend | \`web/src/components/alerts/AddTemplate.vue\` | \`isClone\`
prop — pre-fill without entering update mode. |
| Frontend | \`web/src/components/alerts/AlertsDestinationList.vue\` |
New Template column (ellipsis + tooltip, Default badge),
All/Prebuilt/Custom toggle. |
| Frontend | \`web/src/lib/core/Table/composables/useTableSelection.ts\`
+ \`OTable.vue\` | \`selectableRows()\` helper; Select All respects
\`isRowSelectable\`; getter pattern restored for reactivity. |
| Frontend | \`web/src/ts/interfaces/alert.ts\` |
\`Template.isPrebuilt?: boolean\`. |
| Frontend |
\`web/src/components/alerts/OrganizationDeduplicationSettings.vue\` |
Hide fingerprint groups when org dedup disabled; sanitise payload. |
| Frontend | \`web/src/components/settings/index.vue\` | Remove
\`service_streams_enabled\` guard from Correlation Settings nav item. |
| Frontend | \`web/src/plugins/traces/Index.vue\` | Remove
\`hasLLMStreams\` guard from LLM Insights / Sessions tabs. |
| Frontend | \`web/src/plugins/traces/LLMInsightsDashboard.vue\` +
\`SessionsList.vue\` | \`:key\` on org identifier for re-mount on org
switch; layout fixes. |
| Frontend | \`web/src/plugins/traces/SearchBar.vue\` | AI suggestion
cards use i18n prompt keys. |
| Frontend | \`web/src/components/ai-assistant/welcome/\` | Capability
card prompts via i18n; all 12 locale files updated. |
| Frontend | \`web/src/locales/languages/en.json\` (+ 10 other locales)
| New keys: \`filter{All,Prebuilt,Custom}\`, \`templateDefaultBadge\`,
\`prebuiltBadge\`, \`prebuiltBadgeHint\`, \`customBadge\`, \`clone\`,
\`cloneTitle\`, \`systemReadOnly\`, \`templateDefaultOption\`,
\`templateHelp\`; toast key moved to \`alert_destinations.saved\`. |

## Compatibility
- **No schema migration.** "Is this a system template" is derived from
the name matching \`prebuilt_<canonical_type>\` where canonical types
are the eight registered in \`config/prebuilt-destinations.json\`.
- **No ENT changes needed.** Verified the enterprise tree does not
reference \`TemplateError\`, the public Template DTO, or the new helper.
- **API additive:** \`GET /alerts/templates\` response gains
\`isPrebuilt: bool\`.
- **API breaking (narrow):**
- \`POST /alerts/templates\` rejects the eight canonical reserved names
with 400.
- \`DELETE /alerts/templates/{name}\` rejects the eight canonical names
with 403.

## Test plan
- [x] \`cargo test --lib -p openobserve -- prebuilt_read_only
reserved_name\` — both pass
- [x] \`cargo check --bin openobserve\` — clean
- [x] \`vue-tsc --noEmit\` — exit 0
- [ ] Create 3 Slack destinations — each can be assigned a different
custom template
- [ ] Template dropdown for a Slack destination shows only custom
templates + Default sentinel (no \`prebuilt_servicenow\` etc.)
- [ ] Attempting to delete a \`prebuilt_\` template returns a 403 toast
- [ ] Attempting to create a template named \`prebuilt_slack\` returns a
400 error
- [ ] Clone button pre-fills \`Copy_of_<name>\` and saves as a new
template
- [ ] Select All on a mixed prebuilt/custom template page: prebuilt rows
unchecked, custom rows toggled
- [ ] Enterprise build: Correlation Settings visible in sidebar without
\`service_streams_enabled\`
- [ ] Correlation Settings: disable org dedup → fingerprint groups
section disappears; save → payload has empty
\`alert_fingerprint_groups\`
- [ ] Switch UI language → AI home suggestion cards send prompt in that
language
- [ ] Org with no LLM streams: LLM Insights + Sessions tabs still
visible with correct empty state
- [ ] Switch orgs on LLM Insights → data reloads for new org

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Shrinath Rao
2026-06-03 08:30:27 +00:00
committed by GitHub
co-authored by Claude Opus 4.7 github-actions[bot]
parent ad9fef2242
commit 4ca42e29af
33 changed files with 787 additions and 161 deletions
+14
View File
@@ -184,10 +184,15 @@ impl From<meta_dest::Template> for Template {
meta_dest::TemplateType::Sns => (String::new(), DestinationType::Sns),
};
// Shared rule lives in `config::prebuilt_loader` so the HTTP DTO and
// the service-layer guards can't drift apart.
let is_prebuilt = config::prebuilt_loader::is_prebuilt_template_name(&value.name);
Self {
name: value.name,
body: value.body,
is_default: value.is_default.then_some(true),
is_prebuilt,
template_type,
title,
}
@@ -341,6 +346,12 @@ pub struct Template {
#[serde(rename = "isDefault")]
#[serde(default)]
pub is_default: Option<bool>,
/// True for system-managed prebuilt templates (those whose name starts
/// with `prebuilt_`). The public API refuses updates and deletes for
/// these, so the UI should render them as read-only.
#[serde(rename = "isPrebuilt")]
#[serde(default)]
pub is_prebuilt: bool,
/// Indicates whether the body is
/// http or email body
#[serde(rename = "type")]
@@ -456,6 +467,7 @@ mod tests {
name: "tmpl".to_string(),
body: "body".to_string(),
is_default: None,
is_prebuilt: false,
template_type: DestinationType::Http,
title: String::new(),
};
@@ -472,6 +484,7 @@ mod tests {
name: "email_tmpl".to_string(),
body: "hi".to_string(),
is_default: Some(true),
is_prebuilt: false,
template_type: DestinationType::Email,
title: "My Alert".to_string(),
};
@@ -489,6 +502,7 @@ mod tests {
name: "s".to_string(),
body: "b".to_string(),
is_default: None,
is_prebuilt: false,
template_type: DestinationType::Sns,
title: String::new(),
};
@@ -37,6 +37,9 @@ impl From<TemplateError> for Response {
TemplateError::DeleteWithDestination(e) => {
MetaHttpResponse::conflict(TemplateError::DeleteWithDestination(e))
}
TemplateError::PrebuiltReadOnly(name) => {
MetaHttpResponse::forbidden(TemplateError::PrebuiltReadOnly(name))
}
other_err => MetaHttpResponse::bad_request(other_err),
}
}
@@ -458,4 +461,22 @@ mod tests {
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn test_prebuilt_read_only_is_forbidden() {
assert_eq!(
status(TemplateError::PrebuiltReadOnly(
"prebuilt_slack".to_string()
)),
StatusCode::FORBIDDEN
);
}
#[test]
fn test_reserved_name_is_bad_request() {
assert_eq!(
status(TemplateError::ReservedName("prebuilt_slack".to_string())),
StatusCode::BAD_REQUEST
);
}
}