notice banners added
This commit is contained in:
parent
f652d302b4
commit
d48b4f2b9d
@ -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 />} />
|
||||
|
||||
@ -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
209
src/api/notices.ts
Normal 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.';
|
||||
}
|
||||
77
src/components/common/NoticeBanner.tsx
Normal file
77
src/components/common/NoticeBanner.tsx
Normal 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} />;
|
||||
}
|
||||
25
src/hooks/usePlatformNotices.ts
Normal file
25
src/hooks/usePlatformNotices.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user