diff --git a/src/App.tsx b/src/App.tsx index e5b716f..6d378b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( + {/* Above the routes on purpose: a `closed` block must cover login too. */} + } /> diff --git a/src/api/client.ts b/src/api/client.ts index e2b23d2..92dc0ab 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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, opts?: { version_uuid?: string; version_id?: number }): Promise { + async startInstance(activityUid: string, data: Record, opts?: { version_uuid?: string; version_id?: number }): Promise { + assertWritable(); return this.request('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, ): Promise { + assertWritable(); return this.request('POST', `/app/${APP_ID}/activity`, { workflow_uuid: this.workflowUuid, instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId, diff --git a/src/api/notices.ts b/src/api/notices.ts new file mode 100644 index 0000000..9402036 --- /dev/null +++ b/src/api/notices.ts @@ -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): 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 { + 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 | null = null; +/** After a write was refused for maintenance: one uncached re-read (overlapping refusals share it). */ +export function repollNotices(): Promise { + 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 | 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.'; +} diff --git a/src/components/common/NoticeBanner.tsx b/src/components/common/NoticeBanner.tsx new file mode 100644 index 0000000..c7007d2 --- /dev/null +++ b/src/components/common/NoticeBanner.tsx @@ -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 ( +
+ {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 ( +
+ + + {n.title && {n.title} } + {n.body} + {back && Expected back by {back}.} + + {/* `draining` stays: it is the only explanation for a refused submit. */} + {!draining && ( + + )} +
+ ); + })} +
+ ); +} + +/** 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 ( +
+
+ +

{notice.title || 'We’ll be right back'}

+ {notice.body &&

{notice.body}

} + {back &&

Expected back by {back}

} +

This page updates on its own — no need to refresh.

+
+
+ ); +} + +/** Mount once at the app root: starts polling and shows the block when closed. */ +export function NoticeLayer() { + const { closedNotice } = usePlatformNotices({ poll: true }); + return ; +} diff --git a/src/hooks/usePlatformNotices.ts b/src/hooks/usePlatformNotices.ts new file mode 100644 index 0000000..294a356 --- /dev/null +++ b/src/hooks/usePlatformNotices.ts @@ -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), + }; +} diff --git a/src/screens/ConsoleLayout.tsx b/src/screens/ConsoleLayout.tsx index 08f38fc..2e506ac 100644 --- a/src/screens/ConsoleLayout.tsx +++ b/src/screens/ConsoleLayout.tsx @@ -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() { + + {passwordStatus?.warn && !passwordDueDismissed && (