mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
Merge remote-tracking branch 'origin/main' into feat/query-filter-bar
This commit is contained in:
@@ -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 }}"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ impl From<AlertError> for Response {
|
||||
| AlertError::RecoveryOnRealtimeAlert
|
||||
| AlertError::RecoveryDestinationsWithoutRecovery
|
||||
| AlertError::RecoveryDestinationIsPlatform { .. }
|
||||
| AlertError::RecoveryWithNotificationGrouping
|
||||
| AlertError::RecoveryNeedsJsonPayload { .. }
|
||||
| AlertError::RecoveryNeedsRoutingKey { .. }
|
||||
| AlertError::MultiAlertGroupingError(_)
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(®exp_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: ®ex::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() {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
+2
-2
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user