polish critical alert modal (#4802)

* polish critical alert modal

* Use Table component

* use table component pagination

* Add acknowledge to the table

* Polishing table

* fix nit

* Filter alerts

* Add notification count on modal

* Adjust table height

* Change mute description

* Fix layout

* minor fix

* Fix small screen issue

* fix toast on refresh

* Revert "fix toast on refresh"

This reverts commit ae3593e1af.

* filtering to backend, superadmin also acknowledges workspace (unless CLOUD_HOSTED), simplifications

* sqlx prep

* improve reactivity

* improvements

---------

Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com>
Co-authored-by: Alexander Petric <petric.al@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
This commit is contained in:
Guilhem
2024-12-05 10:07:11 +00:00
committed by GitHub
parent 3f6e40b0d4
commit e2d1565749
28 changed files with 899 additions and 346 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)\n FROM alerts\n WHERE COALESCE(acknowledged, false) = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Bool"
]
},
"nullable": [
null
]
},
"hash": "0a46f1f3047d15227f82ae24ad2113eb91d65b98927eaaba427cbde27dd79bfe"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)\n FROM alerts\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "372eb162ace15a4f07162bb46706eaca91a3616c9bfbd78a408b7079ca9706d4"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts\n SET\n acknowledged = true,\n acknowledged_workspace = CASE\n WHEN $2::text IS NOT NULL AND workspace_id = $2 THEN true\n ELSE acknowledged_workspace\n END\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "65da41c7ded54cdee8d33211561c068b72294cc99ff44ed0a13179df508ebc6a"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts \n SET\n acknowledged = true,\n acknowledged_workspace = CASE\n WHEN $1::text IS NOT NULL THEN true\n ELSE acknowledged_workspace\n END\n WHERE ($1::text IS NOT NULL AND workspace_id = $1)\n OR ($1::text IS NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "7c32176755c6ea2b6ae531860d436caae3fa256fc0803749ec5107632669adb3"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts \n SET\n acknowledged = true,\n acknowledged_workspace = CASE\n WHEN $2 THEN\n CASE\n WHEN $1::text IS NOT NULL THEN true\n ELSE acknowledged_workspace\n END\n ELSE true\n END\n WHERE ($1::text IS NOT NULL AND workspace_id = $1)\n OR ($1::text IS NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "80864158f61adaad8df934acc54ba523c9f17d106298d8781885134d28553d36"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)\n FROM alerts",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "951c053cf756f00e1da26b06edb3d0193a0ee707e6482a892db58915e0c8a27f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)\n FROM alerts\n WHERE workspace_id = $1 AND COALESCE(acknowledged_workspace, false) = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "b9ed42d4b795942251baafd016b4361e75257c0e5faf8795274ec86236a27413"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts\n SET\n acknowledged = true,\n acknowledged_workspace = CASE\n WHEN $3 THEN\n CASE\n WHEN $2::text IS NOT NULL AND workspace_id = $2 THEN true\n ELSE acknowledged_workspace\n END\n ELSE true\n END\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "cd4067e68b375461a495f402d05976da6c6e331d5748bf6f8d59f9f75c027fe8"
}
+28 -6
View File
@@ -872,9 +872,20 @@ paths:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CriticalAlert'
type: object
properties:
alerts:
type: array
items:
$ref: '#/components/schemas/CriticalAlert'
total_rows:
type: integer
description: Total number of rows matching the query.
example: 100
total_pages:
type: integer
description: Total number of pages based on the page size.
example: 10
/settings/critical_alerts/{id}/acknowledge:
post:
@@ -2757,9 +2768,20 @@ paths:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CriticalAlert'
type: object
properties:
alerts:
type: array
items:
$ref: '#/components/schemas/CriticalAlert'
total_rows:
type: integer
description: Total number of rows matching the query.
example: 100
total_pages:
type: integer
description: Total number of pages based on the page size.
example: 10
/w/{workspace}/workspaces/critical_alerts/{id}/acknowledge:
post:
+1 -1
View File
@@ -448,7 +448,7 @@ pub async fn get_critical_alerts(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Query(params): Query<crate::utils::AlertQueryParams>,
) -> JsonResult<Vec<crate::utils::CriticalAlert>> {
) -> JsonResult<serde_json::Value> {
require_devops_role(&db, &authed.email).await?;
crate::utils::get_critical_alerts(db, params, None).await
+70 -8
View File
@@ -10,6 +10,7 @@ use axum::{body::Body, response::Response};
use regex::Regex;
use serde::Deserialize;
use sqlx::{Postgres, Transaction};
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::{
auth::{is_devops_email, is_super_admin_email},
error::{self, Error},
@@ -206,11 +207,55 @@ pub async fn get_critical_alerts(
db: DB,
params: AlertQueryParams,
workspace_id: Option<String>,
) -> JsonResult<Vec<CriticalAlert>> {
) -> JsonResult<serde_json::Value> {
// Returning total rows and total pages
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;
// Count total rows
let total_rows = if let Some(workspace_id) = &workspace_id {
if params.acknowledged.is_none() {
sqlx::query_scalar!(
"SELECT COUNT(*)
FROM alerts
WHERE workspace_id = $1",
workspace_id
)
.fetch_one(&db)
.await?
} else {
sqlx::query_scalar!(
"SELECT COUNT(*)
FROM alerts
WHERE workspace_id = $1 AND COALESCE(acknowledged_workspace, false) = $2",
workspace_id,
params.acknowledged
)
.fetch_one(&db)
.await?
}
} else {
if params.acknowledged.is_none() {
sqlx::query_scalar!(
"SELECT COUNT(*)
FROM alerts"
)
.fetch_one(&db)
.await?
} else {
sqlx::query_scalar!(
"SELECT COUNT(*)
FROM alerts
WHERE COALESCE(acknowledged, false) = $1",
params.acknowledged
)
.fetch_one(&db)
.await?
}
};
// Fetch paginated rows
let alerts = if let Some(workspace_id) = workspace_id {
// `workspace_id` is provided => workspace admin
if params.acknowledged.is_none() {
@@ -278,7 +323,14 @@ pub async fn get_critical_alerts(
}
};
Ok(Json(alerts))
let total_rows = total_rows.unwrap_or(0);
let total_pages = ((total_rows as f64) / (page_size as f64)).ceil() as i64;
Ok(Json(serde_json::json!({
"alerts": alerts,
"total_rows": total_rows,
"total_pages": total_pages
})))
}
#[cfg(feature = "enterprise")]
@@ -292,12 +344,17 @@ pub async fn acknowledge_critical_alert(
SET
acknowledged = true,
acknowledged_workspace = CASE
WHEN $2::text IS NOT NULL AND workspace_id = $2 THEN true
ELSE acknowledged_workspace
WHEN $3 THEN
CASE
WHEN $2::text IS NOT NULL AND workspace_id = $2 THEN true
ELSE acknowledged_workspace
END
ELSE true
END
WHERE id = $1",
id,
workspace_id
workspace_id,
*CLOUD_HOSTED
)
.execute(&db)
.await?;
@@ -320,12 +377,17 @@ pub async fn acknowledge_all_critical_alerts(
SET
acknowledged = true,
acknowledged_workspace = CASE
WHEN $1::text IS NOT NULL THEN true
ELSE acknowledged_workspace
WHEN $2 THEN
CASE
WHEN $1::text IS NOT NULL THEN true
ELSE acknowledged_workspace
END
ELSE true
END
WHERE ($1::text IS NOT NULL AND workspace_id = $1)
OR ($1::text IS NULL)",
workspace_id
workspace_id,
*CLOUD_HOSTED
)
.execute(&db)
.await?;
+1 -1
View File
@@ -3111,7 +3111,7 @@ pub async fn get_critical_alerts(
Path(w_id): Path<String>,
authed: ApiAuthed,
Query(params): Query<crate::utils::AlertQueryParams>,
) -> JsonResult<Vec<crate::utils::CriticalAlert>> {
) -> JsonResult<serde_json::Value> {
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, &db).await?;
crate::utils::get_critical_alerts(db, params, Some(w_id)).await
+1 -1
View File
@@ -31,7 +31,7 @@
<label
for={id}
class="{$$props.class || ''} z-auto inline-flex items-center duration-50 {disabled
class="{$$props.class || ''} z-auto flex flex-row items-center duration-50 {disabled
? 'grayscale opacity-50'
: 'cursor-pointer'}"
>
@@ -0,0 +1,16 @@
<script lang="ts">
export let notificationCount = 0
export let notificationLimit: number | undefined = undefined
</script>
{#if notificationCount > 0}
<div
class="bg-red-500 text-white text-[0.6rem] rounded-md w-5 h-5 flex items-center justify-center"
>
{#if notificationLimit && notificationCount > notificationLimit}
{`${notificationLimit}+`}
{:else}
{notificationCount}
{/if}
</div>
{/if}
@@ -0,0 +1,34 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { RefreshCw } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
export let loading: boolean
let buttonHover = false
</script>
<Popover>
<Button
on:mouseenter={() => (buttonHover = true)}
on:mouseleave={() => (buttonHover = false)}
color="light"
size="xs2"
variant="border"
on:click
>
<RefreshCw class={loading ? 'animate-spin ' : ''} size="14" />
</Button>
<svelte:fragment slot="text">
{#if loading}
{#if buttonHover}
Stop Refreshing
{:else}
Refreshing...
{/if}
{:else}
Refresh
{/if}
</svelte:fragment>
</Popover>
@@ -0,0 +1,38 @@
<script lang="ts">
export let horizontal: boolean = false
export let gap: 'none' | 'sm' | 'md' | 'lg' = 'sm'
export let justify: 'start' | 'center' | 'end' | 'between' = 'start'
export let wFull = true
const gapMap = {
none: '',
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-8'
}
const justifyMap = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between'
}
</script>
{#if horizontal}
<div
class="flex flex-row h-full {wFull ? 'w-full' : ''} {gapMap[gap]} items-center {justifyMap[
justify
]}"
>
<slot />
</div>
{:else}
<div
class="flex flex-col h-full {wFull ? 'w-full' : ''} {gapMap[gap]} items-center {justifyMap[
justify
]}"
>
<slot />
</div>
{/if}
@@ -0,0 +1,3 @@
<div class="w-full flex-grow bg-blue-300">
<slot />
</div>
@@ -70,10 +70,12 @@
>
<div class="flex">
<div class="ml-4 text-left flex-1">
<h3 class="text-lg font-medium text-primary">
{title}
</h3>
<div class="mt-2 text-sm text-tertiary">
<div class="flex flex-row items-center justify-between">
<h3>{title}</h3>
<slot name="settings" />
</div>
<div class="mt-4 text-sm text-tertiary">
<slot />
</div>
</div>
@@ -0,0 +1,110 @@
<script lang="ts">
import Portal from '$lib/components/Portal.svelte'
import { twMerge } from 'tailwind-merge'
import { clickOutside } from '$lib/utils'
import { X } from 'lucide-svelte'
import List from '$lib/components/common/layout/List.svelte'
import { fade } from 'svelte/transition'
export let title: string
export let css: any = {}
export let target: string = ''
export let isOpen = false
export let fixedSize: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' = 'md'
// Add size mapping with custom pixel values
const sizeStyles = {
xs: { width: '400px', height: '250px' },
sm: { width: '600px', height: '400px' },
md: { width: '800px', height: '500px' },
lg: { width: '1400px', height: '720px' },
xl: { width: '1600px', height: '800px' },
xxl: { width: '1600px', height: '1000px' }
}
export function close() {
isOpen = false
}
export function open() {
isOpen = true
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
close()
}
}
function fadeFast(node: HTMLElement) {
return fade(node, { duration: 200 })
}
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if isOpen}
<Portal name="always-mounted" {target}>
<div
class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto z-[1100] bg-black bg-opacity-60 w-full h-full'}
transition:fadeFast|local
>
<div class="flex min-h-full items-center justify-center p-8">
<div
style={`width: ${sizeStyles[fixedSize].width}; height: ${sizeStyles[fixedSize].height}; ${
css?.popup?.style || ''
}`}
class={twMerge(
'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface pt-2 px-4 pb-4',
css?.popup?.class,
'wm-modal-form-popup'
)}
use:clickOutside
on:click_outside={() => {
close()
}}
>
<List gap="md">
<div class="flex w-full">
<List horizontal justify="between">
<h3>{title}</h3>
<div class="grow w-min-0">
<List horizontal justify="between">
<div class="min-w-0 grow">
<slot name="header-left" />
</div>
<div class="min-w-0 grow-0 justify-end">
<List horizontal justify="end">
<slot name="header-right" />
<div class="w-8">
<button
on:click={() => {
isOpen = false
}}
class="hover:bg-surface-hover rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<X class="text-tertiary " />
</button>
</div>
</List>
</div>
</List>
</div>
</List>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="w-full flex grow min-h-0" on:click|stopPropagation={() => {}}>
<slot />
</div>
</List>
</div>
</div>
</div>
</Portal>
{/if}
@@ -17,6 +17,7 @@
export let target: string | HTMLElement | undefined = undefined
export let noTransition = false
export let popupHover = false
export let preventPopupClosingOnClickInside = false
</script>
<Popover on:close class="leading-none">
@@ -28,6 +29,7 @@
<ConditionalPortal condition={shouldUsePortal} {target}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
use:floatingContent
class={`z5000 ${floatingClasses}`}
@@ -37,6 +39,7 @@
on:mouseleave={() => {
popupHover = false
}}
on:click={(e) => preventPopupClosingOnClickInside && e.stopPropagation()}
>
{#if !noTransition}
<Transition
@@ -1,15 +1,28 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import CriticalAlertModalInner from './CriticalAlertModalInner.svelte'
import { SettingService } from '$lib/gen'
import { SettingService, type CriticalAlert } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { workspaceStore, isCriticalAlertsUIOpen, devopsRole } from '$lib/stores'
import Modal from '../common/modal/Modal.svelte'
import {
workspaceStore,
isCriticalAlertsUIOpen,
devopsRole,
userStore,
superadmin
} from '$lib/stores'
import Modal2 from '../common/modal/Modal2.svelte'
import { Button, Popup } from '$lib/components/common'
import List from '$lib/components/common/layout/List.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { BellOff, Bell, ExternalLink, Settings } from 'lucide-svelte'
import { base } from '$lib/base'
import Notification from '$lib/components/common/alert/Notification.svelte'
export let open: boolean = false
export let numUnacknowledgedCriticalAlerts: number = 0
export let muteSettings
export let muteSettings;
let workspaceContext = false
let childRef;
$: {
setupApiFunctions(workspaceContext)
@@ -68,15 +81,50 @@
clearInterval(checkForNewAlertsInterval)
})
async function saveWorkSpaceMuteSetting() {
await SettingService.workspaceMuteCriticalAlertsUi({
workspace: $workspaceStore!,
requestBody: {
mute_critical_alerts: muteSettings.workspace
}
})
sendUserToast(
`Critical alert UI mute settings changed.\nPlease reload page for UI changes to take effect.`
)
childRef.refreshAlerts()
}
async function saveGlobalMuteSetting() {
await SettingService.setGlobal({
key: 'critical_alert_mute_ui',
requestBody: { value: muteSettings.global }
})
sendUserToast(
`Critical alert UI mute settings changed.\nPlease reload page for UI changes to take effect.`
)
childRef.refreshAlerts()
}
async function updateHasUnacknowledgedCriticalAlerts(sendToast: boolean = false) {
if (checkingForNewAlerts) return
checkingForNewAlerts = true
try {
const unacknowledged = await getCriticalAlerts({
const params = {
page: 1,
pageSize: 10,
pageSize: 1000,
acknowledged: false
})
}
let unacknowledged: CriticalAlert[] = []
if (!$devopsRole && $workspaceStore) {
const res = await SettingService.workspaceGetCriticalAlerts({
...params,
workspace: $workspaceStore
})
unacknowledged = res.alerts ?? []
} else {
const res = await SettingService.getCriticalAlerts(params)
unacknowledged = res.alerts ?? []
}
if (
numUnacknowledgedCriticalAlerts === 0 &&
unacknowledged.length > 0 &&
@@ -116,14 +164,122 @@
}
</script>
<Modal bind:open title="Critical Alerts" cancelText="Close" style="max-width: 66%;">
<Modal2 bind:isOpen={open} title="Critical Alerts" target="#content" fixedSize="lg">
<svelte:fragment slot="header-left">
<Notification notificationCount={numUnacknowledgedCriticalAlerts} notificationLimit={9999} />
</svelte:fragment>
<svelte:fragment slot="header-right">
<List horizontal>
{#if $superadmin || $userStore?.is_admin}
<Popup
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
target="#mute-settings-button"
preventPopupClosingOnClickInside={true}
>
<svelte:fragment slot="button">
<div id="mute-settings-button">
<Button variant="border" color="light" nonCaptureEvent>
{#if muteSettings.global || muteSettings.workspace}
<BellOff size="16" />
{:else}
<Bell size="16" />
{/if}
</Button>
</div>
</svelte:fragment>
<List justify="start">
<div class="w-full">
{#if $superadmin}
<Toggle
on:change={saveGlobalMuteSetting}
bind:checked={muteSettings.global}
options={{
right: 'Automatically acknowledge critical alerts instance wide'
}}
size="xs"
stopPropagation={true}
/>
{/if}
</div>
<div class="w-full">
<Toggle
on:change={saveWorkSpaceMuteSetting}
bind:checked={muteSettings.workspace}
options={{
right: 'Automatically acknowledge critical alerts for current workspace'
}}
size="xs"
stopPropagation={true}
/>
</div>
</List>
</Popup>
{/if}
{#if $superadmin}
<Popup
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
target="#settings-button"
>
<svelte:fragment slot="button">
<div id="settings-button">
<Button variant="border" color="light" nonCaptureEvent>
<Settings size="16" />
</Button>
</div>
</svelte:fragment>
<List justify="start" gap="none">
<div class="w-full">
<Button
size="xs"
color="light"
href="{base}/?workspace=admins#superadmin-settings"
target="_blank"
>
<div class="w-full">
<List horizontal justify="between" gap="sm">
<div>Instance Critical Alert Settings</div>
<ExternalLink size="16" />
</List>
</div>
</Button>
</div>
<div class="w-full">
<Button
size="xs"
color="light"
href="{base}/workspace_settings?tab=error_handler"
target="_blank"
>
Workspace Critical Alert Settings <ExternalLink size="16" />
</Button>
</div>
</List>
</Popup>
{:else}
<Button
size="xs"
color="light"
variant="border"
href="{base}/workspace_settings?tab=error_handler"
target="_blank"
>
<List horizontal justify="between" gap="sm">
<Settings size="16" />
<ExternalLink size="16" />
</List>
</Button>
{/if}
</List>
</svelte:fragment>
<CriticalAlertModalInner
{numUnacknowledgedCriticalAlerts}
{updateHasUnacknowledgedCriticalAlerts}
{getCriticalAlerts}
{acknowledgeCriticalAlert}
{acknowledgeAllCriticalAlerts}
{muteSettings}
bind:workspaceContext
bind:this={childRef}
/>
</Modal>
</Modal2>
@@ -1,14 +1,14 @@
<script lang="ts">
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 } from 'svelte'
import { devopsRole, workspaceStore, instanceSettingsSelectedTab, superadmin, userStore } from '$lib/stores'
import { devopsRole, instanceSettingsSelectedTab, superadmin } from '$lib/stores'
import { goto } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
import Section from '$lib/components/Section.svelte'
import List from '$lib/components/common/layout/List.svelte'
import RefreshButton from '$lib/components/common/button/RefreshButton.svelte'
import CriticalAlertTable from './CriticalAlertTable.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
export let updateHasUnacknowledgedCriticalAlerts
export let getCriticalAlerts
@@ -16,59 +16,19 @@
export let acknowledgeAllCriticalAlerts
export let numUnacknowledgedCriticalAlerts
let alerts: CriticalAlert[] = []
let filteredAlerts: CriticalAlert[] = []
let isRefreshing = false
let hasCriticalAlertChannels = true
export let muteSettings = {
workspace: true,
global: true
}
$: muteSettings
$: {
if (
initialMuteSettings.workspace !== muteSettings.workspace ||
initialMuteSettings.global !== muteSettings.global
) {
saveMuteSettings()
}
}
$: numUnacknowledgedCriticalAlerts >= 0 && getAlerts(true)
let initialMuteSettings = muteSettings
async function saveMuteSettings() {
if (initialMuteSettings.workspace !== muteSettings.workspace) {
// Workspace
await SettingService.workspaceMuteCriticalAlertsUi({
workspace: $workspaceStore!,
requestBody: {
mute_critical_alerts: muteSettings.workspace
}
})
}
if ($superadmin && initialMuteSettings.global !== muteSettings.global) {
// Global
await SettingService.setGlobal({
key: 'critical_alert_mute_ui',
requestBody: { value: muteSettings.global }
})
}
sendUserToast(
`Critical alert UI mute settings changed.\nPlease reload page for UI changes to take effect.`
)
getAlerts(true)
initialMuteSettings = { ...muteSettings }
}
$: loading = isRefreshing
$: if (numUnacknowledgedCriticalAlerts) {
refreshAlerts()
}
onMount(() => {
refreshAlerts()
initialMuteSettings = { ...muteSettings }
})
// Pagination
@@ -77,6 +37,7 @@
let hasMore = true
let hideAcknowledged = false
let workspaceContext = false
async function acknowledgeAll() {
await acknowledgeAllCriticalAlerts()
@@ -86,15 +47,14 @@
async function fetchAlerts(pageNumber: number) {
isRefreshing = true
try {
const newAlerts = await getCriticalAlerts({
const res = await getCriticalAlerts({
page: pageNumber,
pageSize: pageSize,
acknowledged: hideAcknowledged ? false : undefined
})
alerts = newAlerts
hasMore = newAlerts.length === pageSize
page = pageNumber
hasMore = pageNumber < res.total_pages
filteredAlerts = res.alerts
updateHasUnacknowledgedCriticalAlerts()
} finally {
setTimeout(() => {
@@ -104,8 +64,11 @@
}
async function getAlerts(reset?: boolean) {
if (reset) page = 1
if (reset) {
page = 1
}
updateHasUnacknowledgedCriticalAlerts()
await getTotalNumber()
await fetchAlerts(page)
}
@@ -119,33 +82,22 @@
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() {
export async function refreshAlerts() {
if ($superadmin) checkCriticalAlertChannels()
await getAlerts(true)
}
function goToPreviousPage() {
if (page > 1) {
fetchAlerts(page - 1)
page -= 1
fetchAlerts(page)
}
}
function goToNextPage() {
if (hasMore) {
fetchAlerts(page + 1)
page += 1
fetchAlerts(page)
}
}
@@ -154,163 +106,73 @@
instanceSettingsSelectedTab.set('Core')
}
export let workspaceContext = false
$: {
workspaceContextChanged(workspaceContext)
function onFiltersChange() {
getAlerts(true)
getTotalNumber()
}
async function workspaceContextChanged(_ctx) {
await getAlerts(true)
// Update filter change handlers
$: hideAcknowledged, workspaceContext, onFiltersChange()
let totalNumberOfAlerts = 0
async function getTotalNumber() {
loading = true
const res = await getCriticalAlerts({
page: 1,
pageSize: 1000,
acknowledged: hideAcknowledged ? false : undefined
})
totalNumberOfAlerts = res.total_rows
loading = false
}
</script>
<div>
<div class="grid grid-cols-3 gap-4 col-start-3">
<div class="pt-1 col-span-2">
{#if !hasCriticalAlertChannels && $superadmin}
<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}
<List gap="sm">
{#if !hasCriticalAlertChannels && $superadmin}
<div class="w-full">
<Alert title="No critical alert channels are set up" type="warning" size="xs">
Go to the
<a href="/#superadmin-settings" on:click|preventDefault={goToCoreTab}>Instance Settings</a>
page to configure critical alert channels.
</Alert>
</div>
{/if}
<div class="flex flex-col justify-between col-start-3">
<div class="flex flex-row justify-end mt-[-38px] pb-3">
<Button
color="green"
startIcon={{ icon: CheckSquare2 }}
size="xs"
disabled={numUnacknowledgedCriticalAlerts === 0}
on:click={acknowledgeAll}
>
Acknowledge All</Button
>
</div>
{#if $devopsRole}
<div class="flex flex-row py-2 pb-3">
<Toggle
bind:checked={workspaceContext}
options={{ right: `Workspace: '${$workspaceStore}'`, left: "Context: 'Global'" }}
size="xs"
/>
</div>
{/if}
{#if $superadmin || $userStore?.is_admin}
<Section label="Mute Settings" collapsable={true} small={true}>
{#if $superadmin}
<div class="flex flex-row pb-1">
<Toggle
bind:checked={muteSettings.global}
options={{ right: 'Mute critical alerts instance wide' }}
size="xs"
/>
</div>
{/if}
<div class="flex flex-row pb-1">
<div class="w-full">
<List horizontal justify="between">
<div class="w-full">
<List horizontal justify="start" gap="md">
{#if $devopsRole}
<Toggle
bind:checked={muteSettings.workspace}
options={{ right: 'Mute critical alerts for current workspace' }}
bind:checked={workspaceContext}
options={{ right: `Workspace only` }}
size="xs"
/>
</div>
</Section>
{/if}
{/if}
<div class="pt-2 flex justify-between items-center">
<div class="pr-2">
<Toggle
bind:checked={hideAcknowledged}
on:change={refreshAlerts}
options={{ right: 'Hide Acknowledged' }}
size="xs"
/>
</div>
<button
class="mb-1 p-2 rounded-full hover:bg-gray-200"
on:click={refreshAlerts}
disabled={loading}
>
<RefreshCw class={loading ? 'animate-spin ' : ''} size="20" />
</button>
<Toggle bind:checked={hideAcknowledged} options={{ right: 'Non-Acked only' }} size="xs" />
</List>
</div>
</div>
<List wFull={false} horizontal gap="md" justify="end">
<div class="text-xs text-tertiary whitespace-nowrap"
>{`${totalNumberOfAlerts === 1000 ? '1000+' : totalNumberOfAlerts ?? '?'} items`}
</div>
<RefreshButton {loading} on:click={refreshAlerts} />
</List>
</List>
</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>
{#if $devopsRole}
<th class="w-[80px] px-4 py-2 text-center">Workspace</th>
{/if}
<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, workspace_id }}
{#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>
{#if $devopsRole}
<td class="border px-4 py-2 w-[150px]">{workspace_id ? workspace_id : 'global'}</td>
{/if}
<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>
<div class="flex flex-1 pt-2 gap-x-4 justify-end">
<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>
{#if alerts.length === 0}
<p class="text-center text-gray-500 mt-4">No critical alerts available.</p>
{/if}
</div>
<CriticalAlertTable
alerts={filteredAlerts}
{acknowledgeAlert}
{hideAcknowledged}
{goToNextPage}
{goToPreviousPage}
bind:page
{hasMore}
{acknowledgeAll}
{numUnacknowledgedCriticalAlerts}
{pageSize}
/>
</List>
@@ -0,0 +1,146 @@
<script lang="ts">
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { AlertCircle, CheckCircle2 } from 'lucide-svelte'
import { devopsRole } from '$lib/stores'
import List from '$lib/components/common/layout/List.svelte'
import { Skeleton } from '../common'
export let alerts: any[]
export let hideAcknowledged = false
export let goToNextPage: () => void
export let goToPreviousPage: () => void
export let acknowledgeAlert: (id: number) => void
export let acknowledgeAll: () => void
export let numUnacknowledgedCriticalAlerts: number
export let page = 1
export let hasMore = true
export let pageSize = 0
let headerHeight = 0
let contentHeight = 0
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)
}
$: availableHeight = (contentHeight - headerHeight - pageSize - 1) / pageSize
</script>
<div class="relative grow min-h-0 w-full">
<DataTable
size="xs"
paginated
on:next={goToNextPage}
on:previous={goToPreviousPage}
bind:currentPage={page}
{hasMore}
bind:contentHeight
>
<Head>
<tr bind:clientHeight={headerHeight}>
<Cell head first class="min-w-12">&nbsp;</Cell>
<Cell head class="min-w-24 w-full">Message</Cell>
<Cell head class="min-w-28">Created At</Cell>
{#if $devopsRole}
<Cell head class="min-w-24">Context</Cell>
{/if}
<Cell head last class="min-w-36">
<List horizontal gap="sm">
<span>Acked</span>
<Button
color="green"
startIcon={{ icon: CheckCircle2 }}
size="xs2"
disabled={numUnacknowledgedCriticalAlerts === 0}
on:click={acknowledgeAll}
title="Acknowledge all"
>
All</Button
>
</List>
</Cell>
</tr>
</Head>
{#if alerts == undefined}
<tbody>
{#each new Array(3) as _}
<Row>
{#each new Array(5) as _}
<Cell>
<Skeleton layout={[[5]]} />
</Cell>
{/each}
</Row>
{/each}
</tbody>
{:else if alerts.length === 0}
<div class="absolute top-0 left-0 w-full h-full center-center">
<p class="text-center text-gray-500 mt-4">No critical alerts.</p>
</div>
{:else}
<tbody class="divide-y border-b w-full overflow-y-auto">
{#each alerts as { id, alert_type, message, created_at, acknowledged, workspace_id }}
{#if !hideAcknowledged || !acknowledged}
<Row disabled={acknowledged}>
<Cell class="py-0">
<div class="flex items-center justify-center" style="height: {availableHeight}px">
{#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}
</div>
</Cell>
<Cell wrap>
<div class="flex-shrink min-w-0 break-words">{message}</div>
</Cell>
<!-- Flexible width -->
<Cell wrap>{formatDate(created_at)}</Cell>
{#if $devopsRole}
<Cell>{workspace_id ? workspace_id : 'global'}</Cell>
{/if}
<Cell>
<div class="w-full flex justify-center items-center">
{#if !acknowledged}
<Button
color="green"
startIcon={{ icon: CheckCircle2 }}
size="xs2"
on:click={() => {
if (id) acknowledgeAlert(id)
}}
title="Acknowledge"
>
Acknowledge
</Button>
{:else}
<CheckCircle2 size="20" />
{/if}
</div>
</Cell>
</Row>
{/if}
{/each}
</tbody>
{/if}
</DataTable>
</div>
@@ -1,14 +1,12 @@
<script lang="ts">
import Notification from '$lib/components/common/alert/Notification.svelte'
export let notificationCount = 0
export let small: boolean = false
</script>
{#if !small}
<div
class="bg-red-500 text-white text-[0.6rem] rounded-md w-5 h-5 flex items-center justify-center"
>
{notificationCount > 9 ? '9+' : notificationCount}
</div>
<Notification {notificationCount} notificationLimit={9} />
{:else}
<div class="bg-red-500 rounded-md w-3 h-3 flex items-center justify-center" />
{/if}
@@ -10,6 +10,7 @@
export let shouldStopPropagation: boolean = false
export let selected = false
export let sticky: boolean = false
export let wrap: boolean = false
let Tag = head ? 'th' : 'td'
@@ -24,7 +25,8 @@
if (shouldStopPropagation) e.stopPropagation()
}}
class={twMerge(
'text-left text-xs text-primary font-normal whitespace-nowrap',
'text-left text-xs text-primary font-normal',
wrap ? 'break-words' : 'whitespace-nowrap',
first ? 'sm:pl-6' : '',
last ? 'sm:pr-6' : '',
@@ -33,13 +35,13 @@
numeric ? 'text-right' : '',
head ? 'font-semibold ' : '',
$$restProps.class,
sticky ? `!p-0 sticky ${first ? 'left-0' : 'right-0'}` : 'px-2 py-3.5',
size === 'sm' ? 'px-1.5 py-2.5' : '',
size === 'lg' ? 'px-3 py-4' : '',
size === 'xs' ? 'px-1 py-1.5' : '',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'transition-all'
'transition-all',
$$restProps.class
)}
>
{#if sticky}
@@ -9,6 +9,7 @@
import Button from '../common/button/Button.svelte'
import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import List from '$lib/components/common/layout/List.svelte'
export let paginated: boolean = false
export let currentPage: number = 1
@@ -21,80 +22,87 @@
export let shouldHidePagination: boolean = false
export let noBorder: boolean = false
export let rowCount: number | undefined = undefined
export let hasMore: boolean = true
export let contentHeight: number = 0
let footerHeight: number = 0
let tableHeight: number = 0
const dispatch = createEventDispatcher()
setContext<DatatableContext>('datatable', {
size
})
$: contentHeight = tableHeight - footerHeight
</script>
<div
class={twMerge(
'h-full overflow-auto',
rounded ? 'rounded-md' : '',
noBorder ? 'border-0' : 'border'
)}
class={twMerge('h-full', rounded ? 'rounded-md' : '', noBorder ? 'border-0' : 'border')}
bind:clientHeight={tableHeight}
>
<div class={twMerge('overflow-auto')}>
<table class={twMerge('min-w-full divide-y')}>
<slot />
</table>
</div>
{#if paginated && !shouldHidePagination}
<div
class="bg-surface border-t flex flex-row justify-between p-1 items-center gap-2 sticky bottom-0"
>
<div>
{#if rowCount}
<span class="text-xs mx-2"> {rowCount} items</span>
{/if}
</div>
<List justify="between" gap="none">
<div class="w-full overflow-auto min-h-0 grow">
<table class={twMerge('min-w-full divide-y')}>
<slot />
</table>
</div>
{#if paginated && !shouldHidePagination}
<div
class="w-full bg-surface border-t flex flex-row justify-between p-1 items-center gap-2 sticky bottom-0"
bind:clientHeight={footerHeight}
>
<div>
{#if rowCount}
<span class="text-xs mx-2"> {rowCount} items</span>
{/if}
</div>
<div class="flex flex-row gap-2 items-center">
<span class="text-xs">
Page: {currentPage}
{perPage && rowCount ? `/ ${Math.ceil(rowCount / perPage)}` : ''}
</span>
<div class="flex flex-row gap-2 items-center">
<span class="text-xs">
Page: {currentPage}
{perPage && rowCount ? `/ ${Math.ceil(rowCount / perPage)}` : ''}
</span>
{#if perPage !== undefined}
<select class="!text-xs !w-16" bind:value={perPage}>
<option value={25}>25</option>
<option value={100}>100</option>
<option value={1000}>1000</option>
</select>
{/if}
<Button
color="light"
size="xs2"
on:click={() => dispatch('previous')}
disabled={currentPage === 1}
startIcon={{ icon: ArrowLeftIcon }}
>
Previous
</Button>
{#if showNext}
{#if perPage !== undefined}
<select class="!text-xs !w-16" bind:value={perPage}>
<option value={25}>25</option>
<option value={100}>100</option>
<option value={1000}>1000</option>
</select>
{/if}
<Button
color="light"
size="xs2"
on:click={() => dispatch('next')}
endIcon={{ icon: ArrowRightIcon }}
on:click={() => dispatch('previous')}
disabled={currentPage === 1}
startIcon={{ icon: ArrowLeftIcon }}
>
Next
Previous
</Button>
{/if}
{#if showNext}
<Button
color="light"
size="xs2"
on:click={() => dispatch('next')}
endIcon={{ icon: ArrowRightIcon }}
disabled={!hasMore}
>
Next
</Button>
{/if}
</div>
</div>
</div>
{:else if shouldLoadMore}
<div class="bg-surface border-t flex flex-row justify-center py-4 items-center gap-2">
<Button
color="light"
size="xs2"
on:click={() => dispatch('loadMore')}
endIcon={{ icon: ArrowDownIcon }}
>
Load {loadMore} more
</Button>
</div>
{/if}
{:else if shouldLoadMore}
<div class="bg-surface border-t flex flex-row justify-center py-4 items-center gap-2">
<Button
color="light"
size="xs2"
on:click={() => dispatch('loadMore')}
endIcon={{ icon: ArrowDownIcon }}
>
Load {loadMore} more
</Button>
</div>
{/if}
</List>
</div>
@@ -1,4 +1,4 @@
<thead class="bg-surface-secondary sticky top-0">
<thead class="bg-surface-secondary sticky top-0 z-10">
<slot />
<div class="absolute top-2 right-2">
<slot name="headerAction" />
+3 -1
View File
@@ -5,6 +5,7 @@
export let hoverable: boolean = false
export let selected: boolean = false
export let dividable: boolean = false
export let disabled: boolean = false
const dispatch = createEventDispatcher()
</script>
@@ -13,7 +14,8 @@
hoverable ? 'hover:bg-surface-hover cursor-pointer' : '',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'transition-all',
dividable ? 'divide-x' : ''
dividable ? 'divide-x' : '',
disabled ? 'opacity-60' : ''
)}
on:click={() => {
dispatch('click')