Merge remote-tracking branch 'origin/main' into feat/query-filter-bar

This commit is contained in:
ktx-vaidehi
2026-10-30 00:10:39 +05:30
403 changed files with 36655 additions and 5247 deletions
-132
View File
@@ -1,132 +0,0 @@
name: Claude Auto-Implement
# Trigger on label or @claude_impl comment on issues/PRs
on:
issues:
types: [labeled]
issue_comment:
types: [created]
concurrency:
group: claude-implement-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
# Security: Check if user is a member of argocd-dev team
check-permission:
if: |
github.event.sender.type != 'Bot' && (
(github.event_name == 'issues' && github.event.label.name == 'auto-implement') ||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude_impl'))
)
runs-on: ubuntu-latest
outputs:
authorized: ${{ steps.check.outputs.authorized }}
steps:
- name: Check team membership
id: check
uses: actions/github-script@v9
with:
github-token: ${{ secrets.E2E_DEV_CI_TOKEN }}
script: |
const team = 'argocd-dev';
const org = 'openobserve';
const username = context.actor;
try {
// Check if user is a member of the team
await github.rest.teams.getMembershipForUserInOrg({
org: org,
team_slug: team,
username: username
});
console.log(`✅ ${username} is a member of ${org}/${team}`);
core.setOutput('authorized', 'true');
} catch (error) {
if (error.status === 404) {
console.log(`❌ ${username} is NOT a member of ${org}/${team}`);
core.setOutput('authorized', 'false');
// Post a comment explaining why the workflow didn't run
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ @${username} You don't have permission to trigger \`@claude_impl\`. Only members of the [argocd-dev](https://github.com/orgs/openobserve/teams/argocd-dev) team can use this command.`
});
} else {
throw error;
}
}
# Prepare job for label/comment triggers - detects if PR or Issue
prepare-auto:
needs: check-permission
if: |
needs.check-permission.outputs.authorized == 'true' &&
github.event.sender.type != 'Bot' && (
(github.event_name == 'issues' && github.event.label.name == 'auto-implement') ||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude_impl'))
)
runs-on: ubuntu-latest
outputs:
mode: ${{ steps.detect.outputs.mode }}
branch: ${{ steps.detect.outputs.branch }}
pr_number: ${{ steps.detect.outputs.pr_number }}
steps:
- name: Detect if PR or Issue
id: detect
uses: actions/github-script@v9
with:
github-token: ${{ secrets.E2E_DEV_CI_TOKEN }}
script: |
const isPR = !!context.payload.issue.pull_request;
const num = context.issue.number;
if (isPR) {
// Get PR details to get branch name
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: num
});
core.setOutput('mode', 'pr_fix');
core.setOutput('branch', pr.head.ref);
core.setOutput('pr_number', String(num));
console.log(`Detected PR #${num} on branch '${pr.head.ref}'`);
} else {
core.setOutput('mode', 'implement');
core.setOutput('branch', '');
core.setOutput('pr_number', '');
console.log(`Detected Issue #${num}`);
}
# Dispatch to o2-enterprise workflow
dispatch:
needs: [check-permission, prepare-auto]
if: |
needs.check-permission.outputs.authorized == 'true' &&
github.event.sender.type != 'Bot' && (
(github.event_name == 'issues' && github.event.label.name == 'auto-implement') ||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude_impl'))
)
runs-on: ubuntu-latest
steps:
- name: Dispatch Claude Implement Event to o2-enterprise
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.E2E_DEV_CI_TOKEN }}
repository: openobserve/o2-enterprise
event-type: claude-impl-requested
client-payload: |-
{
"source_repo": "openobserve",
"issue_number": "${{ github.event.issue.number }}",
"issue_title": ${{ toJSON(github.event.issue.title) }},
"issue_body": ${{ toJSON(github.event.issue.body) }},
"mode": "${{ needs.prepare-auto.outputs.mode }}",
"branch": "${{ needs.prepare-auto.outputs.branch }}",
"pr_number": "${{ needs.prepare-auto.outputs.pr_number }}"
}
-146
View File
@@ -1,146 +0,0 @@
name: Claude Fix
# Trigger on comments containing @claude_fix (works on both PRs and Issues)
on:
issue_comment:
types: [created]
jobs:
# Security: Check if user is a member of argocd-dev team
check-permission:
if: |
github.event.sender.type != 'Bot' &&
contains(github.event.comment.body, '@claude_fix')
runs-on: ubuntu-latest
outputs:
authorized: ${{ steps.check.outputs.authorized }}
steps:
- name: Check team membership
id: check
uses: actions/github-script@v9
with:
github-token: ${{ secrets.E2E_DEV_CI_TOKEN }}
script: |
const team = 'argocd-dev';
const org = 'openobserve';
const username = context.actor;
try {
// Check if user is a member of the team
await github.rest.teams.getMembershipForUserInOrg({
org: org,
team_slug: team,
username: username
});
console.log(`✅ ${username} is a member of ${org}/${team}`);
core.setOutput('authorized', 'true');
} catch (error) {
if (error.status === 404) {
console.log(`❌ ${username} is NOT a member of ${org}/${team}`);
core.setOutput('authorized', 'false');
// Post a comment explaining why the workflow didn't run
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ @${username} You don't have permission to trigger \`@claude_fix\`. Only members of the [argocd-dev](https://github.com/orgs/openobserve/teams/argocd-dev) team can use this command.`
});
} else {
throw error;
}
}
# Determine context and prepare inputs for reusable workflow
prepare:
needs: check-permission
if: |
needs.check-permission.outputs.authorized == 'true' &&
github.event.sender.type != 'Bot' &&
contains(github.event.comment.body, '@claude_fix')
runs-on: ubuntu-latest
outputs:
mode: ${{ steps.context.outputs.mode }}
branch: ${{ steps.context.outputs.branch }}
number: ${{ steps.context.outputs.number }}
title: ${{ steps.context.outputs.title }}
issue_body: ${{ steps.context.outputs.issue_body }}
pr_number: ${{ steps.context.outputs.pr_number }}
trigger_comment: ${{ steps.context.outputs.trigger_comment }}
trigger_comment_id: ${{ steps.context.outputs.trigger_comment_id }}
steps:
- name: Determine Context (PR or Issue)
id: context
uses: actions/github-script@v9
with:
github-token: ${{ secrets.E2E_DEV_CI_TOKEN }}
script: |
const isPR = !!context.payload.issue.pull_request;
core.setOutput('trigger_comment', context.payload.comment.body);
core.setOutput('trigger_comment_id', context.payload.comment.id);
if (isPR) {
// Get PR details
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
core.setOutput('mode', 'pr_fix');
core.setOutput('branch', pr.head.ref);
core.setOutput('number', context.issue.number);
core.setOutput('title', pr.title);
core.setOutput('pr_number', context.issue.number);
core.setOutput('issue_body', pr.body || '');
console.log(`PR #${pr.number}: ${pr.title}`);
console.log(`Branch: ${pr.head.ref}`);
} else {
// Get Issue details
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const branchName = `fix/issue-${issue.number}`;
core.setOutput('mode', 'issue_fix');
core.setOutput('branch', branchName);
core.setOutput('number', context.issue.number);
core.setOutput('title', issue.title);
core.setOutput('pr_number', '');
core.setOutput('issue_body', issue.body || '');
console.log(`Issue #${issue.number}: ${issue.title}`);
console.log(`Will create branch: ${branchName}`);
}
# Dispatch to o2-enterprise workflow (similar to dispatch-event-enterprise.yml pattern)
dispatch:
needs: [check-permission, prepare]
runs-on: ubuntu-latest
steps:
- name: Dispatch Claude Fix Event to o2-enterprise
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.E2E_DEV_CI_TOKEN }}
repository: openobserve/o2-enterprise
event-type: claude-fix-requested
client-payload: |-
{
"source_repo": "openobserve",
"issue_number": "${{ needs.prepare.outputs.number }}",
"issue_title": ${{ toJSON(needs.prepare.outputs.title) }},
"issue_body": ${{ toJSON(needs.prepare.outputs.issue_body) }},
"mode": "${{ needs.prepare.outputs.mode }}",
"branch": "${{ needs.prepare.outputs.branch }}",
"pr_number": "${{ needs.prepare.outputs.pr_number }}",
"trigger_comment": ${{ toJSON(needs.prepare.outputs.trigger_comment) }},
"trigger_comment_id": "${{ needs.prepare.outputs.trigger_comment_id }}"
}
@@ -41,11 +41,17 @@ jobs:
APPROVED=false
if [ "$DECISION" = "APPROVED" ]; then APPROVED=true; fi
# A second approval or an unrelated label must not rebuild a commit the enterprise build already passed.
STATE=$(gh api "repos/$GH_REPO/commits/$SHA/status" --jq '.statuses[] | select(.context == "ent-build-status-check") | .state')
STATUS=$(gh api "repos/$GH_REPO/commits/$SHA/status" --jq '.statuses[] | select(.context == "ent-build-status-check") | "\(.state) \(now - (.updated_at | fromdateiso8601) | floor)"')
read -r STATE AGE <<< "$STATUS"
if [ "$APPROVED" = "true" ] && [ "$STATE" = "success" ]; then
echo "ent-build-status-check already passed on $SHA."
APPROVED=false
fi
# o2-enterprise cancels the older run for a branch, so re-dispatching a SHA still building would restart it; past an hour it is presumed lost.
if [ "$APPROVED" = "true" ] && [ "$STATE" = "pending" ] && [ "${AGE:-3600}" -lt 3600 ]; then
echo "ent-build-status-check has been pending on $SHA for ${AGE}s; not re-dispatching."
APPROVED=false
fi
echo "approved=$APPROVED" >> "$GITHUB_OUTPUT"
initiate_enterprise_build:
@@ -98,7 +104,19 @@ jobs:
if: ${{ steps.classify.outputs.run_enterprise == 'true' }}
run: echo ${{ env.SHORT_COMMIT_ID }}
# Posted before the dispatch so it can never overwrite the enterprise result; the approval guard reads it.
- name: Report dispatched
id: pending
if: ${{ steps.classify.outputs.run_enterprise == 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api -X POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_HEAD_SHA" \
-f state=pending -f context=ent-build-status-check -f description="dispatched to o2-enterprise; waiting for its build"
- name: Repository Dispatch
id: dispatch
if: ${{ steps.classify.outputs.run_enterprise == 'true' }}
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
env:
@@ -109,3 +127,11 @@ jobs:
repository: openobserve/o2-enterprise
event-type: initiate-enterprise-build
client-payload: '{"o2_enterprise_repo": "${{ env.BRANCH_NAME }}", "o2_opensource_repo": "${{ env.BRANCH_NAME }}" ,"o2_commit_id": "${{ env.SHORT_COMMIT_ID }}", "o2_full_commit":"${{env.COMMIT_ID}}", "o2_target_branch": "${{ github.event.pull_request.base.ref }}"}'
# A pending left by a dispatch that never went out would make the approval guard skip the next one for an hour.
- name: Report dispatch failed
if: ${{ (failure() || cancelled()) && steps.pending.outcome == 'success' && steps.dispatch.outcome != 'success' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api -X POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_HEAD_SHA" \
-f state=error -f context=ent-build-status-check -f description="dispatch to o2-enterprise failed; push or re-approve the PR to retry"
-97
View File
@@ -1,97 +0,0 @@
name: Query Agent Caller (openobserve)
# Thin caller: watches openobserve (OSS) PRs and forwards to the reusable
# Query Agent engine in o2-enterprise. All real logic (triage, build, test,
# generation) lives in the engine; this file only gates (allowlist + command
# detection) and forwards.
#
# WHY A CALLER (not self-contained): a reusable workflow runs in the CALLER's
# context, so triggering from here makes the run use openobserve's own
# GITHUB_TOKEN / github-actions[bot] — which can comment on OSS PRs, push
# to OSS branches (for Phase B autoqa commits), and create OSS PRs. The
# engine file in o2-enterprise would run in ENT context and could NOT do any
# of these for OSS.
#
# TWO ENTRY POINTS:
# - issue_comment → an allowlisted engineer comments /query-agent or
# /qa-generate on an OSS PR.
# - workflow_dispatch → manual "Run workflow" button against a PR number.
# Dispatch is inherently restricted to repo write access.
#
# ACCESS CONTROL (comment path): gate on the commenter being in the hardcoded
# engineering-team allowlist. This is checked here AND re-validated in the
# engine's triage job (harmless double-check).
#
# SECRETS: DEEPSEEK_API_KEY_E2E and ORG_ADMIN_TOKEN must exist in
# openobserve secrets. E2E_DEV_CI_TOKEN is forwarded as CROSS_REPO_TOKEN
# for any ENT-side cross-repo operations.
on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: "OSS PR number to run Query Agent on"
required: true
dry_run:
description: "Triage only — skip tests and generation"
type: boolean
default: true
generation_mode:
description: "Phase B: generate new SQL queries for this PR"
type: choice
required: false
default: "none"
options:
- none
- full
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: query-agent-openobserve-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
cancel-in-progress: true
jobs:
# Validate workflow_dispatch inputs before forwarding to the engine.
# Skipped for issue_comment events (no inputs to validate).
validate_inputs:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Validate pr_number is a positive integer
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: |
if ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
echo "::error::pr_number must be a positive integer, got: $PR_NUMBER"
exit 1
fi
call-query-agent:
needs: [validate_inputs]
# Manual dispatch: always run (restricted to repo write access by GitHub).
# Comment: only if the commenter is on the allowlist AND the command matches.
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.issue.pull_request &&
contains(fromJSON('["/query-agent","/qa-generate"]'), github.event.comment.body) &&
contains(fromJSON('["bjp232004","hengfeiyang","prabhatsharma","uddhavdave","oasisk","haohuaijin","Subhra264","Loaki07","007harshmahajan","chaitanya-sistla","ktx-kirtan","omkarK06","ktx-vaidehi","ktx-abhay","neha00290","ktx-akshay","ktx-sadhna","nikhilsaikethe","YashodhanJoshi1","ktx-riya","mmosarafO2","Shrinath-O2","ktx-mihir","vamsikarthikr","pr1sm8"]'), github.actor))
uses: openobserve/o2-enterprise/.github/workflows/query-agent.yml@cb74bab8b9da410f525f7931a9396ec8cd885665
with:
source_repo: openobserve
# dispatch supplies pr_number directly; comment path uses the commented PR number.
pr_number: ${{ inputs.pr_number || github.event.issue.number }}
# generation_mode: dispatch uses the typed choice; comment path: /qa-generate → full.
generation_mode: ${{ github.event_name == 'workflow_dispatch' && (inputs.generation_mode || 'none') || (github.event.comment.body == '/qa-generate' && 'full' || 'none') }}
# dry_run: dispatch honors the typed boolean; comment path always runs (not dry).
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}
actor: ${{ github.actor }}
secrets:
DEEPSEEK_API_KEY_E2E: ${{ secrets.DEEPSEEK_API_KEY_E2E }}
ORG_ADMIN_TOKEN: ${{ secrets.ORG_ADMIN_TOKEN }}
CROSS_REPO_TOKEN: ${{ secrets.E2E_DEV_CI_TOKEN }}
+127 -29
View File
@@ -1145,8 +1145,6 @@
"descriptionPlaceholder": "dc1e7db58b2d8e76",
"expectedOutputScorersOnly": "a758ba9ab3e54c96",
"identitySection": "999f23fcd7bec707",
"leaveMessage": "80d5a857b711267a",
"leaveTitle": "726aa0947f6e5a3c",
"managedPromptLabel": "39f780053bf3874c",
"managedPromptPlaceholder": "1e841b12d6e50a5c",
"modelHelp": "0017404e73bb5f67",
@@ -2059,7 +2057,9 @@
"saveDraft": "6c462142d3681836",
"saveDraftBlocked": "ada35dbc29caf104",
"signingKeyLabel": "1e35ad097b3e5161",
"signingKeyNotKept": "83b1905702beccb4",
"signingKeyOnce": "1aa204d0f83c1125",
"signingKeyRegenerated": "4373f6263a2ff96e",
"signingSection": "5dab0cdd17696359",
"signingToggle": "46290db957af7e4a",
"submit": "859390eb495b2ead",
@@ -4935,6 +4935,8 @@
"refreshCorrelatedData": "be48b202ce487e75",
"relatedAlerts": "0685977cc348e3ab",
"reopen": "a886d1dc4f128e27",
"reopenConfirmMessage": "86fafd629d710a4f",
"reopenConfirmTitle": "ab7b675b9e7d8901",
"reopenIncidentTooltip": "7ceb345025e4d912",
"rerunAnalysisCancelLabel": "49780701dc2d7cc6",
"rerunAnalysisConfirmLabel": "91f507f3f665bfba",
@@ -4942,6 +4944,8 @@
"rerunAnalysisMessageWithCost": "ff93e89be136dccb",
"rerunAnalysisTitle": "1dbbf8659f4c7e93",
"resolve": "c8f193b315c86f3a",
"resolveConfirmMessage": "4944253a343a8f91",
"resolveConfirmTitle": "936a89903b8c7a02",
"scrollToBottom": "6a36c1b26f1c1ab7",
"scrollToTop": "0d26ae7f847f3b0b",
"search": "5e6e84d15f2209b0",
@@ -5745,10 +5749,6 @@
"tools": "ea93d6a262ecb87a",
"warning": "e981ddae45d8f4ca"
},
"keepEditing": "e76fd2add01005ae",
"leaveConfirm": "eb1a70e39274bb76",
"leaveMessage": "49a8469d4708244a",
"leaveTitle": "85bcf41666a53691",
"messageHelp": "24291ae18455f299",
"orgOption": "a37fb6c849811fda",
"orgsPick": "2730183d6b776b40",
@@ -7492,6 +7492,7 @@
"secretTypeRequired": "78222784e82d56e6",
"staticSecret": "48207b624239110a",
"staticSecretName": "dcd1d5223f73b3a9",
"staticSecretNameNotKept": "6af1e9c37ee70a6b",
"staticSecretNameRequired": "5cb676adf8d94110",
"step1WithType": "4811a01103cdae90",
"step2": "14d01bcc63894238",
@@ -7613,6 +7614,87 @@
"durationGte": "6dfb5405b57b3e78",
"durationLte": "2c9e5b46d0083bea",
"edit": "464c4ffd019e1e96",
"editorDraft": {
"changedOnServer": "31c5e2462dff57b5",
"changedOnServerInBrowser": "fa25c5664ea9d527",
"changesNotKept": "bdf2661fc4c03cc2",
"continueDraft": "84ab20d6d5098bb2",
"continueEditing": "422f24539e152077",
"cookieField": "d6ceef50c02cc8c1",
"credentialFileField": "18f53ff77a7b7561",
"credentialsNotSaved": "2a2f354952b1d8d1",
"credentialsNotSavedInBrowser": "3f0d7aab593d1829",
"discard": "eb1a70e39274bb76",
"dotUndated": "f8edaa9e0057bb34",
"endpointUrlField": "1ff7cefbedd3ec3e",
"entities": {
"ai_toolset": "5d4c1b546a390b3c",
"alert": "df905058dd67a409",
"alert_destination": "8f6bdfe63c656d78",
"alert_template": "e53e49efb542ef88",
"announcement_banner": "044a21366f710c63",
"cipher_key": "60dfb555634f1dde",
"dashboard_settings": "66cd9688a2ae0682",
"downtime": "f14ff57ca5d5bcf7",
"eval_job": "146cb507bd28bb93",
"eval_scorer": "c528cab03ad47ed5",
"experiment": "a530c507fd893f52",
"fleet_config": "dc439d1404bfdd76",
"function": "78f9ac018e554365",
"iam_role": "4b168d88dc872a77",
"model_pricing": "a7f057a5cd0dbaa3",
"oncall_policy": "e9a5466ecd86d187",
"oncall_schedule": "f4deccd0b1247598",
"pipeline": "23bf0d244a4cb62c",
"prompt": "cf07194ee232eb53",
"regex_pattern": "b720f8540f13a430",
"remote_task": "f99cbf3b0d4cae6b",
"report": "845e91831319e89c",
"rum_named_event": "162d29356c77bebf",
"slo": "17a0c4286c4f7d56",
"status_page": "6a374b1ae7376e00",
"synthetic_browser": "d3f1197cfb15fb2c",
"synthetic_check": "4ed4872ff67cfd6f"
},
"envVarField": "037619c446acee51",
"headerField": "d27ee64d01cb39e5",
"headersField": "378f37b1fb33fa4d",
"itemDeleted": "43faf9172f4e875f",
"keepDraft": "bbcc810f654fd360",
"keepFailed": "1ee27b6e6b74161c",
"keepFailedSignedOut": "74fc31e3496b7931",
"keepInBrowser": "4ea64db253418471",
"leaveMessage": "40ac82c478964852",
"leaveMessageDiscardOnly": "1d475018243c1f52",
"leaveMessageForced": "2eb1b95e99d92cc3",
"leaveMessageForcedUnnamed": "ee18694e5119d37f",
"leaveMessageInBrowser": "254141f9a24f4d36",
"leaveMessageNoDraft": "13612cc1736c66e3",
"leaveTitle": "44e4660b5596bd91",
"newDot": "71c7942a26e9f022",
"newDotDated": "e186d1313f55bd18",
"passwordField": "5e884898da280471",
"reenterBeforeSave": "be01256782b88803",
"reenterField": "054cfd5a50d55bc5",
"restoreMessage": "4ea177195934a143",
"restoreTitle": "f8edaa9e0057bb34",
"restoreTitleInBrowser": "6b8c8ef67018994d",
"retention": "d5a9e776737f341c",
"retentionInBrowser": "23fbefc046415a38",
"rowDot": "b9bdf652b9717601",
"secretField": "f0794054f5c2ef3d",
"signOutConfirm": "48f0d3d397d49f13",
"signOutDiscardsOthers": "59c04992e719d48a",
"signOutMessage": "aebeeea15b9a9ade",
"signOutTitle": "a10549eb0cd583fa",
"startFresh": "652aceea790a47a6",
"startingUrlField": "39723c1bea479cfc",
"stay": "08fd1de4b04df4a2",
"stepValueField": "79e4ee1042a39163",
"tokenField": "3c469e9d6c5875d3",
"urlField": "e7a241debad56609",
"variableField": "12b704ef49f6f67b"
},
"enable": "5342e09f2729fbc6",
"endTime": "2e46006a5eebb262",
"enterValueOrVariable": "ac0e5aaf865f7ecd",
@@ -7775,8 +7857,6 @@
"refresh": "0e91610117029a62",
"relatedEvents": "2f37d48b3d0949b3",
"relative": "8e99cd986fa2ec26",
"relativeTimeAgo": "f461b89d0e6badeb",
"relativeTimePast": "8636e2360da41d6c",
"remoteDestinationIsRequired": "159822126129b75c",
"remove": "c3812fc4acb861d5",
"removeField": "e6a3c4a1250a6ad3",
@@ -9473,6 +9553,7 @@
"changes": "624d7003ad951738",
"discard": "eb1a70e39274bb76",
"discardAndLeave": "07e5b9156de3452c",
"discardAndSignOut": "72caebf3876e4db0",
"discardMessage": "0b6bd9517e6ab9f0",
"discardTitle": "9897bf7fa36cfa5a",
"discardTooltip": "f9bfa3dc7c8fcdea",
@@ -9481,6 +9562,7 @@
"enter": "e73b0adc4bae0acd",
"keepEditing": "e76fd2add01005ae",
"leaveMessage": "7bc654b25ddf9ce7",
"leaveMessageForced": "d1b708c2d2e14fb2",
"redo": "74273989b0966e23",
"save": "1509f561f2416598",
"saveTooltip": "26ac39b198ea73b7",
@@ -9630,6 +9712,8 @@
"keepDraft": "bbcc810f654fd360",
"keepFailed": "9d2829036f00d895",
"leaveMessage": "b31a14e8f8d5922f",
"leaveMessageDiscardOnly": "15fc0972b15950bc",
"leaveMessageNoDraft": "f52b2524789ebab9",
"leaveTitle": "44e4660b5596bd91",
"panelActionsDot": "88466df69951dc76",
"panelDeleted": "a91c6bb862b8ecfd",
@@ -12748,7 +12832,10 @@
"fitView": "a36cb149f9dab825",
"fromBase": "856100181d79dea9",
"fromBaseHint": "7c8b03f2cff91080",
"importNotApplied": "dda41b70ebe46cac",
"issues": "1b2ac8d46c6ee466",
"leaveNotCreated": "81e3af12f5010d32",
"leaveNotCreatedNoKeep": "a71e6e3d4b0f9ace",
"modeDraft": "70dc58575d8804af",
"modePublished": "5ae693346f17c78e",
"modeRollingOut": "ac44395ce3dbb586",
@@ -13508,10 +13595,6 @@
"jsonConfigHelp": "0c185848ea2f93db",
"k8sPresetNoMatch": "1eee67a46add41db",
"k8sPresetSeeded": "65c1ed300191a282",
"leaveConfirm": {
"message": "64b8d0c4540ac1c7",
"title": "470e7ee2a5795561"
},
"loadFailed": "ace3a6e578ff57eb",
"moduleColumn": "f148224ae44fb843",
"moduleGrantedCount": "5973332663ba05f6",
@@ -17107,6 +17190,7 @@
"bulkResolvePartial": "0f67ef47cee709c0",
"bulkResolveTitle": "013faa5f1379bd6d",
"bulkSnoozeDone": "0667e0708e46a669",
"bulkSnoozeNoTeam": "803789fa895ad7c2",
"bulkSnoozePartial": "e30229bac3f0e6d1",
"calendarDay": "8f2364e11b8be3ff",
"calendarFinal": "1652cbbbc6d266a3",
@@ -17122,6 +17206,7 @@
"calendarWeek": "e78041ab51a818a2",
"cancel": "19766ed6ccb2f4a3",
"causeFilterAny": "0a8329229028ea90",
"causeFilterLabel": "21dcdbd03f5c801f",
"cause_capacity_or_load": "ca276ec89c49b2d2",
"cause_config_change_or_deploy": "7108805fcd9114df",
"cause_data_or_ingestion_issue": "6f03b48c796f149a",
@@ -17267,6 +17352,7 @@
"deleteTeamFailed": "569caeebe391c53d",
"deleteTeamMessage": "b0c885926bab9edc",
"deleteTeamTitle": "ea0e8617ca37d0d8",
"deletedTeam": "d888c4c6f4bf5f03",
"deliveries": "3b301f5885682323",
"deliveriesHint": "3c48497af4397ee0",
"deliveriesNone": "1429bb525cd668b9",
@@ -17323,20 +17409,22 @@
"escalatedNobody": "8691cc3a33dc01ba",
"escalatedTo": "c6ca0a6688721fb6",
"escalation": "35615b824542ec09",
"escalationAcked": "d87cdf8aa30447b1",
"escalationAckedBy": "1aaae9308f1a1387",
"escalationAckedIn": "fdcf1e7fdf7d2dbe",
"escalationClimbing": "4c56e96cdbd1e732",
"escalationDetailCapped": "1db65fce37b21819",
"escalationEditing": "27e39fff96e0b4e8",
"escalationLevel": "903239ff5711c057",
"escalationLevelOpen": "a811eec0cfb75a75",
"escalationHeld": "6eb2dc1844144383",
"escalationLoading": "d441577128d7f2a1",
"escalationNotStarted": "e626cc87c1c4a26c",
"escalationPagesNobody": "760137c721470566",
"escalationPagingStep": "1a1a433a79fa9dc9",
"escalationPagingStepOnly": "5ca6d57cfe146bc5",
"escalationPagingStepOpen": "abaa8beb93e0065f",
"escalationPagingStepOpenOnly": "b781869bcca0a212",
"escalationReadTitle": "6f8cb579631445d3",
"escalationResolvedShort": "5be3c2c8354e1eb9",
"escalationSnoozedShort": "88096584e4a882be",
"escalationSnoozedUntil": "3dc32a87f3026d50",
"escalationSnoozedUntil": "3c3940fa9026ee1b",
"escalationStepReachedNobody": "e9b82d3f128275e8",
"escalationStepReachedNobodyOpen": "5661515915382eef",
"escalationTab": "3776951bd9484253",
"escalationTo": "957a756b4542788f",
"eventKind_ack": "de2e9f389e85fd9f",
@@ -17352,6 +17440,7 @@
"eventKind_severity_promoted": "53421e8f1f7cf9d8",
"eventKind_state": "a3b50c476732c740",
"eventKind_sys": "6725e7bbcd28f3a8",
"exhaustedBanner": "dbd01ddd0a799b39",
"filters": "546ebb8eb993ea56",
"firing": "1d99d0eaede7d441",
"firingCount": "20bd34812d225bcf",
@@ -17394,6 +17483,7 @@
"handoverWeekday": "16d6ae76f67890cf",
"historyFirings": "5031801f64056205",
"historyFirstPage": "96383aaa8a376e9d",
"holdBanner": "d0150171750046e9",
"immediately": "0fe556b25edd844f",
"impactedOf": "7cc91c3428703379",
"inForce": "1652cbbbc6d266a3",
@@ -17411,6 +17501,7 @@
"l0AllowPromotion": "3b05f473702cbfc5",
"l0AllowPromotionNote": "85fb5e2d9cbe2d92",
"l0AllowSuppress": "10b861e19cf7c60d",
"l0BudgetInvalidNotSaved": "c91fd8f660cd2a76",
"l0BudgetLabel": "70059e80720899e4",
"l0BudgetRange": "9142e36abd0e465e",
"l0BudgetSeconds": "08cc4d4ec73f6979",
@@ -17442,7 +17533,6 @@
"ladderEndsStops": "a0f99d3b38a93d21",
"ladderEvery": "187751feedfac3ed",
"ladderExhausted": "e91c8b19905f60cf",
"ladderFinished": "da3b78e51601dac0",
"ladderFinishedDetail": "f192703ff3e22179",
"ladderImminent": "b63968fc7cd52795",
"ladderIn": "e8890007ebf11364",
@@ -17532,6 +17622,7 @@
"membersHint": "de02877507dd45d0",
"membersNextStep": "a02683894cb2b32e",
"membersOfOrg": "4ef8130b153ae216",
"membersPickerLabel": "f653f86c55e175fd",
"membersUnreachableMore": "4306ba74805987ab",
"membersUnreachableOne": "9fb23fb2283b3f79",
"mineDutyOff": "5c810b0ef727a46e",
@@ -17542,12 +17633,14 @@
"mineOnCallNow": "ff453273dcff5086",
"mineOpenPages": "a04526e746faa9c8",
"mineSubtitle": "0a7ecded93d9732d",
"mineTeamsLoadFailed": "69155bfdd6858143",
"mineTeamsTitle": "af1554ff79040bfe",
"mineTitle": "396b850d8e17b768",
"moreActions": "f8d46c2570e70736",
"myDeliveriesFailed": "f84320a9c79ac9c9",
"myDeliveriesLanded": "4b82199cfd653004",
"myDeliveriesLandedColumn": "52bfe584a5fc4505",
"myDeliveriesLoadFailed": "ab034af3851a3b84",
"myDeliveriesMarkAll": "3bc62a9e6a39484a",
"myDeliveriesMarkFailed": "0471a7b6f1d4578f",
"myDeliveriesMarkRead": "b49c9b6cdf43b308",
@@ -17573,6 +17666,7 @@
"noTeamsToAssign": "63987d6418b2f215",
"noUnrouted": "1d1779b69268304f",
"nobodyOnCall": "eee5cc9142948ce8",
"nobodyOnCallTeamDeleted": "2e950593f361c1d9",
"nobodyWasOnCall": "764b1c664fef08b1",
"nobodyYet": "bda050585a00f0f6",
"notAvailableDescription": "adaff9741d7bc16b",
@@ -17590,6 +17684,7 @@
"onCallYou": "08b041935798fbf6",
"onlyMineFilter": "5da4f78e5d260045",
"open": "ed077f3d8125d60d",
"openPagesList": "1338945f850ff5c8",
"openSchedule": "2a724cdf44fb5b71",
"openThatOne": "6144c5c5b273fba6",
"openedAt": "b19fb8d1b395b333",
@@ -17603,6 +17698,7 @@
"ownershipRules": "1f23e47023587dca",
"ownershipRulesHint": "9640a1935045616a",
"ownershipSpecificity": "914182d5e25f69e1",
"pageNow": "4002231337a6a87a",
"pagesCarriedHeader": "ff4f3d6c708a86e5",
"pagesNav": "9046da16aea909ba",
"pagesNobody": "36f3676481aa7fca",
@@ -17782,6 +17878,7 @@
"priorCausesEmpty": "d5c3a15cc890bfc0",
"priorCausesHint": "c5a86a0c5ec8a499",
"priority": "d60dbba079223254",
"priorityFilterLabel": "48b74b8f81c021ef",
"promote": "9e84061d5b6e2695",
"promoteFailed": "d824ed91086cdf8d",
"promoteIncidentTitle": "b1a94ddee16d0e41",
@@ -17866,11 +17963,13 @@
"resolveMessage": "fdf69d4913f60c39",
"resolveTitle": "7a195fdcf89d8861",
"resolved": "a69910daa839178e",
"resolvedAfter": "8e6a8ef37845d779",
"responder": "8630a0dc25e2395a",
"responderRole_impacted": "3f56281cb37d56d9",
"responderRole_owner": "4b1b8aa3608a26da",
"responseDetail": "0a30a815d67d7dd2",
"responseDetailLoading": "91bf928d3527fc16",
"responseNotFoundTitle": "4171395a58310e38",
"responseSubtitle": "5a6c22693c7647c8",
"responsesSubtitle": "3397552682025fd4",
"responsesTitle": "80ec112e53f6a80b",
@@ -18222,6 +18321,7 @@
"statPeoplePaged": "c695dc4b4a9d64a9",
"statReachedRung": "0985cf3e033e4a20",
"statReachedRungOf": "3612e1cd7501afab",
"statReachedStep": "7c4a0141ea494727",
"statRotations": "077244282d7247f2",
"statRoutingRules": "c2b4c04ac81d1ea0",
"statServices": "2f6768f6c1786b80",
@@ -18233,8 +18333,10 @@
"statUncoveredHours": "c8ae17362fd9ce64",
"statUnreachableRungs": "e4cd46bbe3745467",
"statUnrouted": "c8e7249311a3409b",
"statWaitingForTriage": "d6278b3da99e6075",
"state": "920e413c7d411b61",
"state_acknowledged": "d87cdf8aa30447b1",
"state_exhausted": "d2a51a8828a00cf2",
"state_resolved": "5be3c2c8354e1eb9",
"state_triaged": "a48d1a80e2f27ec5",
"state_triggered": "aeffb9182e3d7f3d",
@@ -18313,6 +18415,7 @@
"teamDetail": "5985039f106df054",
"teamDetailLoading": "65cecd1a264fd423",
"teamEscalatedCol": "b710aaaaa7ba3947",
"teamFilterLabel": "29ec8e9091cf82b7",
"teamFilterUnavailable": "76be96c64616a95d",
"teamLastDays": "b902e3177b30cd13",
"teamName": "dcd1d5223f73b3a9",
@@ -18337,10 +18440,13 @@
"teams": "1e1a1c078af2887a",
"teamsSubtitle": "f269a6ca383133a8",
"teamsTitle": "c26639a5e8bfad8e",
"testPageAttemptNotSent": "98c0d06df3bb643c",
"testPageAttemptNotSentBare": "f5abdaf4c670b0eb",
"testPageAttemptSent": "89dbc8c187e0cc99",
"testPageCount": "fe17c18dda2f18c4",
"testPageFailed": "79055df36468753b",
"testPageNobody": "7568e7da8009127b",
"testPageSent": "6ac75ed87257910c",
"testPageUndelivered": "37e944bab49f4e5b",
"testRouting": "532eaabd9574880d",
"testRoutingFailed": "6d059a7fac2495b9",
"thenHandsTo": "4ab8b54dbe853d8d",
@@ -20543,7 +20649,6 @@
"cached": "a251c18205f10e5c",
"cachedReport": "cbe329c91b15af56",
"cachedReportHint": "120c19baaa220811",
"cancelReportChangesConfirm": "00c621ab574df419",
"continue": "31fbef162594de01",
"createdAt": "3d44337010bfbdf7",
"cron": "5b7bb1fa97159bc0",
@@ -20915,8 +21020,6 @@
"deletedElsewhere": "019ac67ad3c6aaae",
"deletedNotice": "1c4d0de5f6f38fad",
"details": "45989de49fb7f66d",
"discardMessage": "92c6572f0180a456",
"discardTitle": "85bcf41666a53691",
"duplicate": "02cdaabfca805d6a",
"duplicateName": "9dff944bff1ce8c6",
"edit": "464c4ffd019e1e96",
@@ -25335,7 +25438,6 @@
"folder": "034a0062488256f1",
"http": "58579fe1c24f6cc3",
"httpDesc": "a9a750071beda385",
"leave": "fc6e4a408d56be96",
"loadEditFailed": "7b1928809f0de0a4",
"modalSubtitle": "fc15d0f77021245b",
"modalTitle": "583f1d0575249e75",
@@ -25349,13 +25451,10 @@
"saving": "4eca334abcd79a68",
"ssh": "01c4d3c21642b217",
"sshDesc": "a918e773651e8e1d",
"stay": "08fd1de4b04df4a2",
"tcp": "a9a0fa445f12a963",
"tcpDesc": "a8bcab1ed196f00f",
"tls": "a4cd41f68fadaef3",
"tlsDesc": "4596da651029630a",
"unsavedBody": "322380c1273e3abd",
"unsavedTitle": "a710c2b90913b537",
"updateCheck": "eb3ab750e977cb82",
"updated": "f76cab03d11c9483",
"willBeCreatedIn": "5cf821e86d65d9e0"
@@ -26185,7 +26284,6 @@
"failedToPreviewChanges": "ca4cbe4fcaa69730",
"failedToSaveChanges": "650940a9a53530c0",
"incidentSeverityUpdatedTo": "dd41b9f4404eca40",
"incidentStatusUpdatedTo": "3eeaf134bb5545af",
"incidentsRefreshed": "d154f9499bcd0ad9",
"invalidJson": "0d2b45241c24aa5f",
"noAlertsSelectedForDeletion": "b6cfe1b86ee14dc7",
@@ -748,8 +748,6 @@ use crate::{
config::meta::alerts::deduplication::GlobalDeduplicationConfig,
config::meta::correlation::FieldAlias,
config::meta::alerts::deduplication::DeduplicationConfig,
config::meta::alerts::deduplication::GroupingConfig,
config::meta::alerts::deduplication::SendStrategy,
openobserve_api_management::request::alerts::dedup_stats::DedupSummaryResponse,
// Backfill
pipelines::backfill::BackfillRequest,
@@ -32,8 +32,6 @@ pub struct DedupSummaryResponse {
pub passed_total: i64,
/// Suppression rate (0.0 to 1.0)
pub suppression_rate: f64,
/// Current pending batches (grouping)
pub pending_batches: i64,
/// Timestamp when data was collected
pub timestamp: i64,
}
@@ -71,13 +69,6 @@ pub async fn get_dedup_summary(Path(org_id): Path<String>) -> Response {
.filter(|alert| alert.deduplication.as_ref().is_some_and(|d| d.enabled))
.count();
// Get current pending batches count from in-memory state
#[cfg(feature = "enterprise")]
let pending_batches = openobserve_core::alerts::grouping::get_pending_batch_count(&org_id);
#[cfg(not(feature = "enterprise"))]
let pending_batches = 0;
// For suppressions/passed counts, we'll use database for now
// In future, could query Prometheus metrics for time-series data
#[cfg(feature = "enterprise")]
@@ -100,7 +91,6 @@ pub async fn get_dedup_summary(Path(org_id): Path<String>) -> Response {
suppressions_total,
passed_total,
suppression_rate,
pending_batches,
timestamp: config::utils::time::now_micros(),
};
@@ -126,14 +116,12 @@ mod tests {
suppressions_total: 100,
passed_total: 200,
suppression_rate: 0.333,
pending_batches: 3,
timestamp: 1000,
};
assert_eq!(r.total_alerts, 10);
assert_eq!(r.alerts_with_dedup, 5);
assert_eq!(r.suppressions_total, 100);
assert_eq!(r.passed_total, 200);
assert_eq!(r.pending_batches, 3);
}
#[test]
@@ -144,7 +132,6 @@ mod tests {
suppressions_total: 0,
passed_total: 0,
suppression_rate: 0.0,
pending_batches: 0,
timestamp: 0,
};
assert_eq!(r.total_alerts, 0);
+72 -13
View File
@@ -82,6 +82,7 @@ pub async fn update_stats_from_file_list() -> Result<(), anyhow::Error> {
let grouped = db::schema::list_all_streams_grouped().await;
let mut total_streams = 0;
let mut failed_streams = 0;
for (org_id, stream_types) in grouped {
for (stream_type, streams) in stream_types {
if stream_type == StreamType::Index || stream_type == StreamType::Filelist {
@@ -95,18 +96,19 @@ pub async fn update_stats_from_file_list() -> Result<(), anyhow::Error> {
}
let start = std::time::Instant::now();
for (date_range, is_recent) in iter.iter() {
if !is_recent && no_need_update_old_stats {
continue;
}
update_stream_stats_with_retry(
&org_id,
stream_type,
&stream_name,
date_range.clone(),
*is_recent,
)
.await?;
// a stream that fails must not stop the stats of every stream after it
if update_stream_stats_ranges(
&org_id,
stream_type,
&stream_name,
&iter,
no_need_update_old_stats,
)
.await
.is_err()
{
failed_streams += 1;
continue;
}
log::info!(
@@ -116,6 +118,9 @@ pub async fn update_stats_from_file_list() -> Result<(), anyhow::Error> {
}
}
}
if failed_streams > 0 {
log::warn!("[STATS] {failed_streams} streams failed to update stats in this scan");
}
// Update global metrics
metrics::STREAM_STATS_STREAMS_TOTAL
@@ -184,6 +189,29 @@ async fn update_stats_lock_node() -> Result<Option<i64>, anyhow::Error> {
}
}
async fn update_stream_stats_ranges(
org_id: &str,
stream_type: StreamType,
stream_name: &str,
ranges: &[((String, String), bool)],
recent_only: bool,
) -> Result<(), anyhow::Error> {
for (date_range, is_recent) in ranges {
if !is_recent && recent_only {
continue;
}
update_stream_stats_with_retry(
org_id,
stream_type,
stream_name,
date_range.clone(),
*is_recent,
)
.await?;
}
Ok(())
}
// Transient metastore errors must not abort the whole run, which would restart it from scratch
async fn update_stream_stats_with_retry(
org_id: &str,
@@ -218,7 +246,10 @@ async fn update_stream_stats_with_retry(
metrics::STREAM_STATS_SCAN_ERRORS_TOTAL
.with_label_values(&labels)
.inc();
if attempt >= STATS_SCAN_MAX_ATTEMPTS {
let permanent = e
.downcast_ref::<infra::errors::Error>()
.is_some_and(infra::errors::Error::is_data_exception);
if attempt >= STATS_SCAN_MAX_ATTEMPTS || permanent {
log::error!(
"[STATS] update stats for {org_id}/{stream_type}/{stream_name} failed after {attempt} attempts: {e}"
);
@@ -342,6 +373,34 @@ mod tests {
assert_eq!(old_range, (String::new(), boundary));
}
/// CI runs this under both meta stores; only PostgreSQL bounds the stream_stats key column.
#[tokio::test]
async fn test_too_long_stream_fails_without_retry() {
infra_file_list::create_table().await.unwrap();
infra_file_list::create_table_index().await.unwrap();
let stream_name = "s".repeat(300);
let start = std::time::Instant::now();
let result = update_stream_stats_ranges(
"org",
StreamType::Logs,
&stream_name,
&stats_date_ranges(),
true,
)
.await;
let meta_store =
config::meta::meta_store::MetaStore::from(get_config().common.meta_store.as_str());
if meta_store == config::meta::meta_store::MetaStore::PostgreSQL {
assert!(result.is_err());
assert!(
start.elapsed() < STATS_SCAN_RETRY_DELAY,
"a data error was retried"
);
} else {
assert!(result.is_ok(), "{result:?}");
}
}
#[test]
fn test_yesterday_boundary_hour_is_zero() {
let boundary = get_yesterday_boundary();
+17 -199
View File
@@ -133,10 +133,6 @@ pub struct DeduplicationConfig {
deserialize_with = "deserialize_optional_i32"
)]
pub time_window_minutes: Option<i32>,
/// Optional alert grouping configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub grouping: Option<GroupingConfig>,
}
impl MemorySize for DeduplicationConfig {
@@ -145,80 +141,6 @@ impl MemorySize for DeduplicationConfig {
}
}
/// Configuration for alert grouping
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, PartialEq)]
pub struct GroupingConfig {
/// Enable alert grouping
#[serde(default)]
pub enabled: bool,
/// Maximum number of alerts in a single group
#[serde(default = "default_max_group_size")]
pub max_group_size: usize,
/// How to send grouped notifications
#[serde(default)]
pub send_strategy: SendStrategy,
/// Initial wait time before sending first notification (seconds)
#[serde(default = "default_group_wait")]
pub group_wait_seconds: i64,
}
impl MemorySize for GroupingConfig {
fn mem_size(&self) -> usize {
std::mem::size_of::<GroupingConfig>()
}
}
impl Default for GroupingConfig {
fn default() -> Self {
Self {
enabled: false,
max_group_size: default_max_group_size(),
send_strategy: SendStrategy::default(),
group_wait_seconds: default_group_wait(),
}
}
}
/// Strategy for sending grouped alert notifications
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SendStrategy {
/// Send only the first alert with a count of others
#[default]
#[serde(rename = "first_with_count")]
FirstWithCount,
/// Send an aggregated summary of all alerts
Summary,
/// Send all alerts in one notification
All,
}
impl std::str::FromStr for SendStrategy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"first_with_count" => Ok(Self::FirstWithCount),
"summary" => Ok(Self::Summary),
"all" => Ok(Self::All),
_ => Ok(Self::default()),
}
}
}
fn default_max_group_size() -> usize {
100
}
fn default_group_wait() -> i64 {
30
}
impl GlobalDeduplicationConfig {
/// Validate the organization-level deduplication configuration
pub fn validate(&self) -> Result<(), String> {
@@ -267,18 +189,6 @@ impl DeduplicationConfig {
return Err("Time window must be positive".to_string());
}
// Validate grouping config if present
if let Some(grouping) = &self.grouping
&& grouping.enabled
{
if grouping.max_group_size == 0 {
return Err("Max group size must be at least 1".to_string());
}
if grouping.group_wait_seconds < 0 {
return Err("Group wait seconds must be non-negative".to_string());
}
}
Ok(())
}
}
@@ -309,7 +219,6 @@ mod tests {
assert!(!config.enabled);
assert!(config.fingerprint_fields.is_empty());
assert_eq!(config.time_window_minutes, None);
assert_eq!(config.grouping, None);
}
#[test]
@@ -339,7 +248,6 @@ mod tests {
enabled: true,
fingerprint_fields: vec!["hostname".to_string(), "service".to_string()],
time_window_minutes: Some(10),
grouping: None,
};
assert!(config.validate().is_ok());
@@ -375,12 +283,6 @@ mod tests {
enabled: true,
fingerprint_fields: vec!["service".to_string(), "hostname".to_string()],
time_window_minutes: Some(10),
grouping: Some(GroupingConfig {
enabled: true,
max_group_size: 50,
send_strategy: SendStrategy::Summary,
group_wait_seconds: 60,
}),
};
let json = serde_json::to_string_pretty(&config).unwrap();
@@ -401,8 +303,6 @@ mod tests {
assert!(json.get("fingerprint_fields").is_none());
// time_window_minutes should be omitted when None
assert!(json.get("time_window_minutes").is_none());
// grouping should be omitted when None
assert!(json.get("grouping").is_none());
}
#[test]
@@ -425,26 +325,6 @@ mod tests {
);
}
#[test]
fn test_send_strategy_from_str() {
use std::str::FromStr;
assert_eq!(
SendStrategy::from_str("first_with_count").unwrap(),
SendStrategy::FirstWithCount
);
assert_eq!(
SendStrategy::from_str("summary").unwrap(),
SendStrategy::Summary
);
assert_eq!(SendStrategy::from_str("all").unwrap(), SendStrategy::All);
// unknown → default (FirstWithCount)
assert_eq!(
SendStrategy::from_str("UNKNOWN").unwrap(),
SendStrategy::default()
);
assert_eq!(SendStrategy::from_str("").unwrap(), SendStrategy::default());
}
#[test]
fn test_global_dedup_config_validate_disabled_ok() {
let config = GlobalDeduplicationConfig {
@@ -502,78 +382,17 @@ mod tests {
assert!(config.validate().is_ok());
}
#[test]
fn test_per_alert_dedup_config_validate_grouping_zero_size_err() {
let config = DeduplicationConfig {
enabled: true,
fingerprint_fields: vec![],
time_window_minutes: None,
grouping: Some(GroupingConfig {
enabled: true,
max_group_size: 0,
send_strategy: SendStrategy::All,
group_wait_seconds: 10,
}),
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Max group size"));
}
#[test]
fn test_per_alert_dedup_config_validate_grouping_negative_wait_err() {
let config = DeduplicationConfig {
enabled: true,
fingerprint_fields: vec![],
time_window_minutes: None,
grouping: Some(GroupingConfig {
enabled: true,
max_group_size: 5,
send_strategy: SendStrategy::Summary,
group_wait_seconds: -1,
}),
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Group wait seconds"));
}
#[test]
fn test_per_alert_dedup_config_validate_grouping_disabled_skip() {
// grouping present but enabled=false → skip grouping validation
let config = DeduplicationConfig {
enabled: true,
fingerprint_fields: vec![],
time_window_minutes: None,
grouping: Some(GroupingConfig {
enabled: false,
max_group_size: 0, // would fail if checked
send_strategy: SendStrategy::All,
group_wait_seconds: -1, // would fail if checked
}),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_deduplication_config_mem_size() {
let config = DeduplicationConfig {
enabled: true,
fingerprint_fields: vec!["host".to_string(), "service".to_string()],
time_window_minutes: Some(5),
grouping: None,
};
let size = config.mem_size();
assert!(size >= std::mem::size_of::<DeduplicationConfig>());
}
#[test]
fn test_grouping_config_mem_size() {
let config = GroupingConfig::default();
let size = config.mem_size();
assert_eq!(size, std::mem::size_of::<GroupingConfig>());
}
#[test]
fn test_deserialize_optional_i32_string_number() {
// time_window_minutes as a JSON string containing a number
@@ -623,7 +442,6 @@ mod tests {
assert!(!obj.contains_key("fingerprint_fields"));
// Option::is_none → absent
assert!(!obj.contains_key("time_window_minutes"));
assert!(!obj.contains_key("grouping"));
}
#[test]
@@ -632,18 +450,28 @@ mod tests {
enabled: true,
fingerprint_fields: vec!["host".to_string()],
time_window_minutes: Some(30),
grouping: Some(GroupingConfig {
enabled: true,
max_group_size: 10,
send_strategy: SendStrategy::Summary,
group_wait_seconds: 30,
}),
};
let json = serde_json::to_value(&config).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.contains_key("fingerprint_fields"));
assert!(obj.contains_key("time_window_minutes"));
assert!(obj.contains_key("grouping"));
}
#[test]
fn test_stored_grouping_object_still_deserializes_and_is_dropped_on_save() {
let stored = serde_json::json!({
"fingerprint_fields": ["host"],
"grouping": {
"enabled": true,
"max_group_size": 50,
"send_strategy": "summary",
"group_wait_seconds": 60
}
});
let config: DeduplicationConfig = serde_json::from_value(stored).unwrap();
assert_eq!(config.fingerprint_fields, vec!["host".to_string()]);
let saved = serde_json::to_value(&config).unwrap();
assert!(saved.get("grouping").is_none());
}
#[test]
@@ -677,16 +505,6 @@ mod tests {
assert_eq!(default_upgrade_window(), 30);
}
#[test]
fn test_default_max_group_size() {
assert_eq!(default_max_group_size(), 100);
}
#[test]
fn test_default_group_wait() {
assert_eq!(default_group_wait(), 30);
}
// ── §5.5 MN-6: reserve, then confirm on successful delivery ─────────────
#[test]
-9
View File
@@ -91,11 +91,6 @@ pub enum MultiAlertError {
/// A PromQL alert opted in without a `promql_condition`: there is no
/// threshold to classify a series against, so no group can have a level.
PromqlConditionMissing,
/// Alert-level notification grouping batches every group under one
/// fingerprint with enqueue-time delivery accounting, collapsing the
/// per-group pages this feature exists to split apart. Rejected until
/// per-group batching (§5.5 MN-4) exists.
NotificationGroupingUnsupported,
}
impl std::fmt::Display for MultiAlertError {
@@ -129,10 +124,6 @@ impl std::fmt::Display for MultiAlertError {
Self::PromqlConditionMissing => f.write_str(
"per-series alerting needs a PromQL condition to classify each series against",
),
Self::NotificationGroupingUnsupported => f.write_str(
"per-group alerting cannot yet be combined with notification grouping; turn off \
notification grouping or per-group alerting",
),
}
}
}
-89
View File
@@ -1420,73 +1420,6 @@ pub static ALERT_RUNS_SUPPRESSED_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
.expect("Metric created")
});
// Alert grouping/batching metrics
pub static ALERT_GROUPING_BATCHES_PENDING: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new(
"alert_grouping_batches_pending",
"Current number of pending alert batches",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["organization"],
)
.expect("Metric created")
});
pub static ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
IntCounterVec::new(
Opts::new(
"alert_grouping_notifications_sent_total",
"Total number of grouped notifications sent",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["organization", "send_strategy", "reason"],
)
.expect("Metric created")
});
pub static ALERT_GROUPING_BATCH_SIZE: Lazy<HistogramVec> = Lazy::new(|| {
HistogramVec::new(
HistogramOpts::new(
"alert_grouping_batch_size",
"Number of alerts per grouped notification",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels())
.buckets(vec![1.0, 2.0, 3.0, 5.0, 10.0, 25.0, 50.0, 100.0]),
&["organization", "send_strategy"],
)
.expect("Metric created")
});
pub static ALERT_GROUPING_WAIT_TIME: Lazy<HistogramVec> = Lazy::new(|| {
HistogramVec::new(
HistogramOpts::new(
"alert_grouping_wait_time_seconds",
"Time batches waited before sending (seconds)",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels())
.buckets(vec![5.0, 10.0, 15.0, 20.0, 30.0, 45.0, 60.0, 90.0, 120.0]),
&["organization"],
)
.expect("Metric created")
});
pub static ALERT_GROUPING_SEND_ERRORS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
IntCounterVec::new(
Opts::new(
"alert_grouping_send_errors_total",
"Total grouped notification send failures",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["organization", "error_type"],
)
.expect("Metric created")
});
pub static META_NUM_DASHBOARDS: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new("meta_num_dashboards", "Metadata dashboard nums")
@@ -3555,23 +3488,6 @@ pub(crate) fn register(registry: &Registry) {
.register(Box::new(ALERT_RUNS_SUPPRESSED_TOTAL.clone()))
.expect("Metric registered");
// alert grouping metrics
registry
.register(Box::new(ALERT_GROUPING_BATCHES_PENDING.clone()))
.expect("Metric registered");
registry
.register(Box::new(ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL.clone()))
.expect("Metric registered");
registry
.register(Box::new(ALERT_GROUPING_BATCH_SIZE.clone()))
.expect("Metric registered");
registry
.register(Box::new(ALERT_GROUPING_WAIT_TIME.clone()))
.expect("Metric registered");
registry
.register(Box::new(ALERT_GROUPING_SEND_ERRORS_TOTAL.clone()))
.expect("Metric registered");
// db stats
registry
.register(Box::new(DB_QUERY_NUMS.clone()))
@@ -4038,11 +3954,6 @@ mod tests {
let _ = ALERT_DEDUP_PASSED_TOTAL.clone();
let _ = ALERT_DEDUP_ERRORS_TOTAL.clone();
let _ = ALERT_RUNS_SUPPRESSED_TOTAL.clone();
let _ = ALERT_GROUPING_BATCHES_PENDING.clone();
let _ = ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL.clone();
let _ = ALERT_GROUPING_BATCH_SIZE.clone();
let _ = ALERT_GROUPING_WAIT_TIME.clone();
let _ = ALERT_GROUPING_SEND_ERRORS_TOTAL.clone();
}
#[test]
+8 -72
View File
@@ -339,10 +339,6 @@ pub enum AlertError {
destination: String,
platform: String,
},
#[error(
"notification grouping batches several alerts into one message, which cannot carry this alert's correlation key — turn off one of grouping or notify_on_recovery"
)]
RecoveryWithNotificationGrouping,
#[error(
"destination '{destination}' is {platform}, which is resolved by updating the alert the firing opened. That needs the firing payload to be a JSON object, and template '{template}' does not render one"
)]
@@ -460,22 +456,7 @@ fn validate_multi_alert_config(alert: &Alert) -> Result<(), AlertError> {
&alert.trigger_condition,
alert.creates_incident,
)
.map_err(AlertError::InvalidMultiAlert)?;
// Checked here, not in `validate_multi_alert`, because the grouping
// config is a sibling of the query condition rather than part of it.
// Same shape as MN-11's incidents rule.
let notification_grouping = alert
.deduplication
.as_ref()
.and_then(|d| d.grouping.as_ref())
.is_some_and(|g| g.enabled);
if alert.query_condition.multi_alert_enabled() && notification_grouping {
return Err(AlertError::InvalidMultiAlert(
config::meta::alerts::grouping::MultiAlertError::NotificationGroupingUnsupported,
));
}
Ok(())
.map_err(AlertError::InvalidMultiAlert)
}
/// Refuses, at save time, a custom-aggregation `group_by` entry that is not a column of the stream.
@@ -537,16 +518,6 @@ async fn validate_recovery_templates(org_id: &str, alert: &Alert) -> Result<(),
}
}
// A batch carries no single alert's correlation key, so its recovery could close nothing.
if alert
.deduplication
.as_ref()
.and_then(|d| d.grouping.as_ref())
.is_some_and(|g| g.enabled)
{
return Err(AlertError::RecoveryWithNotificationGrouping);
}
let alert_template = match alert.template.as_ref().filter(|n| !n.is_empty()) {
Some(name) => db::alerts::templates::get(org_id, name).await.ok(),
None => None,
@@ -4967,37 +4938,14 @@ mod threshold_validation_tests {
assert!(validate_rules(&alert).is_ok());
}
/// §5.5: alert-level notification grouping would collapse per-group pages
/// back into one batch — rejected at save time, never silently rerouted.
#[test]
fn test_multi_alert_rejects_notification_grouping() {
let mut alert = multi_alert_fixture();
alert.deduplication = Some(config::meta::alerts::deduplication::DeduplicationConfig {
enabled: true,
grouping: Some(config::meta::alerts::deduplication::GroupingConfig {
enabled: true,
..serde_json::from_str("{}").expect("GroupingConfig defaults")
}),
..Default::default()
fn test_multi_alert_with_a_stored_grouping_object_loads_and_validates() {
let mut stored = serde_json::to_value(multi_alert_fixture()).unwrap();
stored["deduplication"] = json!({
"enabled": true,
"grouping": {"enabled": true, "max_group_size": 10, "group_wait_seconds": 30}
});
let err = validate_rules(&alert).unwrap_err();
assert!(matches!(
err,
AlertError::InvalidMultiAlert(
config::meta::alerts::grouping::MultiAlertError::NotificationGroupingUnsupported
)
));
// Grouping configured but DISABLED must stay valid.
alert
.deduplication
.as_mut()
.unwrap()
.grouping
.as_mut()
.unwrap()
.enabled = false;
let alert: Alert = serde_json::from_value(stored).expect("legacy grouping must still load");
assert!(validate_rules(&alert).is_ok());
}
@@ -5491,24 +5439,12 @@ mod tests {
use super::*;
#[tokio::test]
async fn a_multi_alert_with_recovery_saves_but_not_with_notification_grouping() {
async fn a_multi_alert_with_recovery_saves() {
let mut alert = Alert::default();
alert.notify_on_recovery = true;
alert.query_condition.query_type = QueryType::PromQL;
alert.query_condition.promql_multi_alert = true;
assert!(validate_recovery_templates("default", &alert).await.is_ok());
alert.deduplication = Some(config::meta::alerts::deduplication::DeduplicationConfig {
grouping: Some(config::meta::alerts::deduplication::GroupingConfig {
enabled: true,
..Default::default()
}),
..Default::default()
});
assert!(matches!(
validate_recovery_templates("default", &alert).await,
Err(AlertError::RecoveryWithNotificationGrouping)
));
}
#[test]
File diff suppressed because it is too large Load Diff
-2
View File
@@ -57,8 +57,6 @@ pub mod destinations;
pub mod downtimes;
pub mod external_alerts;
#[cfg(feature = "enterprise")]
pub mod grouping;
#[cfg(feature = "enterprise")]
pub mod incidents;
pub mod lifecycle;
#[cfg(feature = "enterprise")]
+5 -329
View File
@@ -2782,9 +2782,9 @@ pub(crate) async fn downtime_decision_except(
})
}
/// The identity a downtime is matched on, shared by evaluation and the grouped flush.
/// The identity a downtime is matched on.
#[cfg(feature = "enterprise")]
pub(crate) async fn downtime_identity(
async fn downtime_identity(
alert: &config::meta::alerts::alert::Alert,
rows: &[config::utils::json::Map<String, config::utils::json::Value>],
) -> Vec<HashMap<String, String>> {
@@ -2794,18 +2794,6 @@ pub(crate) async fn downtime_identity(
}
}
/// [downtime_identity] with each map's group key, for the group mutes of a multi-alert.
#[cfg(feature = "enterprise")]
pub(crate) async fn downtime_identity_by_key(
alert: &config::meta::alerts::alert::Alert,
rows: &[config::utils::json::Map<String, config::utils::json::Value>],
) -> Vec<(String, HashMap<String, String>)> {
match identity_slo(alert) {
Some(slo_id) => vec![(String::new(), slo_dimensions(&alert.org_id, slo_id).await)],
None => downtime_identities_by_key(alert, rows).await,
}
}
/// Every group's downtimes merged when each group has one; else `dispatch_per_group` decides.
#[cfg(feature = "enterprise")]
pub(crate) fn muted_in_every_group(
@@ -3182,88 +3170,6 @@ fn correlates_muted_firing(
creates_incident && incidents_enabled && downtime.incident_mode.is_muted()
}
/// The names of the alert's Slack app destinations.
#[cfg(feature = "enterprise")]
async fn slack_app_names(alert: &config::meta::alerts::alert::Alert) -> Vec<String> {
crate::alerts::slack_publish::slack_app_destinations(&alert.org_id, &alert.destinations)
.await
.into_iter()
.map(|(_, name)| name)
.collect()
}
/// The alert as its batch carries it, without its Slack app destinations; `None` when that
/// leaves the batch nothing to notify. [pure]
#[cfg(feature = "enterprise")]
fn batch_member(
alert: &config::meta::alerts::alert::Alert,
slack_app: &[String],
) -> Option<config::meta::alerts::alert::Alert> {
let mut batched = alert.clone();
batched
.destinations
.retain(|name| !slack_app.contains(name));
has_delivery_target(&batched).then_some(batched)
}
/// Sends the alert to its Slack app destinations now, outside its batch; returns why one failed.
#[allow(clippy::too_many_arguments)]
#[cfg(feature = "enterprise")]
async fn send_slack_app_unbatched(
trace_id: &str,
alert: &config::meta::alerts::alert::Alert,
slack: Vec<String>,
rows: &[serde_json::Map<String, serde_json::Value>],
rows_end_time: i64,
start_time: Option<i64>,
triggered_at: i64,
level: Option<config::meta::alerts::level::AlertLevel>,
actual_value: Option<f64>,
) -> Option<String> {
use crate::alerts::alert::AlertExt;
if slack.is_empty() {
return None;
}
let mut direct = alert.clone();
direct.destinations = slack;
direct.workflows.clear();
let sent = direct
.send_notification(
trace_id,
rows,
rows_end_time,
start_time,
triggered_at,
level,
actual_value,
None,
&[],
None,
)
.await;
let error = composite_delivery_error(&sent, None);
if let Some(e) = &error {
log::error!(
"[SCHEDULER trace_id {trace_id}] alert {}: Slack app send outside its batch failed: {e}",
alert.name
);
}
error
}
/// Puts a failed out-of-batch Slack send on the run's row; the run itself stays delivered. [pure]
#[cfg(feature = "enterprise")]
fn note_slack_failure(row: &mut TriggerData, error: Option<String>) {
let Some(error) = error else {
return;
};
row.error = Some(match row.error.take() {
Some(earlier) => format!("{earlier}; {error}"),
None => error,
});
}
/// Who acked a single-row alert's current firing, when that ack withholds this run's send.
async fn single_row_ack(
alert: &config::meta::alerts::alert::Alert,
@@ -4108,10 +4014,9 @@ async fn handle_alert_triggers(
// delivery. Writing only one would either re-page forever or never again.
//
// Called ONLY on paths where the notification was actually delivered (or
// handed to a delivery mechanism that owns it: grouping batch, incident
// correlation). `last_notified_level`'s contract is the last DELIVERED
// level — stamping it before a send that then FAILS would make the retry
// look like a repeat inside an active silence window and suppress it.
// handed to a delivery mechanism that owns it: incident correlation). `last_notified_level`'s
// contract is the last DELIVERED level — stamping it before a send that then FAILS would make
// the retry look like a repeat inside an active silence window and suppress it.
// MN-2: multi-alerts keep their delivery state on the per-group rows, so
// they never write these alert-level fields. Leaving them stale would be
// harmless now that nothing reads them for this path, but writing them
@@ -4186,187 +4091,6 @@ async fn handle_alert_triggers(
// notification is skipped.
&& delivery.should_deliver()
{
// Per-group dispatch IS this alert's batching unit (§5.5 MN-1), so a
// multi-alert never takes the alert-level grouping path. Letting it
// through would batch every group under one alert-scoped fingerprint
// and record delivery at ENQUEUE time — no per-group fingerprint
// (M-5), no per-group delivery state (MN-6), and no per-group failure
// accounting (MN-7). The groups would be silently collapsed back into
// the single notification multi-alerts exist to split apart.
let is_multi_alert = alert.query_condition.multi_alert_enabled();
// Check if grouping is enabled BEFORE deduplication (enterprise-only feature)
#[cfg(feature = "enterprise")]
let grouping_wanted = !is_multi_alert
&& has_target
&& alert
.deduplication
.as_ref()
.and_then(|d| d.grouping.as_ref())
.map(|g| g.enabled)
.unwrap_or(false);
// A batch notifies its first alert's destinations, so one with none must not open it.
#[cfg(feature = "enterprise")]
let batched = if grouping_wanted {
let slack_app = slack_app_names(&alert).await;
batch_member(&alert, &slack_app).map(|member| (member, slack_app))
} else {
None
};
#[cfg(not(feature = "enterprise"))]
let _ = is_multi_alert;
#[cfg(feature = "enterprise")]
if let Some((batch_alert, slack_app)) = batched {
{
let grouping_config = alert
.deduplication
.as_ref()
.and_then(|d| d.grouping.as_ref())
.unwrap();
// Calculate fingerprint for grouping
let fingerprint = if let Some(dedup_config) = alert.deduplication.as_ref() {
let org_config =
match crate::alerts::org_config::get_deduplication_config(&new_trigger.org)
.await
{
Ok(Some(config)) => Some(config),
_ => None,
};
let semantic_groups =
crate::db::system_settings::get_semantic_field_groups(&new_trigger.org)
.await;
if let Some(first_row) = data.first() {
// Level is an implicit fingerprint component: a
// Warning batch must never absorb a Critical
// escalation (§7.1).
crate::alerts::deduplication::calculate_fingerprint(
&alert,
first_row,
dedup_config,
org_config.as_ref(),
&semantic_groups,
eval_level,
)
} else {
alert.get_unique_key()
}
} else {
alert.get_unique_key()
};
log::debug!(
"[SCHEDULER trace_id {scheduler_trace_id}] Adding alert to batch, org: {}, alert: {}, fingerprint: {fingerprint}, rows: {}",
new_trigger.org,
alert.name,
data.len()
);
// R-137: each member firing gets its own Slack app message, never a batch.
let slack_error = send_slack_app_unbatched(
&scheduler_trace_id,
&alert,
slack_app,
&data,
trigger_results.end_time,
Some(start_time),
triggered_at,
eval_level,
trigger_results.actual_value,
)
.await;
note_slack_failure(&mut trigger_data_stream, slack_error);
// Add to batch
let admission = crate::alerts::grouping::add_to_batch(
fingerprint.clone(),
new_trigger.org.clone(),
batch_alert,
data.clone(),
trigger_data_stream.clone(),
grouping_config.group_wait_seconds,
grouping_config.max_group_size,
eval_level,
// Alert-level batch: multi-alerts never reach here (they
// dispatch per group instead), so there is no group
// identity to carry. Per-group batching is the post-v1
// enhancement specified in §5.5.
None,
);
// Whether the batch handoff counts as a DELIVERY for the
// silence/escalation baseline. Queued = yes (the flush worker
// owns it from here). Immediately flushed = only if the send
// actually succeeded — stamping a failed send would start a
// silence window with zero destinations reached and suppress
// the retry.
let grouped_delivery_ok = match admission {
crate::alerts::grouping::BatchAdmission::Ready => {
log::info!(
"[SCHEDULER trace_id {scheduler_trace_id}] Batch {fingerprint} reached max size, sending immediately",
);
// Absent means the expiry worker took the batch and publishes its rows.
match crate::alerts::grouping::get_ready_batch(
&new_trigger.org,
&fingerprint,
) {
Some(batch) => {
crate::alerts::grouping::flush_batch(&scheduler_trace_id, batch)
.await
}
None => true,
}
}
crate::alerts::grouping::BatchAdmission::Queued => {
log::debug!(
"[SCHEDULER trace_id {scheduler_trace_id}] Alert added to batch, waiting for more alerts or timeout, fingerprint: {}",
fingerprint
);
true
}
crate::alerts::grouping::BatchAdmission::Refused => {
publish_triggers_usage(crate::alerts::grouping::refused_row(
&trigger_data_stream,
));
// No batch owns this evaluation, so no silence window may open for it.
false
}
};
// Alert added to batch, don't send individual notification.
if grouped_delivery_ok {
record_delivery(&mut trigger_data);
}
trigger_data.period_end_time = if should_store_last_end_time {
Some(trigger_results.end_time)
} else {
None
};
new_trigger.data = json::to_string(&trigger_data).unwrap();
db::scheduler::update_trigger(new_trigger, true, &query_trace_id).await?;
// No episode: a batch holds several alerts, so no per-alert key can ride it.
if let Some(alert_id) = alert.id.as_ref() {
let _ = persist_alert_run_state(
&alert,
&alert_id.to_string(),
&trigger_data_stream.status,
eval_level,
trigger_results.group_classification.as_ref(),
trigger_results.rule_value.as_deref(),
&config::meta::alerts::recovery::EpisodeInput::undelivered(
alert.keep_firing_for,
),
)
.await;
}
return Ok(());
}
}
// Apply deduplication if enabled (enterprise-only feature)
#[cfg(feature = "enterprise")]
let data = {
@@ -9746,7 +9470,6 @@ mod tests {
}
}
/// The grouped flush calls [downtime_identity] too, so both points judge an SLO alert alike.
#[cfg(feature = "enterprise")]
#[test]
fn an_slo_alert_is_matched_on_its_slo_dimensions_not_its_rows() {
@@ -9984,51 +9707,4 @@ mod tests {
"{written:?}"
);
}
#[cfg(feature = "enterprise")]
#[test]
fn a_slack_app_only_alert_opens_no_batch_and_a_mixed_one_batches_the_rest() {
let slack = vec!["slack-app".to_string()];
let mut alert = config::meta::alerts::alert::Alert::default();
alert.destinations = slack.clone();
assert!(
batch_member(&alert, &slack).is_none(),
"a batch with no destinations would drop the email of an alert that joins it"
);
alert.destinations.push("email".to_string());
let member = batch_member(&alert, &slack).expect("the email still needs the batch");
assert_eq!(member.destinations, vec!["email".to_string()]);
}
#[cfg(feature = "enterprise")]
#[test]
fn a_failed_slack_send_outside_the_batch_shows_on_the_run_row() {
let failed = Err(AlertError::SendNotificationError {
error_message: "TopicNotFound: slack_outbound".to_string(),
});
let mut row = TriggerData {
status: RunOutcome::Firing,
..Default::default()
};
note_slack_failure(&mut row, composite_delivery_error(&failed, None));
assert!(
row.error
.as_deref()
.is_some_and(|e| e.contains("TopicNotFound")),
"{:?}",
row.error
);
assert_eq!(row.status, RunOutcome::Firing, "the run stays delivered");
let mut partial = TriggerData {
error: Some("partial query".to_string()),
..Default::default()
};
note_slack_failure(&mut partial, Some("slack down".to_string()));
assert_eq!(partial.error.as_deref(), Some("partial query; slack down"));
let mut clean = TriggerData::default();
note_slack_failure(&mut clean, None);
assert_eq!(clean.error, None);
}
}
-1
View File
@@ -102,7 +102,6 @@ impl From<AlertError> for Response {
| AlertError::RecoveryOnRealtimeAlert
| AlertError::RecoveryDestinationsWithoutRecovery
| AlertError::RecoveryDestinationIsPlatform { .. }
| AlertError::RecoveryWithNotificationGrouping
| AlertError::RecoveryNeedsJsonPayload { .. }
| AlertError::RecoveryNeedsRoutingKey { .. }
| AlertError::MultiAlertGroupingError(_)
+69
View File
@@ -310,6 +310,16 @@ impl Error {
}
}
/// True for a PostgreSQL data exception (SQLSTATE class 22), which a retry cannot fix.
pub fn is_data_exception(&self) -> bool {
match self {
Error::SqlxError(sqlx::Error::Database(e)) => e
.code()
.is_some_and(|code| code.len() == 5 && code.starts_with("22")),
_ => false,
}
}
/// The HTTP status code this error maps to. This is the single source of
/// truth shared by the HTTP response mapping and audit logging.
pub fn http_status(&self) -> u16 {
@@ -746,4 +756,63 @@ mod tests {
assert_eq!(e.http_status(), 403);
assert_eq!(e.to_string(), "Organization [t1] is deleted");
}
#[derive(Debug)]
struct CodedDbError(&'static str);
impl std::fmt::Display for CodedDbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for CodedDbError {}
impl sqlx::error::DatabaseError for CodedDbError {
fn message(&self) -> &str {
self.0
}
fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
Some(self.0.into())
}
fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
self
}
fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
self
}
fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
self
}
fn kind(&self) -> sqlx::error::ErrorKind {
sqlx::error::ErrorKind::Other
}
}
fn db_error(code: &'static str) -> Error {
Error::SqlxError(sqlx::Error::Database(Box::new(CodedDbError(code))))
}
#[test]
fn test_is_data_exception() {
// 22001: string_data_right_truncation, e.g. a stream key longer than VARCHAR(256)
assert!(db_error("22001").is_data_exception());
assert!(db_error("22P02").is_data_exception());
// 40001 serialization_failure is transient; "22" alone is SQLite's SQLITE_NOLFS
assert!(!db_error("40001").is_data_exception());
assert!(!db_error("22").is_data_exception());
assert!(!Error::Message("22001".to_string()).is_data_exception());
// callers that return anyhow, such as the stats job, downcast to reach it
let wrapped = anyhow::Error::from(db_error("22001"));
assert!(
wrapped
.downcast_ref::<Error>()
.is_some_and(Error::is_data_exception)
);
}
}
+76
View File
@@ -37,6 +37,10 @@ pub mod sqlite;
const STREAM_SCAN_WINDOW_MICROS: i64 = 3600 * 1_000_000;
const STREAMS_PER_QUERY: usize = 1000;
/// Longest org id the PostgreSQL file_list and schema_history `org` columns hold.
pub const ORG_ID_MAX_LEN: usize = 100;
/// Longest `{org}/{stream_type}/{stream}` key the PostgreSQL file_list `stream` columns hold.
pub const STREAM_KEY_MAX_LEN: usize = 256;
static CLIENT: Lazy<Box<dyn FileList>> = Lazy::new(connect_default);
pub static LOCAL_CACHE: Lazy<Box<dyn FileList>> = Lazy::new(connect_local_cache);
@@ -66,6 +70,19 @@ pub fn connect_local_cache() -> Box<dyn FileList> {
Box::<sqlite::SqliteFileList>::default()
}
/// Why a stream's file_list key or its org cannot be stored, if it cannot.
pub fn stream_key_violation(
org_id: &str,
stream_type: StreamType,
stream_name: &str,
) -> Option<String> {
// only the PostgreSQL backend bounds these columns; Nats keeps file_list in SQLite
if MetaStore::from(get_config().common.meta_store.as_str()) != MetaStore::PostgreSQL {
return None;
}
stream_key_length_violation(org_id, stream_type, stream_name)
}
/// The two file tables an org deletion empties stream by stream.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StreamRowsTable {
@@ -848,6 +865,26 @@ fn validate_time_range(time_range: (i64, i64)) -> Result<()> {
Ok(())
}
fn stream_key_length_violation(
org_id: &str,
stream_type: StreamType,
stream_name: &str,
) -> Option<String> {
// VARCHAR(n) counts characters, not bytes
let org_len = org_id.chars().count();
if org_len > ORG_ID_MAX_LEN {
return Some(format!(
"Organization id too long: {org_id} is {org_len} characters, only {ORG_ID_MAX_LEN} accepted"
));
}
let len = org_len + stream_type.as_str().len() + stream_name.chars().count() + 2;
(len > STREAM_KEY_MAX_LEN).then(|| {
format!(
"Stream name too long: {org_id}/{stream_type}/{stream_name} is {len} characters, only {STREAM_KEY_MAX_LEN} accepted"
)
})
}
/// Daily when the stream is dynamically merged (its files can span a day), else the type's level.
pub async fn max_ts_bound_level(
org_id: &str,
@@ -1057,6 +1094,45 @@ mod tests {
use super::*;
#[test]
fn test_stream_key_length_boundary() {
// "org/metrics/" is 12 characters
let fits = "m".repeat(STREAM_KEY_MAX_LEN - 12);
assert!(stream_key_length_violation("org", StreamType::Metrics, &fits).is_none());
let over = format!("{fits}m");
let violation = stream_key_length_violation("org", StreamType::Metrics, &over).unwrap();
assert!(violation.starts_with("Stream name too long"), "{violation}");
assert!(violation.contains("is 257 characters"), "{violation}");
// a multi-byte name is measured in characters, as the VARCHAR column is
let wide = "é".repeat(STREAM_KEY_MAX_LEN - 12);
assert!(stream_key_length_violation("org", StreamType::Metrics, &wide).is_none());
}
#[test]
fn test_stream_key_length_org_boundary() {
let org = "o".repeat(ORG_ID_MAX_LEN);
assert!(stream_key_length_violation(&org, StreamType::Logs, "s").is_none());
let org = format!("{org}o");
let violation = stream_key_length_violation(&org, StreamType::Logs, "s").unwrap();
assert!(
violation.starts_with("Organization id too long"),
"{violation}"
);
}
/// CI runs this under both meta stores; only PostgreSQL bounds the file_list key.
#[test]
fn test_stream_key_violation_follows_meta_store() {
let over = "s".repeat(STREAM_KEY_MAX_LEN);
let bounded =
MetaStore::from(get_config().common.meta_store.as_str()) == MetaStore::PostgreSQL;
assert_eq!(
stream_key_violation("org", StreamType::Logs, &over).is_some(),
bounded
);
assert!(stream_key_violation("org", StreamType::Logs, "s").is_none());
}
// ── FileListJobStatus::from(i32) ──────────────────────────────────────────
#[test]
-49
View File
@@ -1,49 +0,0 @@
// 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/>.
//! Background job to process expired alert batches
use std::time::Duration;
use tokio::time;
/// Background worker that processes expired alert batches
pub async fn process_expired_batches() {
log::info!("[alert_grouping_worker] Starting alert grouping background worker");
let mut interval = time::interval(Duration::from_secs(1));
loop {
interval.tick().await;
#[cfg(feature = "enterprise")]
{
let batches = openobserve_core::alerts::grouping::get_expired_batches();
let trace_id = config::ider::generate_trace_id();
let trace_id = format!("expired_batched_{trace_id}");
if !batches.is_empty() {
log::debug!(
"[alert_grouping_worker] Processing {} expired batches with trace_id {trace_id}",
batches.len()
);
for batch in batches {
openobserve_core::alerts::grouping::flush_batch(&trace_id, batch).await;
}
}
}
}
}
-4
View File
@@ -35,8 +35,6 @@ mod agent_signals;
mod ai_chat_retention;
mod alert_eval_ledger_reaper;
mod alert_group_reaper;
#[cfg(feature = "enterprise")]
pub mod alert_grouping;
#[cfg(feature = "cloud")]
mod cloud;
mod compactor;
@@ -1405,8 +1403,6 @@ pub async fn init() -> Result<(), anyhow::Error> {
tokio::task::spawn(metrics::run());
let _ = promql::run();
tokio::task::spawn(scheduler::run());
#[cfg(feature = "enterprise")]
tokio::task::spawn(alert_grouping::process_expired_batches());
tokio::task::spawn(infra::cache::file_downloader::run());
// Note: Service discovery extraction runs automatically during parquet file processing
// See src/jobs/src/job/files/parquet.rs:queue_services_from_data_file for
+36
View File
@@ -60,6 +60,7 @@ pub use watcher::{
};
const SCHEMA_CONFORMANCE_FAILED: &str = "schema_conformance_failed";
const STREAM_NAME_TOO_LONG: &str = "stream_name_too_long";
/// Shared by both ingestion-window discard messages and the test for them.
const WINDOW_DISCARD_MARKER: &str = " data can be ingested. Data discarded.";
@@ -142,6 +143,27 @@ pub struct SavedStreamSettings {
pub previous_settings: Option<StreamSettings>,
}
/// Rejects a stream whose file_list key or org cannot be stored, counting it as an ingest error.
pub fn check_stream_key_len(
org_id: &str,
stream_type: StreamType,
stream_name: &str,
) -> Result<()> {
let Some(violation) = infra::file_list::stream_key_violation(org_id, stream_type, stream_name)
else {
return Ok(());
};
metrics::INGEST_ERRORS
.with_label_values(&[
org_id,
stream_type.as_str(),
stream_name,
STREAM_NAME_TOO_LONG,
])
.inc();
Err(anyhow::anyhow!("{violation}. Data discarded."))
}
/// Validate, normalize, and persist stream settings stored in schema metadata.
///
/// This is the canonical settings write path outside schema auto-evolution, which persists through
@@ -1059,6 +1081,20 @@ mod tests {
)));
}
#[test]
fn test_check_stream_key_len_counts_and_discards() {
let over = "s".repeat(infra::file_list::STREAM_KEY_MAX_LEN);
let result = check_stream_key_len("org", StreamType::Logs, &over);
assert_eq!(
result.is_err(),
infra::file_list::stream_key_violation("org", StreamType::Logs, &over).is_some()
);
if let Err(err) = result {
assert!(err.to_string().ends_with("Data discarded."), "{err}");
}
assert!(check_stream_key_len("org", StreamType::Logs, "s").is_ok());
}
#[test]
fn test_normalize_stream_settings_index_fields_updated_at() {
let mut settings = StreamSettings {
+135 -74
View File
@@ -19,14 +19,13 @@ use arrow_schema::{Field, FieldRef, Fields};
use datafusion::{
arrow::{
array::{
Array, ArrayData, ArrayRef, BooleanArray, StringArray, StructArray,
as_large_list_array, as_list_array, as_string_array,
Array, ArrayRef, BooleanArray, GenericStringBuilder, OffsetSizeTrait, StructArray,
as_string_array,
},
datatypes::DataType,
},
common::cast::as_generic_string_array,
error::{DataFusionError, Result},
functions::regex::regexpmatch::regexp_match,
logical_expr::{
ReturnFieldArgs, ScalarFunctionArgs, ScalarFunctionImplementation, ScalarUDF,
ScalarUDFImpl, Signature, TypeSignature::Exact, Volatility,
@@ -284,7 +283,6 @@ impl ScalarUDFImpl for RegxpMatchToFields {
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
// 1. Get result from datafusion native regexp_match() function
let len = args
.args
.iter()
@@ -295,80 +293,39 @@ impl ScalarUDFImpl for RegxpMatchToFields {
// All-scalar input still carries one row to match.
let inferred_length = len.unwrap_or(1);
let args_array = args
.args
.iter()
.map(|arg| arg.clone().into_array(inferred_length))
.collect::<Result<Vec<_>>>()?;
let haystack = args.args[0].clone().into_array(inferred_length)?;
let haystack_type = haystack.data_type();
if !matches!(haystack_type, DataType::Utf8 | DataType::LargeUtf8) {
return Err(DataFusionError::Execution(format!(
"Unsupported data type {haystack_type:?} for function regexp_match"
)));
}
let result = match &args_array[0].data_type() {
DataType::Utf8 => regexp_match(&args_array)?,
DataType::LargeUtf8 => regexp_match(&args_array)?,
other => {
return Err(DataFusionError::Execution(format!(
"Unsupported data type {other:?} for function regexp_match"
)));
}
};
let (ret_data_type, regexp_pattern) = match &args.args[1] {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(pattern))) => {
(DataType::Utf8, pattern.to_string().replace('"', ""))
}
ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(pattern))) => {
(DataType::LargeUtf8, pattern.to_string().replace('"', ""))
}
let raw_pattern = match &args.args[1] {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(pattern)))
| ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(pattern))) => pattern.as_str(),
_ => {
return Err(DataFusionError::Execution("regexp_match_to_fields function requires 2 arguments, haystack & pattern, of strings".to_string()));
}
};
let fields = regex_pattern_to_fields(&raw_pattern.replace('"', ""), haystack_type)?;
let re = regex::Regex::new(raw_pattern).map_err(|e| {
DataFusionError::Execution(format!("Regular expression did not compile: {e:?}"))
})?;
if result.is_empty() {
return Err(DataFusionError::Execution(
"regexp_match_to_fields requires at least one input row".to_string(),
));
}
// 2. Unpack result and argument to construct returning struct
let fields = regex_pattern_to_fields(&regexp_pattern, &ret_data_type)?;
// 3. Build returning struct
let mut struct_builder = ArrayData::builder(DataType::Struct(fields.into()));
match ret_data_type {
// Result is a single column of ListArray of StringArray.
// Get the first value of ListArray and iterate the StringArray
// and build individual StringArrays
DataType::Utf8 => {
let result_string_arr = as_list_array(&result).value(0);
let result_string_arr_internal =
as_generic_string_array::<i32>(&result_string_arr)?;
for v in result_string_arr_internal {
let arr = StringArray::from(vec![v]);
struct_builder = struct_builder.len(arr.len()).add_child_data(arr.to_data());
}
}
DataType::LargeUtf8 => {
let result_string_arr = as_large_list_array(&result).value(0);
let result_string_arr_internal =
as_generic_string_array::<i64>(&result_string_arr)?;
for v in result_string_arr_internal {
let arr = StringArray::from(vec![v]);
struct_builder = struct_builder.len(arr.len()).add_child_data(arr.to_data());
}
}
let struct_array: ArrayRef = match haystack_type {
DataType::Utf8 => Arc::new(captures_to_struct::<i32>(&haystack, &re, fields)?),
DataType::LargeUtf8 => Arc::new(captures_to_struct::<i64>(&haystack, &re, fields)?),
_ => unreachable!(), // since checked above
}
let Ok(struct_array_data) = struct_builder.build() else {
return Err(DataFusionError::Execution("regexp_match_to_fields failed to pack result to fields. Named Capturing groups are required in regexp pattern".to_string()));
};
let struct_array = StructArray::from(struct_array_data);
Ok(ColumnarValue::Scalar(ScalarValue::Struct(Arc::new(
struct_array,
))))
if len.is_none() {
return Ok(ColumnarValue::Scalar(ScalarValue::try_from_array(
&struct_array,
0,
)?));
}
Ok(ColumnarValue::Array(struct_array))
}
fn aliases(&self) -> &[String] {
@@ -390,15 +347,44 @@ fn regex_pattern_to_fields(pattern: &str, ret_type: &DataType) -> Result<Vec<Fie
} else {
Ok(field_names
.into_iter()
.map(|field_name| Field::new(field_name, ret_type.to_owned(), false))
.map(|field_name| Field::new(field_name, ret_type.to_owned(), true))
.collect())
}
}
/// Packs per-row regex captures into a struct array; unmatched rows and absent groups are null.
fn captures_to_struct<O: OffsetSizeTrait>(
haystack: &ArrayRef,
re: &regex::Regex,
fields: Vec<Field>,
) -> Result<StructArray> {
let haystack = as_generic_string_array::<O>(haystack)?;
let mut builders: Vec<GenericStringBuilder<O>> = fields
.iter()
.map(|_| GenericStringBuilder::<O>::new())
.collect();
for value in haystack.iter() {
let caps = value.and_then(|v| re.captures(v));
for (builder, field) in builders.iter_mut().zip(&fields) {
let capture = caps.as_ref().and_then(|c| c.name(field.name()));
builder.append_option(capture.map(|m| m.as_str()));
}
}
let columns = builders
.into_iter()
.map(|mut b| Arc::new(b.finish()) as ArrayRef)
.collect();
Ok(StructArray::try_new(Fields::from(fields), columns, None)?)
}
#[cfg(test)]
mod tests {
use datafusion::{
arrow::{array::Int64Array, datatypes::Schema, record_batch::RecordBatch},
arrow::{
array::{Int64Array, StringArray},
datatypes::Schema,
record_batch::RecordBatch,
},
assert_batches_eq,
datasource::MemTable,
prelude::SessionContext,
@@ -568,7 +554,7 @@ mod tests {
}
#[tokio::test]
async fn test_regexp_match_to_fields_empty_input_does_not_panic() {
async fn test_regexp_match_to_fields_empty_batch_returns_no_rows() {
let schema = Arc::new(Schema::new(vec![Field::new("log", DataType::Utf8, false)]));
let batch = RecordBatch::new_empty(schema.clone());
@@ -578,7 +564,82 @@ mod tests {
ctx.register_table("t", Arc::new(provider)).unwrap();
let sql = r#"select regexp_match_to_fields(log, '(?P<a>[^\s]+)') as subquery from t"#;
let result = ctx.sql(sql).await.unwrap().collect().await;
assert!(result.is_err());
let result = ctx.sql(sql).await.unwrap().collect().await.unwrap();
assert_eq!(result.iter().map(|b| b.num_rows()).sum::<usize>(), 0);
}
#[tokio::test]
async fn test_regexp_match_to_fields_builds_one_struct_per_row() {
let schema = Arc::new(Schema::new(vec![Field::new("log", DataType::Utf8, false)]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![Arc::new(StringArray::from(vec![
"no match here",
"GET /a",
"POST /b",
]))],
)
.unwrap();
let ctx = SessionContext::new();
ctx.register_udf(REGEXP_MATCH_TO_FIELDS_UDF.clone());
let provider = MemTable::try_new(schema, vec![vec![batch]]).unwrap();
ctx.register_table("t", Arc::new(provider)).unwrap();
let sql = r#"select regexp_match_to_fields(log, '^(?P<method>[A-Z]+) (?P<path>/[a-z]+)$') as fields from t"#;
let expected = [
"+--------------------------+",
"| fields |",
"+--------------------------+",
"| {method: , path: } |",
"| {method: GET, path: /a} |",
"| {method: POST, path: /b} |",
"+--------------------------+",
];
let result = ctx.sql(sql).await.unwrap().collect().await.unwrap();
assert_batches_eq!(expected, &result);
let fields = result[0]
.column(0)
.as_any()
.downcast_ref::<StructArray>()
.unwrap();
assert!(fields.is_valid(0));
assert!(fields.column(0).is_null(0));
assert!(fields.column(1).is_null(0));
assert!(fields.column(0).is_valid(1));
}
#[tokio::test]
async fn test_regexp_match_to_fields_optional_group_is_null() {
let schema = Arc::new(Schema::new(vec![Field::new("log", DataType::Utf8, false)]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![Arc::new(StringArray::from(vec!["xy", "y"]))],
)
.unwrap();
let ctx = SessionContext::new();
ctx.register_udf(REGEXP_MATCH_TO_FIELDS_UDF.clone());
let provider = MemTable::try_new(schema, vec![vec![batch]]).unwrap();
ctx.register_table("t", Arc::new(provider)).unwrap();
let sql = r#"select regexp_match_to_fields(log, '(?P<a>x)?(?P<b>y)') as fields from t"#;
let expected = [
"+--------------+",
"| fields |",
"+--------------+",
"| {a: x, b: y} |",
"| {a: , b: y} |",
"+--------------+",
];
let result = ctx.sql(sql).await.unwrap().collect().await.unwrap();
assert_batches_eq!(expected, &result);
let fields = result[0]
.column(0)
.as_any()
.downcast_ref::<StructArray>()
.unwrap();
assert!(fields.column(0).is_null(1));
assert!(fields.column(1).is_valid(1));
}
}
@@ -13,6 +13,7 @@ import testLogger from '../../playwright-tests/utils/test-logger.js';
import MonacoEditorHelper from '../../playwright-tests/utils/MonacoEditorHelper.js';
import { openOSelectDropdown } from './oselectHelpers.js';
import { FilterBuilderDriver } from '../generalPages/filterBuilderPage.js';
import { discardEditorDraftIfAsked } from './editorDraftHelpers.js';
export class AlertCreationWizard {
constructor(page, commonActions, locators) {
@@ -76,6 +77,7 @@ export class AlertCreationWizard {
const maxRetries = 2;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
await this.page.locator(this.locators.addAlertBackButton || '[data-test="add-alert-back-btn"]').first().click();
await discardEditorDraftIfAsked(this.page);
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(1500);
@@ -897,6 +899,7 @@ export class AlertCreationWizard {
// Cancel alert creation
await this.page.locator(this.locators.alertBackButton).click();
await discardEditorDraftIfAsked(this.page);
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
return { toggleSuccessful, message };
@@ -110,6 +110,9 @@ export class AlertDestinationsPage {
this.testResultIdle = '[data-test="test-result-idle"]';
this.saveButton = '[data-test="add-destination-submit-btn"]';
this.cancelButton = '[data-test="add-destination-cancel-btn"]';
// Editor drafts: Cancel with unsaved changes opens the shared leave dialog
this.draftLeaveDialog = '[data-test="editor-draft-leave-dialog"]';
this.draftLeaveDiscardBtn = '[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]';
this.backButton = '[data-test="add-destination-back-btn"]';
this.successNotification = '[data-test-variant="success"]';
this.toastSuccess = '[data-test-variant="success"]';
@@ -1561,6 +1564,15 @@ export class AlertDestinationsPage {
await this.page.keyboard.press('Escape');
}
}
// Unsaved changes are discarded through the leave dialog so no draft is left behind.
const asked = await this.page.locator(this.draftLeaveDialog)
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (asked) {
await this.page.locator(this.draftLeaveDiscardBtn).click();
await this.page.locator(this.draftLeaveDialog).waitFor({ state: 'hidden', timeout: 10000 });
}
// Wait for the cancel button (and form) to detach so subsequent navigation sees a clean state.
await this.page.locator(this.cancelButton).first().waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
testLogger.debug('Clicked Cancel button');
@@ -59,6 +59,9 @@ export class AlertTemplatesPage {
this.headerCheckbox = '[data-test="o2-table-select-all"]';
this.addTemplateTitle = '[data-test="add-template-title"]';
this.templateCancelBtn = '[data-test="add-template-cancel-btn"]';
// Editor drafts: Cancel with unsaved changes opens the shared leave dialog
this.draftLeaveDialog = '[data-test="editor-draft-leave-dialog"]';
this.draftLeaveDiscardBtn = '[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]';
this.templateCloneBtnPattern = '[data-test="alert-template-list-{templateName}-clone-template"]';
// Inline locators moved from methods
@@ -1218,6 +1221,15 @@ export class AlertTemplatesPage {
async clickTemplateCancelBtn() {
await this.page.locator(this.templateCancelBtn).click();
// Unsaved changes are discarded through the leave dialog so no draft is left behind.
const asked = await this.page.locator(this.draftLeaveDialog)
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (asked) {
await this.page.locator(this.draftLeaveDiscardBtn).click();
await this.page.locator(this.draftLeaveDialog).waitFor({ state: 'hidden', timeout: 10000 });
}
await this.page.waitForTimeout(1000);
}
@@ -19,6 +19,7 @@ import fs from 'fs';
import { CommonActions } from '../commonActions';
import { AlertCreationWizard } from './alertCreationWizard.js';
import { openOSelectDropdown } from './oselectHelpers.js';
import { discardEditorDraftIfAsked } from './editorDraftHelpers.js';
import { AlertManagement } from './alertManagement.js';
import { AlertBulkOperations } from './alertBulkOperations.js';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
@@ -1965,6 +1966,7 @@ export class AlertsPage {
// Try clicking with force to bypass potential overlays
await backButton.click({ force: true, timeout: 10000 });
await discardEditorDraftIfAsked(this.page);
testLogger.info('Closed alert wizard via back button');
} catch (error) {
testLogger.warn('Back button click failed, trying close button', { error: error.message });
@@ -3724,6 +3726,7 @@ export class AlertsPage {
*/
async clickBackButton() {
await this.page.locator(this.locators.alertBackButton).first().click({ timeout: 15000 });
await discardEditorDraftIfAsked(this.page);
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
}
@@ -33,6 +33,9 @@ export class DowntimesPage {
this.nameValue = '[data-test="add-downtime-name-value"]';
this.saveBtn = '[data-test="add-downtime-save"]';
this.cancelBtn = '[data-test="add-downtime-cancel"]';
// Editor drafts: Cancel with unsaved changes opens the shared leave dialog
this.draftLeaveDialog = '[data-test="editor-draft-leave-dialog"]';
this.draftLeaveDiscardBtn = '[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]';
this.checkScheduleBanner = '[data-test="add-downtime-check-schedule"]';
this.staleBanner = '[data-test="add-downtime-stale-banner"]';
this.reloadBtn = '[data-test="add-downtime-reload"]';
@@ -584,6 +587,15 @@ export class DowntimesPage {
async cancelCreate() {
await this.page.locator(this.cancelBtn).click();
// Unsaved changes are discarded through the leave dialog so no draft is left behind.
const asked = await this.page.locator(this.draftLeaveDialog)
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (asked) {
await this.page.locator(this.draftLeaveDiscardBtn).click();
await this.page.locator(this.draftLeaveDialog).waitFor({ state: 'hidden', timeout: 10000 });
}
}
async expectCheckScheduleNudge() {
@@ -0,0 +1,20 @@
/** Discards through the editor-draft leave dialog when Back/Cancel opens it, so no draft pops the restore dialog later. */
export async function discardEditorDraftIfAsked(page) {
const dialog = page.locator('[data-test="editor-draft-leave-dialog"]');
const asked = await dialog
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (!asked) return;
await dialog.locator('[data-test="o-dialog-neutral-btn"]').click();
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
}
/** A draft an earlier test left (e.g. by page.goto) would open the restore dialog over the next editor; call before opening it. */
export async function clearEditorDrafts(page) {
await page.evaluate(() => {
Object.keys(localStorage)
.filter((key) => key.startsWith('o2_draft:') || key === 'o2_draft_index')
.forEach((key) => localStorage.removeItem(key));
});
}
@@ -31,6 +31,7 @@ const ANOMALY_OPERATOR_IDS = {
};
const MonacoEditorHelper = require('../../playwright-tests/utils/MonacoEditorHelper.js');
const { cleanupTestAnomalies: apiCleanupTestAnomalies } = require('../../playwright-tests/utils/api-helper.js');
const { discardEditorDraftIfAsked } = require('../alertsPages/editorDraftHelpers.js');
class AnomalyDetectionPage {
/**
@@ -744,6 +745,7 @@ class AnomalyDetectionPage {
const btn = this.page.locator(this.selectors.cancelBtn);
await btn.waitFor({ state: 'visible', timeout: 15000 });
await btn.click({ force: true });
await discardEditorDraftIfAsked(this.page);
}
getStreamTypeSelectLocator() { return this.page.locator(this.selectors.streamTypeSelect); }
@@ -149,11 +149,26 @@ export default class DashboardSetting {
async cancelSettingDashboard() {
await this.cancelBtn.waitFor({ state: "visible" });
await this.cancelBtn.click();
await this.discardSettingsDraftIfAsked();
}
// Closing with unsaved settings opens the leave dialog; Discard keeps the old close-and-forget behaviour.
async discardSettingsDraftIfAsked() {
const dialog = this.page.locator('[data-test="editor-draft-leave-dialog"]');
const drawer = this.page.locator('[data-test="dashboard-settings-drawer"]');
const asked = await Promise.race([
dialog.waitFor({ state: "visible", timeout: 5000 }).then(() => true),
drawer.waitFor({ state: "hidden", timeout: 5000 }).then(() => false),
]).catch(() => false);
if (!asked) return;
await dialog.locator('[data-test="o-dialog-neutral-btn"]').click();
await dialog.waitFor({ state: "hidden", timeout: 10000 });
}
//close setting dashboard//
async closeSettingDashboard() {
await this.closeSetting.waitFor({ state: "visible" });
await this.closeSetting.click();
await this.discardSettingsDraftIfAsked();
// The ODrawer animates out over a backdrop that still swallows pointer
// events mid-transition, so a following click (e.g. the back button) can be
// eaten. Wait for it to actually be gone.
@@ -546,6 +561,7 @@ export default class DashboardSetting {
// Wait for close button with a short timeout
await closeBtn.waitFor({ state: "visible", timeout: 3000 });
await closeBtn.click({ timeout: 2000 });
await this.discardSettingsDraftIfAsked();
// Wait for dialog to actually disappear
await settingsDialog.waitFor({ state: "hidden", timeout: 5000 });
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
import { confirmDraftSignOut } from '../generalPages/draftSignOut.js';
import {
dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator,
Past30SecondsValue
@@ -502,6 +503,7 @@ export class DashboardPage {
async signOut() {
await this.profileButton.click();
await this.signOutButton.click();
await confirmDraftSignOut(this.page);
}
@@ -517,6 +519,7 @@ export class DashboardPage {
// Now click the logout item
await this.logoutMenuItem.click({ force: true });
await confirmDraftSignOut(this.page);
}
async notAvailableDashboard() {
@@ -55,6 +55,10 @@ class FunctionsPage {
this.confirmDialog = this.page.locator('[data-test="confirm-dialog"]');
this.confirmDialogPrimaryBtn = this.page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]');
this.confirmDialogSecondaryBtn = this.page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]');
// Editor drafts: Cancel/Back with unsaved changes opens the shared leave dialog
this.draftLeaveDialog = this.page.locator('[data-test="editor-draft-leave-dialog"]');
this.draftLeaveDiscardBtn = this.draftLeaveDialog.locator('[data-test="o-dialog-neutral-btn"]');
}
// ==================== Locator factories (runtime-dynamic) ====================
@@ -186,8 +190,17 @@ class FunctionsPage {
await this.page.waitForTimeout(2000);
}
/** Cancel the editor; unsaved changes are discarded through the leave dialog so no draft is left behind. */
async clickCancelButton() {
await this.cancelButton.click();
const asked = await this.draftLeaveDialog
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (asked) {
await this.draftLeaveDiscardBtn.click();
await this.draftLeaveDialog.waitFor({ state: 'hidden', timeout: 10000 });
}
await this.page.waitForTimeout(1500); // Wait for dialog to fully close
}
@@ -1,6 +1,7 @@
// Copyright 2026 OpenObserve Inc.
const testLogger = require('../../playwright-tests/utils/test-logger.js');
import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js';
export class AiToolsetsFormValidationPage {
/**
@@ -82,6 +83,7 @@ export class AiToolsetsFormValidationPage {
async clickAddToolset() {
testLogger.debug('Clicking Add AI Toolset button');
await clearEditorDrafts(this.page);
await this.page.locator(this.addToolsetBtn).click();
// Wait for the form to mount — name input is the first interactive field
await this.page.locator(this.nameInput).waitFor({ state: 'visible', timeout: 10000 });
@@ -130,6 +132,7 @@ export class AiToolsetsFormValidationPage {
async clickCancel() {
testLogger.debug('Clicking Cancel button');
await this.page.locator(this.cancelBtn).click();
await discardEditorDraftIfAsked(this.page);
}
// ── Locator getters (for expect() assertions in spec) ─────────────────────
@@ -1,5 +1,7 @@
// cipher keys.js
import { expect } from '@playwright/test';
import { confirmDraftSignOut } from './draftSignOut.js';
import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js';
export class CipherKeys {
@@ -28,7 +30,7 @@ export class CipherKeys {
this.cancelButton = '[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]';
this.updateButton = cipherName => `[data-test="cipherkey-list-${cipherName}-update"]`;
this.profileButton = page.locator('[data-test="header-my-account-profile-icon"]');
this.signOutButton = page.locator('[data-test="header-sign-out-btn"]');
this.signOutButton = page.locator('[data-test="menu-link-logout-item"]');
}
@@ -61,8 +63,8 @@ export class CipherKeys {
async addCipherKey() {
await this.addCipherKeyButton.waitFor({ state: 'visible', timeout: 10000 });
await clearEditorDrafts(this.page);
await this.addCipherKeyButton.click();
}
async addCipherKeyName(name) {
@@ -180,6 +182,7 @@ export class CipherKeys {
async updateCipherKeysCancel() {
await this.page.waitForSelector('[data-test="add-cipher-key-cancel-btn"]');
await this.page.locator('[data-test="add-cipher-key-cancel-btn"]').click();
await discardEditorDraftIfAsked(this.page);
}
@@ -244,6 +247,7 @@ export class CipherKeys {
async signOut() {
await this.profileButton.click();
await this.signOutButton.click();
await confirmDraftSignOut(this.page);
}
@@ -0,0 +1,20 @@
// Sign Out stops on the drafts count dialog, or, when an editor with unsaved changes is open, on that editor's leave dialog instead.
export async function confirmDraftSignOut(page, timeout = 5000) {
const confirm = page.locator('[data-test="editor-draft-signout-confirm"]');
const discard = page
.locator('[data-test="editor-draft-leave-dialog"], [data-test="dashboard-panel-leave-dialog"]')
.locator('[data-test="o-dialog-neutral-btn"]')
.first();
// With no drafts and no dirty editor the app goes straight to /login, which ends the wait at once.
await Promise.race([
confirm.waitFor({ state: 'visible', timeout }),
discard.waitFor({ state: 'visible', timeout }),
page.waitForURL(/\/login/, { timeout }),
]).catch(() => {});
if (await confirm.isVisible().catch(() => false)) {
await confirm.click();
} else if (await discard.isVisible().catch(() => false)) {
await discard.click();
}
await page.waitForURL(/\/login/, { timeout }).catch(() => {});
}
@@ -1,5 +1,6 @@
// homePage.js - Landing Page Object for OpenObserve
import { expect } from '@playwright/test';
import { confirmDraftSignOut } from './draftSignOut.js';
import { openNavFlyoutChild, gotoMetricsEditor, clickNavUntilRoute } from '../commonActions.js';
export class HomePage {
@@ -378,6 +379,7 @@ export class HomePage {
async logout() {
await this.openProfileMenu();
await this.logoutButton.click();
await confirmDraftSignOut(this.page);
}
async openThemeManager() {
@@ -5,6 +5,7 @@
// Editor: /web/settings/model_pricing/edit?org_identifier={org}&id={id}
import { expect } from '@playwright/test';
import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js';
const modelPricingStrings = require('../../../../web/src/locales/languages/en-US.json').modelPricing;
const { FilterBuilderDriver } = require('./filterBuilderPage.js');
@@ -181,11 +182,13 @@ export class ModelPricingPage {
// ----------------------------------------------------------------
async clickAdd() {
await clearEditorDrafts(this.page);
await this.addBtn.click();
await this.editorTitle.waitFor({ state: 'visible', timeout: 10000 });
}
async clickEmptyAdd() {
await clearEditorDrafts(this.page);
await this.emptyAddBtn.click();
await this.editorTitle.waitFor({ state: 'visible', timeout: 10000 });
}
@@ -390,11 +393,13 @@ export class ModelPricingPage {
async clickCancel() {
await this.cancelBtn.click();
await discardEditorDraftIfAsked(this.page);
await this.listTitle.waitFor({ state: 'visible', timeout: 10000 });
}
async clickEditorBack() {
await this.editorBackBtn.click();
await discardEditorDraftIfAsked(this.page);
await this.listTitle.waitFor({ state: 'visible', timeout: 10000 });
}
@@ -1,5 +1,7 @@
// Copyright 2026 OpenObserve Inc.
import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js';
export class RegexPatternsFormValidationPage {
/**
* @param {import('@playwright/test').Page} page
@@ -66,6 +68,7 @@ export class RegexPatternsFormValidationPage {
// ── Drawer helpers ────────────────────────────────────────────────────────
async openAddPatternDrawer() {
await clearEditorDrafts(this.page);
await this.page.locator(this.addPatternButton).click();
await this.page.locator(this.drawer).waitFor({ state: 'visible', timeout: 8000 });
}
@@ -84,6 +87,7 @@ export class RegexPatternsFormValidationPage {
async clickCancel() {
await this.page.locator(this.cancelButton).click();
await discardEditorDraftIfAsked(this.page);
}
async touchNameField() {
+4 -3
View File
@@ -113,9 +113,10 @@ export class RolesPage {
// ---------- leaving with unsaved changes ----------
// ConfirmDialog renders inside ODialog, so the buttons carry the dialog's slugs.
this.leaveConfirm = page.locator('[data-test="confirm-dialog"]');
this.leaveConfirmOk = page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]');
this.leaveConfirmCancel = page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]');
// The shared unsaved-changes dialog: Discard drops the changes, closing it stays on the editor.
this.leaveConfirm = page.locator('[data-test="editor-draft-leave-dialog"]');
this.leaveConfirmOk = page.locator('[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]');
this.leaveConfirmCancel = page.locator('[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-close-btn"]');
// ---------- unsaved drawer ----------
this.drawer = page.locator('[data-test="edit-role-unsaved-drawer"]');
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
import { confirmDraftSignOut } from '../generalPages/draftSignOut.js';
import { LogsQueryPage } from './logsQueryPage.js';
import { LoginPage } from '../generalPages/loginPage.js';
import { IngestionPage } from '../generalPages/ingestionPage.js';
@@ -2732,6 +2733,7 @@ export class LogsPage {
await this.page.locator('[data-test="header-my-account-profile-icon"]').click();
// Header.vue data-test="menu-link-logout-item" on the Sign Out menu item
await this.page.locator('[data-test="menu-link-logout-item"]').click();
await confirmDraftSignOut(this.page);
}
// LogsQueryPage methods - delegate to LogsQueryPage
@@ -310,6 +310,10 @@ export class PipelinesPage {
this.queryNode = page.locator('[data-test="pipeline-node-input-query-node"]');
this.queryNodeDeleteBtn = page.locator('[data-test="pipeline-node-input-delete-btn"]');
this.cancelPipelineBtn = page.locator('[data-test="add-pipeline-cancel-btn"]');
// Editor drafts: Cancel/leave with unsaved changes opens the shared leave dialog
this.draftLeaveDialog = page.locator('[data-test="editor-draft-leave-dialog"]');
this.draftLeaveDiscardBtn = this.draftLeaveDialog.locator('[data-test="o-dialog-neutral-btn"]');
this.draftLeaveCloseBtn = this.draftLeaveDialog.locator('[data-test="o-dialog-close-btn"]');
this.dashboardsMenuLink = page.locator('[data-test="menu-link-\\/dashboards-item"]');
// PipelineEditor emits a toast "Please connect all nodes before saving"
// when the user clicks Save without joining nodes. OToast renders it via
@@ -2091,7 +2095,7 @@ export class PipelinesPage {
async cancelAndDeletePipeline(pipelineName, searchPrefix = 'automatepi') {
// Click the cancel/back button
await this.page.locator('[data-test="add-pipeline-cancel-btn"]').click();
await this.confirmButton.click();
await this.discardDraftIfAsked();
await this.page.waitForTimeout(2000);
// Search for the pipeline
@@ -2676,6 +2680,19 @@ export class PipelinesPage {
*/
async clickCancelPipelineBtn() {
await this.cancelPipelineBtn.click();
await this.discardDraftIfAsked();
}
/** Discards unsaved changes through the leave dialog when it opens, so no draft is left behind. */
async discardDraftIfAsked() {
const asked = await this.draftLeaveDialog
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (asked) {
await this.draftLeaveDiscardBtn.click();
await this.draftLeaveDialog.waitFor({ state: 'hidden', timeout: 10000 });
}
}
/**
@@ -2686,8 +2703,8 @@ export class PipelinesPage {
}
async acceptUnsavedChangesPromptAndExpectDashboards() {
await expect(this.discardChangesOkBtn, 'Leaving an unsaved pipeline must show the confirm dialog').toBeVisible({ timeout: 10000 });
await this.discardChangesOkBtn.click();
await expect(this.draftLeaveDialog, 'Leaving an unsaved pipeline must show the leave dialog').toBeVisible({ timeout: 10000 });
await this.draftLeaveDiscardBtn.click();
await expect(this.page).toHaveURL(/dashboards/, { timeout: 15000 });
}
@@ -3050,8 +3067,7 @@ export class PipelinesPage {
*/
async cleanupPipelineCreation() {
await this.cancelPipelineBtn.click().catch(() => {});
await this.page.waitForTimeout(500);
await this.confirmButton.click().catch(() => {});
await this.discardDraftIfAsked();
testLogger.info('Pipeline creation cleanup completed');
}
@@ -3354,6 +3370,7 @@ export class PipelinesPage {
*/
async clickCancelPipelineBtnForce() {
await this.cancelPipelineBtn.click({ force: true });
await this.discardDraftIfAsked();
testLogger.info('Clicked cancel pipeline button (force)');
}
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
import { confirmDraftSignOut } from '../generalPages/draftSignOut.js';
import {
dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator,
Past30SecondsValue, openNavFlyoutChild
@@ -484,6 +485,7 @@ export class ReportsPage {
async signOut() {
await this.profileButton.click({ force: true });
await this.signOutButton.click({ force: true });
await confirmDraftSignOut(this.page);
}
async pauseReport(reportName) {
@@ -573,6 +575,7 @@ export class ReportsPage {
await this.signOutButton.waitFor({ state: 'visible', timeout: 5000 });
// Now click the logout item
await this.signOutButton.click({ force: true });
await confirmDraftSignOut(this.page);
}
async notAvailableReport(reportName) {
@@ -169,8 +169,8 @@ export class RumProductAnalyticsPage {
this.addRuleBtn = page.locator('[data-test="rum-analytics-named-events-add-rule-btn"]');
this.eventPreview = page.locator('[data-test="rum-analytics-named-events-preview"]');
this.eventCancelBtn = page.locator('[data-test="rum-analytics-event-editor-cancel-btn"]');
this.eventDiscardDialog = page.locator('[data-test="rum-analytics-event-editor-discard-dialog"]');
this.eventDiscardConfirmBtn = this.eventDiscardDialog.locator('[data-test="o-dialog-primary-btn"]');
this.eventDiscardDialog = page.locator('[data-test="editor-draft-leave-dialog"]');
this.eventDiscardConfirmBtn = this.eventDiscardDialog.locator('[data-test="o-dialog-neutral-btn"]');
this.eventRules = page.locator('[data-test^="rum-analytics-named-events-rule-"][data-test$="-kind"]');
this.sessionsTable = page.locator('[data-test="rum-sessions-table"]');
@@ -2,6 +2,7 @@
import { expect } from '@playwright/test';
import testLogger from '../../playwright-tests/utils/test-logger.js';
import { getAuthHeaders, getOrgIdentifier } from '../../playwright-tests/utils/cloud-auth.js';
import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js';
export class SDRPatternsPage {
constructor(page) {
@@ -74,10 +75,17 @@ export class SDRPatternsPage {
async clickAddPattern() {
testLogger.info('Clicking Add Pattern button');
await clearEditorDrafts(this.page);
await this.addPatternButton.click();
await this.addPatternTitle.waitFor({ state: 'visible' });
}
async clickCancel() {
testLogger.info('Cancelling the pattern drawer');
await this.cancelButton.click();
await discardEditorDraftIfAsked(this.page);
}
async fillPatternDetails(name, description, pattern) {
testLogger.info(`Filling pattern details: ${name}`);
await this.patternNameInput.click();
@@ -355,6 +355,13 @@ export class SloFormPage {
// Cancel sits beside Save in the page header actions; it is the only
// control there that is not the save button.
await this.page.getByRole('button', { name: /cancel/i }).first().click();
// Unsaved changes open the shared leave dialog; discard so no draft is left behind.
const leaveDialog = this.page.locator('[data-test="editor-draft-leave-dialog"]');
const asked = await leaveDialog
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (asked) await leaveDialog.locator('[data-test="o-dialog-neutral-btn"]').click();
await this.page.waitForURL((url) => !/\/slos\/(add|edit)/.test(url.pathname), {
timeout: 20000,
});
@@ -69,7 +69,8 @@ export class SyntheticsCreatePage {
privateLocationsEmpty: '[data-test="synthetics-check-locations-private-empty"]',
saveButton: '[data-test="synthetics-create-save-btn"]',
cancelButton: '[data-test="synthetics-create-cancel-btn"]',
unsavedDialog: '[data-test="synthetics-create-unsaved-dialog"]',
unsavedDialog: '[data-test="editor-draft-leave-dialog"]',
leaveDiscardButton: '[data-test="o-dialog-neutral-btn"]',
dialogPrimary: '[data-test="o-dialog-primary-btn"]',
gateUrlField: '[data-test="synthetics-create-url-input-field"]',
gateUrlError: '[data-test="synthetics-create-url-input-error"]',
@@ -257,10 +258,11 @@ export class SyntheticsCreatePage {
async cancel() { await this.page.locator(this.locators.cancelButton).click(); }
// Leaves through the shared leave dialog's Discard, so no draft is left behind.
async confirmLeave() {
const dialog = this.page.locator(this.locators.unsavedDialog);
await expect(dialog).toBeVisible({ timeout: 15000 });
await dialog.locator(this.locators.dialogPrimary).click();
await dialog.locator(this.locators.leaveDiscardButton).click();
}
async fillGate(url, name) {
@@ -1,5 +1,6 @@
// tracesPage.js
import { expect } from '@playwright/test';
import { confirmDraftSignOut } from '../generalPages/draftSignOut.js';
import { dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator, Past30SecondsValue } from '../commonActions.js';
@@ -652,6 +653,7 @@ export class TracesPage {
async signOut() {
await this.profileButton.click();
await this.signOutButton.click();
await confirmDraftSignOut(this.page);
}
// Query Editor Methods
@@ -159,11 +159,11 @@ test.describe('IAM · Edit Role · staged changes', { tag: '@enterprise' }, () =
await pm.rolesPage.grantScope('function', 'AllowList');
await pm.rolesPage.cancelButton.click();
// A leave guard may stand in the way; discarding is the point of the test.
const confirm = page.locator('[data-test="confirm-dialog"]');
if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) {
await confirm.locator('[data-test="o-dialog-primary-btn"]').click();
}
// A staged change always asks before leaving; discarding is the point of the test.
const confirm = page.locator('[data-test="editor-draft-leave-dialog"]');
await expect(confirm).toBeVisible({ timeout: 10000 });
await confirm.locator('[data-test="o-dialog-neutral-btn"]').click();
await expect(confirm).toBeHidden();
await page.waitForTimeout(2000);
expect(await getPerms(page, name)).toEqual([]);
});
@@ -502,7 +502,7 @@ test.describe("Traces Pipeline Tests", { tag: ['@all', '@pipelines', '@traces',
await pageManager.pipelinesPage.selectStreamOptionByName(TRACES_STREAM);
await pageManager.pipelinesPage.saveInputNodeStream();
// onBeforeRouteLeave must hold the navigation behind the confirm dialog; accepting it completes the navigation.
// The route guard holds the navigation behind the leave dialog; Discard completes it.
await pageManager.pipelinesPage.clickDashboardsMenu();
await pageManager.pipelinesPage.acceptUnsavedChangesPromptAndExpectDashboards();
@@ -429,11 +429,10 @@ test.describe("Pipeline testcases", { tag: ['@all', '@pipelines'] }, () => {
await pipelinePage.selectStreamOption();
await pipelinePage.saveInputNodeStream();
// Click the dashboards menu — onBeforeRouteLeave fires and shows a Vue
// ConfirmDialog instead of window.confirm (native dialog). Accept it.
// Leaving with unsaved changes opens the shared leave dialog; Discard completes the navigation.
await pipelinePage.clickDashboardsMenu();
await pipelinePage.discardChangesOkBtn.waitFor({ state: 'visible', timeout: 10000 });
await pipelinePage.discardChangesOkBtn.click();
await pipelinePage.draftLeaveDiscardBtn.waitFor({ state: 'visible', timeout: 10000 });
await pipelinePage.draftLeaveDiscardBtn.click();
// Assert the page navigates to the desired URL containing "dashboards"
await page.waitForURL(/dashboards/);
@@ -458,11 +457,10 @@ test.describe("Pipeline testcases", { tag: ['@all', '@pipelines'] }, () => {
await pipelinePage.selectStreamOption();
await pipelinePage.saveInputNodeStream();
// Click the dashboards menu — onBeforeRouteLeave fires and shows a Vue
// ConfirmDialog instead of window.confirm (native dialog). Dismiss it.
// Closing the leave dialog keeps the user on the editor.
await pipelinePage.clickDashboardsMenu();
await pipelinePage.discardChangesCancelBtn.waitFor({ state: 'visible', timeout: 10000 });
await pipelinePage.discardChangesCancelBtn.click();
await pipelinePage.draftLeaveCloseBtn.waitFor({ state: 'visible', timeout: 10000 });
await pipelinePage.draftLeaveCloseBtn.click();
// Assert page url to have pipeline
await expect(page).toHaveURL(/pipeline/);
@@ -1112,7 +1112,7 @@ test.describe('RUM Product Analytics', () => {
await pa.eventCancelBtn.click();
await expect(pa.eventDiscardDialog).toBeVisible({ timeout: 20000 });
await expect(pa.eventDiscardDialog).toContainText('Discard changes?');
await expect(pa.eventDiscardDialog).toContainText('Leave with unsaved changes?');
await pa.eventDiscardConfirmBtn.click();
await expect(pa.namedEventsList).toBeVisible({ timeout: 20000 });
expect(w.writes, 'leaving the editor writes nothing').toEqual([]);
@@ -53,7 +53,7 @@ test.describe('Prebuilt destination form reuse (#13243)', () => {
// a form instance that has already been used, not a fresh page.
await pm.alertDestinationsPage.clickNewDestination();
await pm.alertDestinationsPage.selectDestinationType('discord');
await page.locator('[data-test="add-destination-cancel-btn"]').click();
await pm.alertDestinationsPage.clickCancel();
await page.locator('[data-test="prebuilt-destination-selector"]').waitFor({
state: 'hidden',
timeout: 15000,
@@ -102,7 +102,7 @@ test.describe('Prebuilt destination form reuse (#13243)', () => {
`reuse_${type}`,
);
await page.locator('[data-test="add-destination-cancel-btn"]').click();
await pm.alertDestinationsPage.clickCancel();
await page.locator('[data-test="prebuilt-destination-selector"]').waitFor({
state: 'hidden',
timeout: 15000,
@@ -40,7 +40,7 @@ test.describe("Regex Pattern Management Tests", { tag: '@enterprise' }, () => {
testLogger.info('Verifying drawer stays open after save with empty name');
await expect(pm.sdrPatternsPage.addPatternTitle).toBeVisible({ timeout: 5000 });
await pm.sdrPatternsPage.cancelButton.click();
await pm.sdrPatternsPage.clickCancel();
await page.waitForTimeout(500);
// Test 2: Empty regex pattern — form should not close
@@ -126,7 +126,7 @@ test.describe("Regex Pattern Management Tests", { tag: '@enterprise' }, () => {
'This will be cancelled',
'^test$'
);
await pm.sdrPatternsPage.cancelButton.click();
await pm.sdrPatternsPage.clickCancel();
await page.waitForTimeout(500);
const patternVisible = await pm.sdrPatternsPage.checkPatternExists('cancelled_pattern');
+5 -2
View File
@@ -8,8 +8,11 @@
size="sm"
:title="currentDialog.title"
:primary-button-label="currentDialog.confirmLabel || t('common.ok')"
:secondary-button-label="currentDialog.cancelLabel || t('common.cancel')"
:persistent="currentDialog.persistent ?? true"
:secondary-button-label="
currentDialog.confirmOnly ? undefined : currentDialog.cancelLabel || t('common.cancel')
"
:persistent="currentDialog.confirmOnly || (currentDialog.persistent ?? true)"
:show-close="!currentDialog.confirmOnly"
:primary-button-variant="currentDialog.confirmVariant ?? 'primary'"
:initial-focus="currentDialog.focusCancel ? 'secondary' : 'primary'"
@click:primary="handleConfirm"
@@ -138,6 +138,13 @@ describe("CustomDateTimePicker.vue", () => {
expect(vm.getTrimmedDisplayValue()).toBe("Past 5 Minutes");
});
it("getTrimmedDisplayValue uses the singular unit for a count of one", () => {
wrapper = createWrapper({ modelValue: "1h" });
const vm = wrapper.vm as any;
expect(vm.getTrimmedDisplayValue()).toBe("Past 1 Hour");
});
it("getDisplayValue works with hours", () => {
wrapper = createWrapper({ modelValue: "2h" });
const vm = wrapper.vm as any;
@@ -145,6 +152,30 @@ describe("CustomDateTimePicker.vue", () => {
expect(vm.getDisplayValue()).toBe("2 Hours ago");
});
it("getDisplayValue uses the singular unit for a count of one", () => {
wrapper = createWrapper({ modelValue: "1h" });
const vm = wrapper.vm as any;
expect(vm.getDisplayValue()).toBe("1 Hour ago");
});
it.each([
["1s", "1 Second ago"],
["1m", "1 Minute ago"],
["1d", "1 Day ago"],
["1w", "1 Week ago"],
["1M", "1 Month ago"],
["30s", "30 Seconds ago"],
["3d", "3 Days ago"],
["2w", "2 Weeks ago"],
["6M", "6 Months ago"],
])("getDisplayValue pluralises %s as %s", (modelValue, expected) => {
wrapper = createWrapper({ modelValue });
const vm = wrapper.vm as any;
expect(vm.getDisplayValue()).toBe(expected);
});
it("getTrimmedDisplayValue works with days", () => {
wrapper = createWrapper({ modelValue: "3d" });
const vm = wrapper.vm as any;
+26 -10
View File
@@ -98,7 +98,7 @@ import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import type { DropdownAlign } from "@/lib/overlay/Dropdown/ODropdown.types";
import { reactive, watch, computed } from "vue";
import type { PropType } from "vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
const { t } = useI18nTyped();
@@ -168,6 +168,25 @@ const relativeDates: Record<PeriodKey, number[]> = {
M: [1, 2, 3, 4, 5, 6],
};
// Same plural keys the comparison legend uses (dateTimeUtils), so both read the same.
const agoKeys: Record<PeriodKey, I18nKey> = {
s: "dashboard.utils.secondsAgo",
m: "dashboard.utils.minutesAgo",
h: "dashboard.utils.hoursAgo",
d: "dashboard.utils.daysAgo",
w: "dashboard.utils.weeksAgo",
M: "dashboard.utils.monthsAgo",
};
const pastKeys: Record<PeriodKey, I18nKey> = {
s: "common.pastSecond",
m: "common.pastMinute",
h: "common.pastHour",
d: "common.pastDay",
w: "common.pastWeek",
M: "common.pastMonth",
};
const relativePeriods = computed(() =>
props.excludeMonths ? allRelativePeriods.filter((p) => p.value !== "M") : allRelativePeriods,
);
@@ -218,19 +237,16 @@ const updateCustomPeriod = (newPeriod: SelectModelValue | string) => {
};
// Display the current selected offset
// Parameterised rather than concatenated — word order around the value varies by language.
const getDisplayValue = () => {
return t("common.relativeTimeAgo", {
value: picker.data.selectedDate.relative.value,
unit: picker.data.selectedDate.relative.label,
});
const count = picker.data.selectedDate.relative.value;
const key = agoKeys[picker.data.selectedDate.relative.period as PeriodKey] ?? agoKeys.m;
return t(key, { count }, count);
};
const getTrimmedDisplayValue = () => {
return t("common.relativeTimePast", {
value: picker.data.selectedDate.relative.value,
unit: picker.data.selectedDate.relative.label,
});
const count = picker.data.selectedDate.relative.value;
const key = pastKeys[picker.data.selectedDate.relative.period as PeriodKey] ?? pastKeys.m;
return t(key, { count }, count);
};
// Check if the current selection matches the modelValue
@@ -0,0 +1,509 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import aiToolsetsService from "@/services/ai_toolsets";
import { queryClient } from "@/composables/query/queryClient";
import {
editorDraftKey,
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
import { fillField } from "@/test/unit/helpers/fillField";
import AiToolsets from "@/components/settings/AiToolsets.vue";
import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom";
import AddAiToolset from "./AddAiToolset.vue";
vi.mock("@/services/ai_toolsets", () => ({
default: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(() => Promise.resolve({ data: {} })),
update: vi.fn(() => Promise.resolve({ data: {} })),
delete: vi.fn(() => Promise.resolve({ data: {} })),
},
}));
const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn(() => () => undefined) }));
vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({
...(await importOriginal<object>()),
toast: toastMock,
}));
const QueryEditorStub = {
name: "QueryEditor",
props: ["query", "editorId", "language"],
emits: ["update:query"],
template: '<div class="query-editor-stub" />',
};
interface ToolsetForm {
state: {
values: Record<string, unknown> & {
mcp: { url: string; headers: { key: string; value: string }[] };
};
};
setFieldValue: (k: string, v: unknown) => void;
getFieldMeta: (k: string) => { errors?: unknown[] } | undefined;
handleSubmit: () => Promise<void>;
}
const ORG = store.state.selectedOrganization.identifier;
const EMAIL = store.state.userInfo.email;
const newScope: EditorDraftScope = {
kind: "ai_toolset",
org: ORG,
entityId: null,
userEmail: EMAIL,
};
const editScope: EditorDraftScope = { ...newScope, entityId: "ts1" };
const serverToolset = (headers: Record<string, string> = {}) => ({
id: "ts1",
name: "github_mcp",
kind: "mcp",
description: "GitHub tools",
data: {
url: "https://api.example.com/mcp/",
timeout_seconds: 30,
headers: { Authorization: "Bearer saved-token", "X-Team": "sre-team", ...headers },
},
});
const Stub = { render: () => h("div") };
let router: Router;
let wrapper: VueWrapper | null = null;
const mountPage = async (query: Record<string, string> = { action: "add" }) => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/ai_toolsets", name: "aiToolsets", component: AiToolsets },
{ path: "/logs", name: "logs", component: Stub },
],
});
await router.push({ name: "aiToolsets", query: { org_identifier: ORG, ...query } });
wrapper = mount(
{ render: () => h(RouterView) },
{
attachTo: document.body,
global: {
plugins: [router, store, i18n],
provide: { store },
stubs: { QueryEditor: QueryEditorStub },
},
},
);
await flushPromises();
};
const remount = async (query?: Record<string, string>) => {
wrapper?.unmount();
wrapper = null;
await mountPage(query);
};
const form = () =>
(wrapper!.findComponent(AddAiToolset).vm as unknown as { form: ToolsetForm }).form;
const edit = async (values: Record<string, unknown>) => {
window.dispatchEvent(new Event("pointerdown"));
Object.entries(values).forEach(([field, value]) => form().setFieldValue(field, value));
await nextTick();
};
// The list's own loading toast is not a notice about the toolset.
const noticeVariants = () =>
toastMock.mock.calls
.map(([arg]) => (arg as { variant: string }).variant)
.filter((variant) => variant !== "loading");
describe("AddAiToolset drafts", () => {
beforeEach(() => {
localStorage.clear();
queryClient.clear();
vi.mocked(aiToolsetsService.list).mockResolvedValue({
data: { toolsets: [{ id: "ts1", name: "github_mcp", kind: "mcp", description: "" }] },
} as never);
vi.mocked(aiToolsetsService.get).mockResolvedValue({ data: serverToolset() } as never);
vi.mocked(aiToolsetsService.create).mockClear();
vi.mocked(aiToolsetsService.update).mockClear();
toastMock.mockClear();
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("never writes header, env or credential file values to storage", async () => {
await mountPage();
await edit({
name: "ops_mcp",
"mcp.url": "https://mcp.example.com/",
"mcp.headers": [
{ key: "Authorization", value: "Bearer s3cr3t", visible: false },
{ key: "X-Tenant", value: "tenant-42", visible: true },
],
"cli.env": [{ key: "GH_TOKEN", value: "ghp_abcdef", visible: false }],
"cli.credFiles": [{ key: "KUBECONFIG", value: "apiVersion: v1\ntoken: kube-secret" }],
});
window.dispatchEvent(new Event("pagehide"));
const stored = localStorage.getItem(editorDraftKey(newScope)) ?? "";
expect(stored).toContain("X-Tenant");
expect(stored).toContain("https://mcp.example.com/");
for (const secret of ["s3cr3t", "tenant-42", "ghp_abcdef", "kube-secret"]) {
expect(stored).not.toContain(secret);
}
expect(stored).not.toContain("visible");
await remount();
const note = byTest("editor-draft-credentials-not-saved")?.textContent ?? "";
expect(note).toContain("environment variable GH_TOKEN");
expect(note).toContain("credential file KUBECONFIG");
});
it("names a header value with no header name in lowercase, as part of the sentence", async () => {
await mountPage();
await edit({
name: "ops_mcp",
"mcp.headers": [{ key: "", value: "unnamed-secret", visible: false }],
});
window.dispatchEvent(new Event("pagehide"));
await remount();
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your change to the headers was not kept.",
);
});
it("counts a name typed with no key or pointer event", async () => {
await mountPage();
await fillField("ai-toolset-name-input", "filled_toolset");
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("filled_toolset");
});
it("a password visibility toggle alone is not an unsaved change", async () => {
await mountPage({ action: "edit", id: "ts1" });
await edit({ "mcp.headers[0].visible": true });
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).toBeNull();
click("ai-toolset-cancel-btn");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).toBeNull();
expect(router.currentRoute.value.query.action).toBeUndefined();
});
it("continues a new toolset's draft, and Start fresh or Escape answer the offer", async () => {
await mountPage();
await edit({ name: "draft_tool", description: "From the draft" });
window.dispatchEvent(new Event("pagehide"));
await remount();
expect(byTest("editor-draft-restore-message")?.textContent).toContain(
"unsaved changes to this AI toolset",
);
pressEscape();
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(form().state.values.name).toBe("");
expect(readEditorDraft(newScope)).not.toBeNull();
await remount();
click("editor-draft-continue");
await flushPromises();
expect(form().state.values).toMatchObject({
name: "draft_tool",
description: "From the draft",
});
await remount();
clickRestore("o-dialog-secondary-btn");
await flushPromises();
expect(readEditorDraft(newScope)).toBeNull();
expect(form().state.values.name).toBe("");
});
it("an edit draft refills unchanged headers by name and blocks Save on a changed one", async () => {
await mountPage({ action: "edit", id: "ts1" });
expect(form().state.values.mcp.headers.map((row) => row.key)).toEqual([
"Authorization",
"X-Team",
]);
await edit({
description: "Edited",
"mcp.headers": [
{ key: "X-Team", value: "sre-team", visible: false },
{ key: "Authorization", value: "Bearer new-token", visible: false },
],
});
window.dispatchEvent(new Event("pagehide"));
expect(localStorage.getItem(editorDraftKey(editScope))).not.toContain("new-token");
await remount({ action: "edit", id: "ts1" });
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your change to the Authorization header was not kept.",
);
click("editor-draft-continue");
await flushPromises();
expect(form().state.values.description).toBe("Edited");
expect(form().state.values.mcp.headers).toEqual([
{ key: "X-Team", value: "sre-team", visible: false },
{ key: "Authorization", value: "", visible: false },
]);
expect(form().getFieldMeta("mcp.headers[1].value")?.errors?.length).toBeGreaterThan(0);
await form().handleSubmit();
await flushPromises();
expect(aiToolsetsService.update).not.toHaveBeenCalled();
await edit({ "mcp.headers[1].value": "Bearer typed-again" });
await form().handleSubmit();
await flushPromises();
expect(aiToolsetsService.update).toHaveBeenCalledTimes(1);
expect(vi.mocked(aiToolsetsService.update).mock.calls[0][2]).toMatchObject({
data: { headers: { "X-Team": "sre-team", Authorization: "Bearer typed-again" } },
});
});
it("never stores an endpoint URL that carries a credential, and refills it when unchanged", async () => {
vi.mocked(aiToolsetsService.get).mockResolvedValue({
data: {
...serverToolset(),
data: { ...serverToolset().data, url: "https://mcp.example.com/sse?api_key=saved-key" },
},
} as never);
await mountPage({ action: "edit", id: "ts1" });
await edit({ description: "Edited" });
window.dispatchEvent(new Event("pagehide"));
expect(localStorage.getItem(editorDraftKey(editScope))).not.toContain("saved-key");
await remount({ action: "edit", id: "ts1" });
expect(byTest("editor-draft-changed-on-server")).toBeNull();
expect(byTest("editor-draft-credentials-not-saved")).toBeNull();
click("editor-draft-continue");
await flushPromises();
expect(form().state.values).toMatchObject({
description: "Edited",
mcp: { url: "https://mcp.example.com/sse?api_key=saved-key" },
});
});
it("blanks a changed credential URL, names it in the restore note and blocks Save until re-entered", async () => {
await mountPage({ action: "edit", id: "ts1" });
await edit({ "mcp.url": "https://bot:typed-token@mcp.example.com/sse" });
window.dispatchEvent(new Event("pagehide"));
expect(localStorage.getItem(editorDraftKey(editScope))).not.toContain("typed-token");
await remount({ action: "edit", id: "ts1" });
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your change to the Endpoint URL was not kept.",
);
click("editor-draft-continue");
await flushPromises();
expect(form().state.values.mcp.url).toBe("");
expect(form().getFieldMeta("mcp.url")?.errors?.length).toBeGreaterThan(0);
await form().handleSubmit();
await flushPromises();
expect(aiToolsetsService.update).not.toHaveBeenCalled();
await edit({ "mcp.url": "https://mcp.example.com/sse" });
await form().handleSubmit();
await flushPromises();
expect(aiToolsetsService.update).toHaveBeenCalledTimes(1);
});
it("warns when the toolset changed on the server", async () => {
await mountPage({ action: "edit", id: "ts1" });
await edit({ description: "Edited" });
window.dispatchEvent(new Event("pagehide"));
await remount({ action: "edit", id: "ts1" });
expect(byTest("editor-draft-changed-on-server")).toBeNull();
vi.mocked(aiToolsetsService.get).mockResolvedValue({
data: serverToolset({ "X-Region": "eu" }),
} as never);
await remount({ action: "edit", id: "ts1" });
expect(byTest("editor-draft-changed-on-server")).not.toBeNull();
});
it("drops the draft of a toolset that no longer exists with one notice, and goes back", async () => {
writeEditorDraft(editScope, { data: { name: "github_mcp" } });
vi.mocked(aiToolsetsService.get).mockRejectedValue({ response: { status: 404 } });
await mountPage({ action: "edit", id: "ts1" });
expect(readEditorDraft(editScope)).toBeNull();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(noticeVariants()).toEqual(["info"]);
expect(router.currentRoute.value.query.action).toBeUndefined();
});
it("a deleted toolset with no draft shows the load error and goes back", async () => {
vi.mocked(aiToolsetsService.get).mockRejectedValue({ response: { status: 404 } });
await mountPage({ action: "edit", id: "ts1" });
expect(noticeVariants()).toEqual(["error"]);
expect(router.currentRoute.value.query.action).toBeUndefined();
});
it("a not-kept header does not block Save once the toolset is switched to another kind", async () => {
await mountPage();
await edit({
name: "switch_kind",
"mcp.url": "https://mcp.example.com/",
"mcp.headers": [{ key: "Authorization", value: "Bearer s3cr3t", visible: false }],
});
window.dispatchEvent(new Event("pagehide"));
await remount();
click("editor-draft-continue");
await flushPromises();
await form().handleSubmit();
await flushPromises();
expect(aiToolsetsService.create).not.toHaveBeenCalled();
await edit({ kind: "cli", "cli.command": "kubectl" });
await form().handleSubmit();
await flushPromises();
expect(aiToolsetsService.create).toHaveBeenCalledTimes(1);
});
it("marks a not-kept credential file under its editor until it is filled again", async () => {
vi.mocked(aiToolsetsService.get).mockResolvedValue({
data: {
id: "ts1",
name: "kube_cli",
kind: "cli",
description: "",
data: {
command: "kubectl",
timeout_seconds: 30,
max_output_bytes: 100000,
env: {},
credential_files: { KUBECONFIG: "apiVersion: v1" },
},
},
} as never);
await mountPage({ action: "edit", id: "ts1" });
await edit({ "cli.credFiles[0].value": "apiVersion: v2" });
window.dispatchEvent(new Event("pagehide"));
await remount({ action: "edit", id: "ts1" });
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain("KUBECONFIG");
click("editor-draft-continue");
await flushPromises();
expect(byTest("ai-toolset-cred-file-reenter-0")).not.toBeNull();
expect(byTest("ai-toolset-cred-file-reenter-0")?.classList).toContain("text-status-error-text");
await edit({ "cli.credFiles[0].value": "apiVersion: v3" });
await flushPromises();
expect(byTest("ai-toolset-cred-file-reenter-0")).toBeNull();
});
it("Cancel asks; Keep draft returns to the list with a dot on Add, Discard drops it", async () => {
await mountPage();
await edit({ name: "kept_tool" });
click("ai-toolset-cancel-btn");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
// A slow list reload must not remount the editor and offer the draft just kept.
let releaseList!: () => void;
vi.mocked(aiToolsetsService.list).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseList = () =>
resolve({
data: { toolsets: [{ id: "ts1", name: "github_mcp", kind: "mcp", description: "" }] },
} as never);
}),
);
clickLeave("o-dialog-secondary-btn");
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(wrapper!.findComponent(AddAiToolset).exists()).toBe(false);
releaseList();
await flushPromises();
expect(router.currentRoute.value.query.action).toBeUndefined();
expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("kept_tool");
const add = wrapper!.find('[data-test="ai-toolsets-add-btn"]');
expect(add.find('[data-test="ai-toolsets-add-draft-dot"]').exists()).toBe(true);
expect(add.attributes("aria-label")).toBe("New AI Toolset · unsaved draft");
await remount();
click("editor-draft-continue");
await flushPromises();
click("ai-toolset-back-btn");
await flushPromises();
clickLeave("o-dialog-neutral-btn");
await flushPromises();
expect(router.currentRoute.value.query.action).toBeUndefined();
expect(readEditorDraft(newScope)).toBeNull();
});
it("Save in the leave dialog saves and goes where the user was headed", async () => {
await mountPage();
await edit({ name: "saved_tool", "mcp.url": "https://mcp.example.com/" });
const navigation = router.push("/logs");
await flushPromises();
clickLeave("editor-draft-leave-save");
await navigation;
await flushPromises();
expect(aiToolsetsService.create).toHaveBeenCalledTimes(1);
expect(router.currentRoute.value.name).toBe("logs");
expect(readEditorDraft(newScope)).toBeNull();
});
it("a normal save drops the draft and returns to the list without asking", async () => {
await mountPage();
await edit({ name: "saved_tool", "mcp.url": "https://mcp.example.com/" });
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(newScope)).not.toBeNull();
await form().handleSubmit();
await vi.waitFor(() => expect(router.currentRoute.value.query.action).toBeUndefined());
expect(byTest("editor-draft-leave-dialog")).toBeNull();
expect(readEditorDraft(newScope)).toBeNull();
});
it("marks a toolset row with a draft and drops the draft when the toolset is deleted", async () => {
writeEditorDraft(editScope, { data: { name: "github_mcp" } });
await mountPage({});
expect(byTest("ai-toolsets-github_mcp-draft-dot")).not.toBeNull();
const page = wrapper!.findComponent(AiToolsets).vm as unknown as {
confirmDeleteToolset: (row: { id: string; name: string }) => void;
deleteToolset: () => void;
};
page.confirmDeleteToolset({ id: "ts1", name: "github_mcp" });
page.deleteToolset();
await flushPromises();
expect(readEditorDraft(editScope)).toBeNull();
});
});
+263 -12
View File
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:title="isEditing ? t('aiToolset.update') : t('aiToolset.add')"
:back="{
label: t('aiToolset.header'),
onClick: () => $emit('cancel:hideform'),
onClick: closeEditor,
dataTest: 'ai-toolset-back-btn',
}"
bleed
@@ -273,6 +273,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:query="cred.value"
@update:query="(v: string) => setCredValue(idx, v)"
/>
<div
v-if="pendingCredFileRows.has(idx)"
class="text-status-error-text mt-1 text-xs"
:data-test="`ai-toolset-cred-file-reenter-${idx}`"
>
{{ t("common.editorDraft.reenterField") }}
</div>
</div>
<OButton variant="outline" size="sm" class="mb-4" @click="addCredFile" icon-left="add">
{{ t("aiToolset.addCredFile") }}
@@ -320,21 +327,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="outline"
size="sm-action"
:disabled="isSubmitting"
@click="$emit('cancel:hideform')"
@click="closeEditor"
>
{{ t("common.cancel") }}
</OButton>
</div>
</div>
</OForm>
<EditorDraftDialogs :draft="toolsetDraft" />
</OPageLayout>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent, ref, computed, onMounted } from "vue";
import {
defineAsyncComponent,
defineComponent,
ref,
computed,
onMounted,
nextTick,
shallowRef,
} from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import aiToolsetsService from "@/services/ai_toolsets";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
@@ -345,9 +361,12 @@ import OFormTextarea from "@/lib/forms/Input/OFormTextarea.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import { useOForm, type FormFieldPath } from "@/lib/forms/Form/useOForm";
import type { ToolsetKind } from "@/services/ai_toolsets";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useEditorDraft, useEditorDraftIds } from "@/composables/useEditorDraft";
import { urlCarriesCredential } from "@/utils/drafts/urlCredentials";
import EditorDraftDialogs from "@/components/common/drafts/EditorDraftDialogs.vue";
import {
makeAddAiToolsetSchema,
addAiToolsetDefaults,
@@ -356,6 +375,22 @@ import {
const QueryEditor = defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue"));
type SecretPair = { key: string; value: string };
type SecretRow = { key?: string; value?: string };
type SecretList = "headers" | "env" | "credFiles";
type SecretRowsSource = {
mcp?: { headers?: SecretRow[] };
cli?: { env?: SecretRow[]; credFiles?: SecretRow[] };
};
interface AiToolsetDraftData extends Omit<AddAiToolsetForm, "mcp" | "cli"> {
mcp: Omit<AddAiToolsetForm["mcp"], "headers"> & { headers: SecretPair[] };
cli: Omit<AddAiToolsetForm["cli"], "env" | "credFiles"> & {
env: SecretPair[];
credFiles: SecretPair[];
};
}
export default defineComponent({
name: "AddAiToolset",
components: {
@@ -369,8 +404,9 @@ export default defineComponent({
OFormSelect,
OFormSwitch,
QueryEditor,
EditorDraftDialogs,
},
emits: ["cancel:hideform"],
emits: ["cancel:hideform", "update:list"],
setup(_, { emit }) {
const store = useStore();
const router = useRouter();
@@ -444,6 +480,7 @@ export default defineComponent({
// once the whole schema passes (no manual gate). OForm awaits it → auto Save
// spinner. `value` is the validated payload and the single source of truth.
const saveToolset = async (value: AddAiToolsetForm) => {
if (blockOnPendingReentries()) return;
const org = store.state.selectedOrganization.identifier;
const data = buildData(value);
@@ -469,6 +506,12 @@ export default defineComponent({
message: t("aiToolset.createdSuccessfully"),
});
}
if (leaveSave) {
leaveSave.saved = true;
emit("update:list");
return;
}
toolsetDraft.markSaved();
emit("cancel:hideform");
} catch (err: any) {
const msg =
@@ -599,7 +642,8 @@ export default defineComponent({
// Load existing toolset when editing — seed via form.reset (the record
// arrives async; `:default-values` was read once at creation).
// -----------------------------------------------------------------------
const loadForEdit = async (id: string) => {
const toolsetDrafts = useEditorDraftIds("ai_toolset");
const loadForEdit = async (id: string): Promise<"loaded" | "gone" | "failed"> => {
const org = store.state.selectedOrganization.identifier;
try {
const res = await aiToolsetsService.get(org, id);
@@ -648,16 +692,220 @@ export default defineComponent({
// Async-loaded record → form.reset (NOT per-field setFieldValue). The
// `form.useStore` reads above re-render automatically.
form.reset(record);
} catch {
toast({ variant: "error", message: t("toastMessages.aitoolsets.failedToLoadToolset") });
serverToolset = toolsetSnapshot();
return "loaded";
} catch (err: unknown) {
const gone = (err as { response?: { status?: number } } | null)?.response?.status === 404;
// The draft's own notice replaces the load error for a deleted toolset that has one.
if (!gone || !toolsetDrafts.ids.has(id)) {
toast({ variant: "error", message: t("toastMessages.aitoolsets.failedToLoadToolset") });
}
return gone ? "gone" : "failed";
}
};
onMounted(() => {
const formValues = form.useStore((s: { values: AddAiToolsetForm }) => s.values);
const editId = (): string | null => {
const query = router.currentRoute.value.query;
if (query.action === "edit" && query.id) {
loadForEdit(query.id as string);
return query.action === "edit" && query.id ? String(query.id) : null;
};
// The toolset in the form's shape, so the base hash ignores what the form never shows.
let serverToolset: AiToolsetDraftData | null = null;
// Set while the leave dialog's Save runs, so a successful save does not close the editor itself.
let leaveSave: { saved: boolean } | null = null;
// The password-visibility toggles are view state, so they are neither drafted nor counted as edits.
const toolsetSnapshot = (): AiToolsetDraftData => {
const values = formValues.value;
const pairs = (rows: { key?: string; value?: string }[] | undefined) =>
(rows ?? []).map((row) => ({ key: row.key ?? "", value: row.value ?? "" }));
return {
...values,
mcp: { ...values.mcp, headers: pairs(values.mcp?.headers) },
cli: {
...values.cli,
env: pairs(values.cli?.env),
credFiles: pairs(values.cli?.credFiles),
},
skill: { ...values.skill },
};
};
const SECRET_SECTION: Record<SecretList, "mcp" | "cli"> = {
headers: "mcp",
env: "cli",
credFiles: "cli",
};
const SECRET_PATH = /^\/(?:mcp|cli)\/(headers|env|credFiles)\/(\d+)\/value$/;
const secretRows = (data: SecretRowsSource, list: SecretList): SecretRow[] =>
(list === "headers" ? data.mcp?.headers : data.cli?.[list]) ?? [];
const MCP_URL_PATH = "/mcp/url";
// Header and env values carry auth, credential files are kubeconfigs and a URL can embed a token, so none is drafted.
const redactToolsetDraft = (data: AiToolsetDraftData) => {
const copy = JSON.parse(JSON.stringify(data)) as AiToolsetDraftData;
const paths: string[] = [];
if (copy.mcp?.url && urlCarriesCredential(copy.mcp.url)) {
copy.mcp.url = "";
paths.push(MCP_URL_PATH);
}
for (const list of Object.keys(SECRET_SECTION) as SecretList[]) {
secretRows(copy, list).forEach((row, index) => {
if (!row.value) return;
row.value = "";
paths.push(`/${SECRET_SECTION[list]}/${list}/${index}/value`);
});
}
return { data: copy, paths };
};
const rowOf = (path: string, data: AiToolsetDraftData) => {
const match = SECRET_PATH.exec(path);
if (!match) return null;
const list = match[1] as SecretList;
return { list, key: secretRows(data, list)[Number(match[2])]?.key ?? "" };
};
const describeRedactedToolset = (paths: string[], data: AiToolsetDraftData): I18nText[] => {
const fields = paths.flatMap((path): I18nText[] => {
if (path === MCP_URL_PATH) return [t("aiToolset.mcpUrl")];
const row = rowOf(path, data);
if (!row) return [];
if (row.list === "headers") {
return [
row.key
? t("common.editorDraft.headerField", { name: row.key })
: t("common.editorDraft.headersField"),
];
}
if (!row.key) {
return [row.list === "env" ? t("aiToolset.envVars") : t("aiToolset.credentialFiles")];
}
return [
row.list === "env"
? t("common.editorDraft.envVarField", { name: row.key })
: t("common.editorDraft.credentialFileField", { name: row.key }),
];
});
return [...new Set(fields)];
};
// A not-kept row is found again by its key, since rows may have moved since the draft was written.
const notKeptRows = shallowRef(new Map<string, { list: SecretList; key: string }>());
const currentRowIndex = (path: string): number => {
const row = notKeptRows.value.get(path);
return row ? secretRows(formValues.value, row.list).findIndex((r) => r.key === row.key) : -1;
};
const fieldNameFor = (path: string): FormFieldPath<AddAiToolsetForm> | null => {
if (path === MCP_URL_PATH) return "mcp.url";
const row = notKeptRows.value.get(path);
if (!row) return null;
const index = currentRowIndex(path);
return index < 0
? null
: (`${SECRET_SECTION[row.list]}.${row.list}[${index}].value` as FormFieldPath<AddAiToolsetForm>);
};
const flagReentries = (paths: string[]) => {
for (const path of paths) {
const name = fieldNameFor(path);
const meta = name ? form.getFieldMeta(name) : undefined;
if (!name || !meta) continue;
form.setFieldMeta(name, {
...meta,
errorMap: { ...meta.errorMap, onSubmit: t("common.editorDraft.reenterField") },
});
}
};
// A secret of a kind the user switched away from is never sent, so it is not asked for.
const activePendingReentries = (): string[] =>
toolsetDraft
.pendingReentries()
.filter((path) => path.startsWith(`/${String(selectedKind.value)}/`));
// The credential file editors are not form fields, so their re-entry note is drawn by hand.
const pendingCredFileRows = computed(
() =>
new Set(
activePendingReentries()
.filter((path) => notKeptRows.value.get(path)?.list === "credFiles")
.map(currentRowIndex)
.filter((index) => index >= 0),
),
);
/** A changed secret the draft could not keep must be typed again before saving. */
const blockOnPendingReentries = (): boolean => {
const pending = activePendingReentries();
if (!pending.length) return false;
flagReentries(pending);
toast({ variant: "error", message: t("common.editorDraft.reenterBeforeSave") });
return true;
};
const toolsetDraft = useEditorDraft<AiToolsetDraftData>({
kind: "ai_toolset",
entityLabel: () => t("common.editorDraft.entities.ai_toolset"),
entityId: editId,
snapshot: toolsetSnapshot,
apply: async (data, { notKeptPaths }) => {
const withHidden = (rows: SecretPair[]) => rows.map((row) => ({ ...row, visible: false }));
// A saved toolset's name and kind are locked, so the draft can never change them.
const locked = isEditing.value
? { name: form.state.values.name, kind: form.state.values.kind }
: {};
form.reset({
...data,
...locked,
mcp: { ...data.mcp, headers: withHidden(data.mcp.headers) },
cli: { ...data.cli, env: withHidden(data.cli.env), credFiles: data.cli.credFiles },
} as AddAiToolsetForm);
notKeptRows.value = new Map(
notKeptPaths.flatMap((path) => {
const row = rowOf(path, data);
return row ? [[path, row] as const] : [];
}),
);
// After the form's own error sweep, which runs on the reset values.
await nextTick();
flagReentries(notKeptPaths);
},
base: () => (editId() ? serverToolset : undefined),
redactBase: (base) => redactToolsetDraft(base as AiToolsetDraftData).data,
save: async () => {
const run = { saved: false };
leaveSave = run;
try {
await form.handleSubmit();
} finally {
leaveSave = null;
}
return run.saved;
},
redact: redactToolsetDraft,
describeRedacted: describeRedactedToolset,
leaveOnRouteUpdate: (to, from) =>
to.path !== from.path ||
to.query.action !== from.query.action ||
to.query.id !== from.query.id,
});
const closeEditor = async () => {
if (await toolsetDraft.confirmLeave()) emit("cancel:hideform");
};
onMounted(async () => {
const id = editId();
const loaded = id ? await loadForEdit(id) : "loaded";
if (loaded === "failed") return;
// A deleted toolset still starts the draft, so a draft left for it is dropped with a notice.
await toolsetDraft.start();
if (loaded === "gone") emit("cancel:hideform");
});
return {
@@ -691,6 +939,9 @@ export default defineComponent({
skillContent,
setSkillContent,
skillContentError,
toolsetDraft,
closeEditor,
pendingCredFileRows,
};
},
});
@@ -0,0 +1,776 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { defineComponent, h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import alertsService from "@/services/alerts";
import anomalyDetectionService from "@/services/anomaly_detection";
import { toast } from "@/lib/feedback/Toast/useToast";
import {
hashDraftValue,
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
import { alertDraftBase } from "@/utils/alerts/alertEditorDraft";
import { fillField } from "@/test/unit/helpers/fillField";
import { PANEL_DRAFT_AUTOSAVE_MS } from "@/composables/dashboard/usePanelDraft";
import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom";
import AddAlert from "./AddAlert.vue";
vi.mock("@/composables/useStreams", () => ({
default: () => ({
getStream: vi.fn(() => Promise.resolve({ schema: [{ name: "status", type: "Int64" }] })),
getStreams: vi.fn(() => Promise.resolve({ list: [{ name: "default" }, { name: "k8s" }] })),
}),
}));
vi.mock("@/composables/useFunctions", () => ({
default: () => ({ getAllFunctions: vi.fn().mockResolvedValue({ functions: [] }) }),
}));
vi.mock("@/composables/useParser", () => ({
default: () => ({
sqlParser: async () => ({
astify: vi.fn(() => ({ columns: [] })),
parse: vi.fn(),
sqlify: vi.fn(),
}),
}),
}));
vi.mock("@/utils/zincutils", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/zincutils")>()),
getTimezonesByOffset: vi.fn(() => Promise.resolve(["UTC"])),
}));
vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/feedback/Toast/useToast")>()),
toast: vi.fn(() => () => {}),
}));
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 } })),
update_by_alert_id: vi.fn(() => Promise.resolve({ data: { code: 200 } })),
generate_sql: vi.fn(() => Promise.resolve({ data: { sql: "SELECT * FROM default" } })),
listByFolderId: vi.fn(() => Promise.resolve({ data: { list: [] } })),
},
});
});
vi.mock("@/services/anomaly_detection", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), { default: { get: vi.fn() } });
});
vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }));
vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) }));
interface AlertForm {
setFieldValue: (path: string, value: unknown) => void;
state: { values: Record<string, unknown> };
}
interface AddAlertVm {
form: AlertForm;
formData: Record<string, unknown>;
activeTab: string;
activeFolderId: string;
handleSave: () => Promise<void>;
alertDraft: { hasUnsavedChanges: () => boolean };
}
const newScope: EditorDraftScope = {
kind: "alert",
org: "default",
entityId: null,
userEmail: "example@gmail.com",
};
// Shape of GET /api/v2/{org}/alerts/{id} on a live instance, evaluation timestamps included.
const savedAlert = {
id: "3HGifdlCfgrm1thif69NRJig9E5",
name: "checkout_5xx",
org_id: "default",
stream_type: "logs",
stream_name: "default",
is_real_time: false,
query_condition: {
type: "custom",
conditions: {
version: 2,
conditions: { filterType: "group", logicalOperator: "AND", conditions: [] },
},
sql: "",
promql: null,
promql_condition: null,
aggregation: null,
vrl_function: null,
multi_time_range: [],
},
trigger_condition: {
period: 10,
operator: ">=",
threshold: 1,
frequency: 10,
cron: "",
frequency_type: "minutes",
silence: 10,
timezone: "UTC",
},
destinations: ["EndpointTest"],
context_attributes: {},
row_template: "",
row_template_type: "String",
description: "",
enabled: true,
tz_offset: 0,
last_triggered_at: 1793108452309917,
last_satisfied_at: 1793108452309917,
owner: "example@gmail.com",
last_edited_by: "example@gmail.com",
workflows: [],
pending_period_sec: 0,
recovery_destinations: [],
};
const editScope: EditorDraftScope = { ...newScope, entityId: savedAlert.id };
const stubs = {
QueryConfig: true,
AlertSettings: true,
CompareWithPast: true,
Deduplication: true,
Advanced: true,
PreviewAlert: true,
AlertSummary: true,
AnomalyDetectionConfig: true,
AnomalyAlerting: true,
AnomalySummary: true,
AnomalyDataPreview: true,
JsonEditor: true,
InlineSelectFolderDropdown: true,
OPageHeader: true,
};
let router: Router;
let wrapper: VueWrapper | null = null;
let editorProps: Record<string, unknown> = {};
const emitted: { list: unknown[][]; cancel: number } = { list: [], cancel: 0 };
const EditorRoute = defineComponent({
setup() {
return () =>
h(AddAlert, {
...editorProps,
"onUpdate:list": (...args: unknown[]) => emitted.list.push(args),
"onCancel:hideform": () => emitted.cancel++,
});
},
});
const mountEditor = async (
path = "/alerts?action=add",
props: Record<string, unknown> = {},
settle = true,
stubOverrides: Record<string, boolean> = {},
) => {
editorProps = props;
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/alerts", name: "alertList", component: EditorRoute },
{ path: "/alerts/anomaly/add", name: "addAnomalyDetection", component: EditorRoute },
{
path: "/alerts/anomaly/edit/:anomaly_id",
name: "editAnomalyDetection",
component: EditorRoute,
},
{ path: "/logs", name: "logs", component: { render: () => h("div") } },
],
});
await router.push(path);
wrapper = mount(
{ render: () => h(RouterView) },
{
attachTo: document.body,
global: {
plugins: [i18n, router],
provide: { store },
stubs: { ...stubs, ...stubOverrides },
},
},
);
if (settle) await flushPromises();
return wrapper;
};
const vm = () => wrapper!.findComponent(AddAlert).vm as unknown as AddAlertVm;
const edit = async (path: string, value: unknown) => {
window.dispatchEvent(new Event("pointerdown"));
vm().form.setFieldValue(path, value);
await flushPromises();
};
const seedValidScheduled = (form: AlertForm) => {
form.setFieldValue("name", "checkout_5xx");
form.setFieldValue("stream_type", "logs");
form.setFieldValue("stream_name", "default");
form.setFieldValue("destinations", ["EndpointTest"]);
form.setFieldValue("query_condition.type", "custom");
form.setFieldValue("trigger_condition", {
period: 10,
operator: ">=",
frequency: 10,
cron: "",
threshold: 3,
silence: 10,
frequency_type: "minutes",
timezone: "UTC",
});
};
describe("AddAlert drafts", () => {
beforeEach(() => {
localStorage.clear();
emitted.list = [];
emitted.cancel = 0;
vi.mocked(toast).mockClear();
vi.mocked(alertsService.create_by_alert_id).mockClear();
});
afterEach(() => {
vi.useRealTimers();
wrapper?.unmount();
wrapper = null;
});
it("writes a new alert's form, folder and open tab when the page hides", async () => {
await mountEditor();
await edit("name", "checkout_errors");
vm().activeTab = "advanced";
await nextTick();
window.dispatchEvent(new Event("pagehide"));
const draft = readEditorDraft<Record<string, unknown>>(newScope);
expect(draft?.data).toMatchObject({
tab: "advanced",
folderId: "default",
values: { name: "checkout_errors", is_real_time: "false" },
});
});
it("does not count switching tabs alone as an unsaved change", async () => {
await mountEditor();
window.dispatchEvent(new Event("pointerdown"));
vm().activeTab = "advanced";
await nextTick();
window.dispatchEvent(new Event("pagehide"));
expect(vm().alertDraft.hasUnsavedChanges()).toBe(false);
expect(readEditorDraft(newScope)).toBeNull();
});
it("takes its change detection from the shared draft hook, with no first-input listeners of its own", async () => {
const added = vi.spyOn(window, "addEventListener");
try {
await mountEditor();
const captured = (name: string) =>
added.mock.calls.filter(([type, , opts]) => type === name && opts === true).length;
for (const name of ["pointerdown", "keydown", "focusin", "beforeinput"]) {
expect(captured(name)).toBe(1);
}
} finally {
added.mockRestore();
}
});
it("continues a draft into a fresh editor with its values, tab and folder", async () => {
await mountEditor();
await edit("name", "checkout_errors");
await edit("stream_name", "k8s");
vm().activeTab = "advanced";
vm().activeFolderId = "7400179149878333440";
await nextTick();
window.dispatchEvent(new Event("pagehide"));
wrapper!.unmount();
await mountEditor();
expect(byTest("editor-draft-restore-message")?.textContent).toContain(
"unsaved changes to this alert",
);
click("editor-draft-continue");
await flushPromises();
expect(vm().formData.name).toBe("checkout_errors");
expect(vm().formData.stream_name).toBe("k8s");
expect(vm().activeTab).toBe("advanced");
expect(vm().activeFolderId).toBe("7400179149878333440");
expect(vm().alertDraft.hasUnsavedChanges()).toBe(true);
});
it("drafts a composite alert from its form's update and continues it as a composite", async () => {
const composite = { CompositeAlertForm: true };
await mountEditor("/alerts?action=add", {}, true, composite);
await edit("is_real_time", "composite");
await edit("name", "checkout_composite");
const children = [
{ alert_id: "a1", alias: "A" },
{ alert_id: "a2", alias: "B" },
];
window.dispatchEvent(new Event("pointerdown"));
wrapper!.findComponent({ name: "CompositeAlertForm" }).vm.$emit("update:modelValue", {
composite_condition: {
expression: "A && B",
warning_counts_as_firing: true,
stale_child_policy: "use_last_state",
},
children,
});
await flushPromises();
window.dispatchEvent(new Event("pagehide"));
expect(
readEditorDraft<{ values: Record<string, unknown> }>(newScope)?.data.values,
).toMatchObject({
name: "checkout_composite",
is_real_time: "composite",
composite_condition: { expression: "A && B" },
children,
});
wrapper!.unmount();
await mountEditor("/alerts?action=add", {}, true, composite);
click("editor-draft-continue");
await flushPromises();
expect(vm().formData).toMatchObject({
name: "checkout_composite",
is_real_time: "composite",
composite_condition: { expression: "A && B" },
children,
});
expect(wrapper!.findComponent({ name: "CompositeAlertForm" }).exists()).toBe(true);
expect(vm().alertDraft.hasUnsavedChanges()).toBe(true);
});
it("Escape on the restore dialog keeps the draft, Start fresh drops it", async () => {
await mountEditor();
await edit("name", "from_draft");
window.dispatchEvent(new Event("pagehide"));
wrapper!.unmount();
await mountEditor();
pressEscape();
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(vm().formData.name).toBe("");
expect(readEditorDraft(newScope)).not.toBeNull();
wrapper!.unmount();
await mountEditor();
clickRestore("o-dialog-secondary-btn");
await flushPromises();
expect(vm().formData.name).toBe("");
expect(readEditorDraft(newScope)).toBeNull();
});
it("a new anomaly detection's draft has its own slot, with Escape and Start fresh", async () => {
const anomalyScope = { ...newScope, variant: "anomaly" };
writeEditorDraft(anomalyScope, {
data: { values: { name: "anomaly_from_draft" }, tab: "condition", folderId: "default" },
});
await mountEditor("/alerts/anomaly/add");
expect(byTest("editor-draft-restore-dialog")).not.toBeNull();
pressEscape();
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(readEditorDraft(anomalyScope)).not.toBeNull();
expect(readEditorDraft(newScope)).toBeNull();
wrapper!.unmount();
await mountEditor("/alerts/anomaly/add");
clickRestore("o-dialog-secondary-btn");
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(readEditorDraft(anomalyScope)).toBeNull();
});
it("keys an edited alert's draft by its id and ignores evaluation timestamps in the base", async () => {
await mountEditor("/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5", {
modelValue: { ...savedAlert },
isUpdated: true,
});
await edit("description", "pages on-call");
window.dispatchEvent(new Event("pagehide"));
const draft = readEditorDraft(editScope);
expect(draft?.data).toMatchObject({ values: { description: "pages on-call" } });
expect(draft?.baseHash).toBe(
hashDraftValue(alertDraftBase({ ...savedAlert, last_triggered_at: 1 })),
);
expect(readEditorDraft(newScope)).toBeNull();
});
it("counts an edit typed while the editor is still loading as unsaved, through a later click", async () => {
await mountEditor(
"/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5",
{ modelValue: { ...savedAlert }, isUpdated: true },
false,
{ AlertSettings: false },
);
await wrapper!
.findComponent(AddAlert)
.get('[data-test="alert-settings-period-input-field"]')
.setValue("15");
await flushPromises();
window.dispatchEvent(new Event("pointerdown"));
expect(vm().alertDraft.hasUnsavedChanges()).toBe(true);
const navigation = router.push("/logs");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-secondary-btn");
await navigation;
expect(readEditorDraft(editScope)).not.toBeNull();
});
it("drafts an edit filled with no key or pointer event, and asks on leave", async () => {
await mountEditor(
"/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5",
{ modelValue: { ...savedAlert }, isUpdated: true },
true,
{ AlertSettings: false },
);
await fillField("alert-settings-period-input-field", "25");
// The period field commits after its 300 ms debounce.
await vi.waitFor(() =>
expect(String((vm().formData.trigger_condition as { period: unknown }).period)).toBe("25"),
);
window.dispatchEvent(new Event("pagehide"));
expect(vm().alertDraft.hasUnsavedChanges()).toBe(true);
expect(readEditorDraft(editScope)).not.toBeNull();
const navigation = router.push("/logs");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-secondary-btn");
await navigation;
});
it("Discard on a sidebar leave removes the edit's autosaved draft", async () => {
await mountEditor("/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5", {
modelValue: { ...savedAlert },
isUpdated: true,
});
await edit("trigger_condition.threshold", 7);
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).not.toBeNull();
const navigation = router.push("/logs");
await flushPromises();
clickLeave("o-dialog-neutral-btn");
await navigation;
await flushPromises();
expect(router.currentRoute.value.path).toBe("/logs");
expect(readEditorDraft(editScope)).toBeNull();
});
it("does not take a hidden select's change during the load for an edit, so nothing autosaves", async () => {
await mountEditor(
"/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5",
{ modelValue: { ...savedAlert }, isUpdated: true },
false,
);
const hidden = wrapper!.findComponent(AddAlert).get('select[aria-hidden="true"]');
hidden.element.dispatchEvent(new Event("change", { bubbles: true }));
await flushPromises();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vm().form.setFieldValue("description", "set by a late load");
await vi.advanceTimersByTimeAsync(PANEL_DRAFT_AUTOSAVE_MS + 100);
vi.useRealTimers();
window.dispatchEvent(new Event("pagehide"));
expect(vm().alertDraft.hasUnsavedChanges()).toBe(false);
expect(readEditorDraft(editScope)).toBeNull();
await router.push("/logs");
expect(router.currentRoute.value.path).toBe("/logs");
expect(byTest("editor-draft-leave-dialog")).toBeNull();
});
it("after Discard, a load landing before the editor goes writes nothing back", async () => {
let release!: () => void;
const nextPage = new Promise<void>((resolve) => (release = resolve));
await mountEditor("/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5", {
modelValue: { ...savedAlert },
isUpdated: true,
});
router.beforeEach((to) => (to.path === "/logs" ? nextPage.then(() => true) : true));
await edit("trigger_condition.threshold", 7);
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).not.toBeNull();
const navigation = router.push("/logs");
await flushPromises();
clickLeave("o-dialog-neutral-btn");
await flushPromises();
expect(readEditorDraft(editScope)).toBeNull();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vm().form.setFieldValue("description", "set by a late load");
window.dispatchEvent(new Event("pointerdown"));
await vi.advanceTimersByTimeAsync(PANEL_DRAFT_AUTOSAVE_MS + 100);
vi.useRealTimers();
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).toBeNull();
release();
await navigation;
expect(router.currentRoute.value.path).toBe("/logs");
expect(readEditorDraft(editScope)).toBeNull();
});
it("ignores a shortcut pressed while the editor is still loading", async () => {
await mountEditor(
"/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5",
{ modelValue: { ...savedAlert }, isUpdated: true },
false,
{ AlertSettings: false },
);
await wrapper!
.findComponent(AddAlert)
.get('[data-test="alert-settings-period-input-field"]')
.trigger("keydown", { key: "s", metaKey: true });
await flushPromises();
window.dispatchEvent(new Event("pointerdown"));
expect(vm().alertDraft.hasUnsavedChanges()).toBe(false);
});
it("warns when the saved alert changed after the draft was written", async () => {
writeEditorDraft(editScope, {
data: { values: {}, tab: "condition", folderId: "default", aggregationEnabled: false },
baseHash: hashDraftValue(alertDraftBase(savedAlert)),
});
await mountEditor("/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5", {
modelValue: { ...savedAlert, description: "edited by a teammate" },
isUpdated: true,
});
expect(byTest("editor-draft-changed-on-server")).not.toBeNull();
});
it("asks on Cancel with unsaved changes, and Discard closes and drops the draft", async () => {
await mountEditor();
await edit("name", "checkout_errors");
window.dispatchEvent(new Event("pagehide"));
click("add-alert-cancel-btn");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-neutral-btn");
await flushPromises();
expect(emitted.cancel).toBe(1);
expect(readEditorDraft(newScope)).toBeNull();
});
it("guards closing the list-hosted editor through the route query", async () => {
await mountEditor();
await edit("name", "checkout_errors");
const navigation = router.push("/alerts");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-secondary-btn");
await navigation;
expect(router.currentRoute.value.query.action).toBeUndefined();
expect(readEditorDraft(newScope)?.data).toMatchObject({
values: { name: "checkout_errors" },
});
});
it("saves from the leave dialog without navigating, then continues the navigation", async () => {
await mountEditor();
window.dispatchEvent(new Event("pointerdown"));
seedValidScheduled(vm().form);
await flushPromises();
const navigation = router.push("/logs");
await flushPromises();
click("editor-draft-leave-save");
await navigation;
await flushPromises();
expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1);
expect(emitted.list).toEqual([["default", { navigate: false }]]);
expect(router.currentRoute.value.path).toBe("/logs");
expect(readEditorDraft(newScope)).toBeNull();
});
it("stays in the editor when the leave dialog's Save fails validation", async () => {
await mountEditor();
await edit("name", "bad name");
const navigation = router.push("/logs");
await flushPromises();
click("editor-draft-leave-save");
await navigation;
await flushPromises();
expect(alertsService.create_by_alert_id).not.toHaveBeenCalled();
expect(router.currentRoute.value.path).toBe("/alerts");
expect(emitted.list).toEqual([]);
});
it("drops the draft on a normal save and tells the host to navigate", async () => {
await mountEditor();
window.dispatchEvent(new Event("pointerdown"));
seedValidScheduled(vm().form);
await flushPromises();
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(newScope)).not.toBeNull();
await vm().handleSave();
await flushPromises();
expect(emitted.list).toEqual([["default", { navigate: true }]]);
expect(readEditorDraft(newScope)).toBeNull();
});
it("leaves no new-alert draft behind when the reset form changes after a save, before the editor closes", async () => {
await mountEditor();
window.dispatchEvent(new Event("pointerdown"));
seedValidScheduled(vm().form);
await flushPromises();
await vm().handleSave();
await flushPromises();
vm().form.setFieldValue("stream_type", "metrics");
vm().activeFolderId = "7400179149878333440";
await flushPromises();
window.dispatchEvent(new Event("pagehide"));
wrapper!.unmount();
wrapper = null;
expect(readEditorDraft(newScope)).toBeNull();
await mountEditor();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
});
it("leaves no draft of a saved alert behind once the save reset its form", async () => {
await mountEditor("/alerts?action=update&alert_id=3HGifdlCfgrm1thif69NRJig9E5", {
modelValue: { ...savedAlert },
isUpdated: true,
});
await edit("description", "pages on-call");
await vm().handleSave();
await flushPromises();
vm().form.setFieldValue("stream_type", "metrics");
await flushPromises();
window.dispatchEvent(new Event("pagehide"));
expect(emitted.list).toEqual([["default", { navigate: true }]]);
expect(readEditorDraft(editScope)).toBeNull();
});
describe("new anomaly detection", () => {
const anomalyNewScope: EditorDraftScope = { ...newScope, variant: "anomaly" };
it("drafts in its own slot, never offering or overwriting a new alert's draft", async () => {
await mountEditor();
await edit("name", "checkout_errors");
window.dispatchEvent(new Event("pagehide"));
wrapper!.unmount();
wrapper = null;
await mountEditor("/alerts/anomaly/add");
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(vm().formData.is_real_time).toBe("anomaly");
await edit("name", "latency_spikes");
window.dispatchEvent(new Event("pagehide"));
expect(
readEditorDraft<{ values: Record<string, unknown> }>(anomalyNewScope)?.data.values,
).toMatchObject({
name: "latency_spikes",
is_real_time: "anomaly",
});
expect(
readEditorDraft<{ values: Record<string, unknown> }>(newScope)?.data.values,
).toMatchObject({
name: "checkout_errors",
});
});
it("leaves a new anomaly's draft out of a new alert, and offers it back on the anomaly page", async () => {
await mountEditor("/alerts/anomaly/add");
await edit("name", "latency_spikes");
window.dispatchEvent(new Event("pagehide"));
wrapper!.unmount();
wrapper = null;
await mountEditor();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(vm().formData.is_real_time).toBe("false");
wrapper!.unmount();
wrapper = null;
await mountEditor("/alerts/anomaly/add");
expect(byTest("editor-draft-restore-dialog")).not.toBeNull();
});
});
describe("anomaly edit", () => {
const anomalyScope: EditorDraftScope = { ...newScope, entityId: "3KueJDSDHSuMkVJzytLUMBNEBLN" };
const anomalyDraft = {
values: {},
tab: "anomaly-config",
folderId: "default",
aggregationEnabled: false,
anomaly: {},
};
it("drops the draft of a deleted anomaly config with an info toast", async () => {
writeEditorDraft(anomalyScope, { data: anomalyDraft });
vi.mocked(anomalyDetectionService.get).mockRejectedValueOnce({
response: { status: 404 },
});
await mountEditor("/alerts/anomaly/edit/3KueJDSDHSuMkVJzytLUMBNEBLN");
expect(readEditorDraft(anomalyScope)).toBeNull();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
variant: "info",
message: "This alert no longer exists, so its unsaved draft was discarded.",
}),
);
});
it("keeps the draft when the anomaly config could not be loaded", async () => {
writeEditorDraft(anomalyScope, { data: anomalyDraft });
vi.mocked(anomalyDetectionService.get).mockRejectedValueOnce({
response: { status: 500 },
});
await mountEditor("/alerts/anomaly/edit/3KueJDSDHSuMkVJzytLUMBNEBLN");
expect(readEditorDraft(anomalyScope)).not.toBeNull();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
});
});
});
+33 -5
View File
@@ -167,7 +167,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<div class="flex min-h-0 flex-1 max-lg:flex-col max-lg:overflow-y-auto">
<div :key="draftFormKey" class="flex min-h-0 flex-1 max-lg:flex-col max-lg:overflow-y-auto">
<!-- LEFT column wrapper (flex: 6.5) -->
<div
:class="[
@@ -455,7 +455,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="outline"
size="sm-action"
:disabled="isSubmitting"
@click="$emit('cancel:hideform')"
@click="cancelEditor"
>{{ t("alerts.cancel") }}</OButton
>
<OButton
@@ -629,11 +629,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</p>
</div>
</ODialog>
<EditorDraftDialogs :draft="alertDraft" />
</template>
<script lang="ts">
import { raw } from "@/types/i18n";
import { defineComponent, computed, watch, provide, ref } from "vue";
import { defineComponent, computed, watch, provide, ref, onMounted } from "vue";
import { useStore } from "vuex";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used only in a template `as` cast (:destinations), which eslint-plugin-vue cannot see; vue-tsc keeps it honest
import type { SelectOption } from "@/lib/forms/Select/OSelect.types";
@@ -655,7 +657,9 @@ import AnomalyDetectionConfig from "@/components/anomaly_detection/steps/Anomaly
import AnomalyDataPreview from "@/components/anomaly_detection/AnomalyDataPreview.vue";
import AnomalyAlerting from "@/components/anomaly_detection/steps/AnomalyAlerting.vue";
import AnomalySummary from "@/components/anomaly_detection/AnomalySummary.vue";
import { useAlertForm, defaultAlertValue } from "@/composables/useAlertForm";
import { useAlertForm, defaultAlertValue, type AlertFormEmit } from "@/composables/useAlertForm";
import { useAlertEditorDraft } from "@/composables/alerts/useAlertEditorDraft";
import EditorDraftDialogs from "@/components/common/drafts/EditorDraftDialogs.vue";
import { anomalyNoticeBadgeKeys } from "@/components/anomaly_detection/steps/AnomalyDetectionConfig.schema";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
@@ -728,10 +732,31 @@ export default defineComponent({
OPageHeader,
OTruncatedText,
CompositeAlertForm,
EditorDraftDialogs,
},
setup(props, { emit }) {
const store = useStore();
const alertForm = useAlertForm(props, emit);
// Bound once the draft exists; a save reports to the draft first, which decides whether the host navigates.
let onFormSaved: ((folderId?: string) => void) | null = null;
const formEmit = ((event: string, ...args: unknown[]) => {
if (event === "update:list" && onFormSaved) {
onFormSaved(args[0] as string | undefined);
return;
}
emit(event as Parameters<typeof emit>[0], ...args);
}) as AlertFormEmit;
const alertForm = useAlertForm(props, formEmit);
const alertEditorDraft = useAlertEditorDraft({
form: alertForm,
modelValue: () => props.modelValue,
onSaved: (folderId, options) => emit("update:list", folderId, options),
});
onFormSaved = alertEditorDraft.handleSaved;
onMounted(() => alertEditorDraft.start());
const cancelEditor = async () => {
if (await alertEditorDraft.draft.confirmLeave()) emit("cancel:hideform");
};
// Share server SQL-validation squiggle ranges with the descendant query
// editors (QueryEditorDialog / QueryConfig) via inject.
@@ -1022,6 +1047,9 @@ export default defineComponent({
return {
raw,
...alertForm,
alertDraft: alertEditorDraft.draft,
draftFormKey: alertEditorDraft.formKey,
cancelEditor,
alertAutoName,
headerModeLabel,
isAnomalyDetectionEnabled,
@@ -0,0 +1,477 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { defineComponent, h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import destinationService from "@/services/alert_destination";
import OForm from "@/lib/forms/Form/OForm.vue";
import {
editorDraftKey,
readEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom";
import { fillField } from "@/test/unit/helpers/fillField";
import AddDestination from "./AddDestination.vue";
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: {
orgUsers: vi.fn().mockResolvedValue({ data: { data: [] } }),
getRoles: vi.fn().mockResolvedValue({ data: [] }),
},
}));
vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) }));
interface DestinationForm {
state: { values: Record<string, unknown> & { apiHeaders: { key: string; value: string }[] } };
setFieldValue: (k: string, v: unknown) => void;
}
const newScope: EditorDraftScope = {
kind: "alert_destination",
org: "default",
entityId: null,
userEmail: "example@gmail.com",
};
const editScope: EditorDraftScope = { ...newScope, entityId: "snow_ops" };
const templates = [{ name: "plain_http", body: "{}", type: "http" }];
const serviceNow = {
name: "snow_ops",
type: "http",
destination_type_name: "servicenow",
url: "https://acme.service-now.com",
method: "post",
template: "prebuilt_servicenow",
headers: {
Authorization: `Basic ${btoa("bot:hunter2")}`,
"X-Team-Secret": "team-s3cret",
"X-Region": "eu-west",
},
metadata: JSON.stringify({ credential_assignmentGroup: "noc" }),
};
let router: Router;
let wrapper: VueWrapper | null = null;
let editorProps: Record<string, unknown>;
const emitted = { cancel: 0, refresh: 0 };
const EditorRoute = defineComponent({
setup() {
return () =>
h(AddDestination, {
templates,
destination: null,
isAlerts: true,
...editorProps,
"onCancel:hideform": () => emitted.cancel++,
"onGet:destinations": () => emitted.refresh++,
});
},
});
const mountEditor = async (props: Record<string, unknown> = {}) => {
editorProps = props;
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/alert-destinations", name: "alertDestinations", component: EditorRoute },
{ path: "/alert-templates", name: "alertTemplates", component: { render: () => h("div") } },
],
});
await router.push({
name: "alertDestinations",
query: props.destination ? { action: "update", name: "snow_ops" } : { action: "add" },
});
wrapper = mount(
{ render: () => h(RouterView) },
{
attachTo: document.body,
global: {
plugins: [i18n, store, router],
stubs: {
PrebuiltDestinationSelector: true,
AddUser: true,
DestinationPreview: true,
SlackDestinationSetup: true,
SlackAppDestinationForm: true,
},
},
},
);
await flushPromises();
};
const remount = async (props: Record<string, unknown> = {}) => {
wrapper?.unmount();
wrapper = null;
await mountEditor(props);
};
const form = () =>
(
wrapper!.findComponent(AddDestination).findComponent(OForm).vm as unknown as {
form: DestinationForm;
}
).form;
const edit = async (values: Record<string, unknown>) => {
window.dispatchEvent(new Event("pointerdown"));
Object.entries(values).forEach(([field, value]) => form().setFieldValue(field, value));
await nextTick();
};
const fillCustomWebhook = () =>
edit({
destination_type: "custom",
type: "http",
name: "ops_hook",
template: "plain_http",
url: "https://hooks.example.com/notify?token=very-secret-token",
apiHeaders: [
{ key: "Authorization", value: "Bearer s3cr3t-token" },
{ key: "X-Routing-Key", value: "rk-12345" },
],
});
const storedRaw = (scope: EditorDraftScope) => localStorage.getItem(editorDraftKey(scope)) ?? "";
describe("AddDestination drafts", () => {
beforeEach(() => {
localStorage.clear();
emitted.cancel = 0;
emitted.refresh = 0;
vi.mocked(destinationService.create).mockClear();
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("never writes header values or the webhook URL to storage", async () => {
await mountEditor();
await fillCustomWebhook();
window.dispatchEvent(new Event("pagehide"));
const raw = storedRaw(newScope);
expect(raw).toContain("X-Routing-Key");
expect(raw).not.toContain("s3cr3t-token");
expect(raw).not.toContain("rk-12345");
expect(raw).not.toContain("very-secret-token");
expect(readEditorDraft(newScope)?.redactedPaths).toEqual(
expect.arrayContaining(["/apiHeaders/0/value", "/apiHeaders/1/value", "/url"]),
);
});
it("names a header value with no header name in lowercase, as part of the sentence", async () => {
await mountEditor();
await fillCustomWebhook();
await edit({
url: "https://hooks.example.com/notify/v2",
apiHeaders: [{ key: "", value: "unnamed-secret" }],
});
window.dispatchEvent(new Event("pagehide"));
await remount();
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your change to the headers was not kept.",
);
});
it("keeps a URL that cannot carry a secret, and names every blanked field on restore", async () => {
await mountEditor();
await fillCustomWebhook();
await edit({ url: "https://hooks.example.com/notify/v2" });
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft<{ url: string }>(newScope)?.data.url).toBe(
"https://hooks.example.com/notify/v2",
);
expect(readEditorDraft(newScope)?.redactedPaths).not.toContain("/url");
await remount();
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your changes to the Authorization header and X-Routing-Key header were not kept.",
);
click("editor-draft-continue");
await flushPromises();
expect(form().state.values.url).toBe("https://hooks.example.com/notify/v2");
});
it("a changed URL comes back empty and flagged, blocks Save, and an unchanged header is refilled", async () => {
const hook = {
name: "ops_hook",
type: "http",
url: "https://example.com/hook-v2",
method: "post",
template: "plain_http",
headers: { Authorization: "Bearer saved-token" },
};
const hookScope: EditorDraftScope = { ...newScope, entityId: "ops_hook" };
vi.mocked(destinationService.update).mockClear();
await mountEditor({ destination: { ...hook } });
await edit({ url: "https://hooks.slack.com/services/T000/B000/XXXX" });
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(hookScope)?.changedRedactedPaths).toEqual(["/url"]);
expect(storedRaw(hookScope)).not.toContain("T000/B000");
await remount({ destination: { ...hook } });
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your change to the URL was not kept. Re-enter it before saving.",
);
click("editor-draft-continue");
await flushPromises();
expect(form().state.values.url).toBe("");
expect(form().state.values.apiHeaders).toEqual([
{ key: "Authorization", value: "Bearer saved-token" },
]);
expect(form().getFieldMeta("url")?.errors).toContain(
"Re-enter this value: your change was not kept in the draft.",
);
await edit({ url: "https://example.com/hook-v3" });
await form().handleSubmit();
await flushPromises();
expect(destinationService.update).toHaveBeenCalledTimes(1);
});
it("does not save while a header whose change was not kept is still blank", async () => {
const hook = {
name: "ops_hook",
type: "http",
url: "https://example.com/hook",
method: "post",
template: "plain_http",
headers: { Authorization: "Bearer saved-token" },
};
vi.mocked(destinationService.update).mockClear();
await mountEditor({ destination: { ...hook } });
await edit({ apiHeaders: [{ key: "Authorization", value: "Bearer new-token" }] });
window.dispatchEvent(new Event("pagehide"));
await remount({ destination: { ...hook } });
click("editor-draft-continue");
await flushPromises();
expect(form().state.values.apiHeaders).toEqual([{ key: "Authorization", value: "" }]);
await form().handleSubmit();
await flushPromises();
expect(destinationService.update).not.toHaveBeenCalled();
expect(form().getFieldMeta("apiHeaders[0].value")?.errors?.length).toBeGreaterThan(0);
});
it("blanks a URL with credentials in it, or a Slack webhook URL", async () => {
await mountEditor();
await fillCustomWebhook();
await edit({ url: "https://bot:pw@hooks.example.com/notify" });
window.dispatchEvent(new Event("pagehide"));
expect(storedRaw(newScope)).not.toContain("bot:pw");
await edit({ url: "https://hooks.slack.com/services/T0/B0/xyz" });
window.dispatchEvent(new Event("pagehide"));
expect(storedRaw(newScope)).not.toContain("services/T0/B0/xyz");
await remount();
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain("URL, ");
});
it.each([
[
"a Telegram bot URL",
"https://api.telegram.org/bot123456:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/sendMessage",
],
["a Zapier hook", "https://hooks.zapier.com/hooks/catch/123456/abcdef/"],
["a Mattermost hook key", "https://chat.example.com/hooks/xr3j5x9n7f8gbh6tu1qzw2a4yd"],
["a scheme-less Slack webhook", "hooks.slack.com/services/T000/B000/XXXX"],
["a GovSlack webhook", "https://hooks.slack-gov.com/services/T000/B000/XXXX"],
])("blanks %s", async (_name, url) => {
await mountEditor();
await fillCustomWebhook();
await edit({ url });
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(newScope)?.redactedPaths).toContain("/url");
expect(readEditorDraft<{ url: string }>(newScope)?.data.url).toBe("");
});
it("never writes a prebuilt password, and says so when the draft is offered", async () => {
await mountEditor();
await edit({
destination_type: "servicenow",
name: "snow_new",
credentials: {
instanceUrl: "https://acme.service-now.com",
username: "bot",
password: "hunter2",
},
});
window.dispatchEvent(new Event("pagehide"));
const raw = storedRaw(newScope);
expect(raw).toContain("bot");
expect(raw).not.toContain("hunter2");
await remount();
expect(byTest("editor-draft-credentials-not-saved")).not.toBeNull();
});
it("never writes a secret credential whose name does not say so", async () => {
await mountEditor();
await edit({
destination_type: "pagerduty",
name: "pd_new",
credentials: { integrationKey: "pd-routing-key-0123456789abcdef", severity: "critical" },
});
window.dispatchEvent(new Event("pagehide"));
const raw = storedRaw(newScope);
expect(raw).toContain("critical");
expect(raw).not.toContain("pd-routing-key");
});
it("continuing an edit draft keeps the server's credentials, matched by header name", async () => {
await mountEditor({ destination: { ...serviceNow } });
expect(form().state.values.apiHeaders.map((row) => row.key)).toEqual([
"X-Team-Secret",
"X-Region",
]);
await edit({
apiHeaders: [{ key: "X-Region", value: "eu-west" }],
credentials: { ...(form().state.values.credentials as object), assignmentGroup: "sre" },
});
window.dispatchEvent(new Event("pagehide"));
const raw = storedRaw(editScope);
expect(raw).not.toContain("hunter2");
expect(raw).not.toContain("eu-west");
await remount({ destination: { ...serviceNow } });
click("editor-draft-continue");
await flushPromises();
const values = form().state.values;
expect(values.credentials).toMatchObject({ password: "hunter2", assignmentGroup: "sre" });
expect(values.apiHeaders).toEqual([{ key: "X-Region", value: "eu-west" }]);
});
it("drafts a name filled with no key or pointer event, and Cancel asks", async () => {
await mountEditor();
form().setFieldValue("destination_type", "custom");
form().setFieldValue("type", "http");
await flushPromises();
await fillField("add-destination-name-input", "autofilled_hook");
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("autofilled_hook");
await wrapper!.get('[data-test="add-destination-cancel-btn"]').trigger("click");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
expect(emitted.cancel).toBe(0);
});
it("Escape on the restore dialog keeps the draft, Start fresh drops it", async () => {
await mountEditor();
await fillCustomWebhook();
window.dispatchEvent(new Event("pagehide"));
await remount();
pressEscape();
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(form().state.values.name).toBe("");
expect(readEditorDraft(newScope)).not.toBeNull();
await remount();
clickRestore("o-dialog-secondary-btn");
await flushPromises();
expect(form().state.values.name).toBe("");
expect(readEditorDraft(newScope)).toBeNull();
});
it("Cancel with unsaved changes asks, and Discard closes and drops the draft", async () => {
await mountEditor();
await fillCustomWebhook();
window.dispatchEvent(new Event("pagehide"));
await wrapper!.get('[data-test="add-destination-cancel-btn"]').trigger("click");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-neutral-btn");
await flushPromises();
expect(emitted.cancel).toBe(1);
expect(readEditorDraft(newScope)).toBeNull();
});
it("keeps no draft once the Slack app type is picked, and its Cancel closes without asking", async () => {
await mountEditor();
await fillCustomWebhook();
await edit({ destination_type: "slack_app" });
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(newScope)).toBeNull();
wrapper!.findComponent({ name: "SlackAppDestinationForm" }).vm.$emit("cancel");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).toBeNull();
expect(emitted.cancel).toBe(1);
});
it("Save in the leave dialog saves without closing the editor itself", async () => {
await mountEditor();
await fillCustomWebhook();
await wrapper!.get('[data-test="add-destination-cancel-btn"]').trigger("click");
await flushPromises();
clickLeave("editor-draft-leave-save");
await vi.waitFor(() => expect(emitted.cancel).toBe(1));
expect(destinationService.create).toHaveBeenCalledTimes(1);
expect(emitted.refresh).toBe(1);
expect(readEditorDraft(newScope)).toBeNull();
});
it("Create email template goes through the leave dialog, and Keep draft carries on", async () => {
await mountEditor({ templates: [] });
await edit({ destination_type: "custom", type: "email", name: "mail_ops" });
await flushPromises();
await wrapper!.get('[data-test="add-destination-create-email-template-btn"]').trigger("click");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
expect(router.currentRoute.value.name).toBe("alertDestinations");
clickLeave("o-dialog-secondary-btn");
await flushPromises();
expect(router.currentRoute.value.name).toBe("alertTemplates");
expect(router.currentRoute.value.query).toMatchObject({ action: "add", type: "email" });
expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("mail_ops");
});
});
@@ -35,6 +35,8 @@ vi.mock("vue-router", () => ({
currentRoute: { value: route },
}),
useRoute: () => ({ params: {}, query: {} }),
onBeforeRouteLeave: vi.fn(),
onBeforeRouteUpdate: vi.fn(),
}));
vi.mock("@/services/slack", () => ({
default: {
@@ -45,6 +45,8 @@ const switchRoute = vi.hoisted(() => ({ query: {} as Record<string, string> }));
vi.mock("vue-router", () => ({
useRouter: () => ({ push: vi.fn(), currentRoute: { value: switchRoute } }),
useRoute: () => ({ params: {}, query: {} }),
onBeforeRouteLeave: vi.fn(),
onBeforeRouteUpdate: vi.fn(),
}));
// Passthrough spy: real toasts still render, and specs can read what was raised.
+182 -14
View File
@@ -21,7 +21,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
title-data-test="add-destination-title"
:back="{
label: t('alert_destinations.header'),
onClick: () => emit('cancel:hideform'),
onClick: closeEditor,
}"
bleed
>
@@ -47,7 +47,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:templates="templates"
:confirmation="switchConfirmation"
@saved="onSlackAppSaved"
@cancel="emit('cancel:hideform')"
@cancel="closeEditor"
/>
</div>
<template v-else>
@@ -273,9 +273,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="me-2 text-sm font-medium">
{{ t("alert_destinations.noEmailTemplates") }}
</div>
<OButton variant="outline" size="sm" @click="createEmailTemplate">{{
t("alert_destinations.createEmailTemplate")
}}</OButton>
<OButton
variant="outline"
size="sm"
data-test="add-destination-create-email-template-btn"
@click="createEmailTemplate"
>{{ t("alert_destinations.createEmailTemplate") }}</OButton
>
</div>
<!-- Name + Template row for custom alert destinations -->
<div v-if="isAlerts && dtVal === 'custom'" class="flex w-full gap-3">
@@ -480,7 +484,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-close-popup="true"
variant="outline"
size="sm-action"
@click="$emit('cancel:hideform')"
@click="closeEditor"
>{{ t("alerts.cancel") }}</OButton
>
<OButton
@@ -518,13 +522,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:recipients="previewRecipients"
data-test="destination-preview-modal"
/>
<EditorDraftDialogs :draft="destinationDraft" />
</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 { ref, computed, nextTick, onBeforeMount, onActivated, onMounted, watch } from "vue";
import type { PropType } from "vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { useStore } from "vuex";
@@ -547,7 +552,7 @@ import {
usePrebuiltDestinations,
type SlackSetupMetadata,
} from "@/composables/usePrebuiltDestinations";
import { detectPrebuiltType } from "@/utils/prebuilt-templates";
import { detectPrebuiltType, getPrebuiltConfig } from "@/utils/prebuilt-templates";
import { isValidSlackWebhookUrl } from "@/utils/prebuilt-templates/slack";
import { DEFAULT_SLACK_APP_NAME } from "@/utils/slackManifest";
import PrebuiltDestinationForm from "./PrebuiltDestinationForm.vue";
@@ -574,6 +579,9 @@ import {
addDestinationDefaults,
type AddDestinationForm,
} from "./AddDestination.schema";
import { useEditorDraft } from "@/composables/useEditorDraft";
import { urlCarriesCredential } from "@/utils/drafts/urlCredentials";
import EditorDraftDialogs from "@/components/common/drafts/EditorDraftDialogs.vue";
const props = defineProps({
templates: {
@@ -693,6 +701,7 @@ const form = useOForm({
// pipeline via saveCustomDestination. Returned so OForm awaits the real save (the
// footer Save spinner spans it).
function saveDestination(value: AddDestinationForm): Promise<void> | undefined {
if (blockOnPendingReentries()) return undefined;
return isPrebuiltDestination.value ? handlePrebuiltSave(value) : saveCustomDestination(value);
}
@@ -1402,8 +1411,7 @@ async function handlePrebuiltSave(value: AddDestinationForm) {
// `createDestination`/`updateDestination` above come from usePrebuiltDestinations,
// which invalidates the scope itself.
emit("get:destinations");
emit("cancel:hideform");
finishSave();
} catch {
// The composable presents a sanitized error toast and rethrows so submission
// state settles without exposing credential-bearing request details.
@@ -1459,8 +1467,7 @@ function saveCustomDestination(value: AddDestinationForm) {
.mutateAsync({ destination_name: value.name, data: payload })
.then(() => {
dismiss();
emit("get:destinations");
emit("cancel:hideform");
finishSave();
toast({
variant: "success",
message: t("alert_destinations.saved"),
@@ -1485,8 +1492,7 @@ function saveCustomDestination(value: AddDestinationForm) {
.mutateAsync({ destination_name: value.name, data: payload })
.then(() => {
dismiss();
emit("get:destinations");
emit("cancel:hideform");
finishSave();
toast({
variant: "success",
message: t("alert_destinations.saved"),
@@ -1520,6 +1526,168 @@ const deleteApiHeader = (index: number) => {
if (!rows.length) (form as any).pushFieldValue("apiHeaders", { key: "", value: "" });
};
// Drafts belong to the alert destinations page; the pipeline host keeps the plain editor.
const draftsActive = props.isAlerts && router.currentRoute.value.name === "alertDestinations";
// Set while the leave dialog's Save runs, so a successful save does not close the editor itself.
let leaveSave: { saved: boolean } | null = null;
// These types' URL is the webhook itself, so the URL is the credential.
const SECRET_URL_TYPES = new Set(["slack", "discord", "msteams"]);
const urlCarriesSecret = (url: string, destinationType: string): boolean =>
SECRET_URL_TYPES.has(destinationType) || urlCarriesCredential(url);
// Header values carry auth, and a URL that can hold a token is one, so neither is drafted.
const redactDestinationDraft = (data: AddDestinationForm) => {
const copy = JSON.parse(JSON.stringify(data)) as AddDestinationForm;
const paths: string[] = [];
const blank = (holder: Record<string, unknown>, key: string, path: string) => {
if (typeof holder[key] !== "string" || holder[key] === "") return;
holder[key] = "";
paths.push(path);
};
if (urlCarriesSecret(copy.url, copy.destination_type)) blank(copy, "url", "/url");
copy.apiHeaders.forEach((row, index) => blank(row, "value", `/apiHeaders/${index}/value`));
const credentials = copy.credentials as Record<string, unknown>;
(getPrebuiltConfig(copy.destination_type)?.credentialFields ?? [])
.filter((field) => field.type === "password" || field.key === "webhookUrl")
.forEach((field) => blank(credentials, field.key, `/credentials/${field.key}`));
return { data: copy, paths };
};
const describeRedactedDestination = (paths: string[], data: AddDestinationForm): I18nText[] => {
const credentialFields = getPrebuiltConfig(data.destination_type)?.credentialFields ?? [];
const fields = paths.flatMap((path): I18nText[] => {
if (path === "/url") return [t("alert_destinations.url")];
const header = /^\/apiHeaders\/(\d+)\/value$/.exec(path);
if (header) {
const name = data.apiHeaders[Number(header[1])]?.key;
return [
name ? t("common.editorDraft.headerField", { name }) : t("common.editorDraft.headersField"),
];
}
const field = credentialFields.find((f) => path === `/credentials/${f.key}`);
return field ? [t(field.labelKey)] : [];
});
return [...new Set(fields)];
};
// A not-kept header is found again by its name, since rows may have moved since the draft was written.
let notKeptHeaderKeys = new Map<string, string>();
const fieldNameFor = (path: string): FormFieldPath<AddDestinationForm> | null => {
const key = notKeptHeaderKeys.get(path);
if (key !== undefined) {
const index = form.state.values.apiHeaders.findIndex((row) => row.key === key);
return index < 0 ? null : (`apiHeaders[${index}].value` as FormFieldPath<AddDestinationForm>);
}
return path.slice(1).split("/").join(".") as FormFieldPath<AddDestinationForm>;
};
const flagReentries = (paths: string[]) => {
for (const path of paths) {
const name = fieldNameFor(path);
const meta = name ? form.getFieldMeta(name) : undefined;
if (!name || !meta) continue;
form.setFieldMeta(name, {
...meta,
errorMap: { ...meta.errorMap, onSubmit: t("common.editorDraft.reenterField") },
});
}
};
/** A changed secret the draft could not keep must be typed again before saving. */
const blockOnPendingReentries = (): boolean => {
const pending = draftsActive ? destinationDraft.pendingReentries() : [];
if (!pending.length) return false;
flagReentries(pending);
toast({ variant: "error", message: t("common.editorDraft.reenterBeforeSave") });
return true;
};
const serverDestination = () => {
const saved = props.destination;
if (!saved) return null;
return {
name: saved.name,
type: saved.type,
url: saved.url ?? "",
method: saved.method ?? "",
skip_tls_verify: !!saved.skip_tls_verify,
template: saved.template ?? "",
headers: saved.headers ?? {},
emails: saved.emails ?? [],
output_format: saved.output_format ?? "",
metadata: saved.metadata ?? "",
};
};
const destinationDraft = useEditorDraft<AddDestinationForm>({
kind: "alert_destination",
entityLabel: () => t("common.editorDraft.entities.alert_destination"),
entityId: () => props.destination?.name ?? null,
snapshot: () => ({ ...formValues.value }),
apply: async (data, { notKeptPaths }) => {
form.reset({ ...data, name: props.destination ? form.state.values.name : data.name });
notKeptHeaderKeys = new Map(
notKeptPaths.flatMap((path) => {
const header = /^\/apiHeaders\/(\d+)\/value$/.exec(path);
const key = header ? data.apiHeaders[Number(header[1])]?.key : undefined;
return key === undefined ? [] : [[path, key] as const];
}),
);
// After the form's own error sweep, which runs on the reset values.
await nextTick();
flagReentries(notKeptPaths);
},
base: serverDestination,
// The Slack app type is edited by its own form, which keeps no draft.
hasChanges: () => !isSlackApp.value,
// The saved destination keeps headers as a map and has no form fields, so the form's redaction cannot run on it.
redactBase: (base) => {
const saved = base as NonNullable<ReturnType<typeof serverDestination>>;
return {
...saved,
url: urlCarriesSecret(saved.url, "") ? "" : saved.url,
headers: Object.fromEntries(Object.keys(saved.headers).map((key) => [key, ""])),
};
},
save: async () => {
const run = { saved: false };
leaveSave = run;
try {
onSaveClick();
await form.handleSubmit();
} finally {
leaveSave = null;
}
return run.saved;
},
redact: redactDestinationDraft,
describeRedacted: describeRedactedDestination,
leaveOnRouteUpdate: (to, from) =>
to.path !== from.path ||
to.query.action !== from.query.action ||
to.query.name !== from.query.name,
});
onMounted(async () => {
if (draftsActive) await destinationDraft.start();
});
function finishSave() {
emit("get:destinations");
if (leaveSave) {
leaveSave.saved = true;
return;
}
destinationDraft.markSaved();
emit("cancel:hideform");
}
const closeEditor = async () => {
if (await destinationDraft.confirmLeave()) emit("cancel:hideform");
};
const createEmailTemplate = () => {
router.push({
name: "alertTemplates",
@@ -0,0 +1,322 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { defineComponent, h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import templateService from "@/services/alert_templates";
import OForm from "@/lib/forms/Form/OForm.vue";
import {
editorDraftKey,
hashDraftValue,
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom";
import { fillField } from "@/test/unit/helpers/fillField";
import { emptyContentSpec } from "./template-content/contentSpec";
import AddTemplate from "./AddTemplate.vue";
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() }) }));
vi.mock("@/utils/templates/validation", () => ({
validateTemplateBody: vi.fn().mockReturnValue({ valid: true }),
getTemplateValidationErrorMessage: vi.fn().mockReturnValue(""),
}));
interface TemplateForm {
state: { values: Record<string, unknown> };
setFieldValue: (k: string, v: unknown) => void;
handleSubmit: () => Promise<void>;
}
const newScope: EditorDraftScope = {
kind: "alert_template",
org: "default",
entityId: null,
userEmail: "example@gmail.com",
};
const editScope: EditorDraftScope = { ...newScope, entityId: "pager_body" };
const savedTemplate = {
name: "pager_body",
body: '{"text":"{alert_name}"}',
type: "http" as const,
title: "",
kind: "custom" as const,
};
let router: Router;
let wrapper: VueWrapper | null = null;
let editorProps: Record<string, unknown>;
const emitted = { cancel: 0, refresh: 0 };
const EditorRoute = defineComponent({
setup() {
return () =>
h(AddTemplate, {
template: null,
...editorProps,
"onCancel:hideform": () => emitted.cancel++,
"onGet:templates": () => emitted.refresh++,
});
},
});
const mountEditor = async (props: Record<string, unknown> = {}) => {
editorProps = props;
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/alert-templates", name: "alertTemplates", component: EditorRoute },
{ path: "/logs", name: "logs", component: { render: () => h("div") } },
],
});
await router.push({
name: "alertTemplates",
query: props.template ? { action: "update", name: "pager_body" } : { action: "add" },
});
wrapper = mount(
{ render: () => h(RouterView) },
{
attachTo: document.body,
global: {
plugins: [i18n, store, router],
stubs: {
QueryEditor: { template: "<div />", props: ["query", "editorId", "language"] },
ContentTemplateForm: true,
TemplatePreviewPanel: true,
},
},
},
);
await flushPromises();
};
const form = () =>
(wrapper!.findComponent(AddTemplate).findComponent(OForm).vm as unknown as { form: TemplateForm })
.form;
const edit = async (field: string, value: unknown) => {
window.dispatchEvent(new Event("pointerdown"));
form().setFieldValue(field, value);
await nextTick();
};
describe("AddTemplate drafts", () => {
beforeEach(() => {
localStorage.clear();
emitted.cancel = 0;
emitted.refresh = 0;
vi.mocked(templateService.create).mockClear();
vi.mocked(templateService.update).mockClear();
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("writes a new template's draft when the page hides", async () => {
await mountEditor();
await edit("name", "deploy_notice");
window.dispatchEvent(new Event("pagehide"));
const draft = readEditorDraft<{ values: { name: string; kind: string } }>(newScope);
expect(draft?.data.values).toMatchObject({ name: "deploy_notice", kind: "content" });
});
it("offers a new-template draft on open and continues it", async () => {
writeEditorDraft(newScope, {
data: {
values: { name: "from_draft", type: "http", kind: "custom", title: "", body: '{"a":1}' },
contentSpec: emptyContentSpec(),
customBodyStash: null,
},
});
await mountEditor();
expect(byTest("editor-draft-restore-dialog")?.textContent).toContain(
"unsaved changes to this alert template",
);
click("editor-draft-continue");
await flushPromises();
expect(form().state.values).toMatchObject({
name: "from_draft",
kind: "custom",
body: '{"a":1}',
});
});
it("drafts a name filled with no key or pointer event, and Cancel asks", async () => {
await mountEditor();
await fillField("add-template-name-input", "autofilled_tpl");
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft<{ values: { name: string } }>(newScope)?.data.values.name).toBe(
"autofilled_tpl",
);
await wrapper!.get('[data-test="add-template-cancel-btn"]').trigger("click");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
expect(emitted.cancel).toBe(0);
});
it("Escape on the restore dialog keeps the draft, Start fresh drops it", async () => {
const draft = {
values: { name: "from_draft", type: "http", kind: "custom", title: "", body: '{"a":1}' },
contentSpec: emptyContentSpec(),
customBodyStash: null,
};
writeEditorDraft(newScope, { data: draft });
await mountEditor();
pressEscape();
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(form().state.values.name).toBe("");
expect(readEditorDraft(newScope)).not.toBeNull();
wrapper!.unmount();
await mountEditor();
clickRestore("o-dialog-secondary-btn");
await flushPromises();
expect(form().state.values.name).toBe("");
expect(readEditorDraft(newScope)).toBeNull();
});
it("a clone neither offers nor overwrites the new-template draft, and keeps its own", async () => {
const newDraft = {
values: { name: "from_draft", type: "http", kind: "custom", title: "", body: '{"a":1}' },
contentSpec: emptyContentSpec(),
customBodyStash: null,
};
writeEditorDraft(newScope, { data: newDraft });
const clone = { template: { ...savedTemplate, name: "Copy_of_pager_body" }, isClone: true };
await mountEditor({ ...clone, cloneSource: "pager_body" });
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(form().state.values).toMatchObject({ name: "Copy_of_pager_body" });
await edit("body", '{"text":"cloned"}');
window.dispatchEvent(new Event("pagehide"));
const cloneScope = { ...newScope, variant: "clone:pager_body" };
expect(readEditorDraft<{ values: { body: string } }>(cloneScope)?.data.values.body).toBe(
'{"text":"cloned"}',
);
expect(readEditorDraft(newScope)?.data).toEqual(newDraft);
wrapper!.unmount();
wrapper = null;
await mountEditor({ ...clone, cloneSource: "pager_body" });
expect(byTest("editor-draft-restore-dialog")).not.toBeNull();
});
it("keys an edited template's draft by its name and the server copy", async () => {
await mountEditor({ template: { ...savedTemplate } });
await edit("body", '{"text":"changed"}');
window.dispatchEvent(new Event("pagehide"));
const draft = readEditorDraft<{ values: { body: string } }>(editScope);
expect(draft?.data.values.body).toBe('{"text":"changed"}');
expect(draft?.baseHash).toBe(hashDraftValue(savedTemplate));
expect(localStorage.getItem(editorDraftKey(newScope))).toBeNull();
});
it("Cancel with unsaved changes asks, and Discard closes and drops the draft", async () => {
await mountEditor();
await edit("name", "not_kept");
window.dispatchEvent(new Event("pagehide"));
await wrapper!.get('[data-test="add-template-cancel-btn"]').trigger("click");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
expect(emitted.cancel).toBe(0);
clickLeave("o-dialog-neutral-btn");
await flushPromises();
expect(emitted.cancel).toBe(1);
expect(readEditorDraft(newScope)).toBeNull();
});
it("Save in the leave dialog saves without closing the editor itself, then Cancel closes it", async () => {
await mountEditor();
await edit("name", "saved_from_leave");
await wrapper!.get('[data-test="add-template-cancel-btn"]').trigger("click");
await flushPromises();
clickLeave("editor-draft-leave-save");
await vi.waitFor(() => expect(emitted.cancel).toBe(1));
expect(templateService.create).toHaveBeenCalledTimes(1);
expect(emitted.refresh).toBe(0);
expect(emitted.cancel).toBe(1);
});
it("guards the browser Back that drops ?action, and stays when the dialog is closed", async () => {
await mountEditor();
await edit("name", "half_done");
const navigation = router.push({ name: "alertTemplates", query: {} });
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-close-btn");
await navigation;
expect(router.currentRoute.value.query.action).toBe("add");
});
it("Keep draft lets the route leave and keeps the work", async () => {
await mountEditor();
await edit("name", "kept_for_later");
const navigation = router.push("/logs");
await flushPromises();
clickLeave("o-dialog-secondary-btn");
await navigation;
expect(router.currentRoute.value.name).toBe("logs");
expect(readEditorDraft<{ values: { name: string } }>(newScope)?.data.values.name).toBe(
"kept_for_later",
);
});
it("a normal save drops the draft and closes the editor without asking", async () => {
await mountEditor({ template: { ...savedTemplate } });
await edit("body", '{"text":"saved"}');
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).not.toBeNull();
await wrapper!.get('[data-test="add-template-submit-btn"]').trigger("click");
await vi.waitFor(() => expect(emitted.cancel).toBe(1));
expect(templateService.update).toHaveBeenCalledTimes(1);
expect(emitted).toEqual({ cancel: 1, refresh: 1 });
expect(readEditorDraft(editScope)).toBeNull();
expect(byTest("editor-draft-leave-dialog")).toBeNull();
});
});
@@ -22,6 +22,8 @@ import store from "@/test/unit/helpers/store";
vi.mock("vue-router", () => ({
useRouter: () => ({ currentRoute: { value: { query: {} } }, push: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }),
onBeforeRouteLeave: vi.fn(),
onBeforeRouteUpdate: vi.fn(),
}));
vi.mock("@/services/alert_templates", async (importOriginal) => {
+88 -6
View File
@@ -26,7 +26,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
title-data-test="add-template-title"
:back="{
label: t('alert_templates.header'),
onClick: () => emit('cancel:hideform'),
onClick: closeEditor,
}"
bleed
>
@@ -182,7 +182,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="outline"
size="sm-action"
:disabled="isSubmitting"
@click="$emit('cancel:hideform')"
@click="closeEditor"
data-test="add-template-cancel-btn"
>{{ t("alerts.cancel") }}</OButton
>
@@ -290,13 +290,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
</OSplitter>
<EditorDraftDialogs :draft="templateDraft" />
</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 { ref, onActivated, onMounted, computed, watch, defineAsyncComponent } from "vue";
import type { Ref } from "vue";
import { useI18nTyped, raw } from "@/types/i18n";
@@ -335,6 +336,14 @@ import {
serializeContentSpec,
type ContentSpec,
} from "./template-content/contentSpec";
import { useEditorDraft } from "@/composables/useEditorDraft";
import EditorDraftDialogs from "@/components/common/drafts/EditorDraftDialogs.vue";
interface TemplateDraftData {
values: AddTemplateForm;
contentSpec: ContentSpec;
customBodyStash: string | null;
}
const props = withDefaults(
defineProps<{
@@ -345,8 +354,10 @@ const props = withDefaults(
* "Copy of X" is created instead of overwriting X).
*/
isClone?: boolean;
/** The template a clone was copied from; keys the clone's own draft slot. */
cloneSource?: string | null;
}>(),
{ isClone: false },
{ isClone: false, cloneSource: null },
);
const emit = defineEmits(["get:templates", "cancel:hideform"]);
@@ -664,6 +675,72 @@ const applyTemplateData = () => {
watch(() => props.template, applyTemplateData, { immediate: true });
onActivated(() => applyTemplateData());
// Drafts belong to the templates page; any other host keeps the plain editor.
const draftsActive = router.currentRoute.value.name === "alertTemplates";
const formValues = form.useStore((s: { values: AddTemplateForm }) => s.values);
// Set while the leave dialog's Save runs, so a successful save neither closes the editor nor reloads the list.
let leaveSave: { saved: boolean } | null = null;
const serverTemplate = () =>
isUpdatingTemplate.value && props.template
? {
name: props.template.name,
body: String(props.template.body ?? ""),
type: props.template.type ?? "",
title: String(props.template.title ?? ""),
kind: props.template.kind ?? "",
}
: null;
const templateDraft = useEditorDraft<TemplateDraftData>({
kind: "alert_template",
entityLabel: () => t("common.editorDraft.entities.alert_template"),
entityId: () => (isUpdatingTemplate.value ? (props.template?.name ?? null) : null),
newVariant: () =>
props.isClone && props.template ? `clone:${props.cloneSource ?? props.template.name}` : null,
snapshot: () => ({
values: { ...formValues.value },
contentSpec: contentSpec.value,
customBodyStash: customBodyStash.value,
}),
apply: (data) => {
contentSpec.value = data.contentSpec;
customBodyStash.value = data.customBodyStash;
isSeededTemplate.value =
isSeededTemplate.value &&
serializeContentSpec(data.contentSpec) === serializeContentSpec(starterContentSpec());
// A saved template's name is locked, so the draft can never rename it.
form.reset({
...data.values,
name: isUpdatingTemplate.value ? form.state.values.name : data.values.name,
});
},
base: serverTemplate,
save: async () => {
const run = { saved: false };
leaveSave = run;
try {
await handleSave();
} finally {
leaveSave = null;
}
return run.saved;
},
leaveOnRouteUpdate: (to, from) =>
to.path !== from.path ||
to.query.action !== from.query.action ||
to.query.name !== from.query.name ||
to.query.clone !== from.query.clone,
});
onMounted(async () => {
if (draftsActive) await templateDraft.start();
});
const closeEditor = async () => {
if (await templateDraft.confirmLeave()) emit("cancel:hideform");
};
// http bodies must be valid JSON (with template placeholders). This stays a
// submit-time toast side-effect — NOT a schema rule — to preserve the exact
// pre-migration behaviour (Rule ④).
@@ -746,8 +823,13 @@ async function saveTemplate(value: AddTemplateForm) {
const onSuccess = () => {
dismiss();
emit("get:templates");
emit("cancel:hideform");
if (leaveSave) {
leaveSave.saved = true;
} else {
templateDraft.markSaved();
emit("get:templates");
emit("cancel:hideform");
}
toast({
variant: "success",
message: t("alert_templates.savedSuccessfully"),
@@ -0,0 +1,254 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import alertsService from "@/services/alerts";
import templateService from "@/services/alert_templates";
import destinationService from "@/services/alert_destination";
import { queryClient } from "@/composables/query/queryClient";
import { readEditorDraft, type EditorDraftScope } from "@/utils/drafts/editorDraftStore";
import AlertList from "./AlertList.vue";
import AddAlert from "./AddAlert.vue";
vi.mock("@/aws-exports", () => ({ default: { isCloud: "false", isEnterprise: "false" } }));
vi.mock("@/composables/useStreams", () => ({
default: () => ({
getStream: vi.fn(() => Promise.resolve({ schema: [{ name: "status", type: "Int64" }] })),
getStreams: vi.fn(() => Promise.resolve({ list: [{ name: "default" }] })),
}),
}));
vi.mock("@/composables/useFunctions", () => ({
default: () => ({ getAllFunctions: vi.fn().mockResolvedValue({ functions: [] }) }),
}));
vi.mock("@/composables/useParser", () => ({
default: () => ({
sqlParser: async () => ({
astify: vi.fn(() => ({ columns: [] })),
parse: vi.fn(),
sqlify: vi.fn(),
}),
}),
}));
vi.mock("@/utils/zincutils", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/zincutils")>()),
getTimezonesByOffset: vi.fn(() => Promise.resolve(["UTC"])),
}));
vi.mock("@/services/oncall", () => ({
default: { listTeams: vi.fn().mockResolvedValue({ data: [] }) },
}));
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(),
getCompositeReferences: vi.fn(() =>
Promise.resolve({ data: { references: [], hidden_reference_count: 0 } }),
),
},
});
});
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() } });
});
vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }));
vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) }));
vi.setConfig({ testTimeout: 20000 });
interface AlertForm {
setFieldValue: (path: string, value: unknown) => void;
}
const ALERT_ID = "3HGifdlCfgrm1thif69NRJig9E5";
const savedAlert = {
id: ALERT_ID,
alert_id: ALERT_ID,
name: "Memory_Utilization",
org_id: "default",
folder_id: "default",
stream_type: "metrics",
stream_name: "default",
is_real_time: false,
query_condition: {
type: "custom",
conditions: {
version: 2,
conditions: { filterType: "group", logicalOperator: "AND", conditions: [] },
},
sql: "",
promql: null,
promql_condition: null,
aggregation: null,
vrl_function: null,
multi_time_range: [],
},
condition: { type: "sql" },
trigger_condition: {
period: 10,
operator: ">=",
threshold: 2500000000,
frequency: 10,
cron: "",
frequency_type: "minutes",
silence: 10,
timezone: "UTC",
},
destinations: ["EndpointTest"],
context_attributes: {},
row_template: "",
row_template_type: "String",
description: "",
enabled: true,
tz_offset: 0,
owner: "example@gmail.com",
last_edited_by: "example@gmail.com",
workflows: [],
pending_period_sec: 0,
recovery_destinations: [],
};
const editScope: EditorDraftScope = {
kind: "alert",
org: "default",
entityId: ALERT_ID,
userEmail: "example@gmail.com",
};
const stubs = {
FolderList: { template: '<div data-test="stub-folder-list" />' },
AlertHistoryDrawer: true,
ImportAlert: true,
QueryConfig: true,
AlertSettings: true,
CompareWithPast: true,
Deduplication: true,
Advanced: true,
PreviewAlert: true,
AlertSummary: true,
AnomalyDetectionConfig: true,
AnomalyAlerting: true,
AnomalySummary: true,
AnomalyDataPreview: true,
JsonEditor: true,
InlineSelectFolderDropdown: true,
OPageHeader: true,
};
let router: Router;
let wrapper: VueWrapper | null = null;
const listQuery = { org_identifier: "default", folder: "default" };
const mountApp = async (query: Record<string, string>) => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/alerts", name: "alertList", component: AlertList },
{ path: "/logs", name: "logs", component: { render: () => h("div") } },
],
});
await router.push({ name: "alertList", query: { ...listQuery, ...query } });
await router.isReady();
wrapper = mount(
{ render: () => h(RouterView) },
{
attachTo: document.body,
global: { plugins: [i18n, store, router], stubs },
},
);
await flushPromises();
};
const editor = () => wrapper!.findComponent(AddAlert);
const byTest = (id: string) => document.body.querySelector<HTMLElement>(`[data-test="${id}"]`);
const leaveButton = (id: string) =>
document.body.querySelector<HTMLElement>(
`[data-test="editor-draft-leave-dialog"] [data-test="${id}"]`,
);
describe("AlertList with the real editor: Discard on leave", () => {
beforeEach(() => {
localStorage.clear();
queryClient.clear();
store.state.selectedOrganization = {
...store.state.selectedOrganization,
identifier: "default",
};
Object.assign(store.state.organizationData, {
foldersByType: [{ type: "alerts", folders: [{ id: "default", name: "Default" }] }],
allAlertsListByFolderId: {},
});
vi.mocked(alertsService.listByFolderId).mockResolvedValue({
data: { list: [savedAlert] },
} as never);
vi.mocked(alertsService.get_by_alert_id).mockResolvedValue({
data: { ...savedAlert },
} as never);
vi.mocked(templateService.list).mockResolvedValue({ data: [{ name: "t1" }] } as never);
vi.mocked(destinationService.list).mockResolvedValue({
data: [{ name: "EndpointTest", type: "http" }],
} as never);
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
localStorage.clear();
});
it("removes the edit's autosaved draft and its list dot", async () => {
await mountApp({ action: "update", alert_id: ALERT_ID, name: savedAlert.name });
await vi.waitFor(() => expect(editor().exists()).toBe(true), { timeout: 5000 });
await flushPromises();
window.dispatchEvent(new Event("pointerdown"));
(editor().vm as unknown as { form: AlertForm }).form.setFieldValue(
"trigger_condition.threshold",
9999999999,
);
await nextTick();
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).not.toBeNull();
const navigation = router.push("/logs");
await vi.waitFor(() => expect(byTest("editor-draft-leave-dialog")).not.toBeNull());
leaveButton("o-dialog-neutral-btn")!.click();
await navigation;
await flushPromises();
expect(router.currentRoute.value.path).toBe("/logs");
expect(readEditorDraft(editScope)).toBeNull();
await router.push({ name: "alertList", query: listQuery });
await vi.waitFor(() =>
expect(wrapper!.find('[data-test="alert-list-Memory_Utilization-draft-dot"]').exists()).toBe(
false,
),
);
expect(readEditorDraft(editScope)).toBeNull();
});
});
+246
View File
@@ -13,6 +13,7 @@
// 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 EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
@@ -83,6 +84,12 @@ import { toast } from "@/lib/feedback/Toast/useToast";
import TemplateService from "@/services/alert_templates";
import DestinationService from "@/services/alert_destination";
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
import {
deleteEditorDraft,
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
// Every test mounts the full list; on a loaded CI runner one mount can exceed the 5s default.
vi.setConfig({ testTimeout: 20000 });
@@ -2188,6 +2195,245 @@ describe("AlertList - triggered in the last 15 minutes", () => {
});
}, 15000);
describe("AlertList - editor drafts", () => {
// Read at call time: earlier specs in this file switch the shared store's org and user.
const scope = (entityId: string | null, userEmail?: string): EditorDraftScope => ({
kind: "alert",
org: String(store.state.selectedOrganization?.identifier ?? ""),
entityId,
userEmail: userEmail ?? String(store.state.userInfo?.email ?? ""),
});
const draftData = { values: {}, tab: "condition", folderId: "default" };
interface DraftListRow {
alert_id: string;
name: string;
selected?: boolean;
folder_name?: { id: string; name: string };
}
interface DraftListVm {
activeTab: string;
filteredResults: DraftListRow[];
selectedAlerts: AlertV2[];
showAddAlertDialog: boolean;
router: { currentRoute: { value: { query: Record<string, string> } } };
handleActionQuery: (action: string | undefined) => Promise<void>;
showDeleteDialogFn: (props: { row: Pick<DraftListRow, "alert_id" | "name"> }) => Promise<void>;
deleteAlertByAlertId: () => Promise<void>;
bulkDeleteAlerts: () => Promise<void>;
refreshList: (folderId: string, options: { navigate: boolean }) => Promise<void>;
}
const mountDraftList = async () => {
const wrapper = await mountAlertList();
await waitData(wrapper);
return { wrapper, vm: wrapper.vm as unknown as DraftListVm };
};
beforeEach(() => localStorage.clear());
it("marks the New alert button and the rows that have drafts", async () => {
writeEditorDraft(scope(null), { data: draftData });
writeEditorDraft(scope("alert-3"), { data: draftData });
const { wrapper, vm } = await mountDraftList();
// A stale ?action from an earlier spec's navigation can swap the table for the editor.
await vm.handleActionQuery(undefined);
await flushPromises();
const addButton = wrapper.find('[data-test="alert-list-add-alert-btn"]');
expect(addButton.attributes("aria-label")).toBe("New alert · unsaved draft");
expect(wrapper.find('[data-test="alert-list-add-alert-draft-dot"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-list-Scheduled Alert C-draft-dot"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-test="alert-list-Scheduled Alert A-draft-dot"]').exists()).toBe(
false,
);
});
it("on the anomaly tab, marks the Add button only for a new anomaly detection's draft", async () => {
writeEditorDraft(scope(null), { data: draftData });
const { wrapper, vm } = await mountDraftList();
await vm.handleActionQuery(undefined);
vm.activeTab = "anomalyDetection";
await flushPromises();
const dot = () => wrapper.find('[data-test="alert-list-add-alert-draft-dot"]').exists();
expect(dot()).toBe(false);
writeEditorDraft({ ...scope(null), variant: "anomaly" }, { data: draftData });
await flushPromises();
expect(dot()).toBe(true);
const dotProps = wrapper
.findAllComponents(EditorDraftDot)
.find((c) => c.props("dataTest") === "alert-list-add-alert-draft-dot");
expect(dotProps?.props("savedAt")).toBe(
readEditorDraft({ ...scope(null), variant: "anomaly" })?.savedAt,
);
vm.activeTab = "all";
deleteEditorDraft(scope(null));
await flushPromises();
expect(dot()).toBe(false);
});
it("dates the Add button's dot with the draft that tab's Add opens, even when the other is newer", async () => {
const now = Date.now();
vi.spyOn(Date, "now").mockReturnValue(now - 60_000);
writeEditorDraft(scope(null), { data: draftData });
vi.mocked(Date.now).mockReturnValue(now);
writeEditorDraft({ ...scope(null), variant: "anomaly" }, { data: draftData });
vi.mocked(Date.now).mockRestore();
const { wrapper, vm } = await mountDraftList();
await vm.handleActionQuery(undefined);
vm.activeTab = "all";
await flushPromises();
const dot = () =>
wrapper
.findAllComponents(EditorDraftDot)
.find((c) => c.props("dataTest") === "alert-list-add-alert-draft-dot");
expect(dot()?.props("savedAt")).toBe(now - 60_000);
vm.activeTab = "anomalyDetection";
await flushPromises();
expect(dot()?.props("savedAt")).toBe(now);
});
it("drops every user's draft of a deleted alert and keeps the rest", async () => {
writeEditorDraft(scope("alert-1"), { data: draftData });
writeEditorDraft(scope("alert-1", "teammate@example.com"), { data: draftData });
writeEditorDraft(scope("alert-2"), { data: draftData });
const { vm } = await mountDraftList();
await vm.showDeleteDialogFn({
row: { alert_id: "alert-1", name: "Scheduled Alert A" },
});
await vm.deleteAlertByAlertId();
await flushPromises();
expect(readEditorDraft(scope("alert-1"))).toBeNull();
expect(readEditorDraft(scope("alert-1", "teammate@example.com"))).toBeNull();
expect(readEditorDraft(scope("alert-2"))).not.toBeNull();
});
it("keeps the draft when the delete fails", async () => {
writeEditorDraft(scope("alert-1"), { data: draftData });
alertsSvc.delete_by_alert_id.mockRejectedValueOnce({ response: { status: 500 } });
const { vm } = await mountDraftList();
await vm.showDeleteDialogFn({
row: { alert_id: "alert-1", name: "Scheduled Alert A" },
});
await vm.deleteAlertByAlertId();
await flushPromises();
expect(readEditorDraft(scope("alert-1"))).not.toBeNull();
});
it("drops drafts only for the alerts a bulk delete removed", async () => {
writeEditorDraft(scope("alert-1"), { data: draftData });
writeEditorDraft(scope("alert-2"), { data: draftData });
const { vm } = await mountDraftList();
vm.filteredResults[0].selected = true;
vm.filteredResults[1].selected = true;
await nextTick();
const [first, second] = vm.selectedAlerts.map((a: AlertV2) => a.alert_id);
alertsSvc.bulkDelete.mockResolvedValueOnce({
data: { successful: [first], unsuccessful: [{ alert_id: second, code: "internal" }] },
});
await vm.bulkDeleteAlerts();
await flushPromises();
expect(readEditorDraft(scope(first))).toBeNull();
expect(readEditorDraft(scope(second))).not.toBeNull();
});
const getAlertOr404 = async (_org: string, id: string) => {
const alert = alertsDB.find((a) => a.alert_id === id) ?? (id === "alert-ops" ? { id } : null);
if (!alert) throw { response: { status: 404 } };
return { data: { ...alert, id } };
};
it("on an unrecognised bulk-delete response, drops drafts only of the alerts the server no longer has", async () => {
writeEditorDraft(scope("alert-1"), { data: draftData });
writeEditorDraft(scope("alert-3"), { data: draftData });
writeEditorDraft(scope("alert-ops"), { data: draftData });
const { vm } = await mountDraftList();
vm.filteredResults = [
...vm.filteredResults,
{ alert_id: "alert-ops", name: "Ops alert", folder_name: { id: "ops", name: "Ops" } },
];
for (const row of vm.filteredResults)
row.selected = ["alert-1", "alert-3", "alert-ops"].includes(row.alert_id);
await nextTick();
expect(vm.selectedAlerts).toHaveLength(3);
alertsSvc.get_by_alert_id.mockImplementation(getAlertOr404);
alertsSvc.bulkDelete.mockImplementationOnce(async () => {
alertsDB = alertsDB.filter((a) => a.alert_id !== "alert-1");
return { data: {} };
});
await vm.bulkDeleteAlerts();
await flushPromises();
expect(readEditorDraft(scope("alert-1"))).toBeNull();
expect(readEditorDraft(scope("alert-3"))).not.toBeNull();
expect(readEditorDraft(scope("alert-ops"))).not.toBeNull();
});
it("on an unrecognised bulk-delete response, keeps drafts of kept alerts when the reload returns another folder", async () => {
writeEditorDraft(scope("alert-1"), { data: draftData });
writeEditorDraft(scope("alert-3"), { data: draftData });
const { vm } = await mountDraftList();
for (const row of vm.filteredResults)
row.selected = ["alert-1", "alert-3"].includes(row.alert_id);
await nextTick();
alertsSvc.get_by_alert_id.mockImplementation(getAlertOr404);
alertsSvc.bulkDelete.mockImplementationOnce(async () => {
alertsDB = alertsDB.filter((a) => a.alert_id !== "alert-1");
return { data: {} };
});
alertsSvc.listByFolderId.mockResolvedValueOnce({ data: { list: [] } });
await vm.bulkDeleteAlerts();
await flushPromises();
expect(readEditorDraft(scope("alert-1"))).toBeNull();
expect(readEditorDraft(scope("alert-3"))).not.toBeNull();
});
it("drops the draft of an alert that no longer exists when its edit link opens", async () => {
const { vm } = await mountDraftList();
writeEditorDraft(scope("gone-1"), { data: draftData });
alertsSvc.get_by_alert_id.mockRejectedValueOnce({ response: { status: 404 } });
vm.router.currentRoute.value.query = { action: "update", alert_id: "gone-1" };
await vm.handleActionQuery("update");
await flushPromises();
expect(readEditorDraft(scope("gone-1"))).toBeNull();
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
variant: "info",
message: "This alert no longer exists, so its unsaved draft was discarded.",
}),
);
});
it("refreshes without closing the editor when the leave dialog saved", async () => {
const { vm } = await mountDraftList();
vm.router.currentRoute.value.query = { action: "add" };
vm.showAddAlertDialog = true;
const pushSpy = vi.spyOn(router, "push");
await vm.refreshList("default", { navigate: false });
expect(alertsSvc.listByFolderId).toHaveBeenCalled();
expect(pushSpy).not.toHaveBeenCalled();
expect(vm.showAddAlertDialog).toBe(true);
});
});
describe("AlertList — Acknowledge and alert-scoped Mute (MK-3)", () => {
const ack = { acked_by: "bob@acme.io", acked_at: 1_786_500_000_000_000, ack_source: "slack" };
const quickMute = { id: "d1", name: "Quick mute", ends_at: 4_102_444_800_000_000, version: 2 };
+82 -7
View File
@@ -44,6 +44,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="primary"
size="sm"
:disabled="!templates.length"
:aria-label="
hasNewDraft ? t('common.editorDraft.newDot', { label: t('alerts.add') }) : undefined
"
@click="
activeTab === 'anomalyDetection'
? router.push({
@@ -56,8 +59,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
})
: showAddUpdateFn({})
"
>{{ t(`alerts.add`) }}</OButton
>
>{{ t(`alerts.add`) }}
<EditorDraftDot
v-if="hasNewDraft"
:saved-at="newDraftSavedAt"
corner
data-test="alert-list-add-alert-draft-dot"
/>
</OButton>
</template>
<template #actions-overflow>
@@ -246,6 +255,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon :name="typeIconName(row)" size="sm" :class="typeIconClass(row)" />
</span>
<OTruncatedText>{{ row.name || "--" }}</OTruncatedText>
<EditorDraftDot
v-if="alertDrafts.ids.has(row.alert_id)"
:saved-at="alertDrafts.savedAt(row.alert_id)"
:data-test="`alert-list-${row.name}-draft-dot`"
/>
<AckChip
v-if="row.ack"
:ack="row.ack"
@@ -1126,9 +1140,22 @@ 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 { useEditorDraftIds } from "@/composables/useEditorDraft";
import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import {
ALERT_DRAFT_KIND,
ANOMALY_DRAFT_VARIANT,
dropDraftOfMissingAlert,
findDeletedAlertIds,
isNotFoundError,
type AlertSavedOptions,
} from "@/utils/alerts/alertEditorDraft";
// import alertList from "./alerts";
type AlertRowRef = { alert_id?: unknown; folder_name?: { id?: unknown } };
export default defineComponent({
name: "AlertList",
components: {
@@ -1139,6 +1166,7 @@ export default defineComponent({
QuickMuteDialog,
OPageLayout,
AddAlert: defineAsyncComponent(() => import("@/components/alerts/AddAlert.vue")),
EditorDraftDot,
OEmptyState,
ConfirmDialog,
ImportAlert,
@@ -1229,6 +1257,13 @@ export default defineComponent({
);
const showAddAlertDialog: any = ref(false);
const selectedDelete: any = ref(null);
const alertDrafts = useEditorDraftIds(ALERT_DRAFT_KIND);
const dropAlertDrafts = (alertIds: string[]) => {
const org = String(store.state.selectedOrganization?.identifier ?? "");
alertIds.forEach((entityId) =>
deleteEditorDraftsFor({ kind: ALERT_DRAFT_KIND, org, entityId }),
);
};
const isUpdated: any = ref(false);
/**
* Render a multi-alert group count, marking it as a lower bound when the
@@ -1402,6 +1437,18 @@ export default defineComponent({
);
const filteredResults: Ref<any[]> = ref([]);
// The Add button opens a new anomaly detection on the anomaly tab, which drafts in its own slot.
const hasNewDraft = computed(() =>
activeTab.value === "anomalyDetection"
? alertDrafts.hasNewVariant(ANOMALY_DRAFT_VARIANT)
: alertDrafts.hasNew,
);
// The date belongs to the draft this tab's Add button opens.
const newDraftSavedAt = computed(() =>
activeTab.value === "anomalyDetection"
? alertDrafts.savedAtVariant(ANOMALY_DRAFT_VARIANT)
: alertDrafts.savedAt(null),
);
// ── "Calm Signal" table helpers ─────────────────────────────────────────
// Keyed on firing time, not run time; raw µs keeps it timezone-independent.
@@ -2691,10 +2738,26 @@ export default defineComponent({
}
} catch (error) {
console.error("AlertList: Failed to load alert", error);
toast({
variant: "error",
message: t("toastMessages.alerts.failedToLoadAlertForEditing"),
});
const draftDropped =
isNotFoundError(error) &&
dropDraftOfMissingAlert({
org: String(store.state.selectedOrganization?.identifier ?? ""),
entityId: alertId,
userEmail: String(store.state.userInfo?.email ?? ""),
});
toast(
draftDropped
? {
variant: "info",
message: t("common.editorDraft.itemDeleted", {
entity: t("common.editorDraft.entities.alert"),
}),
}
: {
variant: "error",
message: t("toastMessages.alerts.failedToLoadAlertForEditing"),
},
);
}
}
@@ -3033,7 +3096,7 @@ export default defineComponent({
});
}
};
const refreshList = async (folderId?: string) => {
const refreshList = async (folderId?: string, options?: AlertSavedOptions) => {
//here we are fetching the alerts from the server because after creating the alert we should get the latest alerts
//and then we are setting the activeFolderId to the folderId
//this is done to avoid multiple api calls , when we assign the folderId before fetching it will trigger the watch and it will fetch the alerts again
@@ -3053,6 +3116,8 @@ export default defineComponent({
if (filterQuery.value) {
filterAlertsByQuery(filterQuery.value);
}
// The leave dialog's Save: the pending navigation decides where the user goes.
if (options?.navigate === false) return;
await hideForm();
activeFolderId.value = resolvedFolderId;
};
@@ -3128,6 +3193,7 @@ export default defineComponent({
const dropAlerts = (alertIds: any[]) => {
const gone = new Set(alertIds.filter(Boolean).map((id: any) => String(id)));
if (!gone.size) return;
dropAlertDrafts([...gone]);
const keep = (row: any) => !gone.has(String(row?.alert_id));
// Read before the splice below removes the rows this has to inspect.
@@ -3984,11 +4050,17 @@ export default defineComponent({
response.data.successful.map((entry: any) => entry?.alert_id ?? entry?.id ?? entry),
);
} else {
const org = String(store.state.selectedOrganization.identifier);
const requested = selectedAlerts.value.map((row: AlertRowRef) => String(row?.alert_id));
selectedAlerts.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);
// The list may now show another folder, so each alert is asked for by id and only a 404 proves it gone.
dropAlertDrafts(
await findDeletedAlertIds(requested, (id) => alertsService.get_by_alert_id(org, id)),
);
}
} catch (error: any) {
dismiss();
@@ -4105,6 +4177,9 @@ export default defineComponent({
showDeleteDialogFn,
duplicateAlert,
showAddAlertDialog,
alertDrafts,
hasNewDraft,
newDraftSavedAt,
showForm,
toBeCloneAlertName,
toBeClonedAlert,
@@ -0,0 +1,169 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import destinationService from "@/services/alert_destination";
import templateService from "@/services/alert_templates";
import { queryClient } from "@/composables/query/queryClient";
import OForm from "@/lib/forms/Form/OForm.vue";
import { readEditorDraft, type EditorDraftScope } from "@/utils/drafts/editorDraftStore";
import AlertsDestinationList from "./AlertsDestinationList.vue";
import AddDestination from "./AddDestination.vue";
vi.mock("@/services/alert_destination", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: {
list: vi.fn(),
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_templates", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: { list: vi.fn() },
});
});
vi.mock("@/services/users", () => ({
default: {
orgUsers: vi.fn().mockResolvedValue({ data: { data: [] } }),
getRoles: vi.fn().mockResolvedValue({ data: [] }),
},
}));
vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) }));
const hook = {
name: "ops_hook",
type: "http",
url: "https://hooks.example.com/ops",
method: "post",
template: "plain_http",
headers: {},
};
const otherHook = { ...hook, name: "other_hook", url: "https://hooks.example.com/other" };
const editScope: EditorDraftScope = {
kind: "alert_destination",
org: "default",
entityId: "ops_hook",
userEmail: "example@gmail.com",
};
let router: Router;
let wrapper: VueWrapper | null = null;
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
};
const mountList = async (query: Record<string, string>) => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/alert-destinations", name: "alertDestinations", component: AlertsDestinationList },
{ path: "/alert-templates", name: "alertTemplates", component: { render: () => h("div") } },
],
});
await router.push({ name: "alertDestinations", query: { org_identifier: "default", ...query } });
await router.isReady();
wrapper = mount(
{ render: () => h(RouterView) },
{
attachTo: document.body,
global: {
plugins: [i18n, store, router],
stubs: {
PrebuiltDestinationSelector: true,
AddUser: true,
DestinationPreview: true,
SlackDestinationSetup: true,
},
},
},
);
};
const editor = () => wrapper!.findComponent(AddDestination);
const byTest = (id: string) => document.body.querySelector<HTMLElement>(`[data-test="${id}"]`);
describe("AlertsDestinationList deep link", () => {
beforeEach(() => {
localStorage.clear();
queryClient.clear();
vi.mocked(templateService.list).mockResolvedValue({
data: [{ name: "plain_http", body: "{}", type: "http" }],
} as never);
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("opens the named edit on a cold cache once the list lands, not a blank new form", async () => {
const list = deferred<{ data: unknown[] }>();
vi.mocked(destinationService.list).mockReturnValue(list.promise as never);
await mountList({ action: "update", name: "ops_hook" });
await flushPromises();
expect(editor().exists()).toBe(false);
expect(router.currentRoute.value.query.action).toBe("update");
list.resolve({ data: [otherHook, hook] });
await vi.waitFor(() => expect(editor().exists()).toBe(true));
expect(editor().props("destination")).toMatchObject({ name: "ops_hook" });
expect(router.currentRoute.value.query).toMatchObject({ action: "update", name: "ops_hook" });
});
it("offers the edit's own draft after a reload of the edit URL", async () => {
vi.mocked(destinationService.list).mockResolvedValue({ data: [hook] } as never);
await mountList({ action: "update", name: "ops_hook" });
await vi.waitFor(() => expect(editor().exists()).toBe(true));
await flushPromises();
window.dispatchEvent(new Event("pointerdown"));
(
editor().findComponent(OForm).vm as unknown as {
form: { setFieldValue: (k: string, v: unknown) => void };
}
).form.setFieldValue("method", "put");
await nextTick();
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).not.toBeNull();
wrapper!.unmount();
wrapper = null;
queryClient.clear();
const list = deferred<{ data: unknown[] }>();
vi.mocked(destinationService.list).mockReturnValue(list.promise as never);
await mountList({ action: "update", name: "ops_hook" });
await flushPromises();
list.resolve({ data: [hook] });
await vi.waitFor(() => expect(byTest("editor-draft-restore-dialog")).not.toBeNull());
expect(editor().props("destination")).toMatchObject({ name: "ops_hook" });
});
});
@@ -78,9 +78,15 @@ import templateService from "@/services/alert_templates";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import {
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
import slack from "@/services/slack";
import { queryClient } from "@/composables/query/queryClient";
import { installationView } from "@/test/unit/helpers/slack";
import { clickIn } from "@/test/unit/helpers/draftDom";
// ── stubs ────────────────────────────────────────────────────────────────────
@@ -116,6 +122,7 @@ const OTableStub = {
<slot name="actions" />
<slot name="selection-actions" />
<template v-for="row in data" :key="row.name">
<slot name="cell-name" :row="row" />
<div :data-test="'destination-url-' + row.name">
<slot name="cell-url" :row="row">{{ urlOf(row) }}</slot>
</div>
@@ -155,13 +162,14 @@ const makeTemplate = (idx: number) => ({
// ── mount helper ─────────────────────────────────────────────────────────────
function mountComponent() {
function mountComponent({ realConfirm = false } = {}) {
return mount(AlertsDestinationList, {
attachTo: realConfirm ? document.body : undefined,
global: {
plugins: [i18n, store, router],
stubs: {
OTable: OTableStub,
ConfirmDialog: ConfirmDialogStub,
ConfirmDialog: realConfirm ? false : ConfirmDialogStub,
AddDestination: { template: '<div data-test="add-destination-stub" />' },
ImportDestination: { template: '<div data-test="import-destination-stub" />' },
NoData: { template: '<div data-test="no-data-stub" />' },
@@ -475,6 +483,99 @@ describe("AlertsDestinationList", () => {
});
});
describe("drafts", () => {
interface BulkDeleteVm {
handleSelectedIdsUpdate: (ids: string[]) => void;
bulkDeleteDestinations: () => Promise<void>;
}
const scope = (entityId: string | null): EditorDraftScope => ({
kind: "alert_destination",
org: "default",
entityId,
userEmail: "example@gmail.com",
});
beforeEach(() => {
localStorage.clear();
writeEditorDraft(scope(null), { data: {} });
writeEditorDraft(scope("destination-2"), { data: {} });
});
afterEach(() => localStorage.clear());
it("marks the Add button and the drafted row only", async () => {
wrapper = mountComponent();
await flushPromises();
const add = wrapper.get('[data-test="alert-destination-list-add-alert-btn"]');
expect(add.find('[data-test="alert-destination-list-add-draft-dot"]').exists()).toBe(true);
expect(add.attributes("aria-label")).toContain("unsaved draft");
expect(
wrapper.find('[data-test="alert-destination-list-destination-2-draft-dot"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="alert-destination-list-destination-1-draft-dot"]').exists(),
).toBe(false);
});
const deleteFromRow = async (name: string) => {
await wrapper!
.get(`[data-test="alert-destination-list-${name}-delete-destination"]`)
.trigger("click");
await flushPromises();
clickIn("confirm-dialog", "o-dialog-primary-btn");
await flushPromises();
};
it("drops a deleted destination's draft and keeps it when the delete fails", async () => {
wrapper = mountComponent({ realConfirm: true });
await flushPromises();
vi.mocked(destinationService.delete).mockRejectedValueOnce({ response: { status: 500 } });
await deleteFromRow("destination-2");
expect(destinationService.delete).toHaveBeenCalledTimes(1);
expect(readEditorDraft(scope("destination-2"))).not.toBeNull();
await deleteFromRow("destination-2");
expect(destinationService.delete).toHaveBeenCalledTimes(2);
expect(readEditorDraft(scope("destination-2"))).toBeNull();
expect(readEditorDraft(scope(null))).not.toBeNull();
});
it("drops drafts of bulk-deleted names the refetch no longer lists, on an unrecognised response", async () => {
writeEditorDraft(scope("destination-1"), { data: {} });
wrapper = mountComponent();
await flushPromises();
const vm = wrapper.vm as unknown as BulkDeleteVm;
vm.handleSelectedIdsUpdate(["destination-1", "destination-2"]);
vi.mocked(destinationService.bulkDelete).mockResolvedValueOnce({ data: {} } as never);
vi.mocked(destinationService.list).mockResolvedValue({
data: [makeDestination(1), makeDestination(3)],
} as never);
await vm.bulkDeleteDestinations();
await flushPromises();
expect(readEditorDraft(scope("destination-2"))).toBeNull();
expect(readEditorDraft(scope("destination-1"))).not.toBeNull();
expect(readEditorDraft(scope(null))).not.toBeNull();
});
it("keeps every bulk-delete draft when the fallback refetch fails", async () => {
wrapper = mountComponent();
await flushPromises();
const vm = wrapper.vm as unknown as BulkDeleteVm;
vm.handleSelectedIdsUpdate(["destination-2"]);
vi.mocked(destinationService.bulkDelete).mockResolvedValueOnce({ data: {} } as never);
vi.mocked(destinationService.list).mockRejectedValue({ response: { status: 500 } });
await vm.bulkDeleteDestinations();
await flushPromises();
expect(readEditorDraft(scope("destination-2"))).not.toBeNull();
});
});
// ── bulk selection ─────────────────────────────────────────────────────────
describe("bulk selection", () => {
@@ -36,9 +36,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="primary"
size="sm"
:disabled="!templates.length"
:aria-label="
destinationDrafts.hasNew
? t('common.editorDraft.newDot', { label: t('alert_destinations.add') })
: undefined
"
@click="editDestination(null)"
>{{ t(`alert_destinations.add`) }}</OButton
>
>{{ t(`alert_destinations.add`) }}
<EditorDraftDot
v-if="destinationDrafts.hasNew"
:saved-at="destinationDrafts.savedAt(null)"
corner
data-test="alert-destination-list-add-draft-dot"
/></OButton>
</template>
<template #actions-overflow>
<OButton
@@ -170,6 +180,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</template>
<template #cell-name="{ row }">
<div class="flex min-w-0 items-center gap-2">
<OTruncatedText>{{ row.name }}</OTruncatedText>
<EditorDraftDot
v-if="destinationDrafts.ids.has(row.name)"
:saved-at="destinationDrafts.savedAt(row.name)"
:data-test="`alert-destination-list-${row.name}-draft-dot`"
/>
</div>
</template>
<template #cell-template="{ row }">
<div
v-if="row.template"
@@ -447,6 +468,9 @@ 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 { useEditorDraftIds } from "@/composables/useEditorDraft";
import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import { slackInstallationsQuery } from "@/services/slack.queries";
import { readSlackOrgConfig } from "@/utils/slackSettings";
import { useOncallRoomNames, useSlackAppSwitch } from "@/composables/slack/useSlackAppSwitch";
@@ -483,6 +507,7 @@ export default defineComponent({
OPageLayout,
AlertSectionTabs,
DependencyUsageCell,
EditorDraftDot,
},
setup() {
const store = useStore();
@@ -490,6 +515,7 @@ export default defineComponent({
const { t } = useI18nTyped();
const { graph: depGraph, loadGraph: loadDepGraph } = useDependencyGraph();
const { track } = useReo();
const destinationDrafts = useEditorDraftIds("alert_destination");
const { detectPrebuiltType, availableTypes } = usePrebuiltDestinations();
@@ -693,6 +719,8 @@ 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;
for (const name of gone)
deleteEditorDraftsFor({ kind: "alert_destination", org, entityId: name });
// 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.
@@ -707,6 +735,14 @@ export default defineComponent({
);
};
const dropDraftsOfMissing = (names: string[]) => {
const org = store.state.selectedOrganization.identifier;
const present = new Set(destinations.value.map((d) => d.name));
for (const name of names)
if (!present.has(name))
deleteEditorDraftsFor({ kind: "alert_destination", org, entityId: name });
};
// Deleting an alert 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.
@@ -790,6 +826,7 @@ export default defineComponent({
// "Used by" counts still follow every write.
if (force) invalidateDependencyGraphCache();
loadDepGraph(org, force ? ["alerts", "templates"] : []);
return true;
})
.catch((err: any) => {
forbidden.value = err?.response?.status === 403;
@@ -800,6 +837,7 @@ export default defineComponent({
});
}
dismiss();
return false;
})
.finally(() => {
dismiss();
@@ -1098,7 +1136,7 @@ export default defineComponent({
selectedDestinations.value = [];
// Forced: this branch exists to hear it from the server, and an unforced
// read inside staleTime answers from cache without asking.
getDestinations(true);
if (await getDestinations(true)) dropDraftsOfMissing(payload.ids);
}
} catch (error: any) {
dismiss();
@@ -1144,6 +1182,7 @@ export default defineComponent({
t,
lastUpdatedAt,
depGraph,
destinationDrafts,
showDestinationEditor,
destinations,
columns,
@@ -843,6 +843,40 @@ describe("IncidentDetailDrawer.vue", () => {
});
});
describe("Utility Functions - Trigger Durations", () => {
beforeEach(async () => {
wrapper = await createWrapper();
});
it("reads the period as minutes", () => {
expect(wrapper.vm.formatPeriod(10)).toBe("10 min");
expect(wrapper.vm.formatPeriod(90)).toBe("90 min");
});
it("reads the frequency as seconds", () => {
expect(wrapper.vm.formatFrequency({ frequency: 3600, frequency_type: "minutes" })).toBe(
"60 min",
);
expect(wrapper.vm.formatFrequency({ frequency: 90, frequency_type: "minutes" })).toBe(
"1 min 30 sec",
);
expect(wrapper.vm.formatFrequency({ frequency: 30, frequency_type: "minutes" })).toBe(
"30 sec",
);
});
it("shows the cron expression for a cron frequency", () => {
expect(
wrapper.vm.formatFrequency({ frequency: 0, frequency_type: "cron", cron: "0 */5 * * * *" }),
).toBe("0 */5 * * * *");
});
it("shows N/A when the value is missing", () => {
expect(wrapper.vm.formatPeriod(undefined)).toBe("N/A");
expect(wrapper.vm.formatFrequency(undefined)).toBe("N/A");
});
});
describe("Theme Support", () => {
it("should detect dark mode", async () => {
store.state.theme = "dark";
@@ -1105,14 +1105,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("alerts.incidents.frequency") }}
</span>
<span :class="'text-text-body'" class="text-sm font-medium">
{{
alerts[selectedAlertIndex]?.trigger_condition?.frequency ||
raw("N/A")
}}
{{
alerts[selectedAlertIndex]?.trigger_condition?.frequency_type ||
"min"
}}
{{ formatFrequency(alerts[selectedAlertIndex]?.trigger_condition) }}
</span>
</div>
<div class="flex flex-col gap-0.5">
@@ -1714,7 +1707,9 @@ export default defineComponent({
}
}
const oncallTeamNameFor = (teamId: string) => oncallTeamNames.value[teamId] ?? teamId;
// A page nothing routed has no team, so it reads as unrouted rather than as a blank name.
const oncallTeamNameFor = (teamId: string | null): string =>
teamId ? (oncallTeamNames.value[teamId] ?? teamId) : String(t("oncall.statUnrouted"));
const oncallTeamName = computed(() =>
oncallResponse.value ? oncallTeamNameFor(oncallResponse.value.team_id) : "",
@@ -2822,20 +2817,34 @@ export default defineComponent({
}
};
const formatPeriod = (periodInSeconds: number | undefined) => {
if (!periodInSeconds) return raw("N/A");
const formatSeconds = (totalSeconds: number | undefined) => {
if (!totalSeconds) return raw("N/A");
// Convert seconds to minutes
if (periodInSeconds >= 60) {
const minutes = Math.floor(periodInSeconds / 60);
const seconds = periodInSeconds % 60;
if (totalSeconds >= 60) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (seconds === 0) {
return t("alerts.incidents.periodMinutes", { minutes });
}
return t("alerts.incidents.periodMinutesSeconds", { minutes, seconds });
}
return t("alerts.incidents.periodSeconds", { seconds: periodInSeconds });
return t("alerts.incidents.periodSeconds", { seconds: totalSeconds });
};
// The stored alert keeps `period` in minutes but `frequency` in seconds.
const formatPeriod = (periodInMinutes: number | undefined) =>
formatSeconds(periodInMinutes ? periodInMinutes * 60 : undefined);
const formatFrequency = (triggerCondition?: {
frequency?: number;
frequency_type?: string;
cron?: string;
}) => {
if (triggerCondition?.frequency_type === "cron") {
return triggerCondition?.cron || raw("N/A");
}
return formatSeconds(triggerCondition?.frequency);
};
// Transform V1 format conditions (or/and structure) to readable expression
@@ -3145,6 +3154,7 @@ export default defineComponent({
handleTriggerRowClick,
getStatusLabel,
formatPeriod,
formatFrequency,
formatCustomConditions,
formatTimestamp,
formatTimestampUTC,
+271 -1
View File
@@ -14,15 +14,33 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount, flushPromises, DOMWrapper } from "@vue/test-utils";
import { mount, flushPromises, DOMWrapper, type VueWrapper } from "@vue/test-utils";
import type { RouteLocationNormalizedLoaded } from "vue-router";
import TemplateList from "./TemplateList.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.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";
import type { Template } from "@/ts/interfaces";
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
import {
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
interface TemplateListVm {
showTemplateEditor: boolean;
editingTemplate: Template | null;
cloningTemplate: boolean;
cloningSource: string | null;
templates: Template[];
lastUpdatedAt: number;
cloneTemplate: (template: Template) => void;
}
const node = document.createElement("div");
node.setAttribute("id", "app");
@@ -297,6 +315,7 @@ describe("TemplateList pagination persistence", () => {
const pushedQuery = pushSpy.mock.calls[0][0].query;
expect(pushedQuery.page).toBe("3");
expect(pushedQuery.action).toBe("add");
expect(pushedQuery.clone).toBe("Template2");
});
it("importTemplate preserves the page query param", async () => {
@@ -377,3 +396,254 @@ describe("alertTemplates view state restore", () => {
expect(saved.pageSize).toBe(100);
});
});
describe("TemplateList drafts", () => {
let wrapper: VueWrapper;
const scope = (entityId: string | null): EditorDraftScope => ({
kind: "alert_template",
org: "default",
entityId,
userEmail: "example@gmail.com",
});
beforeEach(async () => {
localStorage.clear();
writeEditorDraft(scope(null), { data: {} });
writeEditorDraft(scope("Template2"), { data: {} });
router.currentRoute.value.query = {};
resetListViewState();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
wrapper = mount(TemplateList, {
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
await flushPromises();
vi.advanceTimersByTime(SKELETON_HOLD_MS);
await flushPromises();
});
afterEach(() => {
wrapper.unmount();
vi.useRealTimers();
vi.clearAllMocks();
localStorage.clear();
});
it("marks the Add button and the drafted row only", () => {
const add = wrapper.get('[data-test="template-list-add-btn"]');
expect(add.find('[data-test="template-list-add-draft-dot"]').exists()).toBe(true);
expect(add.attributes("aria-label")).toContain("unsaved draft");
expect(wrapper.find('[data-test="alert-template-list-Template2-draft-dot"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-test="alert-template-list-Template3-draft-dot"]').exists()).toBe(
false,
);
});
it("marks the clone action of a template whose clone has a kept draft", async () => {
writeEditorDraft({ ...scope(null), variant: "clone:Template2" }, { data: {} });
await flushPromises();
const clone = wrapper.get('[data-test="alert-template-list-Template2-clone-template"]');
expect(clone.find('[data-test="alert-template-list-Template2-clone-draft-dot"]').exists()).toBe(
true,
);
expect(clone.attributes("aria-label")).toMatch(/^Clone · unsaved draft · .*\d/);
// Sighted users read the same dated name in the button's own tooltip, not a bare native title.
expect(clone.attributes("title")).toBeUndefined();
const tip = clone.findComponent(OTooltip);
expect(tip.props("content")).toBe(clone.attributes("aria-label"));
expect(
wrapper.find('[data-test="alert-template-list-Template3-clone-draft-dot"]').exists(),
).toBe(false);
const plain = wrapper.get('[data-test="alert-template-list-Template3-clone-template"]');
expect(plain.findComponent(OTooltip).props("content")).toBe("Clone");
});
it("marks the phone row menu and its Clone item for a kept clone draft", async () => {
writeEditorDraft({ ...scope(null), variant: "clone:Template2" }, { data: {} });
await flushPromises();
const triggers = wrapper.findAll('[data-test="alert-template-list-row-more-actions"]');
const trigger = triggers.find((t: DOMWrapper<Element>) =>
t.find('[data-test="alert-template-list-Template2-more-actions-draft-dot"]').exists(),
);
expect(trigger?.attributes("aria-label")).toMatch(/^More actions · unsaved draft · .*\d/);
expect(trigger?.attributes("title")).toBeUndefined();
expect(wrapper.findAll('[data-test$="-more-actions-draft-dot"]').length).toBe(1);
await trigger!.trigger("keydown", { key: "Enter" });
await flushPromises();
const item = document.body.querySelector(
'[data-test="alert-template-list-Template2-clone-template-menu"]',
);
const menuDot = item?.querySelector(
'[data-test="alert-template-list-Template2-clone-template-menu-draft-dot"]',
);
expect(menuDot?.getAttribute("aria-label")).toMatch(/^Unsaved draft · .*\d/);
});
it("drops a deleted template's draft", async () => {
const templatesUrl = `${store.state.API_ENDPOINT}/api/${store.state.selectedOrganization.identifier}/alerts/templates`;
global.server.use(
http.delete(`${templatesUrl}/Template2`, () => HttpResponse.json({ code: 200 })),
http.get(templatesUrl, () =>
HttpResponse.json(
alertsFixture.templates.get.filter((tpl: { name: string }) => tpl.name !== "Template2"),
),
),
);
await wrapper
.find('[data-test="alert-template-list-Template2-delete-template"]')
.trigger("click");
await flushPromises();
await new DOMWrapper(document.body).find('[data-test="o-dialog-primary-btn"]').trigger("click");
await flushPromises();
expect(readEditorDraft(scope("Template2"))).toBeNull();
expect(readEditorDraft(scope(null))).not.toBeNull();
});
});
describe("TemplateList refetch while the editor is open", () => {
let wrapper: VueWrapper | null = null;
let routeName: RouteLocationNormalizedLoaded["name"];
const route = () => router.currentRoute.value;
const vm = () => wrapper!.vm as unknown as TemplateListVm;
const mountAt = async (query: Record<string, string>) => {
route().query = query;
wrapper = mount(TemplateList, {
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
await vi.waitFor(() => expect(vm().showTemplateEditor).toBe(true), { timeout: 3000 });
// AddTemplate is an async component, so it can land after the list says the editor is open.
await vi.waitFor(
() => expect(wrapper!.findComponent({ name: "AddTemplate" }).exists()).toBe(true),
{ timeout: 3000 },
);
await flushPromises();
};
const refetch = async () => {
const { queryClient } = await import("@/composables/query/queryClient");
const before = vm().lastUpdatedAt;
await queryClient.refetchQueries();
await vi.waitFor(() => expect(vm().lastUpdatedAt).not.toBe(before), { timeout: 3000 });
await flushPromises();
};
beforeEach(() => {
localStorage.clear();
resetListViewState();
// Pinned: a real navigation through the app router lands at an unpredictable moment and rewrites the query.
vi.spyOn(router, "push").mockImplementation((() => Promise.resolve()) as never);
vi.spyOn(router, "replace").mockImplementation((() => Promise.resolve()) as never);
// AddTemplate runs drafts only on its own route.
routeName = route().name;
route().name = "alertTemplates";
});
afterEach(() => {
wrapper?.unmount();
vi.restoreAllMocks();
route().name = routeName;
});
it("keeps the edit open on the same template", async () => {
await mountAt({ action: "update", name: "Template2" });
expect(vm().editingTemplate?.name).toBe("Template2");
await refetch();
expect(vm().showTemplateEditor).toBe(true);
expect(vm().editingTemplate?.name).toBe("Template2");
expect(wrapper!.findComponent({ name: "AddTemplate" }).exists()).toBe(true);
});
it("keeps a new template open", async () => {
await mountAt({ action: "add" });
await refetch();
expect(vm().showTemplateEditor).toBe(true);
expect(vm().editingTemplate).toBeNull();
});
it("opens a clone on its own draft slot, never offering the new-template draft", async () => {
writeEditorDraft(
{ kind: "alert_template", org: "default", entityId: null, userEmail: "example@gmail.com" },
{ data: {} },
);
route().query = {};
wrapper = mount(TemplateList, {
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
await vi.waitFor(() => expect(vm().templates.length).toBeGreaterThan(0), {
timeout: 3000,
});
const source = vm().templates[0].name;
vm().cloneTemplate(vm().templates[0]);
await vi.waitFor(
() => expect(wrapper!.findComponent({ name: "AddTemplate" }).exists()).toBe(true),
{ timeout: 3000 },
);
await flushPromises();
expect(wrapper!.findComponent({ name: "AddTemplate" }).props("cloneSource")).toBe(source);
expect(document.body.querySelector('[data-test="editor-draft-restore-dialog"]')).toBeNull();
});
it("reopens a clone from its URL on reload and offers the clone's own draft", async () => {
writeEditorDraft(
{
kind: "alert_template",
org: "default",
entityId: null,
userEmail: "example@gmail.com",
variant: "clone:Template2",
},
{ data: {} },
);
await mountAt({ action: "add", clone: "Template2" });
expect(vm().cloningTemplate).toBe(true);
expect(vm().editingTemplate?.name).toBe("Copy_of_Template2");
expect(wrapper!.findComponent({ name: "AddTemplate" }).props("cloneSource")).toBe("Template2");
await vi.waitFor(() =>
expect(
document.body.querySelector('[data-test="editor-draft-restore-dialog"]'),
).not.toBeNull(),
);
await refetch();
expect(vm().showTemplateEditor).toBe(true);
expect(vm().cloningSource).toBe("Template2");
});
it("keeps a clone open with its copied content", async () => {
route().query = {};
wrapper = mount(TemplateList, {
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
await vi.waitFor(() => expect(vm().templates.length).toBeGreaterThan(0), {
timeout: 3000,
});
vm().cloneTemplate(vm().templates[0]);
route().query = {
action: "add",
clone: vm().templates[0].name,
};
await flushPromises();
await refetch();
expect(vm().showTemplateEditor).toBe(true);
expect(vm().cloningTemplate).toBe(true);
expect(vm().editingTemplate?.name).toMatch(/^Copy_of_/);
});
});
+103 -14
View File
@@ -34,9 +34,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="template-list-add-btn"
variant="primary"
size="sm"
:aria-label="
templateDrafts.hasNew
? t('common.editorDraft.newDot', { label: t('alert_templates.add') })
: undefined
"
@click="editTemplate(null)"
>{{ t(`alert_templates.add`) }}</OButton
>
>{{ t(`alert_templates.add`) }}
<EditorDraftDot
v-if="templateDrafts.hasNew"
:saved-at="templateDrafts.savedAt(null)"
corner
data-test="template-list-add-draft-dot"
/></OButton>
</template>
<template #actions-overflow>
<OButton
@@ -154,6 +164,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #cell-name="{ row }">
<div class="flex min-w-0 items-center gap-2">
<OTruncatedText>{{ row.name }}</OTruncatedText>
<EditorDraftDot
v-if="templateDrafts.ids.has(row.name)"
:saved-at="templateDrafts.savedAt(row.name)"
:data-test="`alert-template-list-${row.name}-draft-dot`"
/>
<OTag
v-if="row.isPrebuilt"
type="templateOrigin"
@@ -200,11 +215,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="ms-1 max-md:hidden"
variant="ghost"
size="icon-sm"
:title="t('alert_templates.clone')"
:aria-label="cloneActionLabel(row, t('alert_templates.clone'))"
@click="cloneTemplate(row)"
data-row-action="duplicate"
>
<OIcon name="content-copy" size="sm" />
<OTooltip
side="bottom"
:content="cloneActionLabel(row, t('alert_templates.clone'))"
/>
<EditorDraftDot
v-if="templateDrafts.hasNewVariant(`clone:${row.name}`)"
corner
:tooltip="false"
:data-test="`alert-template-list-${row.name}-clone-draft-dot`"
/>
</OButton>
<OButton
:data-test="`alert-template-list-${row.name}-delete-template`"
@@ -227,13 +252,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #trigger>
<OButton
icon-left="more-vert"
:title="t('dashboard.moreActions')"
:aria-label="cloneActionLabel(row, t('dashboard.moreActions'))"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
data-test="alert-template-list-row-more-actions"
@click.stop
/>
>
<OTooltip
side="bottom"
:content="cloneActionLabel(row, t('dashboard.moreActions'))"
/>
<EditorDraftDot
v-if="templateDrafts.hasNewVariant(`clone:${row.name}`)"
corner
:tooltip="false"
:data-test="`alert-template-list-${row.name}-more-actions-draft-dot`"
/>
</OButton>
</template>
<ODropdownItem
icon-left="download"
@@ -263,6 +299,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@select="cloneTemplate(row)"
>
<span>{{ t("alert_templates.clone") }}</span>
<EditorDraftDot
v-if="templateDrafts.hasNewVariant(`clone:${row.name}`)"
:saved-at="templateDrafts.savedAtVariant(`clone:${row.name}`)"
:data-test="`alert-template-list-${row.name}-clone-template-menu-draft-dot`"
/>
</ODropdownItem>
<ODropdownItem
icon-left="delete"
@@ -306,6 +347,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<AddTemplate
:template="editingTemplate"
:is-clone="cloningTemplate"
:clone-source="cloningSource"
@cancel:hideform="toggleTemplateEditor"
@get:templates="refreshTemplates"
/>
@@ -346,7 +388,7 @@ 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 { useI18nTyped, raw, type I18nText } from "@/types/i18n";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
import type { TemplateData, Template } from "@/ts/interfaces";
@@ -379,6 +421,11 @@ import { useReo } from "@/services/reodotdev_analytics";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { useEditorDraftIds } from "@/composables/useEditorDraft";
import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import { useDraftLabel } from "@/components/common/drafts/useDraftLabel";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
const AddTemplate = defineAsyncComponent(() => import("@/components/alerts/AddTemplate.vue"));
@@ -387,6 +434,15 @@ const { t } = useI18nTyped();
const router = useRouter();
const { track } = useReo();
const { graph: depGraph, loadGraph: loadDepGraph } = useDependencyGraph();
const templateDrafts = useEditorDraftIds("alert_template");
const draftLabel = useDraftLabel();
// The corner dot is presentational, so an action that reopens a kept clone says so, dated, in its own name.
const cloneActionLabel = (row: { name: string }, label: I18nText): I18nText => {
const variant = `clone:${row.name}`;
return templateDrafts.hasNewVariant(variant)
? draftLabel(label, templateDrafts.savedAtVariant(variant))
: label;
};
const orgIdForList = useOrgId();
const templatesList = useQuery(() =>
Object.assign(templatesQuery(orgIdForList.value), { enabled: !!orgIdForList.value }),
@@ -426,6 +482,7 @@ const editingTemplate: Ref<TemplateData | null> = ref(null);
// True when the editor was opened via the clone action — the AddTemplate
// form should treat the prefilled data as a fresh template (not an update).
const cloningTemplate = ref(false);
const cloningSource = ref<string | null>(null);
const confirmDelete: Ref<{
visible: boolean;
@@ -565,6 +622,14 @@ const dropTemplates = (names: string[]) => {
// 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;
for (const name of gone) {
deleteEditorDraftsFor({
kind: "alert_template",
org,
entityId: name,
variant: `clone:${name}`,
});
}
// 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,
@@ -577,11 +642,22 @@ const dropTemplates = (names: string[]) => {
const onDependencyDeleted = (_kind: DepNodeKind) => {
loadDepGraph(store.state.selectedOrganization.identifier);
};
// Re-runs on every refetch; editTemplate toggles, so re-opening the target already shown would close it.
const updateRoute = () => {
if (router.currentRoute.value.query.action === "add") editTemplate();
if (router.currentRoute.value.query.action === "update")
editTemplate(getTemplateByName(router.currentRoute.value.query.name as string));
if (router.currentRoute.value.query.action === "import") {
const { action, name, clone } = router.currentRoute.value.query;
const cloneSourceRow = typeof clone === "string" && clone ? getTemplateByName(clone) : undefined;
if (action === "add" && cloneSourceRow) {
if (!showTemplateEditor.value || cloningSource.value !== cloneSourceRow.name)
openClone(cloneSourceRow);
} else if (action === "add") {
if (!showTemplateEditor.value || (editingTemplate.value && !cloningTemplate.value))
editTemplate();
}
if (action === "update") {
if (!showTemplateEditor.value || editingTemplate.value?.name !== name)
editTemplate(getTemplateByName(name as string));
}
if (action === "import") {
showImportTemplate.value = true;
}
};
@@ -597,12 +673,13 @@ const editTemplate = (template: any = null) => {
}
resetEditingTemplate();
cloningTemplate.value = false;
cloningSource.value = null;
toggleTemplateEditor();
if (!template) {
// Strip a stale `name` left over from a previous "update" visit — everything
// else (including `page`) survives the round trip to the editor and back.
const { name: _name, ...restQuery } = router.currentRoute.value.query;
const { name: _name, clone: _clone, ...restQuery } = router.currentRoute.value.query;
router.push({
name: "alertTemplates",
query: {
@@ -613,10 +690,11 @@ const editTemplate = (template: any = null) => {
});
} else {
editingTemplate.value = { ...template };
const { clone: _clone, ...restQuery } = router.currentRoute.value.query;
router.push({
name: "alertTemplates",
query: {
...router.currentRoute.value.query,
...restQuery,
action: "update",
name: template.name,
org_identifier: store.state.selectedOrganization.identifier,
@@ -627,11 +705,15 @@ const editTemplate = (template: any = null) => {
const resetEditingTemplate = () => {
editingTemplate.value = null;
};
const cloneTemplate = (template: any) => {
const cloneTemplate = (template: Template) => {
track("Button Click", {
button: "Clone Template",
page: "Alert Templates",
});
openClone(template);
};
// The source rides in the URL so a reload reopens the clone on its own draft slot.
const openClone = (template: Template) => {
// Pre-fill the editor with a copy of the source template. AddTemplate
// treats this as a create (since isClone=true), so the user can rename
// and save without overwriting the original.
@@ -642,6 +724,7 @@ const cloneTemplate = (template: any) => {
name: `Copy_of_${template.name}`,
};
cloningTemplate.value = true;
cloningSource.value = template.name;
showTemplateEditor.value = true;
const { name: _name, ...restQuery } = router.currentRoute.value.query;
router.push({
@@ -649,6 +732,7 @@ const cloneTemplate = (template: any) => {
query: {
...restQuery,
action: "add",
clone: template.name,
org_identifier: store.state.selectedOrganization.identifier,
},
});
@@ -711,7 +795,12 @@ const cancelDeleteTemplate = () => {
const toggleTemplateEditor = () => {
showTemplateEditor.value = !showTemplateEditor.value;
if (!showTemplateEditor.value) {
const { action: _action, name: _name, ...restQuery } = router.currentRoute.value.query;
const {
action: _action,
name: _name,
clone: _clone,
...restQuery
} = router.currentRoute.value.query;
router.push({
name: "alertTemplates",
query: {
@@ -0,0 +1,401 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import store from "@/test/unit/helpers/store";
import downtimes from "@/services/downtimes";
import common from "@/services/common";
import { queryClient } from "@/composables/query/queryClient";
import OForm from "@/lib/forms/Form/OForm.vue";
import { defaultDowntimeValues } from "@/utils/downtimes/downtimeForm";
import {
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom";
import { fillField } from "@/test/unit/helpers/fillField";
import AddDowntime from "./AddDowntime.vue";
vi.mock("@/composables/downtimes/useDowntimesEnabled", async () => {
const { computed } = await import("vue");
return { useDowntimesEnabled: () => computed(() => true) };
});
vi.mock("@/services/downtimes", () => ({
default: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
cancel: vi.fn(),
remove: vi.fn(),
move: vi.fn(),
preview: vi.fn(),
resources: vi.fn(),
values: vi.fn(() => Promise.resolve({ data: { values: [], partial: false } })),
},
}));
vi.mock("@/services/alert_destination", () => ({
default: { list: vi.fn(() => Promise.resolve({ data: [] })), test: vi.fn() },
}));
vi.mock("@/utils/commons", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getFoldersListByType: vi.fn(() => Promise.resolve([])),
}));
vi.mock("@/services/common", async (importOriginal) => {
const actual = await importOriginal<{ default: Record<string, unknown> }>();
return {
...actual,
default: { ...actual.default, list_Folders: vi.fn() },
};
});
const EMPTY_PREVIEW = {
alerts: [],
resolved_at_fire_time: [],
anomalies: [],
synthetics: [],
slos: [],
alerts_total: 0,
anomalies_total: 0,
synthetics_total: 0,
slos_total: 0,
};
const stored = (version: number) => ({
id: "d1",
org: "default",
folder_id: "default",
name: "Nightly deploy",
reason: "",
targets: [{ module: "alerts", folders: { kind: "all" }, ids: ["a1"] }],
schedule: {
repeat: "none",
starts_at: (Date.now() + 3_600_000) * 1000,
ends_at: (Date.now() + 7_200_000) * 1000,
timezone: "UTC",
duration_secs: 3600,
weekdays: [],
},
show_banner: false,
created_by: "lin",
created_at: 1,
updated_by: "lin",
updated_at: version,
version,
status: "scheduled",
current_window: null,
next_window: null,
matched_alerts: 1,
matched_anomalies: 0,
matched_synthetics: 0,
matched_slos: 0,
affected: { alerts: [], anomalies: [], synthetics: [], slos: [] },
});
const newScope: EditorDraftScope = {
kind: "downtime",
org: "default",
entityId: null,
userEmail: "example@gmail.com",
};
const editScope: EditorDraftScope = { ...newScope, entityId: "d1" };
let router: Router;
let wrapper: VueWrapper | null = null;
const mountAt = async (location: Parameters<Router["push"]>[0]) => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/downtimes", name: "downtimes", component: { render: () => h("div") } },
{ path: "/downtimes/add", name: "addDowntime", component: AddDowntime },
{ path: "/downtimes/:id/edit", name: "editDowntime", component: AddDowntime },
{ path: "/downtimes/:id", name: "downtimeDetail", component: { render: () => h("div") } },
],
});
await router.push(location);
await router.isReady();
wrapper = mount(
{ render: () => h(RouterView) },
{
global: { plugins: [store, router] },
attachTo: document.body,
},
);
await flushPromises();
};
const mountNew = () => mountAt({ name: "addDowntime", query: { org_identifier: "default" } });
const mountEdit = () =>
mountAt({
name: "editDowntime",
params: { id: "d1" },
query: { org_identifier: "default", folder: "default" },
});
const form = () =>
(
wrapper!.findComponent(AddDowntime).findComponent(OForm).vm as unknown as {
form: { setFieldValue: (k: string, v: unknown) => void };
}
).form;
const typeReason = async (reason: string) => {
window.dispatchEvent(new Event("pointerdown"));
form().setFieldValue("reason", reason);
await nextTick();
};
const reasonValue = () => {
const reason = wrapper!.get('[data-test="add-downtime-reason"]');
const textarea = reason.element.matches("textarea") ? reason : reason.get("textarea");
return (textarea.element as HTMLTextAreaElement).value;
};
const remount = async () => {
wrapper?.unmount();
wrapper = null;
await mountNew();
};
describe("AddDowntime drafts", () => {
beforeEach(() => {
localStorage.clear();
queryClient.clear();
vi.mocked(common.list_Folders).mockImplementation(
(_org: string, type: string) =>
Promise.resolve({
data: { list: type === "downtimes" ? [{ folderId: "default", name: "default" }] : [] },
}) as never,
);
vi.mocked(downtimes.preview).mockResolvedValue({ data: EMPTY_PREVIEW } as never);
vi.mocked(downtimes.get).mockReset();
vi.mocked(downtimes.get).mockResolvedValue({ data: stored(3) } as never);
vi.mocked(downtimes.update).mockReset();
vi.mocked(downtimes.update).mockResolvedValue({ data: {} } as never);
store.state.selectedOrganization = {
...store.state.selectedOrganization,
identifier: "default",
};
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("writes a new downtime's draft when the page hides", async () => {
await mountNew();
await typeReason("Quarterly failover");
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft<{ reason: string }>(newScope)?.data.reason).toBe("Quarterly failover");
});
it("offers a new-downtime draft on open and continues it", async () => {
writeEditorDraft(newScope, {
data: { ...defaultDowntimeValues(Date.now(), "UTC"), reason: "From the draft" },
});
await mountNew();
expect(byTest("editor-draft-restore-dialog")?.textContent).toContain(
"unsaved changes to this downtime",
);
click("editor-draft-continue");
await flushPromises();
const reason = wrapper!.get('[data-test="add-downtime-reason"]');
const textarea = reason.element.matches("textarea") ? reason : reason.get("textarea");
expect((textarea.element as HTMLTextAreaElement).value).toBe("From the draft");
});
it("drafts a reason filled with no key or pointer event, and asks on leave", async () => {
await mountNew();
await fillField("add-downtime-reason", "Autofilled reason");
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft<{ reason: string }>(newScope)?.data.reason).toBe("Autofilled reason");
const navigation = router.push({ name: "downtimes" });
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-close-btn");
await navigation;
expect(router.currentRoute.value.name).toBe("addDowntime");
});
it("Escape on the restore dialog keeps the draft, Start fresh drops it", async () => {
writeEditorDraft(newScope, {
data: { ...defaultDowntimeValues(Date.now(), "UTC"), reason: "From the draft" },
});
await mountNew();
pressEscape();
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(reasonValue()).toBe("");
expect(readEditorDraft(newScope)).not.toBeNull();
await remount();
clickRestore("o-dialog-secondary-btn");
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(reasonValue()).toBe("");
expect(readEditorDraft(newScope)).toBeNull();
});
it("Keep draft leaves and offers the work on the next visit", async () => {
await mountNew();
await typeReason("Kept for later");
const navigation = router.push({ name: "downtimes" });
await flushPromises();
clickLeave("o-dialog-secondary-btn");
await navigation;
expect(router.currentRoute.value.name).toBe("downtimes");
expect(readEditorDraft<{ reason: string }>(newScope)?.data.reason).toBe("Kept for later");
await remount();
expect(byTest("editor-draft-restore-dialog")).not.toBeNull();
});
it("a duplicate neither offers nor overwrites the new-downtime draft, and keeps its own", async () => {
const newDraft = { ...defaultDowntimeValues(Date.now(), "UTC"), reason: "Unrelated new one" };
writeEditorDraft(newScope, { data: newDraft });
const mountDuplicate = () =>
mountAt({ name: "addDowntime", query: { org_identifier: "default", duplicate: "d1" } });
await mountDuplicate();
await vi.waitFor(() =>
expect(form().state.values).toMatchObject({ name: expect.stringContaining("Nightly") }),
);
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
await typeReason("Copied window");
window.dispatchEvent(new Event("pagehide"));
const duplicateScope = { ...newScope, variant: "clone:d1" };
expect(readEditorDraft<{ reason: string }>(duplicateScope)?.data.reason).toBe("Copied window");
expect(readEditorDraft(newScope)?.data).toEqual(newDraft);
wrapper!.unmount();
wrapper = null;
await mountDuplicate();
await vi.waitFor(() => expect(byTest("editor-draft-restore-dialog")).not.toBeNull());
});
it("keys an edit by the downtime id and warns when the stored row moved", async () => {
await mountEdit();
await typeReason("Mine");
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)?.baseHash).toBeTruthy();
expect(readEditorDraft(newScope)).toBeNull();
wrapper!.unmount();
wrapper = null;
queryClient.clear();
vi.mocked(downtimes.get).mockResolvedValue({ data: stored(4) } as never);
await mountEdit();
expect(byTest("editor-draft-changed-on-server")).not.toBeNull();
});
it("continuing an edit draft saves over the newer row, as the warning says", async () => {
await mountEdit();
await typeReason("Mine");
window.dispatchEvent(new Event("pagehide"));
wrapper!.unmount();
wrapper = null;
queryClient.clear();
vi.mocked(downtimes.get).mockResolvedValue({ data: stored(4) } as never);
await mountEdit();
await vi.waitFor(() => expect(byTest("editor-draft-continue")).not.toBeNull());
click("editor-draft-continue");
await flushPromises();
await wrapper!.get('[data-test="add-downtime-save"]').trigger("click");
await vi.waitFor(() => expect(downtimes.update).toHaveBeenCalled(), { timeout: 3000 });
const body = vi.mocked(downtimes.update).mock.calls[0][2];
expect(body.reason).toBe("Mine");
expect(body.version).toBe(4);
});
it("drops the draft of a downtime that no longer exists", async () => {
writeEditorDraft(editScope, { data: { ...defaultDowntimeValues(Date.now(), "UTC") } });
vi.mocked(downtimes.get).mockRejectedValue({ response: { status: 404 } });
await mountEdit();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(readEditorDraft(editScope)).toBeNull();
});
it("Cancel with unsaved changes asks; closing stays and Discard leaves without a draft", async () => {
await mountNew();
await typeReason("Not yet");
window.dispatchEvent(new Event("pagehide"));
await wrapper!.get('[data-test="add-downtime-cancel"]').trigger("click");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-close-btn");
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).toBeNull();
expect(router.currentRoute.value.name).toBe("addDowntime");
await wrapper!.get('[data-test="add-downtime-cancel"]').trigger("click");
await flushPromises();
clickLeave("o-dialog-neutral-btn");
await flushPromises();
expect(router.currentRoute.value.name).toBe("downtimes");
expect(readEditorDraft(newScope)).toBeNull();
});
it("Save in the leave dialog saves the edit and goes where the user was heading", async () => {
await mountEdit();
await typeReason("Patched");
const navigation = router.push({ name: "downtimeDetail", params: { id: "d1" } });
await flushPromises();
clickLeave("editor-draft-leave-save");
await navigation;
await flushPromises();
expect(downtimes.update).toHaveBeenCalledTimes(1);
expect(router.currentRoute.value.name).toBe("downtimeDetail");
expect(readEditorDraft(editScope)).toBeNull();
});
it("a normal save drops the draft and returns to the list without asking", async () => {
await mountEdit();
await typeReason("Saved normally");
window.dispatchEvent(new Event("pagehide"));
expect(readEditorDraft(editScope)).not.toBeNull();
await wrapper!.get('[data-test="add-downtime-save"]').trigger("click");
await vi.waitFor(() => expect(router.currentRoute.value.name).toBe("downtimes"), {
timeout: 3000,
});
expect(byTest("editor-draft-leave-dialog")).toBeNull();
expect(readEditorDraft(editScope)).toBeNull();
});
});
@@ -107,6 +107,8 @@ const seeSchedule = async (wrapper: ReturnType<typeof mount>) => {
describe("AddDowntime", () => {
beforeEach(() => {
// An earlier test's unsaved form leaves a draft, whose restore dialog would cover the next page.
localStorage.clear();
queryClient.clear();
vi.mocked(common.list_Folders).mockImplementation(
(_org: string, type: string) =>
@@ -367,11 +367,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<p class="text-text-secondary text-xs">{{ t("alerts.downtimes.muteAllDialog.outro") }}</p>
</div>
</ODialog>
<EditorDraftDialogs :draft="downtimeDraft" />
</OForm>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
@@ -439,6 +440,8 @@ import DowntimeNotifySection from "./DowntimeNotifySection.vue";
import DowntimePreviewPane from "./DowntimePreviewPane.vue";
import DowntimeSummary from "./DowntimeSummary.vue";
import { browserTimezone } from "@/utils/timezoneAliases";
import { useEditorDraft } from "@/composables/useEditorDraft";
import EditorDraftDialogs from "@/components/common/drafts/EditorDraftDialogs.vue";
type DowntimeTab = "targets" | "schedule" | "advanced";
@@ -542,6 +545,8 @@ const MATCHED_KEYS = {
} as const satisfies Record<TargetModule, I18nKey>;
const staleMessage = ref<I18nText | null>(null);
// Set while the leave dialog's Save runs, so a successful save does not navigate itself.
let leaveSave: { saved: boolean } | null = null;
const save = async (submitted: DowntimeFormValues) => {
const body = buildDowntimeRequest(submitted);
@@ -554,6 +559,11 @@ const save = async (submitted: DowntimeFormValues) => {
? t("toastMessages.downtimes.updated")
: t("toastMessages.downtimes.created"),
});
if (leaveSave) {
leaveSave.saved = true;
return;
}
downtimeDraft.markSaved();
await router.push({
name: "downtimes",
query: { org_identifier: orgId.value, folder: body.folder_id },
@@ -757,17 +767,21 @@ const showSchedule = () => {
void nextTick(() => schedulePane.value?.scrollIntoView?.({ block: "start" }));
};
// A failed submit opens the tab that owns the first error, then brings it into view.
const onSaveClick = (event: MouseEvent) => {
// A failed submit opens the tab that owns the first error, then brings it into view; false stops the save to show the schedule.
const prepareSave = (): boolean => {
const result = schema.safeParse(form.state.values);
if (result.success) {
if (scheduleSeen.value) return;
event.preventDefault();
if (scheduleSeen.value) return true;
showSchedule();
return;
return false;
}
activeTab.value = tabForPath((result.error.issues[0]?.path ?? []) as (string | number)[]);
void nextTick(() => scrollToFirstError());
return true;
};
const onSaveClick = (event: MouseEvent) => {
if (!prepareSave()) event.preventDefault();
};
// ── Preview ─────────────────────────────────────────────────────────────────
@@ -841,6 +855,65 @@ const bannerCounts = computed(() =>
const request = computed(() => buildDowntimeRequest(values.value));
// The stored settings only: status and match counts move on their own and must not read as a server edit.
const storedDowntime = (row: DowntimeDetail | undefined) =>
row
? {
name: row.name,
folder_id: row.folder_id,
reason: row.reason ?? "",
condition: row.condition ?? null,
targets: row.targets,
schedule: row.schedule,
show_banner: row.show_banner,
notifications: row.notifications ?? null,
version: row.version ?? null,
}
: null;
const detailMissing = computed(() => {
const err = detailQuery.error.value as { status?: number; response?: { status?: number } } | null;
return isEdit.value && (err?.status === 404 || err?.response?.status === 404);
});
const downtimeDraft = useEditorDraft<DowntimeFormValues>({
kind: "downtime",
entityLabel: () => t("common.editorDraft.entities.downtime"),
entityId: () => editId.value || null,
newVariant: () => (duplicateId.value ? `clone:${duplicateId.value}` : null),
snapshot: () => ({ ...values.value }),
apply: (data) => {
// The loaded version is kept, so saving the draft replaces a newer server row as the restore dialog warns.
form.reset({ ...data, version: values.value.version });
resetToken.value += 1;
},
base: () => (isEdit.value ? storedDowntime(detailQuery.data.value) : undefined),
save: async () => {
if (!prepareSave()) return false;
const run = { saved: false };
leaveSave = run;
try {
await form.handleSubmit();
} finally {
leaveSave = null;
}
return run.saved;
},
});
// Edit and Duplicate start once their row is in the form; a missing row starts so its draft is dropped.
const draftReady = computed(
() => !sourceId.value || loadedFrom.value === sourceId.value || detailMissing.value,
);
let draftStarted = false;
const startDraft = () => {
if (draftStarted || !draftReady.value) return;
draftStarted = true;
void downtimeDraft.start();
};
onMounted(startDraft);
watch(draftReady, startDraft);
// ── Navigation ──────────────────────────────────────────────────────────────
const goBack = () =>
router.push({
@@ -7,8 +7,26 @@ import store from "@/test/unit/helpers/store";
import DowntimeList from "./DowntimeList.vue";
import downtimes, { type DowntimeListItem } from "@/services/downtimes";
import { queryClient } from "@/composables/query/queryClient";
import {
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
// The route guard opens this page only with downtimes on.
const viewport = vi.hoisted(() => ({ mobile: false }));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
isMobile: computed(() => viewport.mobile),
isTablet: computed(() => false),
isDesktop: computed(() => !viewport.mobile),
mdUp: computed(() => !viewport.mobile),
lgUp: computed(() => !viewport.mobile),
}),
};
});
vi.mock("@/composables/downtimes/useDowntimesEnabled", async () => {
const { computed } = await import("vue");
return { useDowntimesEnabled: () => computed(() => true) };
@@ -107,11 +125,14 @@ const mountList = async (stubs: Record<string, unknown> = {}) => {
attachTo: document.body,
});
// The rows land a variable number of ticks after mount, so wait for every one of them.
await vi.waitFor(() => {
expect(wrapper.findAll('[data-test^="downtime-list-"][data-test$="-actions"]')).toHaveLength(
ROWS.length,
);
});
await vi.waitFor(
() => {
expect(wrapper.findAll('[data-test^="downtime-list-"][data-test$="-actions"]')).toHaveLength(
ROWS.length,
);
},
{ timeout: 3000 },
);
return wrapper;
};
@@ -234,3 +255,94 @@ describe("DowntimeList row actions", () => {
wrapper.unmount();
});
});
describe("DowntimeList drafts", () => {
const scope = (entityId: string | null): EditorDraftScope => ({
kind: "downtime",
org: "default",
entityId,
userEmail: "example@gmail.com",
});
beforeEach(() => {
viewport.mobile = false;
queryClient.clear();
localStorage.clear();
writeEditorDraft(scope(null), { data: {} });
writeEditorDraft(scope("scheduled"), { data: {} });
vi.mocked(downtimes.list).mockResolvedValue({
data: {
items: ROWS,
total: ROWS.length,
counts: { active: 1, scheduled: 1, recurring: 0, ended: 1, cancelled: 1, ended_early: 1 },
},
} as never);
vi.mocked(downtimes.remove)
.mockReset()
.mockResolvedValue({ data: {} } as never);
});
it("marks the New button and the drafted row only", async () => {
const wrapper = await mountList();
const add = wrapper.get('[data-test="downtime-list-add-btn"]');
expect(add.find('[data-test="downtime-list-add-draft-dot"]').exists()).toBe(true);
expect(add.attributes("aria-label")).toContain("unsaved draft");
expect(wrapper.find('[data-test="downtime-list-scheduled-draft-dot"]').exists()).toBe(true);
expect(wrapper.find('[data-test="downtime-list-active-draft-dot"]').exists()).toBe(false);
wrapper.unmount();
});
it("marks the Duplicate action of a downtime whose duplicate has a kept draft", async () => {
writeEditorDraft({ ...scope(null), variant: "clone:ended" }, { data: {} });
const wrapper = await mountList();
const duplicate = wrapper.get('[data-test="downtime-list-ended-duplicate"]');
expect(duplicate.find('[data-test="downtime-list-ended-duplicate-draft-dot"]').exists()).toBe(
true,
);
expect(duplicate.attributes("aria-label")).toContain("unsaved draft");
expect(wrapper.find('[data-test="downtime-list-active-duplicate-draft-dot"]').exists()).toBe(
false,
);
wrapper.unmount();
});
it("names the draft on the desktop row menu only where its dot and Duplicate item show", async () => {
writeEditorDraft({ ...scope(null), variant: "clone:ended" }, { data: {} });
const wrapper = await mountList();
const more = wrapper.get('[data-test="downtime-list-ended-more"]');
expect(more.attributes("aria-label")).toBe("More actions");
wrapper.unmount();
});
it("marks the row menu and its Duplicate item for a kept duplicate draft", async () => {
viewport.mobile = true;
writeEditorDraft({ ...scope(null), variant: "clone:ended" }, { data: {} });
const wrapper = await mountList();
const more = wrapper.get('[data-test="downtime-list-ended-more"]');
expect(more.find('[data-test="downtime-list-ended-more-draft-dot"]').exists()).toBe(true);
expect(more.attributes("aria-label")).toMatch(/^More actions · unsaved draft · .*\d/);
expect(wrapper.find('[data-test="downtime-list-active-more-draft-dot"]').exists()).toBe(false);
await more.trigger("keydown", { key: "Enter" });
await flushPromises();
const item = document.body.querySelector('[data-test="downtime-list-ended-duplicate-menu"]');
expect(
item?.querySelector('[data-test="downtime-list-ended-duplicate-menu-draft-dot"]'),
).not.toBeNull();
wrapper.unmount();
});
it("drops a deleted downtime's draft", async () => {
const wrapper = await mountList();
const scheduled = ROWS.find((r) => r.id === "scheduled")!;
(wrapper.vm as unknown as { askDelete: (row: DowntimeListItem) => void }).askDelete(scheduled);
await flushPromises();
dialog()!.querySelector<HTMLButtonElement>('[data-test="o-dialog-primary-btn"]')!.click();
await flushPromises();
expect(downtimes.remove).toHaveBeenCalled();
expect(readEditorDraft(scope("scheduled"))).toBeNull();
expect(readEditorDraft(scope(null))).not.toBeNull();
wrapper.unmount();
});
});
@@ -33,9 +33,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="primary"
size="sm"
data-test="downtime-list-add-btn"
:aria-label="
downtimeDrafts.hasNew
? t('common.editorDraft.newDot', { label: t('alerts.downtimes.newDowntime') })
: undefined
"
@click="createDowntime"
>
{{ t("alerts.downtimes.newDowntime") }}
<EditorDraftDot
v-if="downtimeDrafts.hasNew"
:saved-at="downtimeDrafts.savedAt(null)"
corner
data-test="downtime-list-add-draft-dot"
/>
</OButton>
</template>
@@ -211,6 +222,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</span>
<span class="truncate">{{ row.name }}</span>
<EditorDraftDot
v-if="downtimeDrafts.ids.has(row.id)"
:saved-at="downtimeDrafts.savedAt(row.id)"
:data-test="`downtime-list-${row.id}-draft-dot`"
/>
<span v-if="row.show_banner" class="inline-flex shrink-0">
<OIcon name="campaign" size="xs" class="text-text-secondary" />
<OTooltip :content="t('alerts.downtimes.bannerOn')" />
@@ -366,9 +382,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
size="icon-sm"
icon-left="content-copy"
:data-test="`downtime-list-${row.id}-duplicate`"
:aria-label="
downtimeDrafts.hasNewVariant(`clone:${row.id}`)
? t('common.editorDraft.newDot', {
label: t('alerts.downtimes.actions.duplicate'),
})
: undefined
"
@click.stop="duplicateDowntime(row)"
>
<OTooltip side="bottom" :content="t('alerts.downtimes.actions.duplicate')" />
<OTooltip
side="bottom"
:content="
downtimeDrafts.hasNewVariant(`clone:${row.id}`)
? draftLabel(
t('alerts.downtimes.actions.duplicate'),
downtimeDrafts.savedAtVariant(`clone:${row.id}`),
)
: t('alerts.downtimes.actions.duplicate')
"
/>
<EditorDraftDot
v-if="downtimeDrafts.hasNewVariant(`clone:${row.id}`)"
corner
:tooltip="false"
:data-test="`downtime-list-${row.id}-duplicate-draft-dot`"
/>
</OButton>
</span>
<ODropdown align="end">
@@ -377,10 +416,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
size="icon-sm"
icon-left="more-vert"
:aria-label="t('alerts.downtimes.actions.more')"
:aria-label="moreActionsLabel(row)"
:data-test="`downtime-list-${row.id}-more`"
@click.stop
/>
>
<EditorDraftDot
v-if="downtimeDrafts.hasNewVariant(`clone:${row.id}`)"
corner
class="md:hidden"
:data-test="`downtime-list-${row.id}-more-draft-dot`"
/>
</OButton>
</template>
<MuteMenuItems
v-if="isExtendable(row)"
@@ -424,6 +470,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@select="duplicateDowntime(row)"
>
{{ t("alerts.downtimes.actions.duplicate") }}
<EditorDraftDot
v-if="downtimeDrafts.hasNewVariant(`clone:${row.id}`)"
:saved-at="downtimeDrafts.savedAtVariant(`clone:${row.id}`)"
:data-test="`downtime-list-${row.id}-duplicate-menu-draft-dot`"
/>
</ODropdownItem>
<ODropdownSeparator class="md:hidden" />
<ODropdownItem
@@ -613,6 +664,11 @@ import { DEFAULT_DOWNTIME_FOLDER } from "@/utils/downtimes/folderDefault";
import MoveAcrossFolders from "@/components/common/sidebar/MoveAcrossFolders.vue";
import { useListBoundaryRefetch } from "@/composables/downtimes/useListBoundaryRefetch";
import DowntimeTargetsCell from "./DowntimeTargetsCell.vue";
import { useEditorDraftIds } from "@/composables/useEditorDraft";
import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import { useDraftLabel } from "@/components/common/drafts/useDraftLabel";
import useBreakpoint from "@/composables/useBreakpoint";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import ExtendDowntimeDialog from "./ExtendDowntimeDialog.vue";
import ExtendDowntimeMenu from "./ExtendDowntimeMenu.vue";
import MuteMenuItems from "./MuteMenuItems.vue";
@@ -638,11 +694,22 @@ const RAIL_COLORS: Record<DowntimeListItem["status"], string> = {
const { t } = useI18nTyped();
const store = useStore();
const draftLabel = useDraftLabel();
const viewerZone = useViewerTimezone();
const route = useRoute();
const router = useRouter();
const orgId = useOrgId();
const { toast } = useToast();
const downtimeDrafts = useEditorDraftIds("downtime");
const { isMobile } = useBreakpoint();
// Only below md does the menu hold Duplicate and show the dot, so only there may its name claim the draft.
const moreActionsLabel = (row: DowntimeListItem): I18nText => {
const label = t("alerts.downtimes.actions.more");
const variant = `clone:${row.id}`;
return isMobile.value && downtimeDrafts.hasNewVariant(variant)
? draftLabel(label, downtimeDrafts.savedAtVariant(variant))
: label;
};
const listQuery = useQuery(() =>
Object.assign(downtimesListQuery(orgId.value), { enabled: !!orgId.value }),
@@ -1121,10 +1188,14 @@ const askDelete = (row: DowntimeListItem) => {
const confirmDelete = async () => {
deleteOpen.value = false;
const id = pendingDelete.value;
try {
await deleteMutation.mutateAsync({
id: pendingDelete.value,
folder: folderOf(pendingDelete.value),
await deleteMutation.mutateAsync({ id, folder: folderOf(id) });
deleteEditorDraftsFor({
kind: "downtime",
org: orgId.value,
entityId: id,
variant: `clone:${id}`,
});
toast({ variant: "success", message: t("toastMessages.downtimes.deleted") });
} catch {
@@ -0,0 +1,424 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { h, nextTick } from "vue";
import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import { fillField } from "@/test/unit/helpers/fillField";
import CipherKeysService from "@/services/cipher_keys";
import { toast } from "@/lib/feedback/Toast/useToast";
import { queryClient } from "@/composables/query/queryClient";
import {
editorDraftKey,
hashDraftValue,
readEditorDraft,
writeEditorDraft,
type EditorDraftScope,
} from "@/utils/drafts/editorDraftStore";
import CipherKeys from "@/components/settings/CipherKeys.vue";
import { byTest, click, clickLeave, clickRestore } from "@/test/unit/helpers/draftDom";
import AddCipherKey from "./AddCipherKey.vue";
import { addCipherKeyDefaults, type AddCipherKeyForm } from "./AddCipherKey.schema";
vi.mock("@/services/cipher_keys", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: {
list: vi.fn(),
get_by_name: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
bulkDelete: vi.fn(),
},
});
});
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn(() => vi.fn()) }));
const service = CipherKeysService as unknown as Record<string, ReturnType<typeof vi.fn>>;
const newScope: EditorDraftScope = {
kind: "cipher_key",
org: "default",
entityId: null,
userEmail: "example@gmail.com",
};
const editScope: EditorDraftScope = { ...newScope, entityId: "payments_key" };
const savedKey = (): AddCipherKeyForm => {
const record = addCipherKeyDefaults();
record.name = "payments_key";
record.key.store.local = "c3VwZXItc2VjcmV0LWtleQ==";
return record;
};
const draftOf = (patch: (form: AddCipherKeyForm) => void): AddCipherKeyForm => {
const data = addCipherKeyDefaults();
patch(data);
return data;
};
interface CipherForm {
state: { values: AddCipherKeyForm };
setFieldValue: (name: string, value: unknown) => void;
}
let router: Router;
let wrapper: VueWrapper | null = null;
const mountPage = async (query: Record<string, string> = {}) => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/settings/cipher_keys", name: "cipherKeys", component: CipherKeys },
{ path: "/logs", name: "logs", component: { render: () => h("div") } },
],
});
await router.push({ name: "cipherKeys", query });
wrapper = mount(
{ render: () => h(RouterView) },
{ attachTo: document.body, global: { plugins: [i18n, store, router] } },
);
await flushPromises();
};
const editor = () => wrapper!.findComponent(AddCipherKey);
const form = () => (editor().vm as unknown as { form: CipherForm }).form;
const edit = async (field: string, value: unknown) => {
window.dispatchEvent(new Event("pointerdown"));
form().setFieldValue(field, value);
await nextTick();
};
const clickCancel = async () => {
click("add-cipher-key-cancel-btn");
await flushPromises();
};
describe("AddCipherKey drafts", () => {
beforeEach(() => {
localStorage.clear();
queryClient.clear();
vi.clearAllMocks();
service.list.mockResolvedValue({
data: { keys: [{ ...savedKey() }, { ...savedKey(), name: "audit_key" }] },
});
service.get_by_name.mockResolvedValue({ data: savedKey() });
service.create.mockResolvedValue({ data: {} });
service.update.mockResolvedValue({ data: {} });
service.delete.mockResolvedValue({ data: {} });
service.bulkDelete.mockResolvedValue({
data: { successful: ["payments_key"], unsuccessful: [] },
});
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("drafts a filled-in name and never stores the secret", async () => {
await mountPage({ action: "add" });
await fillField("add-cipher-key-name-input", "billing_key");
await fillField("add-cipher-key-openobserve-secret-input", "dG9wLXNlY3JldA==");
window.dispatchEvent(new Event("pagehide"));
const draft = readEditorDraft<AddCipherKeyForm>(newScope);
expect(draft?.data.name).toBe("billing_key");
expect(draft?.data.key.store.local).toBe("");
expect(draft?.redactedPaths).toContain("/key/store/local");
expect(localStorage.getItem(editorDraftKey(newScope))).not.toContain("dG9wLXNlY3JldA==");
});
it("Continue restores the work, leaves a changed secret blank and blocks Save until it is typed again", async () => {
writeEditorDraft(newScope, {
data: draftOf((f) => (f.name = "from_draft")),
redactedPaths: ["/key/store/local"],
changedRedactedPaths: ["/key/store/local"],
});
await mountPage({ action: "add" });
expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("cipher key");
expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("Secret");
click("editor-draft-continue");
await flushPromises();
expect(form().state.values.name).toBe("from_draft");
expect(form().state.values.key.store.local).toBe("");
click("add-cipher-key-save-btn");
await flushPromises();
expect(service.create).not.toHaveBeenCalled();
await fillField("add-cipher-key-openobserve-secret-input", "bmV3LXNlY3JldA==");
click("add-cipher-key-save-btn");
await vi.waitFor(() => expect(service.create).toHaveBeenCalledTimes(1));
expect(service.create.mock.calls[0][1].key.store.local).toBe("bmV3LXNlY3JldA==");
expect(readEditorDraft(newScope)).toBeNull();
});
it("an optional secret the user changed comes back blank and blocks Save until it is typed again", async () => {
const akeyless = draftOf((f) => {
f.name = "payments_key";
f.key.store.type = "akeyless";
const ak = f.key.store.akeyless;
ak.base_url = "https://akeyless.example.com";
ak.access_id = "p-ab12cd";
ak.auth.access_key = "c2VydmVyLWFjY2Vzcy1rZXk=";
ak.store.type = "dfc";
ak.store.dfc = { name: "payments", iv: "aXYtb2xk", encrypted_data: "ZW5jcnlwdGVk" };
});
service.get_by_name.mockResolvedValue({ data: akeyless });
const stored = JSON.parse(JSON.stringify(akeyless)) as AddCipherKeyForm;
stored.key.store.akeyless.access_id = "";
stored.key.store.akeyless.auth.access_key = "";
stored.key.store.akeyless.store.dfc.iv = "";
stored.key.store.akeyless.store.dfc.encrypted_data = "";
stored.key.mechanism.type = "tink_keyset";
const ak = "/key/store/akeyless";
writeEditorDraft(editScope, {
data: stored,
baseHash: hashDraftValue(akeyless),
redactedPaths: [
`${ak}/access_id`,
`${ak}/auth/access_key`,
`${ak}/store/dfc/iv`,
`${ak}/store/dfc/encrypted_data`,
],
changedRedactedPaths: [`${ak}/store/dfc/iv`],
});
await mountPage({ action: "edit", name: "payments_key" });
expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("DFC IV");
click("editor-draft-continue");
await flushPromises();
const restored = form().state.values.key.store.akeyless;
expect(restored.access_id).toBe("p-ab12cd");
expect(restored.auth.access_key).toBe("c2VydmVyLWFjY2Vzcy1rZXk=");
expect(restored.store.dfc.encrypted_data).toBe("ZW5jcnlwdGVk");
expect(restored.store.dfc.iv).toBe("");
click("add-cipher-key-save-btn");
await flushPromises();
expect(service.update).not.toHaveBeenCalled();
expect(byTest("add-cipher-key-akeyless-dfc-iv-input")?.textContent).toContain("Re-enter");
await fillField("add-cipher-key-akeyless-dfc-iv-input", "aXYtbmV3");
click("add-cipher-key-save-btn");
await vi.waitFor(() => expect(service.update).toHaveBeenCalledTimes(1));
expect(service.update.mock.calls[0][1].key.store.akeyless.store.dfc.iv).toBe("aXYtbmV3");
});
it("names a not-kept Akeyless static secret so it cannot read as the key's own name", async () => {
const ak = "/key/store/akeyless";
writeEditorDraft(newScope, {
data: draftOf((f) => {
f.name = "static_key";
f.key.store.type = "akeyless";
}),
redactedPaths: [`${ak}/store/static_secret`],
changedRedactedPaths: [`${ak}/store/static_secret`],
});
await mountPage({ action: "add" });
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your change to the Akeyless static secret name was not kept.",
);
});
it("Start fresh drops the draft and keeps the empty form", async () => {
writeEditorDraft(newScope, { data: draftOf((f) => (f.name = "from_draft")) });
await mountPage({ action: "add" });
clickRestore("o-dialog-secondary-btn");
await flushPromises();
expect(form().state.values.name).toBe("");
expect(readEditorDraft(newScope)).toBeNull();
});
it("Escape on the restore dialog keeps the draft and the empty form", async () => {
writeEditorDraft(newScope, { data: draftOf((f) => (f.name = "from_draft")) });
await mountPage({ action: "add" });
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
await flushPromises();
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(form().state.values.name).toBe("");
expect(readEditorDraft<AddCipherKeyForm>(newScope)?.data.name).toBe("from_draft");
});
it("an edited key's draft is keyed by its name, and an untouched secret is refilled on Continue", async () => {
await mountPage({ action: "edit", name: "payments_key" });
await edit("key.mechanism.type", "tink_keyset");
window.dispatchEvent(new Event("pagehide"));
const draft = readEditorDraft<AddCipherKeyForm>(editScope);
expect(draft?.data.key.mechanism.type).toBe("tink_keyset");
expect(draft?.data.key.store.local).toBe("");
expect(draft?.changedRedactedPaths).toEqual([]);
// Hashed with the key material blanked, so the stored hash cannot confirm a guessed secret.
expect(draft?.baseHash).toMatch(/^[0-9a-f]{8}$/);
expect(draft?.baseHash).not.toBe(hashDraftValue(savedKey()));
expect(localStorage.getItem(editorDraftKey(newScope))).toBeNull();
wrapper!.unmount();
await mountPage({ action: "edit", name: "payments_key" });
expect(byTest("editor-draft-changed-on-server")).toBeNull();
click("editor-draft-continue");
await flushPromises();
expect(form().state.values.key.mechanism.type).toBe("tink_keyset");
expect(form().state.values.key.store.local).toBe("c3VwZXItc2VjcmV0LWtleQ==");
});
it("opening a deleted key drops its draft with a notice", async () => {
writeEditorDraft(editScope, { data: savedKey() });
service.get_by_name.mockRejectedValue({ status: 404, response: { status: 404, data: {} } });
await mountPage({ action: "edit", name: "payments_key" });
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(readEditorDraft(editScope)).toBeNull();
expect(vi.mocked(toast)).toHaveBeenCalledWith(
expect.objectContaining({ variant: "info", message: expect.stringContaining("cipher key") }),
);
});
it("Cancel asks, and Discard closes the editor and drops the draft", async () => {
await mountPage({ action: "add" });
await fillField("add-cipher-key-name-input", "not_kept");
window.dispatchEvent(new Event("pagehide"));
await clickCancel();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
expect(editor().exists()).toBe(true);
clickLeave("o-dialog-neutral-btn");
await flushPromises();
expect(editor().exists()).toBe(false);
expect(readEditorDraft(newScope)).toBeNull();
});
it("Keep draft closes the editor and keeps the work", async () => {
await mountPage({ action: "add" });
await fillField("add-cipher-key-name-input", "kept_for_later");
await clickCancel();
clickLeave("o-dialog-secondary-btn");
await flushPromises();
expect(editor().exists()).toBe(false);
expect(readEditorDraft<AddCipherKeyForm>(newScope)?.data.name).toBe("kept_for_later");
});
it("closing leaves ?action before the list reloads, so a kept draft is not offered again", async () => {
await mountPage({ action: "add" });
await fillField("add-cipher-key-name-input", "kept_for_later");
service.list.mockReturnValue(new Promise(() => undefined));
await clickCancel();
clickLeave("o-dialog-secondary-btn");
await flushPromises();
expect(router.currentRoute.value.query.action).toBeUndefined();
expect(editor().exists()).toBe(false);
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(readEditorDraft(newScope)).not.toBeNull();
});
it("a clean Cancel after Escape on restore closes without offering the draft again", async () => {
writeEditorDraft(newScope, { data: draftOf((f) => (f.name = "from_draft")) });
await mountPage({ action: "add" });
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
await flushPromises();
service.list.mockReturnValue(new Promise(() => undefined));
await clickCancel();
expect(byTest("editor-draft-leave-dialog")).toBeNull();
expect(editor().exists()).toBe(false);
expect(byTest("editor-draft-restore-dialog")).toBeNull();
expect(readEditorDraft(newScope)).not.toBeNull();
});
it("Save in the leave dialog saves without closing the editor itself", async () => {
await mountPage({ action: "add" });
await fillField("add-cipher-key-name-input", "saved_from_leave");
await fillField("add-cipher-key-openobserve-secret-input", "c2F2ZWQ=");
const navigation = router.push("/logs");
await flushPromises();
clickLeave("editor-draft-leave-save");
await navigation;
expect(service.create).toHaveBeenCalledTimes(1);
expect(router.currentRoute.value.name).toBe("logs");
expect(readEditorDraft(newScope)).toBeNull();
});
it("the browser Back that drops ?action asks, and closing the dialog stays", async () => {
await mountPage({ action: "add" });
await fillField("add-cipher-key-name-input", "half_done");
const navigation = router.push({ name: "cipherKeys", query: {} });
await flushPromises();
expect(byTest("editor-draft-leave-dialog")).not.toBeNull();
clickLeave("o-dialog-close-btn");
await navigation;
expect(router.currentRoute.value.query.action).toBe("add");
});
it("the list marks the Add button and the row that hold drafts", async () => {
writeEditorDraft(newScope, { data: addCipherKeyDefaults() });
writeEditorDraft(editScope, { data: savedKey() });
await mountPage();
expect(byTest("cipher-keys-add-draft-dot")).not.toBeNull();
expect(byTest("cipher-keys-add-btn")?.getAttribute("aria-label")).toContain("unsaved draft");
await vi.waitFor(() => expect(byTest("cipher-keys-payments_key-draft-dot")).not.toBeNull());
expect(byTest("cipher-keys-audit_key-draft-dot")).toBeNull();
});
it("deleting a key drops its drafts, singly and in bulk", async () => {
writeEditorDraft(editScope, { data: savedKey() });
writeEditorDraft({ ...editScope, entityId: "audit_key" }, { data: savedKey() });
await mountPage();
const list = wrapper!.findComponent(CipherKeys).vm as unknown as {
confirmDeleteCipherKey: (row: { name: string }) => void;
deleteCipherKey: () => void;
handleSelectedIdsUpdate: (ids: string[]) => void;
bulkDeleteCipherKeys: () => void;
};
list.confirmDeleteCipherKey({ name: "audit_key" });
list.deleteCipherKey();
await flushPromises();
expect(readEditorDraft({ ...editScope, entityId: "audit_key" })).toBeNull();
expect(readEditorDraft(editScope)).not.toBeNull();
list.handleSelectedIdsUpdate(["payments_key"]);
list.bulkDeleteCipherKeys();
await flushPromises();
expect(readEditorDraft(editScope)).toBeNull();
expect(byTest("cipher-keys-payments_key-draft-dot")).toBeNull();
});
});
@@ -319,17 +319,20 @@ describe("AddCipherKey.vue", () => {
it("emits cancel:hideform directly when there are no changes", async () => {
wrapper = await createWrapper();
await wrapper.find('[data-test="add-cipher-key-cancel-btn"]').trigger("click");
await flush();
expect(wrapper.emitted("cancel:hideform")).toBeTruthy();
expect(wrapper.vm.dialog.show).toBe(false);
expect(document.body.querySelector('[data-test="editor-draft-leave-dialog"]')).toBeNull();
});
it("opens the discard-changes dialog when there are unsaved changes", async () => {
it("asks through the draft leave dialog when there are unsaved changes", async () => {
wrapper = await createWrapper();
window.dispatchEvent(new Event("pointerdown"));
setField(wrapper, "name", "dirty-name");
await nextTick();
await wrapper.find('[data-test="add-cipher-key-cancel-btn"]').trigger("click");
expect(wrapper.vm.dialog.show).toBe(true);
expect(wrapper.vm.dialog.title).toBe("Discard changes");
await flush();
expect(wrapper.emitted("cancel:hideform")).toBeFalsy();
expect(document.body.querySelector('[data-test="editor-draft-leave-dialog"]')).not.toBeNull();
});
});
});
+173 -66
View File
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="min-h-[inherit]"
:back="{
label: t('cipherKey.header'),
onClick: () => emit('cancel:hideform'),
onClick: closeEditor,
}"
bleed
>
@@ -140,7 +140,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="outline"
size="sm-action"
:disabled="isSubmitting"
@click="openCancelDialog"
@click="closeEditor"
>
{{ t("common.cancel") }}
</OButton>
@@ -157,28 +157,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</OForm>
</div>
<ConfirmDialog
v-model="dialog.show"
:title="dialog.title"
:message="dialog.message"
@update:ok="dialog.okCallback"
@update:cancel="dialog.show = false"
/>
<EditorDraftDialogs :draft="cipherKeyDraft" />
</OPageLayout>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onActivated } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { ref, computed, nextTick, onMounted, onActivated } from "vue";
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import AddOpenobserveType from "@/components/cipherkeys/AddOpenobserveType.vue";
import AddAkeylessType from "@/components/cipherkeys/AddAkeylessType.vue";
import AddEncryptionMechanism from "@/components/cipherkeys/AddEncryptionMechanism.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import EditorDraftDialogs from "@/components/common/drafts/EditorDraftDialogs.vue";
import { useEditorDraft } from "@/composables/useEditorDraft";
import CipherKeysService from "@/services/cipher_keys";
import OButton from "@/lib/core/Button/OButton.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import { useOForm, type FormFieldPath } from "@/lib/forms/Form/useOForm";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OStepper from "@/lib/navigation/Stepper/OStepper.vue";
@@ -210,14 +205,6 @@ const cipherKeyTypes = [
{ label: raw("Akeyless"), value: "akeyless" },
];
const dialog = ref({
show: false,
// raw("") is only the empty placeholder — the real values are assigned from t().
title: raw(""),
message: raw(""),
okCallback: () => {},
});
const getTypeLabel = (type: string) => cipherKeyTypes.find((item) => item.value === type)?.label;
// The parent OWNS the form (headless useOForm): it renders <OForm>, so it sits
@@ -258,41 +245,173 @@ function mergeObjects(base: any, updates: any) {
return base;
}
const setupTemplateData = () => {
if (router.currentRoute.value.query.action === "edit") {
isUpdatingCipherKey.value = true;
const name = String(router.currentRoute.value.query.name || "");
if (name === "") {
toast({ variant: "error", message: t("toastMessages.cipherkeys.invalidCipherKeyName") });
emit("cancel:hideform");
return;
}
CipherKeysService.get_by_name(store.state.selectedOrganization.identifier, name)
.then((response) => {
// :default-values is read once at mount; re-seed via reset now the
// record has arrived.
const record = mergeObjects(addCipherKeyDefaults(), response.data) as AddCipherKeyForm;
form.reset(record);
originalData.value = JSON.stringify(form.state.values ?? record);
})
.catch((error) => {
if (error.status != 403) {
toast({
variant: "error",
message: error.response?.data?.message || t("cipherKeys.fetchCipherKeyError"),
});
}
interface LoadFailure {
status?: number;
response?: { status?: number; data?: { message?: string } };
}
// The loaded key in edit mode, or null once a load found it gone.
const serverRecord = ref<AddCipherKeyForm | null>(null);
const loadCipherKey = async (): Promise<boolean> => {
if (router.currentRoute.value.query.action !== "edit") return true;
isUpdatingCipherKey.value = true;
const name = String(router.currentRoute.value.query.name || "");
if (name === "") {
toast({ variant: "error", message: t("toastMessages.cipherkeys.invalidCipherKeyName") });
emit("cancel:hideform");
return false;
}
try {
const response = await CipherKeysService.get_by_name(
store.state.selectedOrganization.identifier,
name,
);
// :default-values is read once at mount; re-seed via reset now the record has arrived.
const record = mergeObjects(addCipherKeyDefaults(), response.data) as AddCipherKeyForm;
form.reset(record);
originalData.value = JSON.stringify(form.state.values ?? record);
serverRecord.value = JSON.parse(originalData.value) as AddCipherKeyForm;
return true;
} catch (error: unknown) {
const failure = (error ?? {}) as LoadFailure;
const status = failure.response?.status ?? failure.status;
if (status !== 403) {
toast({
variant: "error",
message: raw(failure.response?.data?.message) || t("cipherKeys.fetchCipherKeyError"),
});
}
// A key that is gone still opens, so its stale draft is dropped; any other failure keeps it.
return status === 404;
}
};
const setupTemplateData = () => {
void loadCipherKey();
};
onActivated(() => setupTemplateData());
onMounted(() => {
const formValues = form.useStore((s: { values: AddCipherKeyForm }) => s.values);
// Set while the leave dialog's Save runs, so a successful save does not close the editor itself.
let leaveSave: { saved: boolean } | null = null;
const AKEYLESS = "/key/store/akeyless";
// Key material and the credentials that unlock it never reach browser storage.
const SECRET_FIELDS: Record<string, I18nKey> = {
"/key/store/local": "cipherKey.secret",
[`${AKEYLESS}/access_id`]: "cipherKey.accessId",
[`${AKEYLESS}/auth/access_key`]: "cipherKey.accessKey",
[`${AKEYLESS}/auth/ldap/username`]: "cipherKey.ldapUsername",
[`${AKEYLESS}/auth/ldap/password`]: "cipherKey.ldapPassword",
[`${AKEYLESS}/store/static_secret`]: "cipherKey.staticSecretNameNotKept",
[`${AKEYLESS}/store/dfc/iv`]: "cipherKey.dfcIV",
[`${AKEYLESS}/store/dfc/encrypted_data`]: "cipherKey.dfcEncryptedData",
};
const redactCipherKeyDraft = (data: AddCipherKeyForm) => {
const copy = JSON.parse(JSON.stringify(data)) as AddCipherKeyForm;
const paths: string[] = [];
for (const path of Object.keys(SECRET_FIELDS)) {
const segments = path.slice(1).split("/");
const field = segments.pop() as string;
const holder = segments.reduce<Record<string, unknown> | undefined>(
(node, segment) => node?.[segment] as Record<string, unknown> | undefined,
copy as unknown as Record<string, unknown>,
);
if (!holder || typeof holder[field] !== "string" || holder[field] === "") continue;
holder[field] = "";
paths.push(path);
}
return { data: copy, paths };
};
// The product name is never translated, so it is passed into the label.
const STATIC_SECRET_PATH = `${AKEYLESS}/store/static_secret`;
const secretFieldLabel = (path: string): I18nText | null => {
if (path === STATIC_SECRET_PATH) {
return t("cipherKey.staticSecretNameNotKept", { product: raw("Akeyless") });
}
return SECRET_FIELDS[path] ? t(SECRET_FIELDS[path]) : null;
};
const describeRedactedCipherKey = (paths: string[]): I18nText[] => [
...new Set(paths.flatMap((path) => secretFieldLabel(path) ?? [])),
];
const flagReentries = (paths: string[]) => {
for (const path of paths) {
const name = path.slice(1).split("/").join(".") as FormFieldPath<AddCipherKeyForm>;
const meta = form.getFieldMeta(name);
if (!meta) continue;
form.setFieldMeta(name, {
...meta,
errorMap: { ...meta.errorMap, onSubmit: t("common.editorDraft.reenterField") },
});
}
};
/** A changed secret the draft could not keep must be typed again before saving. */
const blockOnPendingReentries = (): boolean => {
const pending = cipherKeyDraft.pendingReentries();
if (!pending.length) return false;
flagReentries(pending);
toast({ variant: "error", message: t("common.editorDraft.reenterBeforeSave") });
return true;
};
const cipherKeyDraft = useEditorDraft<AddCipherKeyForm>({
kind: "cipher_key",
entityLabel: () => t("common.editorDraft.entities.cipher_key"),
entityId: () =>
isUpdatingCipherKey.value ? String(router.currentRoute.value.query.name ?? "") : null,
snapshot: () => JSON.parse(JSON.stringify(formValues.value)) as AddCipherKeyForm,
apply: async (data, { notKeptPaths }) => {
// A saved key's name is locked, so the draft can never rename it.
form.reset({ ...data, name: isUpdatingCipherKey.value ? form.state.values.name : data.name });
// After the form's own error sweep, which runs on the reset values.
await nextTick();
flagReentries(notKeptPaths);
},
base: () => (isUpdatingCipherKey.value ? serverRecord.value : null),
redactBase: (base) => redactCipherKeyDraft(base as AddCipherKeyForm).data,
hasChanges: () => JSON.stringify(form.state.values ?? {}) !== originalData.value,
save: async () => {
const run = { saved: false };
leaveSave = run;
try {
await form.handleSubmit();
} finally {
leaveSave = null;
}
return run.saved;
},
redact: redactCipherKeyDraft,
describeRedacted: describeRedactedCipherKey,
leaveOnRouteUpdate: (to, from) =>
to.path !== from.path ||
to.query.action !== from.query.action ||
to.query.name !== from.query.name,
});
onMounted(async () => {
// Baseline for the cancel diff; edit mode re-snapshots after the record loads.
originalData.value = JSON.stringify(form.state.values ?? addCipherKeyDefaults());
setupTemplateData();
if (await loadCipherKey()) await cipherKeyDraft.start();
});
/** Ends a successful save: the leave dialog's Save stays on the page, any other save closes it. */
const finishSave = () => {
if (leaveSave) {
leaveSave.saved = true;
return;
}
cipherKeyDraft.markSaved();
emit("cancel:hideform");
};
// Continue: validate the whole form by submitting through it (reveals any step-1
// errors on the mounted fields); advance only when valid. pendingContinue makes
// the @submit handler advance instead of save.
@@ -312,6 +431,7 @@ const onFormSubmit = async (value: AddCipherKeyForm) => {
step.value = 2;
return;
}
if (blockOnPendingReentries()) return;
if (isUpdatingCipherKey.value) {
await updateCipherKey(value);
} else {
@@ -341,7 +461,7 @@ const createCipherKey = async (value: AddCipherKeyForm) => {
variant: "success",
message: t("toastMessages.cipherkeys.cipherKeyCreatedSuccessfully"),
});
emit("cancel:hideform");
finishSave();
} catch (error: any) {
dismiss();
if (error.status != 403) {
@@ -354,7 +474,7 @@ const updateCipherKey = async (value: AddCipherKeyForm) => {
const current = JSON.stringify(form.state.values ?? value);
if (current === originalData.value) {
toast({ variant: "success", message: t("toastMessages.cipherkeys.noChangesDetected") });
emit("cancel:hideform");
finishSave();
return;
}
const dismiss = toast({
@@ -375,7 +495,7 @@ const updateCipherKey = async (value: AddCipherKeyForm) => {
variant: "success",
message: t("toastMessages.cipherkeys.cipherKeyUpdatedSuccessfully"),
});
emit("cancel:hideform");
finishSave();
} catch (error: any) {
dismiss();
if (error.status != 403) {
@@ -384,20 +504,8 @@ const updateCipherKey = async (value: AddCipherKeyForm) => {
}
};
const goToCipherList = () => {
emit("cancel:hideform");
};
const openCancelDialog = () => {
const current = JSON.stringify(form.state.values ?? {});
if (current === originalData.value) {
goToCipherList();
return;
}
dialog.value.show = true;
dialog.value.title = t("common.discardChanges");
dialog.value.message = t("common.cancelChangesConfirm");
dialog.value.okCallback = goToCipherList;
const closeEditor = async () => {
if (await cipherKeyDraft.confirmLeave()) emit("cancel:hideform");
};
defineExpose({
@@ -407,8 +515,7 @@ defineExpose({
storeType,
cipherKeyTypes,
getTypeLabel,
dialog,
continueToStep2,
openCancelDialog,
closeEditor,
});
</script>
@@ -43,9 +43,31 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: (...args: unknown[]) => toastMock(...args),
}));
vi.mock("@/utils/sessionServices", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/sessionServices")>()),
stopSessionServices: vi.fn(),
}));
const pushMock = vi.fn();
// Runs the global guards like vue-router does, so the shared sign-out flow can finish.
const routerGuards: ((to: { path: string }) => unknown)[] = [];
vi.mock("vue-router", () => ({
useRouter: () => ({ push: pushMock }),
useRouter: () => ({
push: async (path: string) => {
pushMock(path);
for (const guard of [...routerGuards]) {
// A guard returning false aborts the navigation, which push reports as a failure.
if ((await guard({ path })) === false) return { type: "aborted" };
}
return undefined;
},
beforeEach: (guard: (to: { path: string }) => unknown) => {
routerGuards.push(guard);
return () => {
if (routerGuards.includes(guard)) routerGuards.splice(routerGuards.indexOf(guard), 1);
};
},
}),
}));
const invalidateLoginDataMock = vi.fn();
@@ -60,6 +82,16 @@ vi.mock("@/utils/zincutils", async (importOriginal) => {
});
import passwordPolicyService from "@/services/passwordPolicy";
import {
cancelDraftSignOut,
draftSignOutDiscardOnly,
draftSignOutForced,
resumeDraftSession,
} from "@/utils/drafts/draftSession";
import { stopSessionServices } from "@/utils/sessionServices";
import { readEditorDraft, writeEditorDraft } from "@/utils/drafts/editorDraftStore";
const realLocation = window.location;
import userService from "@/services/users";
const STRICT_COMPLEXITY = {
@@ -97,8 +129,11 @@ const mountDialog = () => {
const { open, close, isOpen } = usePasswordReset();
describe("UpdatePasswordDialog", () => {
const assign = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("location", { ...window.location, assign });
resetPasswordComplexityCache();
close();
(passwordPolicyService.getComplexity as any).mockResolvedValue({ data: STRICT_COMPLEXITY });
@@ -109,6 +144,11 @@ describe("UpdatePasswordDialog", () => {
wrappers.forEach((wrapper) => wrapper.unmount());
wrappers = [];
close();
vi.stubGlobal("location", realLocation);
// A sign-out that reached the reload leaves its flags and the signed-out user behind, as the reload would clear them.
cancelDraftSignOut();
resumeDraftSession();
localStorage.clear();
});
it("renders nothing until the user is flagged", async () => {
@@ -270,6 +310,68 @@ describe("UpdatePasswordDialog", () => {
);
});
it("signs out with editors offering only Discard, and stays open when an editor keeps the user", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
let sawDiscardOnly = false;
const editorGuard = (to: { path: string }) => {
if (to.path !== "/logout") return true;
sawDiscardOnly = draftSignOutDiscardOnly.value;
return false;
};
routerGuards.unshift(editorGuard);
try {
await (wrapper.vm as unknown as { signOut: () => Promise<void> }).signOut();
await flushPromises();
} finally {
routerGuards.splice(routerGuards.indexOf(editorGuard), 1);
}
expect(sawDiscardOnly).toBe(true);
expect(draftSignOutDiscardOnly.value).toBe(false);
expect(invalidateLoginDataMock).not.toHaveBeenCalled();
expect(stopSessionServices).not.toHaveBeenCalled();
expect(assign).not.toHaveBeenCalled();
expect(isOpen.value).toBe(true);
});
it("confirms discarding the user's drafts before its Sign out, as the menu's Sign out does", async () => {
const scope = { kind: "slo" as const, org: "acme", entityId: null, userEmail: "maya@acme.io" };
writeEditorDraft(scope, { data: { name: "unsaved" } });
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await wrapper.find('[data-test="o-dialog-neutral-btn"]').trigger("click");
await flushPromises();
expect(pushMock).not.toHaveBeenCalled();
expect(wrapper.find('[data-test="editor-draft-signout-message"]').text()).toBe(
"You have 1 unsaved draft in this browser. Signing out discards it.",
);
expect(readEditorDraft(scope)).not.toBeNull();
await wrapper.find('[data-test="editor-draft-signout-confirm"]').trigger("click");
await flushPromises();
expect(pushMock).toHaveBeenCalledWith("/logout");
expect(assign).toHaveBeenCalledTimes(1);
resumeDraftSession();
expect(readEditorDraft(scope)).toBeNull();
localStorage.clear();
});
it("signs out at once from its Sign out when the user has no drafts", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await wrapper.find('[data-test="o-dialog-neutral-btn"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-test="editor-draft-signout-dialog"]').exists()).toBe(false);
expect(pushMock).toHaveBeenCalledWith("/logout");
expect(assign).toHaveBeenCalledTimes(1);
});
it("signs the user out on success, because the cookie is the old password", async () => {
open("policy_tightened");
const wrapper = mountDialog();
@@ -283,7 +385,63 @@ describe("UpdatePasswordDialog", () => {
await flushPromises();
expect(invalidateLoginDataMock).toHaveBeenCalled();
expect(stopSessionServices).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/logout");
expect(assign).toHaveBeenCalledTimes(1);
expect(isOpen.value).toBe(false);
});
it("keeps the user's drafts once the password has changed, as an expired session does", async () => {
const scope = { kind: "slo" as const, org: "acme", entityId: null, userEmail: "maya@acme.io" };
writeEditorDraft(scope, { data: { name: "unsaved" } });
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await (wrapper.vm as unknown as { submit: (v: UpdatePasswordForm) => Promise<void> }).submit({
old_password: "old-secret",
new_password: "New-Secret-1!",
confirm_password: "New-Secret-1!",
});
await flushPromises();
expect(assign).toHaveBeenCalledTimes(1);
resumeDraftSession();
expect(readEditorDraft(scope)?.data).toEqual({ name: "unsaved" });
localStorage.clear();
});
it("cannot be kept in the old session once the password has changed", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
let sawForced = false;
const editorGuard = (to: { path: string }) => {
if (to.path !== "/logout") return true;
sawForced = draftSignOutForced.value;
return false;
};
routerGuards.unshift(editorGuard);
const vm = wrapper.vm as unknown as {
submit: (
values: Record<"old_password" | "new_password" | "confirm_password", string>,
) => Promise<void>;
};
try {
await vm.submit({
old_password: "old-secret",
new_password: "New-Secret-1!",
confirm_password: "New-Secret-1!",
});
await flushPromises();
} finally {
routerGuards.splice(routerGuards.indexOf(editorGuard), 1);
}
expect(sawForced).toBe(true);
expect(invalidateLoginDataMock).toHaveBeenCalled();
expect(stopSessionServices).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledTimes(1);
expect(isOpen.value).toBe(false);
});
@@ -29,7 +29,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:secondary-button-label="dismissible ? t('common.cancel') : undefined"
:primary-button-label="t('passwordReset.submit')"
form-id="update-password-form"
@click:neutral="signOut"
@click:neutral="requestSignOut"
@click:secondary="close"
@update:open="(open: boolean) => !open && close()"
>
@@ -83,10 +83,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OForm>
</div>
</ODialog>
<EditorDraftSignOutDialog
v-model:open="signOutDraftsOpen"
:count="signOutDraftCount"
@confirm="signOut()"
/>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
@@ -101,7 +106,8 @@ import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import userService from "@/services/users";
import { raw, useI18nTyped } from "@/types/i18n";
import { reuseRejection } from "@/utils/passwordComplexity";
import { invalidateLoginData, useLocalCurrentUser, useLocalUserInfo } from "@/utils/zincutils";
import { signOutDraftsToConfirm, signOutThroughLeaveGuards } from "@/utils/drafts/signOutFlow";
import EditorDraftSignOutDialog from "@/components/common/drafts/EditorDraftSignOutDialog.vue";
import PasswordRequirementList from "./PasswordRequirementList.vue";
import {
@@ -156,13 +162,24 @@ watch(
{ immediate: true },
);
const signOut = () => {
invalidateLoginData();
store.dispatch("logout");
useLocalCurrentUser("", true);
useLocalUserInfo("", true);
close();
router.push("/logout");
// The server refuses every save until the password changes, so editors offer only Discard; once it has changed the old session is dead, so editors keep their work as drafts and go.
const signOut = async ({ passwordChanged = false } = {}) => {
const options = passwordChanged ? { forced: true } : { discardOnly: true };
if (await signOutThroughLeaveGuards(router, store, options)) close();
};
const signOutDraftsOpen = ref(false);
const signOutDraftCount = ref(0);
// The user's own Sign out discards every draft of theirs in this browser, so it is confirmed first, as the menu's Sign out does.
const requestSignOut = () => {
const count = signOutDraftsToConfirm(store);
if (count > 0) {
signOutDraftCount.value = count;
signOutDraftsOpen.value = true;
return;
}
void signOut();
};
const submit = async (values: UpdatePasswordForm) => {
@@ -192,6 +209,6 @@ const submit = async (values: UpdatePasswordForm) => {
// The session cookie IS the password, so the browser's credential went stale the moment the change landed.
toast({ variant: "success", message: t("passwordReset.updated") });
signOut();
void signOut({ passwordChanged: true });
};
</script>
@@ -0,0 +1,83 @@
// 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, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { reactive } from "vue";
import { raw } from "@/types/i18n";
import type { EditorDraftController } from "@/composables/useEditorDraft";
import EditorDraftDialogs from "./EditorDraftDialogs.vue";
import EditorDraftRestoreDialog from "./EditorDraftRestoreDialog.vue";
import EditorDraftLeaveDialog from "./EditorDraftLeaveDialog.vue";
const controller = (): EditorDraftController => ({
start: vi.fn(),
confirmLeave: vi.fn(),
markSaved: vi.fn(),
discard: vi.fn(),
hasUnsavedChanges: vi.fn(() => false),
storageAvailable: true,
state: reactive({
entityLabel: raw("pipeline"),
restoreTitle: raw("Unsaved draft"),
restoreOpen: true,
restoreSavedAt: 42,
changedOnServer: true,
credentialsNotSaved: false,
leaveOpen: false,
leaveSaving: true,
canKeepDraft: false,
}),
chooseRestore: vi.fn(),
chooseLeave: vi.fn(),
});
describe("EditorDraftDialogs", () => {
it("binds both dialogs to the controller's state and choices", async () => {
const draft = controller();
const wrapper = mount(EditorDraftDialogs, {
props: { draft },
global: { stubs: { EditorDraftRestoreDialog: true, EditorDraftLeaveDialog: true } },
});
const restore = wrapper.findComponent(EditorDraftRestoreDialog);
expect(restore.props()).toMatchObject({
open: true,
savedAt: 42,
entityLabel: "pipeline",
title: "Unsaved draft",
changedOnServer: true,
credentialsNotSaved: false,
});
const leave = wrapper.findComponent(EditorDraftLeaveDialog);
expect(leave.props()).toMatchObject({
open: false,
entityLabel: "pipeline",
saving: true,
canKeepDraft: false,
});
restore.vm.$emit("continue");
restore.vm.$emit("fresh");
restore.vm.$emit("dismiss");
leave.vm.$emit("choose", "keep");
expect(vi.mocked(draft.chooseRestore).mock.calls).toEqual([
["continue"],
["fresh"],
["dismiss"],
]);
expect(draft.chooseLeave).toHaveBeenCalledWith("keep");
});
});
@@ -0,0 +1,55 @@
<!--
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/>.
-->
<template>
<EditorDraftRestoreDialog
:open="draft.state.restoreOpen"
:saved-at="draft.state.restoreSavedAt"
:entity-label="draft.state.entityLabel"
:title="draft.state.restoreTitle"
:changed-on-server="draft.state.changedOnServer"
:credentials-not-saved="draft.state.credentialsNotSaved"
:not-kept-fields="draft.state.notKeptFields"
:note="draft.state.restoreNote"
:server-draft-item="draft.state.serverDraftItem"
@continue="draft.chooseRestore('continue')"
@fresh="draft.chooseRestore('fresh')"
@dismiss="draft.chooseRestore('dismiss')"
/>
<EditorDraftLeaveDialog
:open="draft.state.leaveOpen"
:entity-label="draft.state.entityLabel"
:saving="draft.state.leaveSaving"
:can-keep-draft="draft.state.canKeepDraft"
:signing-out="draft.state.signingOut"
:other-draft-count="draft.state.signOutOtherDrafts"
:can-save="draft.state.canSave"
:server-draft-item="draft.state.serverDraftItem"
:save-label="draft.state.saveLabel"
@choose="draft.chooseLeave"
/>
</template>
<script setup lang="ts">
import type { EditorDraftController } from "@/composables/useEditorDraft";
import EditorDraftRestoreDialog from "./EditorDraftRestoreDialog.vue";
import EditorDraftLeaveDialog from "./EditorDraftLeaveDialog.vue";
defineProps<{
draft: EditorDraftController;
}>();
</script>
@@ -0,0 +1,172 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { defineComponent, h, ref } from "vue";
import { raw } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import { createStore } from "vuex";
import EditorDraftDot from "./EditorDraftDot.vue";
const store = createStore({ state: { timezone: "UTC" } });
describe("EditorDraftDot", () => {
it("names a row's draft with its time", () => {
const wrapper = mount(EditorDraftDot, {
props: { savedAt: Date.UTC(2026, 9, 21, 14, 32), dataTest: "row-dot" },
global: { plugins: [store] },
});
const dot = wrapper.get('[data-test="row-dot"]');
expect(dot.attributes("role")).toBe("img");
expect(dot.attributes("aria-label")).toMatch(/^Unsaved draft · .*2026/);
expect(dot.classes()).toContain("bg-button-pending-bg");
});
it("pins a silent dot to a button corner", () => {
const wrapper = mount(EditorDraftDot, {
props: { corner: true },
global: { plugins: [store] },
});
const dot = wrapper.get('[data-test="editor-draft-dot"]');
expect(dot.attributes("aria-hidden")).toBe("true");
expect(dot.attributes("role")).toBeUndefined();
expect(dot.classes()).toEqual(expect.arrayContaining(["absolute", "-end-1", "-top-1"]));
});
describe("corner dot tooltip on its host button", () => {
let wrapper: VueWrapper | null = null;
beforeEach(() => {
// jsdom has no layout, so a box is faked for every element except one CSS would hide.
vi.spyOn(Element.prototype, "getClientRects").mockImplementation(function (this: Element) {
const hidden = !!this.closest(".hidden");
return { length: hidden ? 0 : 1, item: () => null } as unknown as DOMRectList;
});
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
vi.useRealTimers();
vi.restoreAllMocks();
});
const mountOnButton = (dotProps: Record<string, unknown> = {}) => {
const Host = defineComponent({
setup: () => () =>
h(
OButton,
{ variant: "primary", "data-test": "host-btn", "aria-label": raw("New alert") },
() => [raw("New alert"), h(EditorDraftDot, { corner: true, ...dotProps })],
),
});
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [store] } });
return document.body.querySelector<HTMLElement>('[data-test="host-btn"]')!;
};
const tooltipText = () =>
document.body.querySelector('[data-test="o-tooltip-content"]')?.textContent ?? null;
it("opens on hovering the button, dated, and closes on leave", async () => {
vi.useFakeTimers();
const button = mountOnButton({ savedAt: Date.UTC(2026, 9, 21, 14, 32) });
await flushPromises();
expect(button.tagName).toBe("BUTTON");
button.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(800);
await flushPromises();
expect(tooltipText()).toMatch(/Unsaved draft · .*2026/);
button.dispatchEvent(new MouseEvent("mouseleave"));
await flushPromises();
expect(tooltipText()).toBeNull();
});
it("opens on keyboard focus of the button, undated when no time is known", async () => {
vi.useFakeTimers();
const button = mountOnButton();
await flushPromises();
button.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
vi.advanceTimersByTime(800);
await flushPromises();
expect(tooltipText()).toContain("Unsaved draft");
expect(tooltipText()).not.toContain("·");
});
it("shows nothing for a dot CSS hides, e.g. md:hidden at desktop", async () => {
vi.useFakeTimers();
const button = mountOnButton({ class: "hidden" });
await flushPromises();
button.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(800);
await flushPromises();
expect(tooltipText()).toBeNull();
});
it("follows a tooltip prop that turns off and back on while mounted", async () => {
vi.useFakeTimers();
const on = ref(true);
const Host = defineComponent({
setup: () => () =>
h("button", { "data-test": "toggle-btn" }, [
h(EditorDraftDot, { corner: true, tooltip: on.value }),
]),
});
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [store] } });
await flushPromises();
const button = document.body.querySelector<HTMLElement>('[data-test="toggle-btn"]')!;
const hover = async () => {
button.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(800);
await flushPromises();
};
await hover();
expect(tooltipText()).toContain("Unsaved draft");
on.value = false;
await flushPromises();
expect(tooltipText()).toBeNull();
await hover();
expect(tooltipText()).toBeNull();
button.dispatchEvent(new MouseEvent("mouseleave"));
on.value = true;
await flushPromises();
await hover();
expect(tooltipText()).toContain("Unsaved draft");
});
it("shows nothing with tooltip off, and keeps a single root so host classes apply", async () => {
vi.useFakeTimers();
const Host = defineComponent({
setup: () => () =>
h("button", { "data-test": "plain-btn" }, [
h(EditorDraftDot, { corner: true, tooltip: false, class: "md:hidden" }),
]),
});
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [store] } });
await flushPromises();
const dot = document.body.querySelector('[data-test="editor-draft-dot"]');
expect(dot?.classList.contains("md:hidden")).toBe(true);
const button = document.body.querySelector<HTMLElement>('[data-test="plain-btn"]')!;
button.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(800);
await flushPromises();
expect(tooltipText()).toBeNull();
});
});
});
@@ -0,0 +1,128 @@
<!--
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/>.
-->
<template>
<!-- A button's children are presentational, so the corner dot's meaning lives in the button's own name; the tooltip shows it. -->
<span
v-if="corner"
ref="dotRef"
class="bg-button-pending-bg ring-button-pending-bg/30 pointer-events-none absolute -end-1 -top-1 size-2.5 rounded-full ring-4"
aria-hidden="true"
:data-test="dataTest"
>
<OTooltip
v-if="host && tooltip"
:anchor="host"
:open="hostActive"
:content="label"
side="bottom"
@update:open="hostActive = $event"
/>
</span>
<OTooltip v-else :content="label" side="right">
<span
role="img"
:aria-label="label"
class="bg-button-pending-bg ring-button-pending-bg/30 inline-block size-2 shrink-0 rounded-full ring-2"
:data-test="dataTest"
/>
</OTooltip>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
import { useStore } from "vuex";
import { useI18nTyped } from "@/types/i18n";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { formatEditorDraftTime } from "@/utils/drafts/editorDraftStore";
const props = withDefaults(
defineProps<{
/** When the row's draft was written; the inline dot's tooltip shows it. */
savedAt?: number | null;
/** Pins the dot to the top-end corner of a positioned parent such as an OButton. */
corner?: boolean;
/** Off when the host button already has a tooltip that says it has a draft. */
tooltip?: boolean;
dataTest?: string;
}>(),
{ savedAt: null, corner: false, tooltip: true, dataTest: "editor-draft-dot" },
);
const { t, locale } = useI18nTyped();
const store = useStore();
const label = computed(() =>
props.savedAt === null
? t("common.editorDraft.dotUndated")
: t("common.editorDraft.rowDot", {
date: formatEditorDraftTime(props.savedAt, locale.value, store.state.timezone || undefined),
}),
);
// The dot cannot be hovered, so its tooltip opens on the host button's own hover and focus.
const HOVER_DELAY_MS = 700;
const HOST_SELECTOR = "button, a[href], [role='button']";
const dotRef = ref<HTMLElement | null>(null);
const host = shallowRef<HTMLElement | null>(null);
const hostActive = ref(false);
let showTimer: ReturnType<typeof setTimeout> | null = null;
// A dot CSS hides (e.g. `md:hidden`) has no box, and a tooltip for a dot nobody sees would point at nothing.
const dotVisible = () => !!dotRef.value?.getClientRects().length;
const show = () => {
if (showTimer) clearTimeout(showTimer);
if (!props.tooltip || !dotVisible()) return;
showTimer = setTimeout(() => {
if (props.tooltip && dotVisible()) hostActive.value = true;
}, HOVER_DELAY_MS);
};
const hide = () => {
if (showTimer) clearTimeout(showTimer);
showTimer = null;
hostActive.value = false;
};
const HOST_EVENTS = [
["mouseenter", show],
["focusin", show],
["mouseleave", hide],
["focusout", hide],
] as const;
// The host may turn its own tooltip on and off (e.g. a cap message), so this one follows the prop live.
watch(
() => props.tooltip,
(on) => {
if (!on) hide();
},
);
onMounted(() => {
if (!props.corner) return;
host.value = dotRef.value?.parentElement?.closest<HTMLElement>(HOST_SELECTOR) ?? null;
HOST_EVENTS.forEach(([name, handler]) => host.value?.addEventListener(name, handler));
});
onBeforeUnmount(() => {
hide();
HOST_EVENTS.forEach(([name, handler]) => host.value?.removeEventListener(name, handler));
});
</script>
@@ -0,0 +1,40 @@
<!--
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/>.
-->
<template>
<OButton
variant="ghost-muted"
size="icon-xs-sq"
icon-left="info-outline"
class="shrink-0"
:aria-label="content"
:data-test="dataTest"
>
<OTooltip :content="content" side="top" />
</OButton>
</template>
<script setup lang="ts">
import type { I18nText } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
defineProps<{
content: I18nText;
dataTest: string;
}>();
</script>
@@ -0,0 +1,181 @@
// 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 { afterEach, describe, expect, it } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { raw } from "@/types/i18n";
import EditorDraftLeaveDialog from "./EditorDraftLeaveDialog.vue";
let wrapper: VueWrapper | null = null;
const mountDialog = async (props: Record<string, unknown> = {}) => {
wrapper = mount(EditorDraftLeaveDialog, {
attachTo: document.body,
props: { open: true, entityLabel: raw("report"), canKeepDraft: true, ...props },
});
await flushPromises();
return wrapper;
};
const button = (id: string) =>
document.body.querySelector<HTMLElement>(
`[data-test="editor-draft-leave-dialog"] [data-test="${id}"]`,
);
const click = (id: string) => {
const target = button(id);
if (!target) throw new Error(`no ${id} in the leave dialog`);
target.click();
};
const messageText = () =>
document.body.querySelector('[data-test="editor-draft-leave-message"]')?.textContent ?? "";
const pressEscape = async () => {
const content = document.body.querySelector('[data-test="editor-draft-leave-dialog"]');
content?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
await flushPromises();
};
describe("EditorDraftLeaveDialog", () => {
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("asks about the entity and offers save, keep draft and discard", async () => {
const dialog = await mountDialog();
const root = document.body.querySelector('[data-test="editor-draft-leave-dialog"]');
expect(root?.textContent).toContain("Leave with unsaved changes?");
expect(messageText()).toContain("Save the report");
expect(messageText()).toBe(
"Save the report, keep your changes as a draft in this browser, or discard them.",
);
click("editor-draft-leave-save");
click("o-dialog-secondary-btn");
click("o-dialog-neutral-btn");
expect(dialog.emitted("choose")).toEqual([["save"], ["keep"], ["discard"]]);
expect(button("o-dialog-neutral-btn")?.textContent?.trim()).toBe("Discard");
});
it("states how long a kept draft lasts behind the info icon, only when Keep is offered", async () => {
await mountDialog();
const hint = document.body.querySelector('[data-test="editor-draft-leave-retention"]');
expect(hint?.tagName).toBe("BUTTON");
expect(hint?.getAttribute("aria-label")).toBe(
"Drafts are kept in this browser for 7 days, or until you sign out. Only the 20 newest of each kind are kept.",
);
wrapper?.unmount();
await mountDialog({ canKeepDraft: false });
expect(document.body.querySelector('[data-test="editor-draft-leave-retention"]')).toBeNull();
});
it("says in the dialog text that signing out discards the other drafts, and explains it behind the info icon", async () => {
await mountDialog({ canKeepDraft: false, signingOut: true, otherDraftCount: 2 });
expect(messageText()).toContain("Save the report or discard your changes.");
expect(messageText()).not.toContain("draft");
expect(
document.body.querySelector('[data-test="editor-draft-leave-sign-out-others"]')?.textContent,
).toContain("Signing out also discards 2 other unsaved drafts in this browser.");
const hint = document.body.querySelector('[data-test="editor-draft-leave-sign-out"]');
expect(hint?.tagName).toBe("BUTTON");
expect(hint?.getAttribute("aria-label")).toBe("Drafts are not kept when you sign out.");
expect(button("o-dialog-secondary-btn")).toBeNull();
expect(button("o-dialog-neutral-btn")).not.toBeNull();
});
it("says why there is no Keep when the sign-out leaves no other draft", async () => {
await mountDialog({ canKeepDraft: false, signingOut: true });
expect(
document.body
.querySelector('[data-test="editor-draft-leave-sign-out"]')
?.getAttribute("aria-label"),
).toBe("Drafts are not kept when you sign out.");
expect(
document.body.querySelector('[data-test="editor-draft-leave-sign-out-others"]'),
).toBeNull();
});
it("hides keep draft when drafts cannot be stored", async () => {
await mountDialog({ canKeepDraft: false });
expect(button("o-dialog-secondary-btn")).toBeNull();
expect(button("editor-draft-leave-save")).not.toBeNull();
expect(document.body.querySelector('[data-test="editor-draft-leave-sign-out"]')).toBeNull();
});
it("offers a Stay button beside Discard when the item cannot be saved", async () => {
const dialog = await mountDialog({ canKeepDraft: false, canSave: false, signingOut: true });
expect(messageText()).toContain("or stay on this page");
expect(button("editor-draft-leave-save")).toBeNull();
expect(button("o-dialog-secondary-btn")?.textContent?.trim()).toBe("Stay");
click("o-dialog-secondary-btn");
click("o-dialog-neutral-btn");
expect(dialog.emitted("choose")).toEqual([["stay"], ["discard"]]);
});
it("cannot be closed or stayed in during a forced sign-out", async () => {
const dialog = await mountDialog({
canKeepDraft: false,
canSave: false,
forced: true,
signingOut: true,
otherDraftCount: 2,
});
expect(messageText()).toBe(
"Your password changed, so you are being signed out and this report cannot be saved. Your changes to it will be discarded.",
);
expect(button("o-dialog-close-btn")).toBeNull();
expect(button("o-dialog-secondary-btn")).toBeNull();
expect(button("editor-draft-leave-save")).toBeNull();
expect(document.body.querySelector('[data-test="editor-draft-leave-sign-out"]')).toBeNull();
expect(
document.body.querySelector('[data-test="editor-draft-leave-sign-out-others"]'),
).toBeNull();
await pressEscape();
expect(dialog.emitted("choose")).toBeUndefined();
expect(document.body.querySelector('[data-test="editor-draft-leave-dialog"]')).not.toBeNull();
click("o-dialog-neutral-btn");
expect(dialog.emitted("choose")).toEqual([["discard"]]);
});
it("shows the host's own message in place of the default copy", async () => {
await mountDialog({ canKeepDraft: false, message: raw("Save it as a server draft.") });
expect(messageText().trim()).toBe("Save it as a server draft.");
});
it("stays on close", async () => {
const dialog = await mountDialog();
click("o-dialog-close-btn");
await flushPromises();
expect(dialog.emitted("choose")).toEqual([["stay"]]);
});
it("stays on Escape when the sign-out is not forced", async () => {
const dialog = await mountDialog({ canSave: false, canKeepDraft: false });
await pressEscape();
expect(dialog.emitted("choose")?.at(-1)).toEqual(["stay"]);
});
it("is persistent and disables its buttons while saving", async () => {
const dialog = await mountDialog({ saving: true });
expect(dialog.findComponent({ name: "ODialog" }).props("persistent")).toBe(true);
expect(button("o-dialog-neutral-btn")?.hasAttribute("disabled")).toBe(true);
expect(button("o-dialog-secondary-btn")?.hasAttribute("disabled")).toBe(true);
});
});
@@ -0,0 +1,141 @@
<!--
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/>.
-->
<template>
<ODialog
:open="open"
size="sm"
:persistent="saving || forced"
:show-close="!forced"
:title="t('common.editorDraft.leaveTitle')"
:primary-button-label="canSave ? (saveLabel ?? t('common.save')) : undefined"
primary-button-data-test="editor-draft-leave-save"
:primary-button-loading="saving"
:secondary-button-label="secondaryLabel"
:neutral-button-label="t('common.editorDraft.discard')"
neutral-button-variant="ghost-destructive"
data-test="editor-draft-leave-dialog"
@update:open="onOpenChange"
@click:primary="emit('choose', 'save')"
@click:secondary="emit('choose', canKeepDraft ? 'keep' : 'stay')"
@click:neutral="emit('choose', 'discard')"
>
<div class="flex items-start gap-1">
<div class="flex min-w-0 flex-1 flex-col gap-2">
<p class="text-text-body text-sm" data-test="editor-draft-leave-message">
{{ leaveMessage }}
</p>
<p
v-if="discardsOthers"
class="text-text-body text-sm"
data-test="editor-draft-leave-sign-out-others"
>
{{
t(
"common.editorDraft.signOutDiscardsOthers",
{ count: otherDraftCount },
otherDraftCount,
)
}}
</p>
</div>
<EditorDraftInfoHint
v-if="signingOut && !forced"
:content="t('common.editorDraft.signOutDiscardsOthers', { count: 0 }, 0)"
data-test="editor-draft-leave-sign-out"
/>
<EditorDraftInfoHint
v-else-if="canKeepDraft"
:content="retention"
data-test="editor-draft-leave-retention"
/>
</div>
</ODialog>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import EditorDraftInfoHint from "./EditorDraftInfoHint.vue";
import type { EditorDraftLeaveChoice } from "@/composables/useEditorDraft";
import { EDITOR_DRAFT_MAX_PER_KIND, EDITOR_DRAFT_TTL_MS } from "@/utils/drafts/editorDraftStore";
const props = withDefaults(
defineProps<{
open: boolean;
entityLabel: I18nText;
saving?: boolean;
canKeepDraft?: boolean;
signingOut?: boolean;
canSave?: boolean;
/** The sign-out cannot be cancelled (the password changed), so the dialog offers only Discard and cannot be closed. */
forced?: boolean;
/** While signing out, how many of the user's other drafts the sign-out discards. */
otherDraftCount?: number;
/** The item has a server-side draft of its own, so the copy avoids the word "draft". */
serverDraftItem?: boolean;
saveLabel?: I18nText | null;
/** The host's own message, when the default copy does not fit its buttons. */
message?: I18nText | null;
}>(),
{ canSave: true, otherDraftCount: 0 },
);
const emit = defineEmits<{
choose: [choice: EditorDraftLeaveChoice];
}>();
const { t } = useI18nTyped();
const leaveMessage = computed(() => {
if (props.message) return props.message;
const entity = props.entityLabel;
if (props.forced) return t("common.editorDraft.leaveMessageForced", { entity });
if (!props.canSave) return t("common.editorDraft.leaveMessageDiscardOnly", { entity });
if (!props.canKeepDraft) return t("common.editorDraft.leaveMessageNoDraft", { entity });
return props.serverDraftItem
? t("common.editorDraft.leaveMessageInBrowser", { entity })
: t("common.editorDraft.leaveMessage", { entity });
});
const retention = computed(() => {
const params = { days: EDITOR_DRAFT_TTL_MS / 86_400_000, max: EDITOR_DRAFT_MAX_PER_KIND };
return props.serverDraftItem
? t("common.editorDraft.retentionInBrowser", params)
: t("common.editorDraft.retention", params);
});
// Deleting the user's other drafts is destructive, so it is said in the dialog, not only behind the info icon.
const discardsOthers = computed(
() => !!props.signingOut && !props.forced && props.otherDraftCount > 0,
);
// The discard-only copy offers to stay, so it carries a button for it.
const secondaryLabel = computed<I18nText | undefined>(() => {
if (props.canKeepDraft) {
return props.serverDraftItem
? t("common.editorDraft.keepInBrowser")
: t("common.editorDraft.keepDraft");
}
return !props.canSave && !props.forced ? t("common.editorDraft.stay") : undefined;
});
const onOpenChange = (value: boolean) => {
if (!value && !props.forced) emit("choose", "stay");
};
</script>
@@ -0,0 +1,132 @@
// 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 { afterEach, describe, expect, it } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { createStore } from "vuex";
import { raw } from "@/types/i18n";
import EditorDraftRestoreDialog from "./EditorDraftRestoreDialog.vue";
const store = createStore({ state: { timezone: "UTC" } });
let wrapper: VueWrapper | null = null;
const savedAt = Date.UTC(2026, 9, 21, 14, 32);
const mountDialog = async (props: Record<string, unknown> = {}) => {
wrapper = mount(EditorDraftRestoreDialog, {
attachTo: document.body,
props: { open: true, savedAt, entityLabel: raw("alert template"), ...props },
global: { plugins: [store] },
});
await flushPromises();
return wrapper;
};
const byTest = (id: string) => document.body.querySelector<HTMLElement>(`[data-test="${id}"]`);
describe("EditorDraftRestoreDialog", () => {
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("names the entity and the draft time to the minute", async () => {
await mountDialog();
expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("Unsaved draft");
const message = byTest("editor-draft-restore-message")?.textContent ?? "";
expect(message).toContain("unsaved changes to this alert template from");
expect(message).toContain("2026");
expect(message).toMatch(/2:32|14:32/);
});
it("emits continue and fresh from its buttons", async () => {
const dialog = await mountDialog();
byTest("editor-draft-continue")?.click();
document.body
.querySelector<HTMLElement>(
'[data-test="editor-draft-restore-dialog"] [data-test="o-dialog-secondary-btn"]',
)
?.click();
expect(dialog.emitted("continue")).toHaveLength(1);
expect(dialog.emitted("fresh")).toHaveLength(1);
});
it("emits dismiss when closed", async () => {
const dialog = await mountDialog();
byTest("o-dialog-close-btn")?.click();
await flushPromises();
expect(dialog.emitted("dismiss")).toHaveLength(1);
});
it("shows the server-change and credentials notes only when set", async () => {
await mountDialog();
expect(byTest("editor-draft-changed-on-server")).toBeNull();
expect(byTest("editor-draft-credentials-not-saved")).toBeNull();
wrapper?.unmount();
await mountDialog({ changedOnServer: true, credentialsNotSaved: true });
expect(byTest("editor-draft-changed-on-server")?.textContent).toContain(
"This alert template was changed",
);
expect(byTest("editor-draft-credentials-not-saved")).not.toBeNull();
});
it("uses a custom title", async () => {
await mountDialog({ title: raw("Unsaved local changes") });
expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("Unsaved local changes");
});
it("lists every notice in one banner, a warning when the item changed on the server", async () => {
await mountDialog({ changedOnServer: true, credentialsNotSaved: true });
const banners = document.body.querySelectorAll(
'[data-test="editor-draft-restore-dialog"] [role="alert"], [data-test="editor-draft-restore-dialog"] [role="status"]',
);
expect(banners).toHaveLength(1);
expect(byTest("editor-draft-restore-notices")?.querySelectorAll("li")).toHaveLength(2);
expect(byTest("editor-draft-restore-notices")?.getAttribute("role")).toBe("alert");
wrapper?.unmount();
await mountDialog({ credentialsNotSaved: true });
expect(byTest("editor-draft-restore-notices")?.getAttribute("role")).toBe("status");
expect(byTest("editor-draft-restore-notices")?.querySelectorAll("li")).toHaveLength(1);
});
it("shows the editor's note in the notices, since it is what Continue will do", async () => {
await mountDialog({ note: raw("Continuing generates a new key.") });
const note = byTest("editor-draft-restore-note");
expect(note?.tagName).toBe("LI");
expect(note?.textContent?.trim()).toBe("Continuing generates a new key.");
expect(byTest("editor-draft-restore-notices")?.querySelectorAll("li")).toHaveLength(1);
expect(byTest("editor-draft-restore-notices")?.getAttribute("role")).toBe("status");
});
it("avoids the word draft in its notices for an item with a server-side draft", async () => {
await mountDialog({ changedOnServer: true, credentialsNotSaved: true, serverDraftItem: true });
const notices = byTest("editor-draft-restore-notices")?.textContent ?? "";
expect(notices).toContain("after your changes were kept in this browser");
expect(notices).toContain("are not kept in this browser");
expect(notices).not.toContain("draft");
});
it("names the changed fields that were not kept", async () => {
await mountDialog({
credentialsNotSaved: true,
notKeptFields: [raw("token"), raw("X-Api-Key header")],
});
expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain(
"Your changes to the token and X-Api-Key header were not kept.",
);
});
});
@@ -0,0 +1,122 @@
<!--
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/>.
-->
<template>
<ODialog
:open="open"
size="sm"
:title="title ?? t('common.editorDraft.restoreTitle')"
:primary-button-label="
serverDraftItem
? t('common.editorDraft.continueEditing')
: t('common.editorDraft.continueDraft')
"
primary-button-data-test="editor-draft-continue"
:secondary-button-label="t('common.editorDraft.startFresh')"
data-test="editor-draft-restore-dialog"
@update:open="onOpenChange"
@click:primary="emit('continue')"
@click:secondary="emit('fresh')"
>
<div class="flex flex-col gap-3">
<p class="text-text-body text-sm" data-test="editor-draft-restore-message">
{{ t("common.editorDraft.restoreMessage", { entity: entityLabel, date: savedAtText }) }}
</p>
<OBanner
v-if="notices.length"
:variant="changedOnServer ? 'warning' : 'info'"
:icon="changedOnServer ? 'warning' : 'info'"
data-test="editor-draft-restore-notices"
>
<ul class="flex flex-col gap-1">
<li v-for="notice in notices" :key="notice.id" :data-test="notice.id">
{{ notice.text }}
</li>
</ul>
</OBanner>
</div>
</ODialog>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import { formatEditorDraftTime } from "@/utils/drafts/editorDraftStore";
const props = defineProps<{
open: boolean;
savedAt: number;
entityLabel: I18nText;
title?: I18nText;
changedOnServer?: boolean;
credentialsNotSaved?: boolean;
notKeptFields?: I18nText[];
/** The item has a server-side draft of its own, so the copy avoids the word "draft". */
serverDraftItem?: boolean;
note?: I18nText | null;
}>();
const emit = defineEmits<{
continue: [];
fresh: [];
dismiss: [];
}>();
const { t, locale } = useI18nTyped();
const store = useStore();
const savedAtText = computed(() =>
formatEditorDraftTime(props.savedAt, locale.value, store.state.timezone || undefined),
);
const credentialsNote = computed(() => {
const fields = props.notKeptFields ?? [];
if (fields.length) {
const list = new Intl.ListFormat(locale.value, { type: "conjunction" }).format(fields);
return t("common.editorDraft.changesNotKept", { fields: list }, fields.length);
}
return props.serverDraftItem
? t("common.editorDraft.credentialsNotSavedInBrowser")
: t("common.editorDraft.credentialsNotSaved");
});
const notices = computed(() => {
const entity = props.entityLabel;
const list: { id: string; text: I18nText }[] = [];
if (props.changedOnServer) {
list.push({
id: "editor-draft-changed-on-server",
text: props.serverDraftItem
? t("common.editorDraft.changedOnServerInBrowser", { entity })
: t("common.editorDraft.changedOnServer", { entity }),
});
}
if (props.credentialsNotSaved) {
list.push({ id: "editor-draft-credentials-not-saved", text: credentialsNote.value });
}
// The note says what Continue will do, so it is a consequence shown with the others, not a hint.
if (props.note) list.push({ id: "editor-draft-restore-note", text: props.note });
return list;
});
const onOpenChange = (value: boolean) => {
if (!value) emit("dismiss");
};
</script>
@@ -0,0 +1,66 @@
// 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 { afterEach, describe, expect, it } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import EditorDraftSignOutDialog from "./EditorDraftSignOutDialog.vue";
let wrapper: VueWrapper | null = null;
const mountDialog = async (count: number) => {
wrapper = mount(EditorDraftSignOutDialog, {
attachTo: document.body,
props: { open: true, count },
});
await flushPromises();
return wrapper;
};
const inDialog = (id: string) =>
document.body.querySelector<HTMLElement>(
`[data-test="editor-draft-signout-dialog"] [data-test="${id}"]`,
);
describe("EditorDraftSignOutDialog", () => {
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
it("says how many drafts signing out discards, in the plural", async () => {
await mountDialog(3);
expect(
document.body.querySelector('[data-test="editor-draft-signout-message"]')?.textContent,
).toContain("You have 3 unsaved drafts in this browser. Signing out discards them.");
});
it("uses the singular for one draft", async () => {
await mountDialog(1);
expect(
document.body.querySelector('[data-test="editor-draft-signout-message"]')?.textContent,
).toContain("You have 1 unsaved draft in this browser. Signing out discards it.");
});
it("confirms with Sign out and closes with Cancel", async () => {
const dialog = await mountDialog(2);
inDialog("editor-draft-signout-confirm")?.click();
expect(dialog.emitted("confirm")).toHaveLength(1);
expect(dialog.emitted("update:open")?.[0]).toEqual([false]);
inDialog("o-dialog-secondary-btn")?.click();
expect(dialog.emitted("confirm")).toHaveLength(1);
expect(dialog.emitted("update:open")).toHaveLength(2);
});
});
@@ -0,0 +1,57 @@
<!--
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/>.
-->
<template>
<ODialog
:open="open"
size="sm"
:title="t('common.editorDraft.signOutTitle')"
:primary-button-label="t('common.editorDraft.signOutConfirm')"
primary-button-variant="destructive"
primary-button-data-test="editor-draft-signout-confirm"
:secondary-button-label="t('common.cancel')"
data-test="editor-draft-signout-dialog"
@update:open="emit('update:open', $event)"
@click:primary="confirm"
@click:secondary="emit('update:open', false)"
>
<p class="text-text-body text-sm" data-test="editor-draft-signout-message">
{{ t("common.editorDraft.signOutMessage", { count }, count) }}
</p>
</ODialog>
</template>
<script setup lang="ts">
import { useI18nTyped } from "@/types/i18n";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
defineProps<{
open: boolean;
count: number;
}>();
const emit = defineEmits<{
"update:open": [open: boolean];
confirm: [];
}>();
const { t } = useI18nTyped();
const confirm = () => {
emit("update:open", false);
emit("confirm");
};
</script>
@@ -0,0 +1,31 @@
// 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 { useStore } from "vuex";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { formatEditorDraftTime } from "@/utils/drafts/editorDraftStore";
/** A host's own tooltip text for its draft: "{label} · unsaved draft · {date}", undated when no time is known. */
export const useDraftLabel = () => {
const { t, locale } = useI18nTyped();
const store = useStore();
return (label: I18nText, savedAt: number | null): I18nText =>
savedAt === null
? t("common.editorDraft.newDot", { label })
: t("common.editorDraft.newDotDated", {
label,
date: formatEditorDraftTime(savedAt, locale.value, store.state.timezone || undefined),
});
};
@@ -142,11 +142,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Query Builder -->
<DashboardQueryBuilder
v-if="resolvedConfig.showQueryBuilder"
v-if="
resolvedConfig.showQueryBuilder &&
!isCustomSqlPanel(dashboardPanelData.data.type)
"
:dashboardData="dashboardData"
@custom-chart-template-selected="handleCustomChartTemplateSelected"
/>
<OSeparator v-if="resolvedConfig.showQueryBuilder" />
<OSeparator
v-if="
resolvedConfig.showQueryBuilder &&
!isCustomSqlPanel(dashboardPanelData.data.type)
"
/>
<!-- Variables Selector (dashboard mode only) -->
<VariablesValueSelector
@@ -734,6 +742,7 @@ import { usePanelEditor } from "./composables/usePanelEditor";
import useBreakpoint from "@/composables/useBreakpoint";
import useDashboardPanelData from "@/composables/dashboard/useDashboardPanel";
import { SERIES_OVERRIDE_CHART_TYPES } from "@/composables/dashboard/useConfigPanel";
import { isCustomSqlPanel } from "@/utils/dashboard/chartTypeGroups";
// ============================================================================
// Component Imports

Some files were not shown because too many files have changed in this diff Show More