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:
parent
8d54fec8eb
commit
415f229b8f
8 changed files with 253 additions and 21 deletions
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
49
web/src/components/ServerStatusOverlay.tsx
Normal file
49
web/src/components/ServerStatusOverlay.tsx
Normal 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;
|
||||
44
web/src/components/ServerUpdateBanner.tsx
Normal file
44
web/src/components/ServerUpdateBanner.tsx
Normal 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;
|
||||
73
web/src/hooks/useServerStatus.ts
Normal file
73
web/src/hooks/useServerStatus.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in a new issue