feat(frontend): critical alerts UI (#4653)

This commit is contained in:
Alexander Petric
2024-11-09 00:42:10 +01:00
committed by GitHub
parent 274eb78152
commit d9148eaa78
28 changed files with 3213 additions and 706 deletions
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message) VALUES ('recovered_critical_error', $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "00ce4ed3ca0eac7cb6283b047353a64b9e78c4beb423f04baef9a53fbf87e9f9"
}
@@ -0,0 +1,48 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, alert_type, message, created_at, acknowledged \n FROM alerts \n WHERE acknowledged = $1\n ORDER BY created_at DESC \n LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "alert_type",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "message",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "acknowledged",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Bool",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "0b955f2cff82a2d4ba3840588143e08952f029480d4a42503ecc3c5e70437995"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value::BOOLEAN as value FROM global_settings WHERE name = 'critical_alert_mute_ui'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "33cfb00f61dbae09467bd3732ce8e83c9835ccd30c90cb24788cdb54d1ceb575"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message, acknowledged) VALUES ('recovered_critical_error', $1, $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "3a94ad52c6b7cde844fa868167248cd9ff63e5fdfa1d93d8fbec32a257b6b05e"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message) VALUES ('critical_error', $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "4c0067c2135a259aea5cc2db60f7375a9a33671be8ef406427d90f67a98c9c9f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message, acknowledged) VALUES ('critical_error', $1, $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "4d22084a5d9860832f30e8f08cbfa1848ed3c1336fa4790f45b8189c4ac97d91"
}
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "bool",
"name": "?column?",
"type_info": "Bool"
}
],
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts SET acknowledged = true WHERE acknowledged = false",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a7c5008aa7ea43d0afac7d9f19846976ed7af2e90270902f001115e023cb947d"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts SET acknowledged = true WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "be3ae231557e794172336bc27d725f862dcf039bbb3d75ced9d54c86f53d2580"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, alert_type, message, created_at, acknowledged \n FROM alerts \n ORDER BY created_at DESC \n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "alert_type",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "message",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "acknowledged",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "cc5ab80241b88c5befea279f16c4ec68cec17b31dcd277b321f652917346496b"
}
@@ -0,0 +1 @@
ALTER TABLE alerts DROP COLUMN acknowledged;
@@ -0,0 +1,9 @@
-- Step 1: Add the new column 'acknowledged' to the 'alerts' table
ALTER TABLE alerts
ADD COLUMN acknowledged BOOLEAN DEFAULT NULL;
-- Step 2: Update all existing rows to set 'acknowledged' to true
-- we don't want to pop up notifications to all users after migrations
-- but only show new alerts from the point of the upgrade
UPDATE alerts
SET acknowledged = TRUE;
+8 -2
View File
@@ -36,7 +36,7 @@ use windmill_common::{
ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_BASE_URL_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, NPM_CONFIG_REGISTRY_SETTING,
OAUTH_SETTING, PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
OAUTH_SETTING, PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
},
@@ -78,7 +78,7 @@ use crate::monitor::{
reload_hub_base_url_setting, reload_job_default_timeout_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_pip_index_url_setting,
reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config,
reload_worker_config,
reload_worker_config, reload_critical_alert_mute_ui_setting,
};
#[cfg(feature = "parquet")]
@@ -817,6 +817,12 @@ Windmill Community Edition {GIT_VERSION}
tracing::error!(error = %e, "Could not reload jwt secret setting");
}
},
CRITICAL_ALERT_MUTE_UI_SETTING => {
tracing::info!("Critical alert UI setting changed");
if let Err(e) = reload_critical_alert_mute_ui_setting(&db).await {
tracing::error!(error = %e, "Could not reload critical alert UI setting");
}
},
a @_ => {
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", a);
}
+17 -2
View File
@@ -39,7 +39,7 @@ use windmill_common::{
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_BASE_URL_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, NPM_CONFIG_REGISTRY_SETTING, OAUTH_SETTING,
PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
},
@@ -55,7 +55,7 @@ use windmill_common::{
WORKER_GROUP,
},
BASE_URL, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS,
METRICS_DEBUG_ENABLED, METRICS_ENABLED,
METRICS_DEBUG_ENABLED, METRICS_ENABLED, CRITICAL_ALERT_MUTE_UI_ENABLED
};
use windmill_queue::cancel_job;
use windmill_worker::{
@@ -131,6 +131,10 @@ pub async fn initial_load(
tracing::error!("Error loading expose debug metrics: {e:#}");
}
if let Err(e) = reload_critical_alert_mute_ui_setting(db).await {
tracing::error!("Error loading critical alert mute ui setting: {e:#}");
}
if let Err(e) = load_tag_per_workspace_enabled(db).await {
tracing::error!("Error loading default tag per workpsace: {e:#}");
}
@@ -226,6 +230,17 @@ pub async fn load_tag_per_workspace_workspaces(db: &DB) -> error::Result<()> {
Ok(())
}
pub async fn reload_critical_alert_mute_ui_setting(db: &DB) -> error::Result<()> {
let mute = load_value_from_global_settings(db, CRITICAL_ALERT_MUTE_UI_SETTING).await;
match mute {
Ok(Some(serde_json::Value::Bool(t))) => {
CRITICAL_ALERT_MUTE_UI_ENABLED.store(t, Ordering::Relaxed);
}
_ => (),
};
Ok(())
}
pub async fn load_metrics_debug_enabled(db: &DB) -> error::Result<()> {
let metrics_enabled = load_value_from_global_settings(db, EXPOSE_DEBUG_METRICS_SETTING).await;
match metrics_enabled {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+92
View File
@@ -773,6 +773,78 @@ paths:
schema:
type: string
/settings/critical_alerts:
get:
summary: Get all critical alerts
operationId: getCriticalAlerts
tags:
- setting
parameters:
- in: query
name: page
schema:
type: integer
default: 1
description: The page number to retrieve (minimum value is 1)
- in: query
name: page_size
schema:
type: integer
default: 10
maximum: 100
description: Number of alerts per page (maximum is 100)
- in: query
name: acknowledged
schema:
type: boolean
nullable: true
description: Filter by acknowledgment status; true for acknowledged, false for unacknowledged, and omit for all alerts
responses:
"200":
description: Successfully retrieved all critical alerts
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CriticalAlert'
/settings/critical_alerts/{id}/acknowledge:
post:
summary: Acknowledge a critical alert
operationId: acknowledgeCriticalAlert
tags:
- setting
parameters:
- in: path
name: id
required: true
schema:
type: integer
description: The ID of the critical alert to acknowledge
responses:
"200":
description: Successfully acknowledged the critical alert
content:
application/json:
schema:
type: string
example: "Critical alert acknowledged"
/settings/critical_alerts/acknowledge_all:
post:
summary: Acknowledge all unacknowledged critical alerts
operationId: acknowledgeAllCriticalAlerts
tags:
- setting
responses:
"200":
description: Successfully acknowledged all unacknowledged critical alerts.
content:
application/json:
schema:
type: string
example: "All unacknowledged critical alerts acknowledged"
/settings/test_license_key:
post:
@@ -12721,3 +12793,23 @@ components:
type: string
format: date-time
CriticalAlert:
type: object
properties:
id:
type: integer
description: Unique identifier for the alert
alert_type:
type: string
description: Type of alert (e.g., critical_error)
message:
type: string
description: The message content of the alert
created_at:
type: string
format: date-time
description: Time when the alert was created
acknowledged:
type: boolean
nullable: true
description: Acknowledgment status of the alert, can be true, false, or null if not set
+118 -1
View File
@@ -58,7 +58,10 @@ pub fn global_service() -> Router {
)
.route("/renew_license_key", post(renew_license_key))
.route("/customer_portal", post(create_customer_portal_session))
.route("/test_critical_channels", post(test_critical_channels));
.route("/test_critical_channels", post(test_critical_channels))
.route("/critical_alerts", get(get_critical_alerts))
.route("/critical_alerts/:id/acknowledge", post(acknowledge_critical_alert))
.route("/critical_alerts/acknowledge_all", post(acknowledge_all_critical_alerts));
#[cfg(feature = "parquet")]
{
@@ -430,3 +433,117 @@ pub async fn test_critical_channels(
pub async fn test_critical_channels() -> Result<String> {
Ok("Critical channels require EE".to_string())
}
use serde::Serialize;
#[derive(Serialize)]
pub struct CriticalAlert {
id: i32,
alert_type: String,
message: String,
created_at: chrono::DateTime<chrono::Utc>,
acknowledged: Option<bool>,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize)]
pub struct AlertQueryParams {
pub page: Option<i32>,
pub page_size: Option<i32>,
pub acknowledged: Option<bool>,
}
#[cfg(feature = "enterprise")]
pub async fn get_critical_alerts(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Query(params): Query<AlertQueryParams>,
) -> JsonResult<Vec<CriticalAlert>> {
require_super_admin(&db, &authed.email).await?;
// Default pagination values if not provided
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(10).min(100) as i64;
let offset = ((page - 1) * page_size as i32) as i64;
let alerts = if let Some(acknowledged) = params.acknowledged {
sqlx::query_as!(
CriticalAlert,
"SELECT id, alert_type, message, created_at, acknowledged
FROM alerts
WHERE acknowledged = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3",
acknowledged,
page_size,
offset
)
.fetch_all(&db)
.await?
} else {
sqlx::query_as!(
CriticalAlert,
"SELECT id, alert_type, message, created_at, acknowledged
FROM alerts
ORDER BY created_at DESC
LIMIT $1 OFFSET $2",
page_size,
offset
)
.fetch_all(&db)
.await?
};
Ok(Json(alerts))
}
#[cfg(not(feature = "enterprise"))]
pub async fn get_critical_alerts() -> error::Error {
error::Error::NotFound("Critical Alerts require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_critical_alert(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Path(id): Path<i32>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
sqlx::query!(
"UPDATE alerts SET acknowledged = true WHERE id = $1",
id
)
.execute(&db)
.await?;
tracing::info!("Acknowledged critical alert with id: {}", id);
Ok("Critical alert acknowledged".to_string())
}
#[cfg(not(feature = "enterprise"))]
pub async fn acknowledge_critical_alert() -> error::Error {
error::Error::NotFound("Critical Alerts require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_all_critical_alerts(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
sqlx::query!(
"UPDATE alerts SET acknowledged = true WHERE acknowledged = false"
)
.execute(&db)
.await?;
tracing::info!("Acknowledged all unacknowledged critical alerts");
Ok("All unacknowledged critical alerts acknowledged".to_string())
}
#[cfg(not(feature = "enterprise"))]
pub async fn acknowledge_all_critical_alerts() -> error::Error {
error::Error::NotFound("Critical Alerts require EE".to_string())
}
@@ -29,6 +29,7 @@ pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation
pub const HUB_BASE_URL_SETTING: &str = "hub_base_url";
pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url";
pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels";
pub const CRITICAL_ALERT_MUTE_UI_SETTING: &str = "critical_alert_mute_ui";
pub const DEV_INSTANCE_SETTING: &str = "dev_instance";
pub const JWT_SECRET_SETTING: &str = "jwt_secret";
pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";
+2
View File
@@ -86,6 +86,8 @@ lazy_static::lazy_static! {
pub static ref METRICS_ENABLED: AtomicBool = AtomicBool::new(std::env::var("METRICS_PORT").is_ok() || std::env::var("METRICS_ADDR").is_ok());
pub static ref METRICS_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false);
pub static ref CRITICAL_ALERT_MUTE_UI_ENABLED: AtomicBool = AtomicBool::new(false);
pub static ref BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
pub static ref IS_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+13 -4
View File
@@ -28,6 +28,9 @@ pub const DEFAULT_PER_PAGE: usize = 1000;
pub const GIT_VERSION: &str =
git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
use std::sync::atomic::Ordering;
use crate::CRITICAL_ALERT_MUTE_UI_ENABLED;
lazy_static::lazy_static! {
pub static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
.user_agent("windmill/beta")
@@ -244,9 +247,12 @@ pub fn generate_lock_id(database_name: &str) -> i64 {
pub async fn report_critical_error(error_message: String, _db: DB) -> () {
tracing::error!("CRITICAL ERROR: {error_message}");
let mute = CRITICAL_ALERT_MUTE_UI_ENABLED.load(Ordering::Relaxed);
if let Err(err) = sqlx::query!(
"INSERT INTO alerts (alert_type, message) VALUES ('critical_error', $1)",
error_message
"INSERT INTO alerts (alert_type, message, acknowledged) VALUES ('critical_error', $1, $2)",
error_message,
mute
)
.execute(&_db)
.await
@@ -261,9 +267,12 @@ pub async fn report_critical_error(error_message: String, _db: DB) -> () {
pub async fn report_recovered_critical_error(message: String, _db: DB) -> () {
tracing::info!("RECOVERED CRITICAL ERROR: {message}");
let mute = CRITICAL_ALERT_MUTE_UI_ENABLED.load(Ordering::Relaxed);
if let Err(err) = sqlx::query!(
"INSERT INTO alerts (alert_type, message) VALUES ('recovered_critical_error', $1)",
message
"INSERT INTO alerts (alert_type, message, acknowledged) VALUES ('recovered_critical_error', $1, $2)",
message,
mute
)
.execute(&_db)
.await
@@ -23,6 +23,7 @@
import { isCloudHosted } from '$lib/cloud'
import InstanceNameEditor from './InstanceNameEditor.svelte'
import Toggle from './Toggle.svelte'
import { instanceSettingsSelectedTab } from '$lib/stores';
let drawer: Drawer
let filter = ''
@@ -56,6 +57,9 @@
let tab: 'users' | string = 'users'
$: $instanceSettingsSelectedTab, tab = $instanceSettingsSelectedTab
$: tab, instanceSettingsSelectedTab.set(tab)
let nbDisplayed = 50
let instanceSettings
@@ -146,6 +146,15 @@ export const settings: Record<string, Setting[]> = {
storage: 'setting',
ee_only: 'Channels other than tracing are only available in the EE version'
},
{
label: 'Mute critical alerts in UI',
description: 'Enable to mute critical alerts in the UI',
key: 'critical_alert_mute_ui',
fieldType: 'boolean',
storage: 'setting',
requiresReloadOnChange: true,
ee_only: 'Critical alerts in UI are only available in the EE version'
},
{
label: 'Azure OpenAI base path',
description:
@@ -0,0 +1,64 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import Popover from '../Popover.svelte'
import { createEventDispatcher } from 'svelte'
export let label: string | undefined = undefined
export let numUnacknowledgedCriticalAlerts: number
export let isCollapsed: boolean
export let disabled: boolean = false
export let lightMode: boolean = false
export let stopPropagationOnClick: boolean = false
export let shortcut: string = ''
let dispatch = createEventDispatcher()
</script>
{#if !disabled}
<Popover appearTimeout={0} disappearTimeout={0} class="w-full" disablePopup={!isCollapsed}>
<div class="py-1.5 px-1 border-t border-gray-700">
<button
on:click={(e) => {
if (stopPropagationOnClick) e.preventDefault()
dispatch('click')
}}
class={twMerge(
'group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full',
lightMode
? 'text-primary hover:bg-surface-hover '
: ' hover:bg-[#2A3648] text-primary-inverse dark:text-primary',
'transition-all',
$$props.class
)}
title={label}
>
<span
class="flex items-center justify-center h-6 w-6 rounded-lg bg-red-600 text-white font-bold text-xs"
>
{numUnacknowledgedCriticalAlerts > 9 ? '9+' : numUnacknowledgedCriticalAlerts}
</span>
{#if !isCollapsed && label}
<span
class={twMerge(
'whitespace-pre truncate',
lightMode ? 'text-primary' : 'text-primary-inverse dark:text-primary',
'transition-all',
$$props.class
)}
>
{label}
<span class="pl-2 text-xs dark:text-secondary light:text-secondary-inverse font-semibold">
{shortcut}
</span>
</span>
{/if}
</button>
</div>
<svelte:fragment slot="text">
{#if label}
{label}
{/if}
</svelte:fragment>
</Popover>
{/if}
@@ -0,0 +1,69 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import CriticalAlertModalInner from './CriticalAlertModalInner.svelte'
import { SettingService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
export let open: boolean = false
export let numUnacknowledgedCriticalAlerts: number = 0
let checkForNewAlertsInterval: ReturnType<typeof setInterval>
let checkingForNewAlerts = false
onMount(() => {
updateHasUnacknowledgedCriticalAlerts(true)
checkForNewAlertsInterval = setInterval(() => {
updateHasUnacknowledgedCriticalAlerts(true)
}, 15000)
})
onDestroy(() => {
clearInterval(checkForNewAlertsInterval)
})
async function updateHasUnacknowledgedCriticalAlerts(sendToast: boolean = false) {
if (checkingForNewAlerts) return
checkingForNewAlerts = true
try {
const unacknowledged = await SettingService.getCriticalAlerts({
page: 1,
pageSize: 10,
acknowledged: false
})
if (numUnacknowledgedCriticalAlerts === 0 && unacknowledged.length > 0 && sendToast) {
sendUserToast(
'Critical Alert:',
true,
[
{
label: 'View',
callback: () => {
open = true
}
},
{
label: 'Acknowledge',
callback: () => {
if (unacknowledged[0].id) acknowledgeAlert(unacknowledged[0].id)
}
}
],
unacknowledged[0].message,
10000
)
}
numUnacknowledgedCriticalAlerts = unacknowledged.length
} finally {
checkingForNewAlerts = false
}
}
async function acknowledgeAlert(id: number) {
await SettingService.acknowledgeCriticalAlert({ id })
updateHasUnacknowledgedCriticalAlerts()
}
</script>
<CriticalAlertModalInner bind:open {updateHasUnacknowledgedCriticalAlerts} />
@@ -0,0 +1,213 @@
<script lang="ts">
import Modal from '../common/modal/Modal.svelte'
import Button from '../common/button/Button.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { SettingService } from '$lib/gen'
import { CheckCircle2, AlertCircle, RefreshCw, CheckSquare2, AlertTriangle } from 'lucide-svelte'
import type { CriticalAlert } from '$lib/gen'
import { onMount, beforeUpdate } from 'svelte'
import { instanceSettingsSelectedTab } from '$lib/stores'
import { goto } from '$app/navigation'
export let open: boolean = false
export let updateHasUnacknowledgedCriticalAlerts: () => void = () => {}
let alerts: CriticalAlert[] = []
let isRefreshing = false
let previousOpen = open
let hasCriticalAlertChannels = false
$: loading = isRefreshing
onMount(() => {
if (open) refreshAlerts()
})
beforeUpdate(() => {
if (open && !previousOpen) {
refreshAlerts()
}
previousOpen = open
})
// Pagination
let page = 1
let pageSize = 10
let hasMore = true
let hideAcknowledged = false
async function acknowledgeAll() {
await SettingService.acknowledgeAllCriticalAlerts()
getAlerts(false)
}
async function fetchAlerts(pageNumber: number) {
isRefreshing = true
try {
const newAlerts = await SettingService.getCriticalAlerts({
page: pageNumber,
pageSize: pageSize,
acknowledged: hideAcknowledged ? false : undefined
})
alerts = newAlerts
hasMore = newAlerts.length === pageSize
page = pageNumber
updateHasUnacknowledgedCriticalAlerts()
} finally {
setTimeout(() => {
isRefreshing = false
}, 500)
}
}
async function getAlerts(reset?: boolean) {
if (reset) page = 1
await fetchAlerts(page)
}
async function checkCriticalAlertChannels() {
const channels = (await SettingService.getGlobal({ key: 'critical_error_channels' })) as any[]
hasCriticalAlertChannels = channels && channels.length > 0
}
async function acknowledgeAlert(id: number) {
await SettingService.acknowledgeCriticalAlert({ id })
getAlerts(false)
}
function formatDate(dateString: string | undefined): string {
if (!dateString) return ''
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date)
}
async function refreshAlerts() {
checkCriticalAlertChannels()
await getAlerts(true)
}
function goToPreviousPage() {
if (page > 1) {
fetchAlerts(page - 1)
}
}
function goToNextPage() {
if (hasMore) {
fetchAlerts(page + 1)
}
}
function goToCoreTab() {
goto('/#superadmin-settings')
instanceSettingsSelectedTab.set('Core')
}
</script>
<Modal bind:open title="Critical Alerts" cancelText="Close" style="max-width: 66%;">
{#if !hasCriticalAlertChannels}
<div class="flex flex-row pb-4">
<AlertTriangle color="orange" class="w-6 h-6 mr-2" />
<p>
No critical alert channels are set up. Go to the
<a href="/#superadmin-settings" on:click|preventDefault={goToCoreTab}>Instance Settings</a>
page to configure critical alert channels.
</p>
</div>
{/if}
<!-- Row of action buttons above the table -->
<div class="flex justify-between items-center mb-4">
<div class="flex space-x-2">
<Button color="green" startIcon={{ icon: CheckSquare2 }} size="sm" on:click={acknowledgeAll}
>Acknowledge All</Button
>
</div>
<button class="p-2 rounded-full hover:bg-gray-200" on:click={refreshAlerts} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin ' : ''} size="20" />
</button>
</div>
<!-- Pagination controls above the table -->
<div class="flex justify-between items-center mb-2">
<div class="flex items-center space-x-4">
<Button size="xs2" on:click={goToPreviousPage} disabled={page <= 1}>Previous</Button>
<span>Page {page}</span>
<Button size="xs2" on:click={goToNextPage} disabled={!hasMore}>Next</Button>
</div>
<div class="pr-3">
<Toggle
bind:checked={hideAcknowledged}
on:change={refreshAlerts}
options={{ right: 'Hide Acknowledged' }}
size="xs"
/>
</div>
</div>
<!-- Table of alerts with scrollable body -->
<div class="overflow-y-auto max-h-1/2">
<table class="min-w-full w-full">
<thead class="bg-gray-600 text-white sticky top-0 z-10">
<tr>
<th class="w-[60px] px-4 py-2 text-center">Type</th>
<th class="px-4 py-2 text-center">Message</th>
<th class="w-[150px] px-4 py-2 text-center">Created At</th>
<th class="w-[180px] px-4 py-2 text-center">Acknowledge</th>
</tr>
</thead>
<tbody>
{#each alerts as { id, alert_type, message, created_at, acknowledged }}
{#if !hideAcknowledged || !acknowledged}
<tr class="bg-gray-100 dark:bg-gray-700 dark:text-white text-center">
<td class="border px-4 py-2 w-[100px]">
{#if alert_type === 'recovered_critical_error'}
<span title="Recovered Critical Alert">
<CheckCircle2 size="20" color="green" />
</span>
{:else}
<span title="Critical Alert">
<AlertCircle size="20" color="red" />
</span>
{/if}
</td>
<td class="border px-4 py-2">{message}</td>
<!-- Flexible width -->
<td class="border px-4 py-2 w-[150px]">{formatDate(created_at)}</td>
<td class="border px-4 py-2 w-[180px]">
<div class="flex justify-center items-center">
{#if !acknowledged}
<Button
color="green"
startIcon={{ icon: CheckSquare2 }}
size="xs2"
on:click={() => {
if (id) acknowledgeAlert(id)
}}>Acknowledge</Button
>
{:else}
<CheckCircle2 size="20" color="green" />
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if alerts.length === 0}
<p class="text-center text-gray-500 mt-4">No critical alerts available.</p>
{/if}
</Modal>
+2
View File
@@ -121,3 +121,5 @@ export type DBSchema = SQLSchema | GraphqlSchema
export type DBSchemas = Partial<Record<string, DBSchema>>
export const dbSchemas = writable<DBSchemas>({})
export const instanceSettingsSelectedTab = writable('Core')
@@ -14,7 +14,9 @@
import { classNames, getModifierKey } from '$lib/utils'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
import {
enterpriseLicense,
copilotInfo,
isPremiumStore,
starStore,
@@ -48,6 +50,7 @@
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { setContext } from 'svelte'
import { base } from '$app/paths'
import CriticalAlertButton from '$lib/components/sidebar/CriticalAlertButton.svelte'
OpenAPI.WITH_CREDENTIALS = true
let menuOpen = false
@@ -275,6 +278,26 @@
}
setContext('openSearchWithPrefilledText', openSearchModal)
$: {
if ($enterpriseLicense && $superadmin) {
loadCriticalAlertsMuted()
}
}
let numUnacknowledgedCriticalAlerts = 0
let isCriticalAlertsModalOpen = false
let isCriticalAlertsUiMuted = false
async function loadCriticalAlertsMuted() {
isCriticalAlertsUiMuted = (await SettingService.getGlobal({
key: 'critical_alert_mute_ui'
})) as boolean
}
function openCriticalAlertsModal(text?: string): void {
isCriticalAlertsModalOpen = true
}
</script>
<svelte:window bind:innerWidth />
@@ -291,6 +314,12 @@
{:else if $userStore}
<GlobalSearchModal bind:this={globalSearchModal} />
{#if $superadmin}
{#if !isCriticalAlertsUiMuted && $enterpriseLicense}
<CriticalAlertModal
bind:open={isCriticalAlertsModalOpen}
bind:numUnacknowledgedCriticalAlerts
/>
{/if}
<SuperadminSettings bind:this={superadminSettings} />
{/if}
<div>
@@ -356,7 +385,6 @@
<WindmillIcon white={true} height="20px" width="20px" />
Windmill
</div>
<div class="px-2 py-4 space-y-2 border-y border-gray-500">
<WorkspaceMenu />
<FavoriteMenu {favoriteLinks} />
@@ -405,6 +433,17 @@
{/if}
</div>
</button>
{#if $superadmin && $enterpriseLicense}
<CriticalAlertButton
stopPropagationOnClick={true}
on:click={() => openCriticalAlertsModal()}
{numUnacknowledgedCriticalAlerts}
{isCollapsed}
label="Critical Alerts"
class="!text-xs"
disabled={numUnacknowledgedCriticalAlerts === 0}
/>
{/if}
<div class="px-2 py-4 space-y-2 border-y border-gray-700">
<WorkspaceMenu {isCollapsed} />
<FavoriteMenu {favoriteLinks} {isCollapsed} />