Files
windmill/frontend/src/lib/components/InstanceBanner.svelte
T
Ruben FiszelandClaude Opus 5 abf4c6c234 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>
2026-09-09 11:20:25 +02:00

98 lines
3.4 KiB
Svelte

<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}