Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function AlertChangeStatusModal({
const api = useApi();
const [disposeOnNewAlert, setDisposeOnNewAlert] = useState(true);
const [selectedStatus, setSelectedStatus] = useState<Status | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const revalidateMultiple = useRevalidateMultiple();
const { alertsMutator } = useAlerts();
const presetsMutator = () => revalidateMultiple(["/preset"]);
Expand Down Expand Up @@ -82,6 +83,7 @@ export function AlertChangeStatusModal({
showErrorToast(new Error("Batch status change should use batch handler."));
return;
}
setIsSubmitting(true);
try {
await api.post(
`/alerts/enrich?dispose_on_new_alert=${disposeOnNewAlert}`,
Expand All @@ -106,14 +108,21 @@ export function AlertChangeStatusModal({
await presetsMutator();
} catch (error) {
showErrorToast(error, "Failed to change alert status.");
} finally {
setIsSubmitting(false);
}
};

const handleChangeStatusBatch = async () => {
if (!selectedStatus) {
showErrorToast(new Error("Please select a new status."));
return;
}
let fingerprints = new Set<string>();
if (Array.isArray(alert)) {
alert.forEach((a) => fingerprints.add(a.fingerprint));
}
setIsSubmitting(true);
try {
await api.post(
`/alerts/batch_enrich?dispose_on_new_alert=${disposeOnNewAlert}`,
Expand All @@ -138,6 +147,8 @@ export function AlertChangeStatusModal({
await presetsMutator();
} catch (error) {
showErrorToast(error, "Failed to change alert(s) status.");
} finally {
setIsSubmitting(false);
}
};

Expand Down Expand Up @@ -190,10 +201,20 @@ export function AlertChangeStatusModal({
</div>
</div>
<div className="flex justify-end mt-4 gap-2">
<Button onClick={handleClose} color="orange" variant="secondary">
<Button
onClick={handleClose}
color="orange"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleChangeStatus} color="orange">
<Button
onClick={handleChangeStatus}
color="orange"
loading={isSubmitting}
disabled={isSubmitting}
>
Change Status
</Button>
</div>
Expand Down Expand Up @@ -248,10 +269,20 @@ export function AlertChangeStatusModal({
</div>
</div>
<div className="flex justify-end mt-4 gap-2">
<Button onClick={handleClose} color="blue" variant="secondary">
<Button
onClick={handleClose}
color="blue"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleChangeStatusBatch} color="blue">
<Button
onClick={handleChangeStatusBatch}
color="blue"
loading={isSubmitting}
disabled={isSubmitting}
>
Change Status
</Button>
</div>
Expand Down
24 changes: 15 additions & 9 deletions keep/api/routes/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,15 +1015,21 @@ def batch_enrich_alerts(
# @tb add "and session" cuz I saw AttributeError: 'NoneType' object has no attribute 'add'"
if should_check_incidents_resolution and session:
enrich_alerts_with_incidents(tenant_id=tenant_id, alerts=alerts)
for alert in alerts:
for incident in alert._incidents:
if (
incident.resolve_on == ResolveOn.ALL.value
and is_all_alerts_resolved(incident=incident, session=session)
):
incident.status = IncidentStatus.RESOLVED.value
session.add(incident)
session.commit()
# the same incident may be linked to many of the enriched alerts,
# so check each incident only once and commit once at the end
unique_incidents = {
incident.id: incident
for alert in alerts
for incident in alert._incidents
}
for incident in unique_incidents.values():
if (
incident.resolve_on == ResolveOn.ALL.value
and is_all_alerts_resolved(incident=incident, session=session)
):
incident.status = IncidentStatus.RESOLVED.value
session.add(incident)
session.commit()

return {"status": "ok"}
except HTTPException:
Expand Down
53 changes: 35 additions & 18 deletions keep/workflowmanager/workflowmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,25 +285,34 @@ def _convert_filters_to_cel(self, filters: list[dict[str, str]]):
raise

def insert_events(self, tenant_id, events: typing.List[AlertDto | IncidentDto]):
for event in events:
self.logger.info("Getting all workflows", extra={"tenant_id": tenant_id})
all_workflow_models = self.workflow_store.get_all_workflows(
tenant_id, exclude_disabled=True
)
self.logger.info(
"Got all workflows",
extra={
"num_of_workflows": len(all_workflow_models),
"tenant_id": tenant_id,
},
)
for workflow_model in all_workflow_models:
workflow = self._get_workflow_from_store(tenant_id, workflow_model)
if not events:
return

if workflow is None:
# Exception is thrown in _get_workflow_from_store, we don't need to log it here, just continue.
continue
self.logger.info("Getting all workflows", extra={"tenant_id": tenant_id})
all_workflow_models = self.workflow_store.get_all_workflows(
tenant_id, exclude_disabled=True
)
self.logger.info(
"Got all workflows",
extra={
"num_of_workflows": len(all_workflow_models),
"tenant_id": tenant_id,
},
)
# Parse each workflow once and reuse it to evaluate triggers for every
# event, instead of re-reading and re-parsing all workflows per event.
parsed_workflows = []
for workflow_model in all_workflow_models:
workflow = self._get_workflow_from_store(tenant_id, workflow_model)

if workflow is None:
# Exception is thrown in _get_workflow_from_store, we don't need to log it here, just continue.
continue

parsed_workflows.append((workflow_model, workflow))

for event in events:
for workflow_model, workflow in parsed_workflows:
for trigger in workflow.workflow_triggers:
# If the trigger is not an alert, it's not relevant for this event.
if not trigger.get("type") == "alert":
Expand Down Expand Up @@ -566,10 +575,18 @@ async def enqueue_workflow():
},
)
"""
# Running a workflow mutates its context manager, so every
# queued run gets its own parsed instance instead of the
# shared one used for trigger evaluation.
workflow_instance = self._get_workflow_from_store(
tenant_id, workflow_model
)
if workflow_instance is None:
continue
with self.scheduler.lock:
self.scheduler.workflows_to_run.append(
{
"workflow": workflow,
"workflow": workflow_instance,
"workflow_id": workflow_model.id,
"tenant_id": tenant_id,
"triggered_by": "alert",
Expand Down
2 changes: 1 addition & 1 deletion tests/deduplication/test_deduplications.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def test_deduplication_sanity(db_session, client, test_app):
assert dedup_rule.get("default")


@pytest.mark.timeout(10)
@pytest.mark.timeout(20)
@pytest.mark.parametrize(
"test_app",
[
Expand Down
17 changes: 5 additions & 12 deletions tests/test_workflowstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,18 +550,11 @@ def test_get_workflow_run_logs_sorted_by_timestamp(db_session):
db_session.commit()
db_session.flush()

# Create logs with timestamps in random order
timestamps = [
datetime.now(tz=timezone.utc) - timedelta(seconds=10),
datetime.now(tz=timezone.utc) + timedelta(seconds=5),
datetime.now(tz=timezone.utc) - timedelta(seconds=3),
datetime.now(tz=timezone.utc) + timedelta(seconds=2),
datetime.now(tz=timezone.utc) - timedelta(seconds=1),
datetime.now(tz=timezone.utc),
datetime.now(tz=timezone.utc) - timedelta(seconds=1),
datetime.now(tz=timezone.utc) - timedelta(seconds=2),
datetime.now(tz=timezone.utc) + timedelta(seconds=3),
]
# Create logs with timestamps in random order. Anchor on a single "now" and
# use distinct offsets so timestamps can't tie under coarse clock resolution.
now = datetime.now(tz=timezone.utc)
offsets = [-10, 5, -3, 2, -1, 0, 1, -2, 3]
timestamps = [now + timedelta(seconds=offset) for offset in offsets]

for i, ts in enumerate(timestamps):
workflow_execution_log = WorkflowExecutionLog(
Expand Down
Loading