mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(offline): persisted query cache + unreachable banner (web + mobile) (#70)
* feat(offline): persisted query cache + unreachable banner (web + mobile) - Web: PersistQueryClientProvider on localStorage (24h maxAge); mobile: AsyncStorage persister (7d). Both exclude high-churn keys (logs, notifications, metrics) — stale tails are worse than none. - networkMode 'offlineFirst' so cached data serves while the refetch attempt fails. - Web: ConnectivityBanner in PlatformShell pings public /health every 15s + listens to online/offline events — slim "last-known data" bar so persisted state can't masquerade as live. - Mobile: same banner in the tabs layout — /health ping plus expo-network's local signal distinguishes "no connection" from "instance down". Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(mobile): cron jobs screen — schedules, run-now, execution history - /service/cron lists the service's jobs with schedule, timezone, last/next run, and last status; "Run now" triggers via the existing POST /cron-jobs/:id/trigger and "History" expands recent executions with status + output/error. - Manage row on the service detail + Stack registration. Verified live: created */5 job on a running service, triggered it — execution recorded with status success and captured stdout. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9243d020cc
commit
26f495ac8e
13 files changed
+510
-22
No files matched your search
Generated
+54
-8
@@ -31,7 +31,9 @@
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -3441,22 +3443,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
|
||||
"integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||
"node_modules/@tanstack/query-persist-client-core": {
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.1.tgz",
|
||||
"integrity": "sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
"@tanstack/query-core": "5.104.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-sync-storage-persister": {
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.104.1.tgz",
|
||||
"integrity": "sha512-eDsBMNds48+sjXILn0dQKu+uYwfh9tGIB+Rb0cKRdoxD22HHBMzJes5DJOhJVLUhBQbxtoFq+Hpha/Vohh14NQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.104.1",
|
||||
"@tanstack/query-persist-client-core": "5.104.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
|
||||
"integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.104.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3466,6 +3495,23 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-persist-client": {
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.104.1.tgz",
|
||||
"integrity": "sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-persist-client-core": "5.104.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
|
||||
@@ -30,10 +30,12 @@ import {
|
||||
User,
|
||||
Users,
|
||||
Webhook,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getCurrentUserProfile,
|
||||
getUpgradeStatus,
|
||||
pingServer,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
@@ -105,6 +107,42 @@ function getInitialSidebar(): boolean {
|
||||
return readStorage('containr.sidebar.expanded') !== '0';
|
||||
}
|
||||
|
||||
// ConnectivityBanner pings /health every 15s and listens for browser
|
||||
// online/offline events — when the instance is unreachable it shows a
|
||||
// slim bar so the persisted last-known data doesn't masquerade as live.
|
||||
function ConnectivityBanner() {
|
||||
const [offline, setOffline] = useState(() => !navigator.onLine);
|
||||
useEffect(() => {
|
||||
const up = () => setOffline(false);
|
||||
const down = () => setOffline(true);
|
||||
window.addEventListener('online', up);
|
||||
window.addEventListener('offline', down);
|
||||
return () => {
|
||||
window.removeEventListener('online', up);
|
||||
window.removeEventListener('offline', down);
|
||||
};
|
||||
}, []);
|
||||
const ping = useQuery({
|
||||
queryKey: ['connectivity'],
|
||||
queryFn: pingServer,
|
||||
refetchInterval: 15_000,
|
||||
retry: 0,
|
||||
networkMode: 'always',
|
||||
staleTime: 0,
|
||||
});
|
||||
if (!offline && !ping.isError) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 border-b border-[var(--warning,var(--border-subtle))] bg-[var(--surface-muted)] px-4 py-1.5 text-xs text-[var(--text-secondary)]">
|
||||
<WifiOff size={13} className="text-[var(--warning,#f59e0b)]" />
|
||||
{offline
|
||||
? 'No network connection — showing last-known data.'
|
||||
: 'Cannot reach this Containr instance — showing last-known data. Retrying…'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -557,6 +595,7 @@ export function PlatformShell() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<ConnectivityBanner />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -490,6 +490,15 @@ export function getApiBaseUrl(): string {
|
||||
return API_BASE;
|
||||
}
|
||||
|
||||
// pingServer hits the public /health route — any HTTP response proves
|
||||
// reachability, and no auth means no 401 noise in the connectivity probe.
|
||||
export async function pingServer(): Promise<void> {
|
||||
const res = await fetch(`${API_ROOT}/health`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentPublicBaseUrl(): string {
|
||||
return `${API_ROOT}/api/agents`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './app/App';
|
||||
import { ToastProvider } from './shared/components';
|
||||
@@ -12,18 +14,49 @@ const queryClient = new QueryClient({
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
// Serve the persisted snapshot while offline instead of erroring.
|
||||
networkMode: 'offlineFirst',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// High-churn or bulky keys never hit localStorage — a stale log tail is
|
||||
// worse than none.
|
||||
const NEVER_PERSIST = new Set([
|
||||
'shell-notifications',
|
||||
'notifications',
|
||||
'service-logs',
|
||||
'logs',
|
||||
'activity',
|
||||
'audit-logs',
|
||||
'audit',
|
||||
'metrics',
|
||||
'connectivity',
|
||||
]);
|
||||
|
||||
const persister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
key: 'containr-query-cache-v1',
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (q) =>
|
||||
q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
Reference in new issue
Block a user