mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
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:
co-authored by
Claude Opus 5
parent
0b63e0a692
commit
abf4c6c234
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:00–14: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"
|
||||
|
||||
Reference in New Issue
Block a user