mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
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:
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"> </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" />
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user