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:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-10 01:55:34 +02:00
1 parent 9243d020cc
commit 26f495ac8e
13 files changed
+510 -22

No files matched your search

@@ -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>
+9
View File
@@ -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`;
}
+36 -3
View File
@@ -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>,
);