From abf4c6c2348014ea4401b9be62b5b15e5800e879 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 9 Sep 2026 11:20:25 +0200 Subject: [PATCH] feat: add a dismissible instance-wide announcement banner (#11037) * feat: add a dismissible instance-wide announcement banner Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs * fix: harden instance banner validation and mandatory-banner visibility Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs * fix: sequence instance banner loads and match the backend character cap Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs * fix: gate the settings save on a valid instance banner link Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs * feat: restrict the announcement banner to the managed cloud Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/instance_config.rs | 41 +++++ backend/windmill-api-settings/src/lib.rs | 23 ++- .../windmill-common/src/global_settings.rs | 161 ++++++++++++++++++ .../windmill-common/src/instance_config.rs | 10 ++ .../src/lib/components/InstanceBanner.svelte | 97 +++++++++++ .../src/lib/components/InstanceSetting.svelte | 3 + .../lib/components/InstanceSettings.svelte | 8 +- .../src/lib/components/instanceBanner.test.ts | 59 +++++++ frontend/src/lib/components/instanceBanner.ts | 129 ++++++++++++++ .../src/lib/components/instanceSettings.ts | 15 ++ .../InstanceBannerSetting.svelte | 159 +++++++++++++++++ .../src/routes/(root)/(logged)/+layout.svelte | 8 + 12 files changed, 707 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/components/InstanceBanner.svelte create mode 100644 frontend/src/lib/components/instanceBanner.test.ts create mode 100644 frontend/src/lib/components/instanceBanner.ts create mode 100644 frontend/src/lib/components/instanceSettings/InstanceBannerSetting.svelte diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index ddd94ea079..76102656a5 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1485,3 +1485,44 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool) { + clear_settings_and_configs(&db).await; + let before = count_global_settings(&db).await; + + let mut desired = BTreeMap::new(); + desired.insert( + "base_url".to_string(), + serde_json::json!("https://wm.example.com"), + ); + desired.insert( + "instance_banner".to_string(), + serde_json::json!({ "enabled": true, "message": "down", "link": "javascript:alert(1)" }), + ); + + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect_err("a javascript: banner link must fail the sync"); + assert!( + err.to_string().contains("instance_banner"), + "the error should name the offending setting, got: {err}" + ); + + assert_eq!( + count_global_settings(&db).await, + before, + "validation must run before anything is applied" + ); + assert!( + get_global_setting(&db, "base_url").await.is_none(), + "the other settings in the same apply must not have been written either" + ); +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 18a8d52ac0..81528912a0 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -59,11 +59,11 @@ use windmill_common::{ CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, - HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, - RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, - WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, - WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, - WS_BASE_URL_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING, + MAX_RETENTION_OVERRIDE_WORKSPACES, RETENTION_PERIOD_SECS_OVERRIDES_SETTING, + RUFF_CONFIG_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, + WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, + WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -1172,6 +1172,18 @@ async fn run_setting_pre_write_hook( } } } + INSTANCE_BANNER_SETTING => { + match value { + // Clearing (delete row) is handled by the caller; allow it through. + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.trim().is_empty() => {} + v => { + windmill_common::global_settings::validate_instance_banner(v).map_err(|e| { + error::Error::BadRequest(format!("{INSTANCE_BANNER_SETTING}: {e}")) + })?; + } + } + } _ => {} } Ok(()) @@ -1312,6 +1324,7 @@ pub async fn get_global_setting( && key != APP_WORKSPACED_ROUTE_SETTING && key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING && key != WS_BASE_URL_SETTING + && key != INSTANCE_BANNER_SETTING { require_super_admin(&db, &authed).await?; } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 8180e820f2..fdeeb2a92d 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -125,6 +125,112 @@ pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; /// `base_url` when unset; set it when the browser-facing URL is not reachable /// from GitHub and a separate ingress fronts the API for inbound webhooks. pub const GITHUB_APP_WEBHOOK_BASE_URL_SETTING: &str = "github_app_webhook_base_url"; +/// Instance-wide announcement rendered above every page of the app (maintenance +/// windows, incidents). Readable by any authenticated user, unlike most settings: +/// the banner exists to be shown to everyone, so it must never hold anything the +/// whole instance may not see. +pub const INSTANCE_BANNER_SETTING: &str = "instance_banner"; + +/// Ceiling on the banner message. The banner is a one-or-two-line strip above every +/// page, so anything longer is a layout accident rather than an announcement. +pub const INSTANCE_BANNER_MESSAGE_MAX_LEN: usize = 500; + +/// Ceiling on the banner's link label, which renders as a button inside that same strip. +pub const INSTANCE_BANNER_LINK_LABEL_MAX_LEN: usize = 60; + +/// Validate an [`INSTANCE_BANNER_SETTING`] value. +/// +/// The banner is the one setting rendered to every user of the instance, so its +/// shape is checked at the boundary rather than trusted from the writer: a value +/// that reaches the browser malformed breaks the layout for everyone at once. +/// +/// The link is restricted to http(s) so a stored `javascript:`/`data:` URL can +/// never become the href of an anchor every user sees. +/// +/// Only shapes that would *misrender* are rejected. An enabled banner with no message +/// is left alone deliberately: it renders as nothing, and every write path here runs +/// under the bulk settings save, so rejecting it would fail an admin's whole settings +/// edit — retention, SMTP and all — over a half-typed announcement. +pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> { + let obj = value + .as_object() + .ok_or_else(|| "must be a JSON object".to_string())?; + + // Field types are checked before their contents. Every read below is an `as_str`/ + // `as_bool`, which reports a wrong-typed field as absent — so without this a + // `"link": 123` would skip the URL checks entirely and be stored, and the settings + // form would then throw on it (`link.trim()` on a number) instead of rendering. + for (field, expected, ok) in [ + ( + "enabled", + "a boolean", + obj.get("enabled").is_none_or(|v| v.is_boolean()), + ), + ( + "dismissible", + "a boolean", + obj.get("dismissible").is_none_or(|v| v.is_boolean()), + ), + ( + "message", + "a string", + obj.get("message").is_none_or(|v| v.is_string()), + ), + ( + "severity", + "a string", + obj.get("severity").is_none_or(|v| v.is_string()), + ), + ( + "link", + "a string", + obj.get("link").is_none_or(|v| v.is_string()), + ), + ( + "link_label", + "a string", + obj.get("link_label").is_none_or(|v| v.is_string()), + ), + ] { + if !ok { + return Err(format!("{field} must be {expected}")); + } + } + + for (field, max) in [ + ("message", INSTANCE_BANNER_MESSAGE_MAX_LEN), + ("link_label", INSTANCE_BANNER_LINK_LABEL_MAX_LEN), + ] { + let len = obj + .get(field) + .and_then(|v| v.as_str()) + .map_or(0, |s| s.chars().count()); + if len > max { + return Err(format!("{field} must be at most {max} characters")); + } + } + + if let Some(severity) = obj.get("severity").and_then(|v| v.as_str()) { + if !matches!(severity, "info" | "warning" | "error") { + return Err("severity must be one of info, warning, error".to_string()); + } + } + + if let Some(link) = obj.get("link").and_then(|v| v.as_str()) { + if !link.trim().is_empty() { + let url = url::Url::parse(link.trim()) + .map_err(|e| format!("link must be an absolute http(s) URL: {e}"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err("link must use the http or https scheme".to_string()); + } + if !url.has_host() { + return Err("link must include a host".to_string()); + } + } + } + + Ok(()) +} /// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value. /// @@ -590,6 +696,61 @@ mod tests { } } + #[test] + fn instance_banner_rejects_unsafe_and_malformed_values() { + // The link becomes the href of an anchor shown to every user of the instance, + // so a non-http(s) scheme must not survive a write. + for link in [ + "javascript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + "not-a-url", + "https://", + ] { + let banner = serde_json::json!({ "enabled": true, "message": "down", "link": link }); + assert!( + validate_instance_banner(&banner).is_err(), + "link '{link}' should be rejected" + ); + } + // A wrong-typed field reads as absent to every accessor here, so without an + // explicit type check it would skip validation and be stored. + for bad in [ + serde_json::json!({ "enabled": true, "message": "down", "link": 123 }), + serde_json::json!({ "enabled": true, "message": "down", "link_label": ["a"] }), + serde_json::json!({ "enabled": true, "message": { "text": "down" } }), + serde_json::json!({ "enabled": true, "message": "down", "severity": 2 }), + serde_json::json!({ "enabled": "yes", "message": "down" }), + serde_json::json!({ "enabled": true, "message": "down", "dismissible": "no" }), + ] { + assert!( + validate_instance_banner(&bad).is_err(), + "{bad} should be rejected" + ); + } + // The strip is one or two lines tall; both of its texts are bounded. + for (field, over) in [ + ("message", INSTANCE_BANNER_MESSAGE_MAX_LEN + 1), + ("link_label", INSTANCE_BANNER_LINK_LABEL_MAX_LEN + 1), + ] { + let mut banner = serde_json::json!({ "enabled": true, "message": "down" }); + banner[field] = serde_json::Value::String("x".repeat(over)); + assert!( + validate_instance_banner(&banner).is_err(), + "an over-long {field} should be rejected" + ); + } + // Enabled with no message renders as nothing and must stay writable: every path + // into this validator is a bulk settings save, so rejecting it would fail an + // admin's unrelated edits over a half-typed announcement. + assert!(validate_instance_banner(&serde_json::json!({ "enabled": true })).is_ok()); + let ok = serde_json::json!({ + "enabled": true, "message": "down", "severity": "warning", + "link": "https://status.example.com", "dismissible": false + }); + assert!(validate_instance_banner(&ok).is_ok()); + } + #[test] fn webhook_base_url_matches_the_ui_validator() { // Kept in lockstep with `isValidWebhookBaseUrl` in diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index c3060e9a89..e28bf139cf 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1338,6 +1338,16 @@ pub async fn sync_global_settings_declarative( } } + let banner_key = crate::global_settings::INSTANCE_BANNER_SETTING; + match desired.get(banner_key) { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(s)) if s.trim().is_empty() => {} + Some(banner) => crate::global_settings::validate_instance_banner(banner) + // The validator's messages name the offending field and its expected type, + // never the submitted value, so they are safe to surface here. + .map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?, + } + let diff = diff_global_settings(current, desired, ApplyMode::Replace); apply_settings_diff(db, &diff).await?; diff --git a/frontend/src/lib/components/InstanceBanner.svelte b/frontend/src/lib/components/InstanceBanner.svelte new file mode 100644 index 0000000000..6325861b63 --- /dev/null +++ b/frontend/src/lib/components/InstanceBanner.svelte @@ -0,0 +1,97 @@ + + +{#if shown && banner} + +
+ + {banner.message} + {#if banner.link} + + {/if} + {#if banner.dismissible} +
+{/if} diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 19b5ddb533..7b6c5e312e 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -26,6 +26,7 @@ import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte' import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte' + import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte' import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte' import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte' import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte' @@ -861,6 +862,8 @@ {:else if setting.fieldType == 'ws_connectivity'} + {:else if setting.fieldType == 'instance_banner'} + {/if} {#if hasError} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 39efa2d090..087e5e79eb 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -14,6 +14,7 @@ import { sleep } from '$lib/utils' import { enterpriseLicense } from '$lib/stores' + import { isCloudHosted } from '$lib/cloud' import { createEventDispatcher } from 'svelte' import { setLicense } from '$lib/enterpriseUtils' @@ -100,7 +101,8 @@ otel: {}, indexer_settings: {}, critical_error_channels: [], - github_enterprise_app: {} + github_enterprise_app: {}, + instance_banner: {} } function applyFormDefaults(vals: Record): void { @@ -524,6 +526,10 @@ for (const category of settingsKeys) { const categorySettings = getSettingsForCategory(category) result[category] = categorySettings.some((s) => { + // A field the build never renders must not be able to block Save: off-cloud its + // value is unreachable, so an invalid one (from config sync, say) would leave the + // category permanently unsaveable with nothing on screen to fix. + if (s.cloudonly && !isCloudHosted()) return false if (s.isValid && !s.isValid(currentValues?.[s.key])) return true if (s.validate) { const errors = s.validate(currentValues?.[s.key]) diff --git a/frontend/src/lib/components/instanceBanner.test.ts b/frontend/src/lib/components/instanceBanner.test.ts new file mode 100644 index 0000000000..bc93af18a1 --- /dev/null +++ b/frontend/src/lib/components/instanceBanner.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { + INSTANCE_BANNER_MESSAGE_MAX_LEN, + isInstanceBannerVisible, + resolveInstanceBanner +} from './instanceBanner' + +const BANNER = { + enabled: true, + message: 'Scheduled maintenance on Saturday.', + severity: 'warning', + dismissible: true +} + +describe('resolveInstanceBanner', () => { + it('drops a link that is not an absolute http(s) URL', () => { + // Declarative instance config writes global_settings rows directly, so the API's + // scheme check is not the only thing standing between a stored value and an href. + for (const link of ['javascript:alert(1)', 'data:text/html,x', 'status.example.com', 123]) { + expect(resolveInstanceBanner({ ...BANNER, link })?.link).toBeUndefined() + } + expect(resolveInstanceBanner({ ...BANNER, link: 'https://status.example.com' })?.link).toBe( + 'https://status.example.com' + ) + }) + + it('keeps a message the backend accepted whole', () => { + // The backend caps at INSTANCE_BANNER_MESSAGE_MAX_LEN code points (`chars().count()`). + // Truncating with `slice` here would count UTF-16 units and halve an all-emoji message + // that passed validation, so the two sides must measure the same way. + const emoji = '\u{1F6A7}'.repeat(INSTANCE_BANNER_MESSAGE_MAX_LEN) + expect([...resolveInstanceBanner({ ...BANNER, message: emoji })!.message]).toHaveLength( + INSTANCE_BANNER_MESSAGE_MAX_LEN + ) + }) + + it('shows nothing when disabled or without a message', () => { + // An enabled banner with no message is a writable state (the backend accepts it so a + // half-typed announcement cannot fail an admin's whole settings save), so this is the + // only thing keeping it off everyone's screen. + expect(resolveInstanceBanner({ ...BANNER, enabled: false })).toBeUndefined() + expect(resolveInstanceBanner({ ...BANNER, message: ' ' })).toBeUndefined() + expect(resolveInstanceBanner({ ...BANNER, message: 42 })).toBeUndefined() + }) +}) + +describe('isInstanceBannerVisible', () => { + it('honours a dismissal only while the announcement is dismissible', () => { + const dismissible = resolveInstanceBanner(BANNER)! + expect(isInstanceBannerVisible(dismissible, dismissible.fingerprint)).toBe(false) + expect(isInstanceBannerVisible(dismissible, 'some other announcement')).toBe(true) + + // Escalating the same announcement to mandatory must reach the people who already + // dismissed it — the fingerprint does not change, so nothing else would bring it back. + const mandatory = resolveInstanceBanner({ ...BANNER, dismissible: false })! + expect(mandatory.fingerprint).toBe(dismissible.fingerprint) + expect(isInstanceBannerVisible(mandatory, mandatory.fingerprint)).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/instanceBanner.ts b/frontend/src/lib/components/instanceBanner.ts new file mode 100644 index 0000000000..f1693024b8 --- /dev/null +++ b/frontend/src/lib/components/instanceBanner.ts @@ -0,0 +1,129 @@ +import { SettingService } from '$lib/gen' + +/** Drives the banner palette and icon; the subset of `AlertType` that fits an announcement. */ +export type InstanceBannerSeverity = 'info' | 'warning' | 'error' + +/** Stored shape of the `instance_banner` global setting. Every field is optional: a + * stored value can predate a field this code knows about. */ +export interface InstanceBanner { + enabled?: boolean + message?: string + severity?: InstanceBannerSeverity + /** Whether a viewer may dismiss the banner for themselves. Absent means yes. */ + dismissible?: boolean + link?: string + link_label?: string +} + +export const INSTANCE_BANNER_SETTING = 'instance_banner' + +/** Mirror `INSTANCE_BANNER_MESSAGE_MAX_LEN` / `INSTANCE_BANNER_LINK_LABEL_MAX_LEN` in + * backend/windmill-common/src/global_settings.rs, which reject longer values at write time. */ +export const INSTANCE_BANNER_MESSAGE_MAX_LEN = 500 +export const INSTANCE_BANNER_LINK_LABEL_MAX_LEN = 60 + +export type ResolvedInstanceBanner = { + message: string + severity: InstanceBannerSeverity + dismissible: boolean + link?: string + linkLabel: string + /** Dismissal token: a viewer who dismissed one announcement sees the next one, + * because editing any displayed part of the banner changes this string. */ + fingerprint: string +} + +/** + * Read a stored banner field as a string. + * + * The setting is a raw `global_settings` row, so its shape is only ever as good as the + * writer that last touched it — and anything here that calls `.trim()` on a number throws, + * taking the whole settings form down with it. + */ +export function bannerString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** + * Truncate to `max` code points, matching the backend's `chars().count()` cap. + * + * `String.slice` counts UTF-16 code units, so it would cut a 500-emoji message the backend + * accepted in half — and the two sides must agree on what "500 characters" means. + */ +function truncateChars(value: string, max: number): string { + const chars = [...value] + return chars.length > max ? chars.slice(0, max).join('') : value +} + +export function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +/** + * Turn the raw setting into what the banner renders, or `undefined` for "show nothing". + * + * The scheme check repeats the one the writers run on purpose. The link becomes the href of + * an anchor shown to every user of the instance, and this is the last place that can refuse + * it — a row predating the validator, or written straight to the table, reaches here having + * passed nothing. + */ +export function resolveInstanceBanner(raw: unknown): ResolvedInstanceBanner | undefined { + if (!raw || typeof raw !== 'object') return undefined + const banner = raw as InstanceBanner + const message = bannerString(banner.message).trim() + if (banner.enabled !== true || message === '') return undefined + + const severity: InstanceBannerSeverity = + banner.severity === 'warning' || banner.severity === 'error' ? banner.severity : 'info' + const rawLink = bannerString(banner.link).trim() + const link = isHttpUrl(rawLink) ? rawLink : undefined + const linkLabel = + truncateChars(bannerString(banner.link_label).trim(), INSTANCE_BANNER_LINK_LABEL_MAX_LEN) || + 'Learn more' + + return { + message: truncateChars(message, INSTANCE_BANNER_MESSAGE_MAX_LEN), + severity, + dismissible: banner.dismissible !== false, + link, + linkLabel, + fingerprint: JSON.stringify([message, severity, link ?? '', link ? linkLabel : '']) + } +} + +/** + * The reason the banner form cannot be saved, or `undefined` when it can. + * + * Shared with the setting's `isValid` so the Save button and the inline message agree: the + * backend refuses a bad link, and a category save fires its settings concurrently, so a Save + * that got this far would persist the other Core settings and fail only the banner. + */ +export function instanceBannerFormError(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined + const link = bannerString((value as InstanceBanner).link).trim() + return link !== '' && !isHttpUrl(link) ? 'Link must be an absolute http(s) URL' : undefined +} + +/** + * Whether a viewer holding `dismissedFingerprint` should see this announcement. + * + * A non-dismissible announcement ignores stored dismissals entirely: an admin escalating an + * existing notice to mandatory must reach the people who already dismissed it, and the + * fingerprint deliberately does not cover `dismissible`, so nothing else would bring it back. + */ +export function isInstanceBannerVisible( + banner: ResolvedInstanceBanner | undefined, + dismissedFingerprint: string +): banner is ResolvedInstanceBanner { + if (banner == undefined) return false + return !banner.dismissible || dismissedFingerprint !== banner.fingerprint +} + +export async function fetchInstanceBanner(): Promise { + return resolveInstanceBanner(await SettingService.getGlobal({ key: INSTANCE_BANNER_SETTING })) +} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 8bbca5b4be..0548d3ded5 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -1,5 +1,6 @@ import type { ButtonType } from './common/button/model' import { z } from 'zod' +import { instanceBannerFormError } from './instanceBanner' import { writable } from 'svelte/store' /** @@ -68,6 +69,7 @@ export interface Setting { | 'webhook_base_url' | 'ws_connectivity' | 'retention_overrides' + | 'instance_banner' storage: SettingStorage advancedToggle?: { label: string @@ -236,6 +238,19 @@ export const settings: Record = { placeholder: 'only for EE', storage: 'setting' }, + { + label: 'Announcement banner', + description: + 'Message shown above every page of the instance, for maintenance windows and incidents.', + key: 'instance_banner', + fieldType: 'instance_banner', + storage: 'setting', + // The banner only renders on the managed cloud, so only offer it there. + cloudonly: true, + hideInQuickSetup: true, + // Gates Save. The card renders the specific message itself, so no `error` here. + isValid: (value: any) => instanceBannerFormError(value) == undefined + }, { label: 'Non-prod instance', description: diff --git a/frontend/src/lib/components/instanceSettings/InstanceBannerSetting.svelte b/frontend/src/lib/components/instanceSettings/InstanceBannerSetting.svelte new file mode 100644 index 0000000000..293f567a0b --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/InstanceBannerSetting.svelte @@ -0,0 +1,159 @@ + + +
+ banner.enabled === true, (v) => ($values[INSTANCE_BANNER_SETTING].enabled = v) + } + options={{ right: 'Show the banner to every user' }} + /> + +
+ Message + bannerString(banner.message), + (v) => ($values[INSTANCE_BANNER_SETTING].message = String(v)) + } + /> +
+ +
+ Severity + bannerString(banner.severity) || 'info', + (v) => ($values[INSTANCE_BANNER_SETTING].severity = v) + } + > + {#snippet children({ item })} + {#each severities as severity (severity.value)} + + {/each} + {/snippet} + +
+ +
+ Link (optional) +
+ bannerString(banner.link), + (v) => ($values[INSTANCE_BANNER_SETTING].link = String(v)) + } + /> + bannerString(banner.link_label), + (v) => ($values[INSTANCE_BANNER_SETTING].link_label = String(v)) + } + /> +
+ {#if linkError} + {linkError} + {/if} +
+ + banner.dismissible !== false, (v) => ($values[INSTANCE_BANNER_SETTING].dismissible = v) + } + options={{ + right: 'Let users dismiss it', + rightTooltip: + 'Dismissal is remembered per browser and only for this exact announcement: editing the message, severity or link brings it back for everyone. Turning this off also shows it again to everyone who had dismissed it.' + }} + /> + +
+ Preview + {#if preview} + {@const palette = alertClasses[preview.severity]} + {@const Icon = alertIcons[preview.severity]} +
+ + {preview.message} + {#if preview.link} + {preview.linkLabel} + {/if} +
+ {:else} + + {banner.enabled === true + ? 'Nothing is shown until the message is filled in.' + : 'Nothing is shown while the banner is off.'} + + {/if} +
+
diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 5aa90b80a2..3f10420af9 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -76,6 +76,7 @@ import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration' import { pruneMeaninglessDrafts } from '$lib/userDraftPrune' import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' + import InstanceBanner from '$lib/components/InstanceBanner.svelte' import { onDestroy, setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -1369,6 +1370,13 @@ {/if}
+ {#if isCloudHosted() && !menuHidden} + + + {/if} {#if $userStore?.is_service_account}