feat(cloud): add configurable Cloud-only WebUI notice banner

Cloud deployments need a way to tell readers what is happening to the
instance they are using (maintenance windows, service notices) without
shipping a release per notice.

Add a cloud.banner config block (switch, tone, dismissal, link targets and
per-language copy) and expose the resolved banner on the unauthenticated
/api/v1/system/info payload so the WebUI can render it before login. The
banner resolves to null on OSS deployments, so a self-hosted copy of
config.yaml never shows managed-service copy. Copy is plain text; only
absolute http(s) link targets are rendered.

The WebUI renders it once from the root layout, offsets the fixed app shell
and the wizard through --cloud-banner-height, and keeps a dismissal bound to
the notice it dismissed so new copy reappears.

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-21 16:26:43 +00:00
co-authored by multica-agent
parent 0d365c98d8
commit d45b0a3957
20 changed files with 747 additions and 7 deletions
+10 -2
View File
@@ -1,9 +1,17 @@
import { Outlet } from 'react-router-dom';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { CloudBanner } from '@/components/CloudBanner';
// Top-level route layout: drives the dynamic document title from the active
// route and renders the matched child route via <Outlet />.
// route and renders the matched child route via <Outlet />. The Cloud-only
// instance notice lives here so it is present on every route (login included);
// it publishes `--cloud-banner-height` for the fixed shell to offset itself.
export default function RootLayout() {
useDocumentTitle();
return <Outlet />;
return (
<>
<CloudBanner />
<Outlet />
</>
);
}
+16
View File
@@ -562,12 +562,28 @@ export interface WizardProgress {
selected_runner: string | null;
}
export interface ApiRespCloudBanner {
enabled: boolean;
/** Visual tone requested by the deployment: info, warning, maintenance. */
level: 'info' | 'warning' | 'maintenance';
dismissible: boolean;
/** Append the dedicated-environment / self-hosting links after the copy. */
show_links: boolean;
dedicated_url: string;
repository_url: string;
/** Language key (`zh_hans`, `en_us`, ...) -> operator-authored copy. */
content: Record<string, string>;
}
export interface ApiRespSystemInfo {
debug: boolean;
version: string;
edition: string;
beta: boolean;
deployment_mode: 'cloud' | 'oss';
/** Cloud-only instance notice from `cloud.banner` in config.yaml. Null for
* OSS deployments or when the banner is disabled. */
cloud_banner?: ApiRespCloudBanner | null;
/** Independent instance-level gate for local stdio MCP transports. */
mcp_stdio_enabled: boolean;
cloud_service_url: string;
+1
View File
@@ -27,6 +27,7 @@ export const systemInfo: ApiRespSystemInfo = {
edition: 'community',
beta: false,
deployment_mode: 'oss',
cloud_banner: null,
mcp_stdio_enabled: false,
enable_marketplace: true,
cloud_service_url: '',
+8 -2
View File
@@ -1088,7 +1088,10 @@ export default function WizardPage() {
if (isLoading) {
return (
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center">
<div
style={{ top: 'var(--cloud-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex items-center justify-center"
>
<LoadingSpinner text={t('wizard.loading')} />
</div>
);
@@ -1102,7 +1105,10 @@ export default function WizardPage() {
];
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
<div
style={{ top: 'var(--cloud-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex flex-col"
>
{/* Top bar: Skip button */}
<div className="shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 border-b">
<div className="flex items-center gap-2">
+224
View File
@@ -0,0 +1,224 @@
import { useTranslation } from 'react-i18next';
import { AlertCircle, Info, Wrench, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { backendClient } from '@/app/infra/http';
import { Alert, AlertDescription } from '@/components/ui/alert';
import type { ApiRespCloudBanner } from '@/app/infra/entities/api';
import {
cloudBannerSignature,
resolveCloudBannerCopy,
} from '@/lib/cloud-banner';
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
// The dismissal is bound to the copy that was dismissed, so publishing a new
// notice (or editing the current one) brings the banner back instead of
// leaving every returning reader permanently blind to it.
const DISMISSED_SIGNATURE_KEY = 'langbot_cloud_banner_dismissed';
const TONE_CLASSES: Record<string, string> = {
info: 'bg-sky-50 dark:bg-sky-950/25 border-sky-200 dark:border-sky-900/50 text-sky-900 dark:text-sky-100 [&>svg]:text-sky-600 dark:[&>svg]:text-sky-400',
warning:
'bg-amber-50 dark:bg-amber-950/25 border-amber-200 dark:border-amber-900/50 text-amber-900 dark:text-amber-100 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400',
maintenance:
'bg-rose-50 dark:bg-rose-950/25 border-rose-200 dark:border-rose-900/50 text-rose-900 dark:text-rose-100 [&>svg]:text-rose-600 dark:[&>svg]:text-rose-400',
};
const TONE_ICONS: Record<string, typeof Info> = {
info: Info,
warning: AlertCircle,
maintenance: Wrench,
};
const LOCALE_KEYS = [
'zh-Hans',
'zh-Hant',
'en-US',
'ja-JP',
'th-TH',
'vi-VN',
'es-ES',
'ru-RU',
] as const;
function currentLanguage(): string {
const candidates = [
typeof document !== 'undefined' ? document.documentElement.lang : '',
typeof navigator !== 'undefined' ? navigator.language : '',
...LOCALE_KEYS,
];
return (
candidates.find(
(value) => typeof value === 'string' && value.trim() !== '',
) || 'en-US'
);
}
/**
* Instance notice banner for Cloud deployments.
*
* Rendered once by the root layout so every route (login included) sees the
* same announcement. It publishes its own height as ``--cloud-banner-height``
* so the fixed app shell starts below it rather than underneath it.
*/
export function CloudBanner() {
const { t, i18n } = useTranslation();
const bannerRef = useRef<HTMLDivElement>(null);
const [banner, setBanner] = useState<ApiRespCloudBanner | null>(null);
const [dismissedSignature, setDismissedSignature] = useState<string | null>(
() =>
typeof window === 'undefined'
? null
: window.sessionStorage.getItem(DISMISSED_SIGNATURE_KEY),
);
useEffect(() => {
let cancelled = false;
let retryTimer: number | undefined;
const probe = async (attempt = 0) => {
try {
const info = await backendClient.getSystemInfo();
if (cancelled) {
return;
}
setBanner(
info.deployment_mode === 'cloud' && info.cloud_banner?.enabled
? info.cloud_banner
: null,
);
} catch {
if (cancelled) {
return;
}
// A cold start or a restarting backend must not hide an active notice
// for the rest of the session, so retry with backoff and keep the last
// known state in between.
if (attempt < RETRY_DELAYS_MS.length) {
retryTimer = window.setTimeout(
() => void probe(attempt + 1),
RETRY_DELAYS_MS[attempt],
);
}
}
};
void probe();
const recheck = () => {
if (!cancelled) {
void probe();
}
};
window.addEventListener('focus', recheck);
return () => {
cancelled = true;
clearTimeout(retryTimer);
window.removeEventListener('focus', recheck);
};
}, []);
const language = i18n?.language || currentLanguage();
const customCopy = banner ? resolveCloudBannerCopy(banner, language) : null;
const message = customCopy ?? (banner ? t('cloud_banner.message') : '');
const signature = banner ? cloudBannerSignature(banner, message) : '';
const visible =
Boolean(banner && message) && signature !== dismissedSignature;
// Publish the occupied height so the sidebar/shell can offset itself.
useEffect(() => {
const root = document.documentElement;
if (!visible) {
root.style.removeProperty('--cloud-banner-height');
return;
}
const element = bannerRef.current;
if (!element) {
return;
}
const sync = () => {
root.style.setProperty(
'--cloud-banner-height',
`${element.getBoundingClientRect().height}px`,
);
};
sync();
const observer = new ResizeObserver(sync);
observer.observe(element);
return () => {
observer.disconnect();
root.style.removeProperty('--cloud-banner-height');
};
}, [visible, signature]);
const dismiss = useCallback(() => {
setDismissedSignature(signature);
try {
window.sessionStorage.setItem(DISMISSED_SIGNATURE_KEY, signature);
} catch {
// Private-mode storage failures must not break the dismissal itself.
}
}, [signature]);
if (!banner || !visible) {
return null;
}
const tone = banner.level in TONE_CLASSES ? banner.level : 'info';
const Icon = TONE_ICONS[tone];
return (
<Alert
ref={bannerRef}
className={`relative z-40 rounded-none border-x-0 border-t-0 py-2 ${TONE_CLASSES[tone]}`}
>
<Icon />
<AlertDescription className="text-sm">
<div className="flex w-full items-start gap-3">
<span className="flex-1">
{customCopy ? (
customCopy
) : (
<>
{t('cloud_banner.message')}{' '}
{banner.show_links && (
<>
<a
href={banner.dedicated_url}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline hover:opacity-80"
>
{t('cloud_banner.dedicated_link')}
</a>{' '}
{t('cloud_banner.or')}{' '}
<a
href={banner.repository_url}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline hover:opacity-80"
>
{t('cloud_banner.self_hosted_link')}
</a>
</>
)}
{t('cloud_banner.period')}
</>
)}
</span>
{banner.dismissible && (
<button
type="button"
onClick={dismiss}
className="-mr-2 -mt-1 shrink-0 p-1 opacity-80 hover:opacity-100"
aria-label={t('cloud_banner.dismiss')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</AlertDescription>
</Alert>
);
}
+5 -3
View File
@@ -203,7 +203,9 @@ function SidebarProvider({
{
'--sidebar-width': `${sidebarWidth}px`,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
height: '100svh',
// A Cloud notice banner publishes its height so the shell starts
// below the announcement instead of underneath it.
height: 'calc(100svh - var(--cloud-banner-height, 0px))',
...style,
} as React.CSSProperties
}
@@ -300,8 +302,8 @@ function Sidebar({
<div
data-slot="sidebar-container"
style={{
top: 0,
height: '100svh',
top: 'var(--cloud-banner-height, 0px)',
height: 'calc(100svh - var(--cloud-banner-height, 0px))',
}}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
+9
View File
@@ -3764,5 +3764,14 @@ const enUS = {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',
},
cloud_banner: {
message:
'You are using LangBot Cloud, the managed service. For dedicated capacity and higher quotas, consider',
dedicated_link: 'LangBot Cloud (dedicated environment)',
or: 'or',
self_hosted_link: 'the self-hosted open-source version',
period: '.',
dismiss: 'Dismiss',
},
};
export default enUS;
+9
View File
@@ -3832,5 +3832,14 @@ const esES = {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
},
cloud_banner: {
message:
'Estás usando LangBot Cloud, el servicio gestionado. Para obtener recursos dedicados y cuotas mayores, considera usar',
dedicated_link: 'LangBot Cloud (entorno dedicado)',
or: 'o',
self_hosted_link: 'la versión de código abierto autoalojada',
period: '.',
dismiss: 'Cerrar',
},
};
export default esES;
+9
View File
@@ -3775,5 +3775,14 @@ const jaJP = {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',
},
cloud_banner: {
message:
'現在は LangBot Cloud のマネージド環境です。専用リソースと高い利用上限が必要な場合は、',
dedicated_link: 'LangBot Cloud(専用環境)',
or: 'または',
self_hosted_link: 'セルフホスト版',
period: 'をご検討ください。',
dismiss: '閉じる',
},
};
export default jaJP;
+9
View File
@@ -3790,5 +3790,14 @@ const ruRU = {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
},
cloud_banner: {
message:
'Вы используете LangBot Cloud — управляемый сервис. Для выделенных ресурсов и повышенных лимитов рекомендуем',
dedicated_link: 'LangBot Cloud (выделенная среда)',
or: 'или',
self_hosted_link: 'версию с открытым исходным кодом на своём сервере',
period: '.',
dismiss: 'Закрыть',
},
};
export default ruRU;
+9
View File
@@ -3691,5 +3691,14 @@ const thTH = {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
},
cloud_banner: {
message:
'คุณกำลังใช้งาน LangBot Cloud ซึ่งเป็นบริการแบบมีการจัดการ หากต้องการทรัพยากรเฉพาะและโควตาที่สูงขึ้น โปรดพิจารณาใช้',
dedicated_link: 'LangBot Cloud (สภาพแวดล้อมเฉพาะ)',
or: 'หรือ',
self_hosted_link: 'เวอร์ชันโอเพนซอร์สที่โฮสต์เอง',
period: '',
dismiss: 'ปิด',
},
};
export default thTH;
+9
View File
@@ -3745,5 +3745,14 @@ const viVN = {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
},
cloud_banner: {
message:
'Bạn đang sử dụng LangBot Cloud, dịch vụ được quản lý. Nếu cần tài nguyên riêng và hạn mức cao hơn, hãy cân nhắc',
dedicated_link: 'LangBot Cloud (môi trường riêng)',
or: 'hoặc',
self_hosted_link: 'phiên bản mã nguồn mở tự lưu trữ',
period: '.',
dismiss: 'Đóng',
},
};
export default viVN;
+8
View File
@@ -3547,5 +3547,13 @@ const zhHans = {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',
},
cloud_banner: {
message: '当前为 LangBot Cloud 云服务环境。如需独享资源与更高配额,可选择',
dedicated_link: '独立环境版 LangBot Cloud',
or: '或',
self_hosted_link: '自部署开源版本',
period: '。',
dismiss: '关闭横幅',
},
};
export default zhHans;
+9
View File
@@ -3547,5 +3547,14 @@ const zhHant = {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',
},
cloud_banner: {
message:
'目前為 LangBot Cloud 雲端服務環境。如需專屬資源與更高配額,可選擇',
dedicated_link: '獨立環境版 LangBot Cloud',
or: '或',
self_hosted_link: '自行部署的開源版本',
period: '。',
dismiss: '關閉橫幅',
},
};
export default zhHant;
+47
View File
@@ -0,0 +1,47 @@
import type { ApiRespCloudBanner } from '@/app/infra/entities/api';
/**
* Map an i18next locale (`zh-Hans`) to the config key form (`zh_hans`).
*
* `config.yaml` exposes per-language copy through the `CLOUD__...` environment
* override namespace, which only splits on `__`, so languages are stored with
* underscores on the backend.
*/
export function cloudBannerLanguageKey(language: string): string {
return language
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Resolve the copy for one language: the configured override for that
* language, then the configured English copy, then `null` so the caller falls
* back to the built-in translated message with its inline links.
*/
export function resolveCloudBannerCopy(
banner: Pick<ApiRespCloudBanner, 'content'>,
language: string,
): string | null {
const content = banner?.content || {};
for (const key of [cloudBannerLanguageKey(language), 'en_us']) {
const value = content[key];
if (typeof value === 'string' && value.trim() !== '') {
return value.trim();
}
}
return null;
}
/**
* Identity of the notice currently on screen. A dismissal is bound to this
* value so publishing different copy brings the banner back, while a reload
* of the same notice stays dismissed.
*/
export function cloudBannerSignature(
banner: Pick<ApiRespCloudBanner, 'level'>,
message: string,
): string {
return `${banner.level}:${message}`;
}