diff --git a/dashboard-ui/src/components/layouts/AppLayout.tsx b/dashboard-ui/src/components/layouts/AppLayout.tsx index fb0ace2b9..89c94c8a9 100644 --- a/dashboard-ui/src/components/layouts/AppLayout.tsx +++ b/dashboard-ui/src/components/layouts/AppLayout.tsx @@ -14,10 +14,10 @@ import Footer from '@/components/widgets/Footer'; import UpdateBanner from '@/components/widgets/UpdateBanner'; -import { useUpdateNotification } from '@/lib/update-notifications'; +import { useCLIUpdateNotification } from '@/lib/update-notifications'; export default function AppLayout({ children }: React.PropsWithChildren) { - const { showBanner, currentVersion, latestVersion, dismiss, dontRemindMe } = useUpdateNotification(); + const { showBanner, currentVersion, latestVersion, dismiss, dontRemindMe } = useCLIUpdateNotification(); return (
diff --git a/dashboard-ui/src/components/widgets/NotificationsPopover.tsx b/dashboard-ui/src/components/widgets/NotificationsPopover.tsx index c0b5b3be6..848bdb2d2 100644 --- a/dashboard-ui/src/components/widgets/NotificationsPopover.tsx +++ b/dashboard-ui/src/components/widgets/NotificationsPopover.tsx @@ -12,39 +12,70 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { useSubscription } from '@apollo/client/react'; import { ArrowUpCircle } from 'lucide-react'; import { useState } from 'react'; import { Popover, PopoverTrigger, PopoverContent } from '@kubetail/ui/elements/popover'; -import { useUpdateNotification } from '@/lib/update-notifications'; +import appConfig from '@/app-config'; +import * as dashboardOps from '@/lib/graphql/dashboard/ops'; +import { useCLIUpdateNotification, useClusterUpdateNotification } from '@/lib/update-notifications'; + +const ClusterUpdateEntry = ({ kubeContext }: { kubeContext: string }) => { + const { updateAvailable, currentVersion, latestVersion } = useClusterUpdateNotification(kubeContext); + + if (!updateAvailable || !latestVersion) return null; + + return ( +
+ +

+ Cluster update: {currentVersion} → {latestVersion} +

+
+ ); +}; + +const useActiveKubeContext = (): string | null => { + const { data } = useSubscription(dashboardOps.KUBE_CONFIG_WATCH, { + skip: appConfig.environment !== 'desktop', + }); + return data?.kubeConfigWatch?.object?.currentContext ?? null; +}; export const NotificationsPopover = ({ children }: React.PropsWithChildren) => { const [isOpen, setIsOpen] = useState(false); - const { updateAvailable, currentVersion, latestVersion } = useUpdateNotification(); + const { updateAvailable, currentVersion, latestVersion } = useCLIUpdateNotification(); + const activeKubeContext = useActiveKubeContext(); + const { updateAvailable: clusterUpdateAvailable } = useClusterUpdateNotification(activeKubeContext ?? ''); + + const hasCliUpdate = updateAvailable && !!latestVersion; + const hasClusterUpdate = appConfig.environment === 'desktop' && !!activeKubeContext && clusterUpdateAvailable; + const hasNotifications = hasCliUpdate || hasClusterUpdate; return (
{children} - {updateAvailable && } + {hasNotifications && }
{isOpen && (

Notifications

- {updateAvailable && latestVersion ? ( + {hasCliUpdate && (

CLI update: {currentVersion} → {latestVersion}

- ) : ( -

No new notifications

)} + {hasClusterUpdate && } + {!hasNotifications &&

No new notifications

}
)} diff --git a/dashboard-ui/src/components/widgets/ServerStatus.tsx b/dashboard-ui/src/components/widgets/ServerStatus.tsx index 210470bad..7be2fa458 100644 --- a/dashboard-ui/src/components/widgets/ServerStatus.tsx +++ b/dashboard-ui/src/components/widgets/ServerStatus.tsx @@ -20,7 +20,6 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } import { Table, TableBody, TableCell, TableRow } from '@kubetail/ui/elements/table'; import appConfig from '@/app-config'; -import ClusterAPIInstallButton from '@/components/widgets/ClusterAPIInstallButton'; import * as dashboardOps from '@/lib/graphql/dashboard/ops'; import { ServerStatus, @@ -29,6 +28,7 @@ import { useKubernetesAPIServerStatus, useClusterAPIServerStatus, } from '@/lib/server-status'; +import { useClusterUpdateNotification } from '@/lib/update-notifications'; import { cn } from '@/lib/util'; const kubernetesAPIServerStatusMapState = atom(new Map()); @@ -56,6 +56,9 @@ const HealthDot = ({ className, status }: { className?: string; status: Status } case Status.Unknown: color = 'chrome'; break; + case Status.UpdateAvailable: + color = 'blue'; + break; default: throw new Error('not implemented'); } @@ -67,6 +70,7 @@ const HealthDot = ({ className, status }: { className?: string; status: Status } 'bg-red-500': color === 'red', 'bg-green-500': color === 'green', 'bg-yellow-500': color === 'yellow', + 'bg-blue-500': color === 'blue', })} /> ); @@ -170,6 +174,9 @@ const KubernetesAPIServerStatusRow = ({ kubeContext, dashboardServerStatus }: Se const ClusterAPIServerStatusRow = ({ kubeContext, dashboardServerStatus }: ServerStatusRowProps) => { const serverStatusMap = useAtomValue(clusterAPIServerStatusMapState); const serverStatus = serverStatusMap.get(kubeContext) || new ServerStatus(); + const { updateAvailable } = useClusterUpdateNotification(appConfig.environment === 'desktop' ? kubeContext : ''); + + const showClusterUpdate = appConfig.environment === 'desktop' && updateAvailable; return ( @@ -179,13 +186,10 @@ const ClusterAPIServerStatusRow = ({ kubeContext, dashboardServerStatus }: Serve ) : ( <> - + - - {statusMessage(serverStatus, 'Uknown')} - {appConfig.environment === 'desktop' && serverStatus.status === Status.NotFound && ( - - )} + + {showClusterUpdate ? 'Update available' : statusMessage(serverStatus, 'Uknown')} )} diff --git a/dashboard-ui/src/lib/server-status.ts b/dashboard-ui/src/lib/server-status.ts index f5fe424a2..2b29721c1 100644 --- a/dashboard-ui/src/lib/server-status.ts +++ b/dashboard-ui/src/lib/server-status.ts @@ -27,6 +27,7 @@ export const enum Status { Degraded = 'DEGRADED', Unknown = 'UNKNOWN', NotFound = 'NOTFOUND', + UpdateAvailable = 'UPDATE_AVAILABLE', } export class ServerStatus { diff --git a/dashboard-ui/src/lib/update-notifications.test.tsx b/dashboard-ui/src/lib/update-notifications.test.tsx index 30c7d8dfe..13173304b 100644 --- a/dashboard-ui/src/lib/update-notifications.test.tsx +++ b/dashboard-ui/src/lib/update-notifications.test.tsx @@ -16,15 +16,39 @@ import type { MockedResponse } from '@apollo/client/testing'; import { act, screen, waitFor } from '@testing-library/react'; import appConfig from '@/app-config'; -import { CLI_LATEST_VERSION } from '@/lib/graphql/dashboard/ops'; +import { CLI_LATEST_VERSION, CLUSTER_VERSION_STATUS, KUBE_CONFIG_WATCH } from '@/lib/graphql/dashboard/ops'; import { renderElement } from '@/test-utils'; -import { UpdateNotificationProvider, useUpdateNotification, compareSemver } from './update-notifications'; +import { + compareSemver, + UpdateNotificationProvider, + useCLIUpdateNotification, + useClusterUpdateNotification, +} from './update-notifications'; const STORAGE_KEY = 'kubetail:updates:cli'; +/** Satisfies KUBE_CONFIG_WATCH inside UpdateNotificationProvider for CLI-only tests. */ +const notificationProviderKubeMock: MockedResponse = { + request: { query: KUBE_CONFIG_WATCH }, + maxUsageCount: 20, + result: { + data: { + kubeConfigWatch: { + __typename: 'KubeConfigWatchEvent', + type: 'ADDED', + object: { + __typename: 'KubeConfig', + currentContext: '', + contexts: [], + }, + }, + }, + }, +}; + function TestConsumer() { - const { showBanner, updateAvailable, latestVersion } = useUpdateNotification(); + const { showBanner, updateAvailable, latestVersion } = useCLIUpdateNotification(); return (
{showBanner && visible} @@ -38,7 +62,7 @@ function renderWithProvider(mocks: MockedResponse[]) { , - mocks, + [notificationProviderKubeMock, ...mocks], ); } @@ -81,7 +105,7 @@ describe('compareSemver', () => { }); }); -describe('useUpdateNotification', () => { +describe('useCLIUpdateNotification', () => { it('does not show banner immediately (respects delay)', () => { renderWithProvider([latestVersionMock]); expect(screen.queryByTestId('banner-visible')).not.toBeInTheDocument(); @@ -164,3 +188,201 @@ describe('useUpdateNotification', () => { }); }); }); + +const CLUSTER_STORAGE_PREFIX = 'kubetail:updates:cluster:'; +const KUBE_CONTEXT = 'test-cluster'; + +/** Keeps CLI query satisfied while cluster tests run under UpdateNotificationProvider. */ +const clusterTestCliMock: MockedResponse = { + request: { query: CLI_LATEST_VERSION }, + result: { data: { cliLatestVersion: '0.9.0' } }, +}; + +function kubeConfigWatchMock(contextNames: string[], currentContext?: string): MockedResponse { + return { + request: { query: KUBE_CONFIG_WATCH }, + result: { + data: { + kubeConfigWatch: { + type: 'ADDED', + object: { + currentContext: currentContext ?? contextNames[0] ?? '', + contexts: contextNames.map((name) => ({ name, cluster: 'c', namespace: 'default' })), + }, + }, + }, + }, + }; +} + +function ClusterTestConsumer({ kubeContext = KUBE_CONTEXT }: { kubeContext?: string }) { + const { updateAvailable, currentVersion, latestVersion } = useClusterUpdateNotification(kubeContext); + return ( +
+ {updateAvailable && {latestVersion}} + {currentVersion && {currentVersion}} +
+ ); +} + +function renderClusterWithMocks( + mocks: MockedResponse[], + kubeContext?: string, + kubeContextNames: string[] = [KUBE_CONTEXT], +) { + return renderElement( + + + , + [clusterTestCliMock, kubeConfigWatchMock(kubeContextNames), ...mocks], + ); +} + +const clusterUpdateAvailableMock: MockedResponse = { + request: { query: CLUSTER_VERSION_STATUS, variables: { kubeContext: KUBE_CONTEXT } }, + result: { + data: { clusterVersionStatus: { currentVersion: '0.9.0', latestVersion: '1.0.0', updateAvailable: true } }, + }, +}; + +const clusterNoUpdateMock: MockedResponse = { + request: { query: CLUSTER_VERSION_STATUS, variables: { kubeContext: KUBE_CONTEXT } }, + result: { + data: { clusterVersionStatus: { currentVersion: '1.0.0', latestVersion: '1.0.0', updateAvailable: false } }, + }, +}; + +const clusterNullResultMock: MockedResponse = { + request: { query: CLUSTER_VERSION_STATUS, variables: { kubeContext: KUBE_CONTEXT } }, + result: { data: { clusterVersionStatus: null } }, +}; + +const clusterErrorMock: MockedResponse = { + request: { query: CLUSTER_VERSION_STATUS, variables: { kubeContext: KUBE_CONTEXT } }, + error: new Error('Internal Server Error'), +}; + +describe('useClusterUpdateNotification', () => { + it('shows update notification when updateAvailable is true', async () => { + renderClusterWithMocks([clusterUpdateAvailableMock]); + + await waitFor(() => { + expect(screen.getByTestId('cluster-update')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-update')).toHaveTextContent('1.0.0'); + }); + }); + + it('does not show update notification when updateAvailable is false', async () => { + renderClusterWithMocks([clusterNoUpdateMock]); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.queryByTestId('cluster-update')).not.toBeInTheDocument(); + }); + }); + + it('does not show notification when dismissed less than 24h ago', async () => { + localStorage.setItem(`${CLUSTER_STORAGE_PREFIX}${KUBE_CONTEXT}`, JSON.stringify({ dismissedAt: Date.now() })); + renderClusterWithMocks([clusterUpdateAvailableMock]); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.queryByTestId('cluster-update')).not.toBeInTheDocument(); + }); + }); + + it('does not show notification when version is in skipped list', async () => { + localStorage.setItem(`${CLUSTER_STORAGE_PREFIX}${KUBE_CONTEXT}`, JSON.stringify({ skippedVersions: ['1.0.0'] })); + renderClusterWithMocks([clusterUpdateAvailableMock]); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.queryByTestId('cluster-update')).not.toBeInTheDocument(); + }); + }); + + it('fails silently when query returns null', async () => { + renderClusterWithMocks([clusterNullResultMock]); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.queryByTestId('cluster-update')).not.toBeInTheDocument(); + }); + }); + + it('fails silently when query errors', async () => { + renderClusterWithMocks([clusterErrorMock]); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.queryByTestId('cluster-update')).not.toBeInTheDocument(); + }); + }); + + it('uses cached data when cache is fresh', async () => { + localStorage.setItem( + `${CLUSTER_STORAGE_PREFIX}${KUBE_CONTEXT}`, + JSON.stringify({ + currentVersion: '0.9.0', + latestVersion: '0.9.0', + fetchedAt: Date.now(), + }), + ); + + renderClusterWithMocks([]); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.queryByTestId('cluster-update')).not.toBeInTheDocument(); + }); + }); + + it('skips query when environment is not desktop', async () => { + Object.defineProperty(appConfig, 'environment', { value: 'cluster', writable: true }); + renderClusterWithMocks([clusterUpdateAvailableMock]); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.queryByTestId('cluster-update')).not.toBeInTheDocument(); + }); + }); + + it('keys state per kubeContext', async () => { + const context2 = 'other-cluster'; + const mock2: MockedResponse = { + request: { query: CLUSTER_VERSION_STATUS, variables: { kubeContext: context2 } }, + result: { + data: { clusterVersionStatus: { currentVersion: '0.8.0', latestVersion: '1.0.0', updateAvailable: true } }, + }, + }; + + localStorage.setItem(`${CLUSTER_STORAGE_PREFIX}${KUBE_CONTEXT}`, JSON.stringify({ dismissedAt: Date.now() })); + + renderClusterWithMocks([clusterUpdateAvailableMock, mock2], context2, [KUBE_CONTEXT, context2]); + + await waitFor(() => { + expect(screen.getByTestId('cluster-update')).toHaveTextContent('1.0.0'); + }); + }); +}); diff --git a/dashboard-ui/src/lib/update-notifications.tsx b/dashboard-ui/src/lib/update-notifications.tsx index 7ec0b579e..18d8a58b4 100644 --- a/dashboard-ui/src/lib/update-notifications.tsx +++ b/dashboard-ui/src/lib/update-notifications.tsx @@ -12,13 +12,24 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useQuery } from '@apollo/client/react'; +import { useQuery, useSubscription } from '@apollo/client/react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import appConfig from '@/app-config'; -import { CLI_LATEST_VERSION } from '@/lib/graphql/dashboard/ops'; +import { CLI_LATEST_VERSION, CLUSTER_VERSION_STATUS, KUBE_CONFIG_WATCH } from '@/lib/graphql/dashboard/ops'; -const STORAGE_KEY = 'kubetail:updates:cli'; +/** + * CLI vs cluster update hints: localStorage + Apollo, exposed via separate React contexts. + * + * Layout: + * - Persistence + cache helpers (this file, top) + * - `ClusterVersionSubscriber`: one `CLUSTER_VERSION_STATUS` query per kubeContext on desktop + * - `UpdateNotificationProvider`: wires CLI + cluster subscribers and context providers + * - `useCLIUpdateNotification` / `useClusterUpdateNotification`: read-only hooks for UI + */ + +const STORAGE_KEY_CLI = 'kubetail:updates:cli'; +const CLUSTER_STORAGE_PREFIX = 'kubetail:updates:cluster:'; const CACHE_TTL_MS = 12 * 60 * 60 * 1000; const DISMISS_TTL_MS = 24 * 60 * 60 * 1000; @@ -31,111 +42,384 @@ interface UpdateState { skippedVersions?: string[]; } -export function compareSemver(a: string, b: string): number { - const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number); - const pa = parse(a); - const pb = parse(b); - for (let i = 0; i < 3; i += 1) { - const diff = (pa[i] || 0) - (pb[i] || 0); - if (diff !== 0) return diff; - } - return 0; +interface ClusterUpdateState extends UpdateState { + currentVersion?: string; +} + +// --- localStorage (CLI key + one blob per kubeContext) +function clusterStorageKey(kubeContext: string): string { + return `${CLUSTER_STORAGE_PREFIX}${kubeContext}`; } -function readState(): UpdateState { +function readPersisted(storageKey: string): T { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return {}; - return JSON.parse(raw); + const raw = localStorage.getItem(storageKey); + if (!raw) return {} as T; + return JSON.parse(raw) as T; } catch { - return {}; + return {} as T; } } -function writeState(state: UpdateState) { +function writePersisted(storageKey: string, state: object) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + localStorage.setItem(storageKey, JSON.stringify(state)); } catch { // fail silently } } -function patchState(patch: Partial) { - writeState({ ...readState(), ...patch }); +function patchPersisted(storageKey: string, patch: Partial) { + writePersisted(storageKey, { ...readPersisted(storageKey), ...patch }); } -function isCacheValid(state: UpdateState): boolean { +function readCLIState(): UpdateState { + return readPersisted(STORAGE_KEY_CLI); +} + +function readClusterState(kubeContext: string): ClusterUpdateState { + return readPersisted(clusterStorageKey(kubeContext)); +} + +function patchCLIState(patch: Partial) { + patchPersisted(STORAGE_KEY_CLI, patch); +} + +function patchClusterState(kubeContext: string, patch: Partial) { + patchPersisted(clusterStorageKey(kubeContext), patch); +} + +// Cache TTL (avoid hammering the network; still respect dismiss/skip in the view builders) +function isCLICacheValid(state: UpdateState): boolean { return !!state.latestVersion && !!state.fetchedAt && Date.now() - state.fetchedAt < CACHE_TTL_MS; } -export interface UpdateNotificationState { - showBanner: boolean; - currentVersion: string; +function isClusterCacheValid(state: ClusterUpdateState): boolean { + return state.fetchedAt !== undefined && Date.now() - state.fetchedAt < CACHE_TTL_MS; +} + +export function compareSemver(a: string, b: string): number { + const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number); + const pa = parse(a); + const pb = parse(b); + for (let i = 0; i < 3; i += 1) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +// Public shapes for hooks +interface BaseUpdateNotificationState { latestVersion: string | null; - updateAvailable: boolean; dismiss: () => void; dontRemindMe: () => void; } -const UpdateNotificationContext = createContext({} as UpdateNotificationState); +export interface UpdateNotificationState extends BaseUpdateNotificationState { + showBanner: boolean; + currentVersion: string; + updateAvailable: boolean; +} + +export interface ClusterUpdateNotificationState extends BaseUpdateNotificationState { + updateAvailable: boolean; + currentVersion: string | null; +} + +type ClusterVersionQuerySnapshot = { + data?: { + clusterVersionStatus?: { + currentVersion?: string | null; + latestVersion?: string | null; + updateAvailable: boolean; + } | null; + } | null; + error?: Error; + loading: boolean; +}; + +type ClusterNotificationsRegistry = { + querySnapshots: Record; +}; +// CLI value vs cluster registry are split so CLI-only UI does not subscribe to cluster updates. +const CLIUpdateNotificationContext = createContext({} as UpdateNotificationState); +const ClusterNotificationsContext = createContext(null); +const ClusterNotificationsInvalidateContext = createContext<(() => void) | null>(null); + +/** Derive what to show for one kubeContext from persisted state + latest Apollo snapshot. */ +function buildClusterNotificationView( + kubeContext: string, + snapshot: ClusterVersionQuerySnapshot | undefined, + dismiss: () => void, + dontRemindMe: () => void, +): ClusterUpdateNotificationState { + const persisted = readClusterState(kubeContext); + const cacheValid = isClusterCacheValid(persisted); + const data = snapshot?.data; + + const currentVersion = cacheValid + ? (persisted.currentVersion ?? null) + : (data?.clusterVersionStatus?.currentVersion ?? null); + + const latestVersion = cacheValid + ? (persisted.latestVersion ?? null) + : (data?.clusterVersionStatus?.latestVersion ?? null); + + const queryUpdateAvailable = cacheValid + ? currentVersion !== null && latestVersion !== null && currentVersion !== latestVersion + : (data?.clusterVersionStatus?.updateAvailable ?? false); + + const dismissed = persisted.dismissedAt !== undefined && Date.now() - persisted.dismissedAt < DISMISS_TTL_MS; + const skipped = latestVersion !== null && (persisted.skippedVersions ?? []).includes(latestVersion); + const updateAvailable = queryUpdateAvailable && !dismissed && !skipped; + + return { + updateAvailable, + currentVersion, + latestVersion, + dismiss, + dontRemindMe, + }; +} + +/** + * Invisible per-context worker: runs `CLUSTER_VERSION_STATUS`, mirrors results into `querySnapshots`, + * and refreshes localStorage after a successful fetch (or error) so the cache stays coherent. + */ +function ClusterVersionSubscriber({ + kubeContext, + bumpCluster, + setSnapshot, +}: { + kubeContext: string; + bumpCluster: () => void; + setSnapshot: (ctx: string, snap: ClusterVersionQuerySnapshot) => void; +}) { + const isDesktop = appConfig.environment === 'desktop'; + const [persisted, setPersisted] = useState(() => readClusterState(kubeContext)); + + useEffect(() => { + setPersisted(readClusterState(kubeContext)); + }, [kubeContext]); + + const cacheValid = isClusterCacheValid(persisted); + + // Skip while we already have a fresh persisted row (see patch effect below). + const { data, error, loading } = useQuery(CLUSTER_VERSION_STATUS, { + skip: !isDesktop || !kubeContext || cacheValid, + variables: { kubeContext }, + fetchPolicy: 'network-only', + }); + + useEffect(() => { + setSnapshot(kubeContext, { data, error, loading }); + }, [kubeContext, data, error, loading, setSnapshot]); + + // After load: persist versions (or mark fetch time on failure), then refresh registry (see bumpCluster). + useEffect(() => { + if (!isDesktop || !kubeContext || cacheValid) return; + if (loading) return; + + if (error) { + patchClusterState(kubeContext, { + fetchedAt: Date.now(), + currentVersion: undefined, + latestVersion: undefined, + }); + setPersisted(readClusterState(kubeContext)); + bumpCluster(); + return; + } + + if (data === undefined) return; + + const result = data.clusterVersionStatus; + if (result) { + patchClusterState(kubeContext, { + currentVersion: result.currentVersion, + latestVersion: result.latestVersion, + fetchedAt: Date.now(), + }); + } else { + patchClusterState(kubeContext, { + fetchedAt: Date.now(), + currentVersion: undefined, + latestVersion: undefined, + }); + } + setPersisted(readClusterState(kubeContext)); + bumpCluster(); + }, [data, error, loading, isDesktop, kubeContext, cacheValid, bumpCluster]); + + return null; +} + +/** + * Root provider: CLI banner state + one `ClusterVersionSubscriber` per kubeContext (desktop). + * Children render after subscribers so snapshots exist before hooks in descendants run. + */ export function UpdateNotificationProvider({ children }: React.PropsWithChildren) { const isDesktop = appConfig.environment === 'desktop'; - const currentVersion = appConfig.cliVersion; + const currentVersionCLI = appConfig.cliVersion; const [ready, setReady] = useState(false); - const [state, setState] = useState(() => readState()); - const [now] = useState(() => Date.now()); + const [cliPersisted, setCLIPersisted] = useState(() => readCLIState()); + const [mountedAt] = useState(() => Date.now()); + + const cliCacheValid = isCLICacheValid(cliPersisted); + + // Desktop: watch kubeconfig so we know which contexts exist (cluster mode uses a single synthetic context). + const { data: kubeData } = useSubscription(KUBE_CONFIG_WATCH, { + skip: appConfig.environment !== 'desktop', + }); + + const kubeContexts = useMemo(() => { + if (appConfig.environment === 'cluster') return ['']; + const contexts = kubeData?.kubeConfigWatch?.object?.contexts; + return contexts?.map((c) => c.name) ?? []; + }, [kubeData]); + + // Cluster registry: Apollo snapshots per kubeContext; `bumpCluster` shallow-copies the map to invalidate + // consumers when only localStorage changed (dismiss/skip) or after persist without a new Apollo payload. + const [querySnapshots, setQuerySnapshots] = useState>({}); + + const bumpCluster = useCallback(() => { + setQuerySnapshots((prev) => ({ ...prev })); + }, []); + + const setSnapshot = useCallback((kubeContext: string, snap: ClusterVersionQuerySnapshot) => { + setQuerySnapshots((prev) => { + const cur = prev[kubeContext]; + if (cur && cur.data === snap.data && cur.error === snap.error && cur.loading === snap.loading) { + return prev; + } + return { ...prev, [kubeContext]: snap }; + }); + }, []); - const cacheValid = isCacheValid(state); + const clusterRegistry = useMemo(() => ({ querySnapshots }), [querySnapshots]); + // Delay showing the CLI banner so it does not flash on cold load. useEffect(() => { const timer = setTimeout(() => setReady(true), SHOW_DELAY_MS); return () => clearTimeout(timer); }, []); - const { data } = useQuery(CLI_LATEST_VERSION, { - skip: !isDesktop || !currentVersion || cacheValid, + const { data: cliQueryData } = useQuery(CLI_LATEST_VERSION, { + skip: !isDesktop || !currentVersionCLI || cliCacheValid, fetchPolicy: 'network-only', }); useEffect(() => { - const version = data?.cliLatestVersion; - if (version) patchState({ latestVersion: version, fetchedAt: Date.now() }); - }, [data]); + const version = cliQueryData?.cliLatestVersion; + if (version) patchCLIState({ latestVersion: version, fetchedAt: Date.now() }); + }, [cliQueryData]); - const latestVersion = cacheValid ? state.latestVersion! : (data?.cliLatestVersion ?? null); + const latestVersionCLI = cliCacheValid ? cliPersisted.latestVersion! : (cliQueryData?.cliLatestVersion ?? null); - const updateAvailable = - currentVersion !== '' && latestVersion !== null && compareSemver(latestVersion, currentVersion) > 0; + const cliUpdateAvailable = + currentVersionCLI !== '' && latestVersionCLI !== null && compareSemver(latestVersionCLI, currentVersionCLI) > 0; - const dismissed = state.dismissedAt !== undefined && now - state.dismissedAt < DISMISS_TTL_MS; - const skipped = latestVersion !== null && (state.skippedVersions ?? []).includes(latestVersion); - const hasUpdate = updateAvailable && !skipped; + // `mountedAt` is fixed at provider mount so dismiss TTL is stable for this session (matches prior behavior). + const cliDismissed = cliPersisted.dismissedAt !== undefined && mountedAt - cliPersisted.dismissedAt < DISMISS_TTL_MS; + const cliSkipped = latestVersionCLI !== null && (cliPersisted.skippedVersions ?? []).includes(latestVersionCLI); + const cliHasUpdate = cliUpdateAvailable && !cliSkipped; - const showBanner = ready && !dismissed && hasUpdate; + const showBanner = ready && !cliDismissed && cliHasUpdate; - const dismiss = useCallback(() => { - patchState({ dismissedAt: Date.now() }); - setState(readState()); + const dismissCLI = useCallback(() => { + patchCLIState({ dismissedAt: Date.now() }); + setCLIPersisted(readCLIState()); }, []); - const dontRemindMe = useCallback(() => { - const { skippedVersions = [] } = readState(); - if (latestVersion && !skippedVersions.includes(latestVersion)) { - skippedVersions.push(latestVersion); + const dontRemindCLI = useCallback(() => { + const { skippedVersions = [] } = readCLIState(); + if (latestVersionCLI && !skippedVersions.includes(latestVersionCLI)) { + skippedVersions.push(latestVersionCLI); } - patchState({ skippedVersions, dismissedAt: Date.now() }); - setState(readState()); - }, [latestVersion]); + patchCLIState({ skippedVersions, dismissedAt: Date.now() }); + setCLIPersisted(readCLIState()); + }, [latestVersionCLI]); - const value = useMemo( - () => ({ showBanner, currentVersion, latestVersion, updateAvailable, dismiss, dontRemindMe }), - [showBanner, currentVersion, latestVersion, updateAvailable, dismiss, dontRemindMe], + const cliValue = useMemo( + () => ({ + showBanner, + currentVersion: currentVersionCLI, + latestVersion: latestVersionCLI, + updateAvailable: cliUpdateAvailable, + dismiss: dismissCLI, + dontRemindMe: dontRemindCLI, + }), + [showBanner, currentVersionCLI, latestVersionCLI, cliUpdateAvailable, dismissCLI, dontRemindCLI], ); - return {children}; + return ( + + {/* Invalidate is separate from registry data: dismiss/remind only needs the callback, not the snapshot map. */} + + + {kubeContexts.map((ctx) => ( + + ))} + {children} + + + + ); +} + +/** Latest CLI update banner state (no cluster context subscription). */ +export function useCLIUpdateNotification(): UpdateNotificationState { + return useContext(CLIUpdateNotificationContext); } -export function useUpdateNotification(): UpdateNotificationState { - return useContext(UpdateNotificationContext); +/** Per-kubeContext cluster update row; reads shared registry + bumps via invalidate after mutations. */ +export function useClusterUpdateNotification(kubeContext: string): ClusterUpdateNotificationState { + const clusterRegistry = useContext(ClusterNotificationsContext); + const invalidateClusterNotifications = useContext(ClusterNotificationsInvalidateContext); + + const dismissCluster = useCallback(() => { + if (!clusterRegistry || !invalidateClusterNotifications) return; + patchClusterState(kubeContext, { dismissedAt: Date.now() }); + invalidateClusterNotifications(); + }, [kubeContext, clusterRegistry, invalidateClusterNotifications]); + + const dontRemindCluster = useCallback(() => { + if (!clusterRegistry || !invalidateClusterNotifications) return; + const persisted = readClusterState(kubeContext); + const valid = isClusterCacheValid(persisted); + const snap = clusterRegistry.querySnapshots[kubeContext]; + const lv = valid ? (persisted.latestVersion ?? null) : (snap?.data?.clusterVersionStatus?.latestVersion ?? null); + const { skippedVersions = [] } = persisted; + if (lv && !skippedVersions.includes(lv)) { + skippedVersions.push(lv); + } + patchClusterState(kubeContext, { skippedVersions, dismissedAt: Date.now() }); + invalidateClusterNotifications(); + }, [kubeContext, clusterRegistry, invalidateClusterNotifications]); + + const clusterView = useMemo(() => { + if (!clusterRegistry) { + return { + updateAvailable: false, + currentVersion: null, + latestVersion: null, + dismiss: dismissCluster, + dontRemindMe: dontRemindCluster, + } as ClusterUpdateNotificationState; + } + return buildClusterNotificationView( + kubeContext, + clusterRegistry.querySnapshots[kubeContext], + dismissCluster, + dontRemindCluster, + ); + }, [kubeContext, clusterRegistry, dismissCluster, dontRemindCluster]); + return clusterView; }