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 { useAuth } from './auth/context'
|
||||||
import { routeConfig } from './routesConfig'
|
import { routeConfig } from './routesConfig'
|
||||||
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
||||||
|
import { NoticeLayer } from './components/common/NoticeBanner'
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { roles, isAdmin } = useAuth();
|
const { roles, isAdmin } = useAuth();
|
||||||
@ -22,6 +23,8 @@ const routerBasename =
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
{/* Above the routes on purpose: a `closed` block must cover login too. */}
|
||||||
|
<NoticeLayer />
|
||||||
<BrowserRouter basename={routerBasename}>
|
<BrowserRouter basename={routerBasename}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|||||||
@ -12,6 +12,14 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { APP_ID, PIPELINE } from './config';
|
import { APP_ID, PIPELINE } from './config';
|
||||||
import { sessionClient, AUTH_CREDENTIALS } from './session';
|
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';
|
const TOKEN_KEY = 'krishna_sales_token';
|
||||||
|
|
||||||
@ -94,6 +102,19 @@ export class ZinoClient {
|
|||||||
} catch {
|
} catch {
|
||||||
/* non-JSON body */
|
/* 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 {
|
throw {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
message,
|
message,
|
||||||
@ -244,7 +265,8 @@ export class ZinoClient {
|
|||||||
|
|
||||||
// --- Workflow execution (core) ---
|
// --- 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`, {
|
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
|
||||||
workflow_uuid: this.workflowUuid,
|
workflow_uuid: this.workflowUuid,
|
||||||
...(opts?.version_uuid && { version_uuid: opts.version_uuid }),
|
...(opts?.version_uuid && { version_uuid: opts.version_uuid }),
|
||||||
@ -254,11 +276,12 @@ export class ZinoClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
performActivity(
|
async performActivity(
|
||||||
instanceId: number | string,
|
instanceId: number | string,
|
||||||
activityUid: string,
|
activityUid: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
): Promise<ActivityResult> {
|
): Promise<ActivityResult> {
|
||||||
|
assertWritable();
|
||||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
|
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
|
||||||
workflow_uuid: this.workflowUuid,
|
workflow_uuid: this.workflowUuid,
|
||||||
instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId,
|
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 { routeConfig } from '../routesConfig';
|
||||||
import { APP_VERSION } from '../api/config';
|
import { APP_VERSION } from '../api/config';
|
||||||
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
||||||
|
import { NoticeStrips } from '../components/common/NoticeBanner';
|
||||||
import logoUrl from '../assets/logo.png';
|
import logoUrl from '../assets/logo.png';
|
||||||
import './screen.css';
|
import './screen.css';
|
||||||
|
|
||||||
@ -60,6 +61,8 @@ export function ConsoleLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<NoticeStrips />
|
||||||
|
|
||||||
{passwordStatus?.warn && !passwordDueDismissed && (
|
{passwordStatus?.warn && !passwordDueDismissed && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user