Files
windmill/frontend/src/lib/components/instanceBanner.ts
T
Ruben FiszelandClaude Opus 5.5 1fd729ac1a feat: add an instance-wide accent color setting with sidebar tint (#11335)
* feat: add an instance-wide accent color setting with sidebar tint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: paint the cached accent before the license resolves

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: serve the banner and accent color from one cached endpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: invalidate the instance ui cache and bound it with a ttl

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the instance ui ttl under the client poll period

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: load the banner and accent color once per page load

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: read the banner and accent color without a server cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: show a cleared accent color as off

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:23:30 +02:00

124 lines
4.7 KiB
TypeScript

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