notice banners added

This commit is contained in:
suryac 2026-11-06 13:03:14 +05:30
parent f652d302b4
commit d48b4f2b9d
6 changed files with 342 additions and 2 deletions

View File

@ -7,6 +7,7 @@ import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
import { useAuth } from './auth/context'
import { routeConfig } from './routesConfig'
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
import { NoticeLayer } from './components/common/NoticeBanner'
function RootRedirect() {
const { roles, isAdmin } = useAuth();
@ -22,6 +23,8 @@ const routerBasename =
function App() {
return (
<AuthProvider>
{/* Above the routes on purpose: a `closed` block must cover login too. */}
<NoticeLayer />
<BrowserRouter basename={routerBasename}>
<Routes>
<Route path="/login" element={<LoginPage />} />

View File

@ -12,6 +12,14 @@ import type {
} from './types';
import { APP_ID, PIPELINE } from './config';
import { sessionClient, AUTH_CREDENTIALS } from './session';
import { getNoticesState, isWritesPaused, raiseLocalNotice, repollNotices, writeBlockReason } from './notices';
/** Refuses a user-initiated write while a notice is draining or closed the app. */
function assertWritable(): void {
if (isWritesPaused(getNoticesState().mode)) {
throw { status: 503, code: 'PLATFORM_MAINTENANCE', message: writeBlockReason() } as ApiError;
}
}
const TOKEN_KEY = 'krishna_sales_token';
@ -94,6 +102,19 @@ export class ZinoClient {
} catch {
/* non-JSON body */
}
// A write refused for maintenance: the answer that let the user click is
// stale, so read the notices once more (the strip / block follow from it).
// The caller keeps its form; nothing here resends.
if (res.status === 503 && (code === 'PLATFORM_MAINTENANCE' || res.headers.get('X-Error-Code') === 'PLATFORM_MAINTENANCE')) {
code = 'PLATFORM_MAINTENANCE';
await repollNotices();
// No notice explains it (or the read failed): show the server's reason locally.
if (!isWritesPaused(getNoticesState().mode)) raiseLocalNotice('draining', message);
message = writeBlockReason();
} else if (res.status === 503) {
// Any other 503 (gateway / backend down): say so, without blocking writes.
raiseLocalNotice('unavailable', 'Some features may not work right now. Please try again in a moment.');
}
throw {
status: res.status,
message,
@ -244,7 +265,8 @@ export class ZinoClient {
// --- Workflow execution (core) ---
startInstance(activityUid: string, data: Record<string, unknown>, opts?: { version_uuid?: string; version_id?: number }): Promise<ActivityResult> {
async startInstance(activityUid: string, data: Record<string, unknown>, opts?: { version_uuid?: string; version_id?: number }): Promise<ActivityResult> {
assertWritable();
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
workflow_uuid: this.workflowUuid,
...(opts?.version_uuid && { version_uuid: opts.version_uuid }),
@ -254,11 +276,12 @@ export class ZinoClient {
});
}
performActivity(
async performActivity(
instanceId: number | string,
activityUid: string,
data: Record<string, unknown>,
): Promise<ActivityResult> {
assertWritable();
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
workflow_uuid: this.workflowUuid,
instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId,

209
src/api/notices.ts Normal file
View File

@ -0,0 +1,209 @@
// Platform notices (maintenance announcements) — read side + a tiny store.
// Mirrors renderer-desktop/services/notices.js: GET /usr/notices?app_id=… is the
// one source; the server computes the effective `mode`.
//
// announced → dismissible strip
// draining → strip stays, writes are refused (client guard + server 503)
// closed → full-screen block, never dismissible, lifts by itself on poll
//
// A bare fetch, never the ZinoClient: it needs no token and must not be able to
// start a refresh or a logout. It NEVER rejects — a failed read is "no notices",
// because a notice that could break the app would be worse than the outage.
import { APP_ID, BASE_URL } from './config';
export const MODE_ANNOUNCED = 'announced';
export const MODE_DRAINING = 'draining';
export const MODE_CLOSED = 'closed';
export interface Notice {
id: string | number;
mode: string;
title: string;
body: string;
endsAt: string;
updatedAt: string;
}
export interface NoticesState {
notices: Notice[];
mode: string;
dismissed: string[];
}
interface RawNotice {
notice_id: string | number;
mode?: string;
title?: string;
body?: string;
ends_at?: string;
updated_at?: string;
}
export const isClosed = (mode: string) => mode === MODE_CLOSED;
/** `closed` implies writes are paused. An unknown mode is treated as the mildest case. */
export const isWritesPaused = (mode: string) => mode === MODE_DRAINING || mode === MODE_CLOSED;
export const dismissKey = (n: Notice) => `${n.id}:${n.updatedAt}`;
/** A `closed` notice is never dismissible: it explains a screen the user cannot pass. */
export function visibleNotices(notices: Notice[], dismissed: string[]): Notice[] {
const seen = new Set(dismissed);
return notices.filter((n) => isClosed(n.mode) || !seen.has(dismissKey(n)));
}
/** "Expected back by 23:30" — an absolute clock time, plus the date when not today. */
export function fmtEndsAt(endsAt: string, now = Date.now()): string {
if (!endsAt) return '';
const d = new Date(endsAt);
if (Number.isNaN(d.getTime())) return '';
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const sameDay = new Date(now).toDateString() === d.toDateString();
return sameDay ? time : `${d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}, ${time}`;
}
// ── dismissal storage (may be empty or throw: private window, blocked data) ──
const DISMISS_KEY = 'krishna_sales_notice_dismissed';
const DISMISS_CAP = 20;
function readDismissed(): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]');
return Array.isArray(parsed) ? parsed.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
}
// ── read ──
const NOTICES_TIMEOUT_MS = 2000;
const POLL_MS = 30_000;
async function fetchNotices(noStore = false): Promise<{ notices: Notice[]; mode: string } | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), NOTICES_TIMEOUT_MS);
try {
const res = await fetch(`${BASE_URL}/usr/notices?app_id=${encodeURIComponent(APP_ID)}`, {
signal: controller.signal,
...(noStore ? { cache: 'no-store' as RequestCache } : {}),
});
if (!res.ok) return null;
const data = await res.json();
if (!data || !Array.isArray(data.notices)) return null;
return {
notices: (data.notices as RawNotice[]).map((n) => ({
id: n.notice_id,
mode: n.mode || '',
title: n.title || '',
body: n.body || '',
endsAt: n.ends_at || '',
updatedAt: n.updated_at || '',
})),
mode: data.mode || '',
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
// ── store (useSyncExternalStore) ──
let state: NoticesState = { notices: [], mode: '', dismissed: readDismissed() };
const listeners = new Set<() => void>();
let issued = 0;
let applied = 0;
function setState(next: Partial<NoticesState>): void {
state = { ...state, ...next };
listeners.forEach((l) => l());
}
export const subscribeNotices = (cb: () => void) => {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
};
export const getNoticesState = () => state;
/** Reads once and applies the answer; an older read never overwrites a newer one. */
async function readNotices(noStore = false): Promise<void> {
const seq = ++issued;
const result = await fetchNotices(noStore);
// A failed read keeps what is shown: briefly losing the network must not lift a
// `closed` block, and the next poll corrects it either way.
if (!result || seq < applied) return;
applied = seq;
setState(result);
}
let reread: Promise<void> | null = null;
/** After a write was refused for maintenance: one uncached re-read (overlapping refusals share it). */
export function repollNotices(): Promise<void> {
if (!reread) reread = readNotices(true).finally(() => (reread = null));
return reread;
}
/**
* The backend answered 503 and no notice explains it (or the notice read failed):
* show it anyway, locally, until the next poll gives the real answer.
* draining — the server said writes are refused (PLATFORM_MAINTENANCE): pause them here too
* unavailable — any other 503: say so, but do not block writes (it may be a blip)
*/
export function raiseLocalNotice(mode: 'draining' | 'unavailable', body: string): void {
if (state.notices.some((n) => isWritesPaused(n.mode)) && mode === 'draining') return;
const local: Notice = {
id: 'local-503',
mode,
title: mode === 'draining' ? 'Maintenance in progress.' : 'Service temporarily unavailable.',
body,
endsAt: '',
updatedAt: String(Date.now()),
};
setState({
notices: [...state.notices.filter((n) => n.id !== 'local-503'), local],
mode: mode === 'draining' || !state.mode ? mode : state.mode,
});
}
export function dismissNotice(n: Notice): void {
const key = dismissKey(n);
const next = [key, ...state.dismissed.filter((k) => k !== key)].slice(0, DISMISS_CAP);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
/* stays dismissed for this page view either way */
}
setState({ dismissed: next });
}
/** Polls every 30s, paused while the tab is hidden, re-read on return. Returns stop. */
export function startNoticePolling(): () => void {
let timer: ReturnType<typeof setInterval> | null = null;
const stop = () => {
if (timer != null) clearInterval(timer);
timer = null;
};
const start = () => {
stop();
void readNotices();
timer = setInterval(() => void readNotices(), POLL_MS);
};
const onVisibility = () => (document.visibilityState === 'visible' ? start() : stop());
if (document.visibilityState === 'visible') start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
stop();
document.removeEventListener('visibilitychange', onVisibility);
};
}
/** The reason a refused write shows: the notice's own words, else a fixed sentence. */
export function writeBlockReason(s: NoticesState = state): string {
const n = s.notices.find((x) => isWritesPaused(x.mode));
const text = n && [n.title, n.body].filter(Boolean).join(' — ');
return text || 'The app is under maintenance right now. Please try again shortly.';
}

View File

@ -0,0 +1,77 @@
import { AlertTriangle, Info, Wrench, X } from 'lucide-react';
import { fmtEndsAt, isClosed, MODE_DRAINING, type Notice } from '../../api/notices';
import { usePlatformNotices } from '../../hooks/usePlatformNotices';
/** Strips for announced / draining notices (closed is NoticeBlock's job). */
export function NoticeStrips() {
const { notices, dismiss } = usePlatformNotices();
const strips = notices.filter((n) => !isClosed(n.mode));
if (strips.length === 0) return null;
return (
<div className="print:hidden shrink-0 flex flex-col">
{strips.map((n) => {
// draining and the local "unavailable" 503 notice are the amber, undismissible kind.
const draining = n.mode === MODE_DRAINING || n.mode === 'unavailable';
const Icon = draining ? AlertTriangle : Info;
const back = fmtEndsAt(n.endsAt);
return (
<div
key={`${n.id}:${n.updatedAt}`}
role={draining ? 'alert' : 'status'}
className={
draining
? 'flex items-start gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900'
: 'flex items-start gap-2 px-4 py-2 bg-sky-50 border-b border-sky-200 text-xs text-sky-900'
}
>
<Icon size={16} className="shrink-0 mt-0.5" />
<span className="flex-1">
{n.title && <strong>{n.title} </strong>}
{n.body}
{back && <span className="font-semibold"> Expected back by {back}.</span>}
</span>
{/* `draining` stays: it is the only explanation for a refused submit. */}
{!draining && (
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(n)}
className="shrink-0 opacity-70 hover:opacity-100 cursor-pointer"
>
<X size={14} />
</button>
)}
</div>
);
})}
</div>
);
}
/** Full-screen, opaque block while the app is `closed`. Lifts itself on the next poll. */
export function NoticeBlock({ notice }: { notice?: Notice }) {
if (!notice) return null;
const back = fmtEndsAt(notice.endsAt);
return (
<div
role="alertdialog"
aria-modal="true"
className="fixed inset-0 z-[1000] bg-white flex items-center justify-center p-6 text-center"
>
<div className="max-w-sm flex flex-col items-center gap-3">
<Wrench size={40} className="text-emerald-700" />
<h1 className="m-0 text-xl font-extrabold text-emerald-900">{notice.title || 'We’ll be right back'}</h1>
{notice.body && <p className="m-0 text-sm text-slate-600 leading-relaxed">{notice.body}</p>}
{back && <p className="m-0 text-sm font-semibold text-slate-800">Expected back by {back}</p>}
<p className="m-0 text-xs text-slate-400">This page updates on its own — no need to refresh.</p>
</div>
</div>
);
}
/** Mount once at the app root: starts polling and shows the block when closed. */
export function NoticeLayer() {
const { closedNotice } = usePlatformNotices({ poll: true });
return <NoticeBlock notice={closedNotice} />;
}

View File

@ -0,0 +1,25 @@
import { useEffect, useSyncExternalStore } from 'react';
import {
dismissNotice,
getNoticesState,
isClosed,
startNoticePolling,
subscribeNotices,
visibleNotices,
type Notice,
} from '../api/notices';
/** The notices in force for this app. Reading is free; polling starts with `poll`. */
export function usePlatformNotices({ poll = false }: { poll?: boolean } = {}) {
const state = useSyncExternalStore(subscribeNotices, getNoticesState);
useEffect(() => (poll ? startNoticePolling() : undefined), [poll]);
const notices = visibleNotices(state.notices, state.dismissed);
return {
notices,
mode: state.mode,
closedNotice: state.notices.find((n) => isClosed(n.mode)),
dismiss: (n: Notice) => dismissNotice(n),
};
}

View File

@ -8,6 +8,7 @@ import { SCREENS } from './tabs';
import { routeConfig } from '../routesConfig';
import { APP_VERSION } from '../api/config';
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
import { NoticeStrips } from '../components/common/NoticeBanner';
import logoUrl from '../assets/logo.png';
import './screen.css';
@ -60,6 +61,8 @@ export function ConsoleLayout() {
</div>
</header>
<NoticeStrips />
{passwordStatus?.warn && !passwordDueDismissed && (
<div
role="alert"