Watch
1
0
Fork
You've already forked RedFlag
0

web: server status awareness — version header, disconnect overlay, update banner

X-RedFlag-Version on every response; axios interceptor tracks connection state, no dedicated health poll. Recovered from stash — never meant to be parked.
This commit is contained in:
Fimeg 2026-06-12 13:41:11 -04:00
commit 415f229b8f
8 changed files with 253 additions and 21 deletions

View file

@ -530,6 +530,13 @@ func main() {
// Add CORS middleware
recorder.Use(middleware.CORSMiddleware())
// Expose server version on every response so the frontend can detect
// restarts without a dedicated health poll.
router.Use(func(c *gin.Context) {
c.Header("X-RedFlag-Version", version.AgentVersion)
c.Next()
})
// Health check
recorder.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{

View file

@ -7,6 +7,8 @@ import ErrorBoundary from '@/components/ErrorBoundary';
import Layout from '@/components/Layout';
import { WelcomeChecker } from '@/components/WelcomeChecker';
import { SetupCompletionChecker } from '@/components/SetupCompletionChecker';
import { useServerStatus } from '@/hooks/useServerStatus';
import ServerStatusOverlay from '@/components/ServerStatusOverlay';
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const Agents = lazy(() => import('@/pages/Agents'));
@ -48,6 +50,7 @@ const RouteFallback: React.FC = () => (
const App: React.FC = () => {
const { isAuthenticated, token } = useAuthStore();
const { theme } = useUIStore();
const { connected, checkNow } = useServerStatus();
// Apply theme to document
useEffect(() => {
@ -173,6 +176,11 @@ const App: React.FC = () => {
/>
</Routes>
</Suspense>
{/* Floating overlay when backend is unreachable */}
{!connected && (
<ServerStatusOverlay onRetryNow={checkNow} />
)}
</div>
</ErrorBoundary>
);

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
@ -25,6 +25,8 @@ import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
import { cn, formatRelativeTime } from '@/lib/utils';
import AdvisoryBanner from '@/components/AdvisoryBanner';
import { useGlobalEvents } from '@/hooks/useGlobalEvents';
import ServerUpdateBanner from '@/components/ServerUpdateBanner';
import { useServerStatus } from '@/hooks/useServerStatus';
interface LayoutProps {
children: React.ReactNode;
@ -39,28 +41,13 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
const [searchQuery, setSearchQuery] = useState('');
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
const [serverVersion, setServerVersion] = useState<string | null>(null);
// Global event feed — powers the notification bell across all pages.
useGlobalEvents();
const { version, versionChanged } = useServerStatus();
const unreadCount = notifications.filter(n => !n.read).length;
// Fetch server version from health endpoint
useEffect(() => {
let cancelled = false;
// Use a direct fetch to avoid requiring auth for health
fetch('/api/health')
.then(res => res.json())
.then(data => {
if (!cancelled && data?.version) {
setServerVersion(data.version);
}
})
.catch(() => { /* health endpoint may not be reachable yet */ });
return () => { cancelled = true; };
}, []);
const navigation = [
{
name: 'Dashboard',
@ -203,9 +190,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
{/* User section */}
<div className="absolute bottom-0 left-0 right-0 border-t border-gray-200">
{/* Version display */}
{serverVersion && (
{version && (
<div className="px-4 py-2 text-xs text-gray-400 text-center border-b border-gray-100">
v{serverVersion}
v{version}
</div>
)}
<div className="p-4 space-y-1">
@ -365,6 +352,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
{/* Advisory-feed degradation banner (only renders when degraded) */}
<AdvisoryBanner />
{/* Server update notification (only renders when version changed) */}
{versionChanged && <ServerUpdateBanner version={version} />}
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<div className="py-6">

View file

@ -0,0 +1,49 @@
import React from 'react';
import { WifiOff, RefreshCw } from 'lucide-react';
interface ServerStatusOverlayProps {
onRetryNow: () => void;
}
// ServerStatusOverlay is a floating card that appears when the backend is
// unreachable. Positioned bottom-right so it doesn't block critical UI but is
// always visible. Rendered in App.tsx above the router so it persists across
// page navigations.
const ServerStatusOverlay: React.FC<ServerStatusOverlayProps> = ({
onRetryNow,
}) => {
return (
<div className="fixed bottom-6 right-6 z-[100] animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="bg-white rounded-xl shadow-2xl border border-red-200 p-4 w-72">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<div className="relative">
<WifiOff className="w-5 h-5 text-red-500" />
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500" />
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900">
Backend unreachable
</p>
<p className="text-xs text-gray-500 mt-0.5">
Retrying automatically
</p>
<button
onClick={onRetryNow}
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-red-600 hover:text-red-700 transition-colors"
>
<RefreshCw className="w-3 h-3" />
Retry now
</button>
</div>
</div>
</div>
</div>
);
};
export default ServerStatusOverlay;

View file

@ -0,0 +1,44 @@
import React, { useState } from 'react';
import { RefreshCw, X } from 'lucide-react';
interface ServerUpdateBannerProps {
version: string | null;
}
// ServerUpdateBanner is an inline banner (same pattern as AdvisoryBanner) that
// appears when the server version changes while the tab is open. The operator
// can click "Refresh" to reload or dismiss the banner to keep working with
// potentially stale data.
const ServerUpdateBanner: React.FC<ServerUpdateBannerProps> = ({ version }) => {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="bg-blue-50 border-b border-blue-200 px-4 sm:px-6 lg:px-8 py-2">
<div className="flex items-center gap-2 text-sm text-blue-900">
<span className="font-medium">Server updated</span>
<span className="text-blue-800">
{version ? `v${version} is running.` : 'Server restarted.'}{' '}
Refresh for the latest UI.
</span>
<button
onClick={() => window.location.reload()}
className="ml-auto inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-3 h-3" />
Refresh
</button>
<button
onClick={() => setDismissed(true)}
className="flex-shrink-0 text-blue-400 hover:text-blue-600 transition-colors p-1"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default ServerUpdateBanner;

View file

@ -0,0 +1,73 @@
import { useEffect, useRef, useCallback } from 'react';
import { useServerStatusStore, useAuthStore } from '@/lib/store';
import { POLL } from '@/lib/polling';
// useServerStatus is a thin wrapper around the useServerStatusStore.
//
// Connection state and version tracking are handled by the axios interceptor
// in api.ts — every authenticated API call updates the store. This hook adds
// one thing: a fallback health poll for when the user is NOT authenticated
// (login page) and no authenticated API calls are running.
//
// The fallback polls the unauthenticated /api/health endpoint at POLL.HEALTH
// cadence, but ONLY when connected === false (to avoid redundant fetches when
// the authenticated polling is already keeping the store warm).
const HEALTH_TIMEOUT = 5_000;
export interface ServerStatus {
connected: boolean;
version: string | null;
versionChanged: boolean;
checkNow: () => void;
}
export function useServerStatus(): ServerStatus {
const { connected, version, versionChanged } = useServerStatusStore();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const healthCheck = useCallback(async () => {
try {
const res = await fetch('/api/health', {
method: 'GET',
cache: 'no-store',
signal: AbortSignal.timeout(HEALTH_TIMEOUT),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
useServerStatusStore.getState().markConnected(data?.version || null);
} catch {
useServerStatusStore.getState().markDisconnected();
}
}, []);
// Manual retry — fires an immediate health check.
const checkNow = useCallback(() => {
healthCheck();
}, [healthCheck]);
// Fallback health poll — only when disconnected AND not authenticated.
// Authenticated users get connection signals from the existing API polling.
useEffect(() => {
if (connected || isAuthenticated) {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
return;
}
healthCheck();
timerRef.current = setInterval(healthCheck, POLL.HEALTH);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [connected, isAuthenticated, healthCheck]);
return { connected, version, versionChanged, checkNow };
}

View file

@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { useAuthStore } from './store';
import { useAuthStore, useServerStatusStore } from './store';
import {
Agent,
UpdatePackage,
@ -116,6 +116,24 @@ api.interceptors.response.use(
}
);
// Server status interceptor — piggybacks on every API call to track
// connection state and server version. No separate health poll needed.
api.interceptors.response.use(
(response: AxiosResponse) => {
const version = response.headers['x-redflag-version'];
useServerStatusStore.getState().markConnected(version || null);
return response;
},
async (error) => {
// Network error (no response) = server unreachable. HTTP errors (4xx/5xx)
// mean the server IS reachable — don't mark disconnected for those.
if (!error.response) {
useServerStatusStore.getState().markDisconnected();
}
return Promise.reject(error);
}
);
function extractSubsystem(url: string = ''): string {
const matches = url.match(/\/(storage|system|docker|updates|agent)/);
return matches ? matches[1] : 'unknown';

View file

@ -243,4 +243,47 @@ export const useSettingsStore = create<SettingsState>()(
storage: createJSONStorage(() => localStorage),
}
)
);
);
// Server connection status. Updated by the axios response interceptor in
// api.ts on every authenticated API call — no separate polling timer needed.
// Not persisted; a fresh tab starts connected=true (optimistic) and the first
// failed poll corrects it.
const VERSION_STORAGE_KEY = 'redflag_server_version';
interface ServerStatusState {
connected: boolean;
version: string | null;
versionChanged: boolean;
markConnected: (version: string | null) => void;
markDisconnected: () => void;
}
export const useServerStatusStore = create<ServerStatusState>((set) => ({
connected: true,
version: null,
versionChanged: false,
markConnected: (version) => {
if (!version) {
set({ connected: true });
return;
}
const stored = sessionStorage.getItem(VERSION_STORAGE_KEY);
if (stored === null) {
// First successful response this tab — record baseline.
sessionStorage.setItem(VERSION_STORAGE_KEY, version);
set({ connected: true, version });
} else if (stored !== version) {
// Server restarted with a new version.
sessionStorage.setItem(VERSION_STORAGE_KEY, version);
set({ connected: true, version, versionChanged: true });
} else {
set({ connected: true, version });
}
},
markDisconnected: () => {
set({ connected: false });
},
}));