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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-09 11:20:25 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 0b63e0a692
commit abf4c6c234
12 changed files with 707 additions and 6 deletions
+41
View File
@@ -1485,3 +1485,44 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool<Postgres
"the other settings in the same apply must not have been written either"
);
}
/// Same contract for the announcement banner: this path owns its validation, and a value
/// that lands here unchecked reaches every user's browser. A rejected banner must not be
/// half-applied either.
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>) {
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"
);
}
+18 -5
View File
@@ -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?;
}
@@ -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,<script>alert(1)</script>",
"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
@@ -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?;
@@ -0,0 +1,97 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { classes as alertClasses, icons as alertIcons } from '$lib/components/common/alert/model'
import { ExternalLink, X } from 'lucide-svelte'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import {
fetchInstanceBanner,
isInstanceBannerVisible,
type ResolvedInstanceBanner
} from './instanceBanner'
import { instanceSettingsSaved } from './instanceSettings'
// Mounted only on the managed cloud (see the render site in the logged layout), so the
// poll costs nothing anywhere else. An announcement is only worth broadcasting while it
// is current, hence polling rather than waiting for the next full page load: a session
// left open all day is exactly the one that needs to hear about the maintenance window.
const POLL_MS = 60_000
let banner = $state<ResolvedInstanceBanner | undefined>(undefined)
// Per-viewer, per-announcement. Holds the fingerprint of the dismissed banner, so
// a new announcement shows up again for everyone who dismissed the previous one.
const dismissed = useLocalStorageValue<string>('instance_banner_dismissed', '', 'string')
// The poll, the tab-focus refresh and the post-save refresh all call `load()` and can
// overlap. Responses are not ordered, so without this a slow earlier fetch lands last
// and puts a retracted announcement back on screen until the next successful poll.
let latestLoad = 0
async function load() {
const generation = ++latestLoad
try {
const next = await fetchInstanceBanner()
if (generation === latestLoad) banner = next
} catch (e) {
// Keep whatever is on screen: a transient failure must not silently retract
// an announcement that is still in force.
console.warn('Could not fetch the instance banner', e)
}
}
$effect(() => {
// Re-runs on save so a superadmin who just published an announcement sees it
// immediately rather than on the next poll or page load.
$instanceSettingsSaved
load()
const interval = setInterval(() => {
if (!document.hidden) load()
}, POLL_MS)
const onVisible = () => {
if (!document.hidden) load()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(interval)
document.removeEventListener('visibilitychange', onVisible)
}
})
let shown = $derived(isInstanceBannerVisible(banner, dismissed.val))
let palette = $derived(alertClasses[banner?.severity ?? 'info'])
let Icon = $derived(alertIcons[banner?.severity ?? 'info'])
</script>
{#if shown && banner}
<!-- Sits in the content column above the page, in flow rather than overlaid, so it
pushes the app down instead of covering the top of whatever page is open. -->
<div
class="shrink-0 px-4 py-1.5 flex items-center justify-center gap-x-3 gap-y-1 flex-wrap {palette.bgClass}"
role="status"
>
<Icon size={16} class="shrink-0 {palette.iconClass}" />
<span class="text-xs font-medium {palette.titleClass}">{banner.message}</span>
{#if banner.link}
<Button
variant="subtle"
unifiedSize="xs"
href={banner.link}
target="_blank"
endIcon={{ icon: ExternalLink }}
>
{banner.linkLabel}
</Button>
{/if}
{#if banner.dismissible}
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: X }}
title="Dismiss"
aria-label="Dismiss announcement"
onclick={() => (dismissed.val = banner?.fingerprint ?? '')}
/>
{/if}
</div>
{/if}
@@ -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 @@
<WebhookBaseUrlSetting {values} disabled={loading || !$enterpriseLicense} />
{:else if setting.fieldType == 'ws_connectivity'}
<WsConnectivityTest {values} />
{:else if setting.fieldType == 'instance_banner'}
<InstanceBannerSetting {values} disabled={loading} />
{/if}
{#if hasError}
<span class="text-red-600 dark:text-red-400 text-xs">
@@ -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<string, any>): 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])
@@ -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)
})
})
@@ -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<ResolvedInstanceBanner | undefined> {
return resolveInstanceBanner(await SettingService.getGlobal({ key: INSTANCE_BANNER_SETTING }))
}
@@ -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<string, Setting[]> = {
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:
@@ -0,0 +1,159 @@
<script lang="ts">
import type { Writable } from 'svelte/store'
import Toggle from '../Toggle.svelte'
import TextInput from '../text_input/TextInput.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import { classes as alertClasses, icons as alertIcons } from '../common/alert/model'
import {
bannerString,
INSTANCE_BANNER_LINK_LABEL_MAX_LEN,
INSTANCE_BANNER_MESSAGE_MAX_LEN,
INSTANCE_BANNER_SETTING,
instanceBannerFormError,
resolveInstanceBanner,
type InstanceBanner,
type InstanceBannerSeverity
} from '../instanceBanner'
interface Props {
values: Writable<Record<string, any>>
disabled?: boolean
}
let { values, disabled = false }: Props = $props()
// Writes go through `$values[...]` member assignments: only those push the nested
// mutation back into the store, which is what the unsaved-changes check and the Save
// button watch. Reads go through a *snapshot* because the form mutates that object in
// place — a derived returning the object itself keeps its identity across edits, and
// Svelte then stops propagating to whatever depends on it. Every getter below applies
// the same default `resolveInstanceBanner` does, so a value missing a field displays
// as it will actually render.
let banner: InstanceBanner = $derived(
$state.snapshot($values[INSTANCE_BANNER_SETTING] ?? {}) as InstanceBanner
)
const severities: { value: InstanceBannerSeverity; label: string }[] = [
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Critical' }
]
// Runs the resolver the banner itself uses, so this shows what the instance gets —
// including the "nothing is shown" cases (disabled, or an empty message).
let preview = $derived(resolveInstanceBanner(banner))
let linkError = $derived(instanceBannerFormError(banner))
</script>
<div class="flex flex-col gap-3">
<Toggle
{disabled}
bind:checked={
() => banner.enabled === true, (v) => ($values[INSTANCE_BANNER_SETTING].enabled = v)
}
options={{ right: 'Show the banner to every user' }}
/>
<div class="flex flex-col gap-1">
<span class="text-secondary text-xs">Message</span>
<TextInput
underlyingInputEl="textarea"
size="sm"
class="min-h-14 resize-y"
inputProps={{
disabled,
rows: 2,
maxlength: INSTANCE_BANNER_MESSAGE_MAX_LEN,
placeholder: 'Scheduled maintenance on Saturday 12:0014:00 UTC. Jobs may be delayed.'
}}
bind:value={
() => bannerString(banner.message),
(v) => ($values[INSTANCE_BANNER_SETTING].message = String(v))
}
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-secondary text-xs">Severity</span>
<ToggleButtonGroup
{disabled}
bind:selected={
() => bannerString(banner.severity) || 'info',
(v) => ($values[INSTANCE_BANNER_SETTING].severity = v)
}
>
{#snippet children({ item })}
{#each severities as severity (severity.value)}
<ToggleButton value={severity.value} label={severity.label} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
</div>
<div class="flex flex-col gap-1">
<span class="text-secondary text-xs">Link (optional)</span>
<div class="flex flex-col sm:flex-row gap-2">
<TextInput
size="sm"
inputProps={{ type: 'text', disabled, placeholder: 'https://status.windmill.dev' }}
error={linkError}
bind:value={
() => bannerString(banner.link),
(v) => ($values[INSTANCE_BANNER_SETTING].link = String(v))
}
/>
<TextInput
size="sm"
inputProps={{
type: 'text',
disabled,
maxlength: INSTANCE_BANNER_LINK_LABEL_MAX_LEN,
placeholder: 'Learn more'
}}
bind:value={
() => bannerString(banner.link_label),
(v) => ($values[INSTANCE_BANNER_SETTING].link_label = String(v))
}
/>
</div>
{#if linkError}
<span class="text-red-600 dark:text-red-400 text-xs">{linkError}</span>
{/if}
</div>
<Toggle
{disabled}
bind:checked={
() => 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.'
}}
/>
<div class="flex flex-col gap-1">
<span class="text-secondary text-xs">Preview</span>
{#if preview}
{@const palette = alertClasses[preview.severity]}
{@const Icon = alertIcons[preview.severity]}
<div
class="px-4 py-1.5 rounded-md flex items-center justify-center gap-3 flex-wrap {palette.bgClass}"
>
<Icon size={16} class="shrink-0 {palette.iconClass}" />
<span class="text-xs font-medium {palette.titleClass}">{preview.message}</span>
{#if preview.link}
<span class="text-xs underline {palette.titleClass}">{preview.linkLabel}</span>
{/if}
</div>
{:else}
<span class="text-tertiary text-xs">
{banner.enabled === true
? 'Nothing is shown until the message is filled in.'
: 'Nothing is shown while the banner is off.'}
</span>
{/if}
</div>
</div>
@@ -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 @@
</div>
{/if}
<div class="flex flex-col h-full w-full">
{#if isCloudHosted() && !menuHidden}
<!-- Announcements are a managed-cloud operations tool, so the component never
mounts elsewhere: no fetch, no poll, no listener on a self-hosted instance.
Also skipped when the menu is hidden — that is an embed or an OAuth
callback, where the announcement would land inside someone else's page. -->
<InstanceBanner />
{/if}
{#if $userStore?.is_service_account}
<div
class="bg-yellow-100 dark:bg-yellow-900/50 border-b border-yellow-300 dark:border-yellow-700 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200 flex items-center justify-center gap-4 shrink-0"