diff --git a/ROADMAP2.md b/ROADMAP2.md
index 692d067..645d02a 100644
--- a/ROADMAP2.md
+++ b/ROADMAP2.md
@@ -507,15 +507,19 @@ and wired refs, one click or one CLI call.
deployment history, env-doctor issues surfaced on the service view.
- [x] **env var edit + domain management + replica scaling** (PR #61 —
mobile v1.5: variables editor, domains screen, scale + sleep
- settings, notifications tab). **cron execution history** still open.
+ settings, notifications tab, cron jobs screen with run-now and
+ execution history).
- [x] **Push notifications** — per-user `notification_channels`
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
to enabled channels; CRUD + live test-ping via web settings, CLI
`notifications channels`, and MCP. FCM/APNs remain optional.
- - [ ] **Offline resilience** — cached last-known state + unreachable
- banner.
-- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
- the instance is unreachable (cached last-known state, clear banner).
+ - [x] **Offline resilience** — AsyncStorage-persisted query cache
+ (7d, high-churn keys excluded) + banner when the instance is
+ unreachable (`/health` ping + expo-network).
+- [x] **Status/offline resilience** — web persists the query cache to
+ localStorage (24h) and PlatformShell shows a "last-known data" banner
+ on offline/unreachable; CLI exits `ExitUnavailable` with a clean
+ "cannot reach" message (no stack traces).
## 10. Carried-over debt (ROADMAP.md items still open)
diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json
index d76d961..c848ebe 100644
--- a/app/frontend/package-lock.json
+++ b/app/frontend/package-lock.json
@@ -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",
diff --git a/app/frontend/package.json b/app/frontend/package.json
index b758cd3..539ffe1 100644
--- a/app/frontend/package.json
+++ b/app/frontend/package.json
@@ -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",
diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx
index 24af13f..c76fa28 100644
--- a/app/frontend/src/app/layout/PlatformShell.tsx
+++ b/app/frontend/src/app/layout/PlatformShell.tsx
@@ -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 (
+
+
+ {offline
+ ? 'No network connection — showing last-known data.'
+ : 'Cannot reach this Containr instance — showing last-known data. Retrying…'}
+
+ );
+}
+
export function PlatformShell() {
const location = useLocation();
const navigate = useNavigate();
@@ -557,6 +595,7 @@ export function PlatformShell() {
+
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts
index c81d263..8cda35b 100644
--- a/app/frontend/src/lib/api-client.ts
+++ b/app/frontend/src/lib/api-client.ts
@@ -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 {
+ 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`;
}
diff --git a/app/frontend/src/main.tsx b/app/frontend/src/main.tsx
index 1d81905..55fd0c3 100644
--- a/app/frontend/src/main.tsx
+++ b/app/frontend/src/main.tsx
@@ -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(
-
+
+ q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
+ },
+ }}
+ >
-
+
,
);
diff --git a/app/mobile/package-lock.json b/app/mobile/package-lock.json
index 80bf911..e73d7e3 100644
--- a/app/mobile/package-lock.json
+++ b/app/mobile/package-lock.json
@@ -9,11 +9,15 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@tanstack/query-async-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.104.1",
+ "@tanstack/react-query-persist-client": "^5.104.1",
"expo": "~57.0.27",
"expo-constants": "~57.0.21",
"expo-font": "~57.0.4",
"expo-linking": "~57.0.12",
+ "expo-network": "~57.0.2",
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1",
@@ -2165,6 +2169,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz",
@@ -2469,6 +2485,20 @@
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT"
},
+ "node_modules/@tanstack/query-async-storage-persister": {
+ "version": "5.104.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.104.1.tgz",
+ "integrity": "sha512-wSs/HrangoSMVW3zLifawRT9lA+fbiilPQmaY+YhxBOM/VS9oqx+MQvROHpW+hKwDpdvFmhyBjWiyDhTHL1YXg==",
+ "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/query-core": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
@@ -2479,6 +2509,19 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "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.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",
@@ -2495,6 +2538,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/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
@@ -5416,6 +5476,16 @@
"react-native": "*"
}
},
+ "node_modules/expo-network": {
+ "version": "57.0.2",
+ "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz",
+ "integrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*"
+ }
+ },
"node_modules/expo-router": {
"version": "57.0.25",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz",
@@ -7084,6 +7154,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -7968,6 +8047,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/app/mobile/package.json b/app/mobile/package.json
index 8626ba9..1502969 100644
--- a/app/mobile/package.json
+++ b/app/mobile/package.json
@@ -4,11 +4,15 @@
"main": "expo-router/entry",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@tanstack/query-async-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.104.1",
+ "@tanstack/react-query-persist-client": "^5.104.1",
"expo": "~57.0.27",
"expo-constants": "~57.0.21",
"expo-font": "~57.0.4",
"expo-linking": "~57.0.12",
+ "expo-network": "~57.0.2",
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1",
diff --git a/app/mobile/src/api.ts b/app/mobile/src/api.ts
index 46ea2c9..3a13a86 100644
--- a/app/mobile/src/api.ts
+++ b/app/mobile/src/api.ts
@@ -102,6 +102,30 @@ export interface NotificationChannel {
created_at?: string;
}
+export interface CronJob {
+ id: string;
+ service_id?: string;
+ name: string;
+ schedule: string;
+ command: string;
+ timezone?: string;
+ enabled?: boolean;
+ last_run_at?: string;
+ next_run_at?: string;
+ last_status?: string;
+ last_output?: string;
+}
+
+export interface CronExecution {
+ id: string;
+ cron_job_id: string;
+ started_at?: string;
+ finished_at?: string;
+ status?: string;
+ output?: string;
+ error?: string;
+}
+
// Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext.
export const MASKED_SECRET = '********';
@@ -273,6 +297,24 @@ export class Api {
return this.req('POST', `/notifications/channels/${id}/test`, {});
}
+ cronJobs(serviceId: string) {
+ return this.req<{ cron_jobs: CronJob[] }>(
+ 'GET',
+ `/cron-jobs?service_id=${serviceId}`,
+ ).then((r) => r.cron_jobs ?? []);
+ }
+
+ cronExecutions(jobId: string) {
+ return this.req<{ executions: CronExecution[] }>(
+ 'GET',
+ `/cron-jobs/${jobId}/executions`,
+ ).then((r) => r.executions ?? []);
+ }
+
+ triggerCronJob(jobId: string) {
+ return this.req('POST', `/cron-jobs/${jobId}/trigger`, {});
+ }
+
serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>(
'GET',
diff --git a/app/mobile/src/app/(tabs)/_layout.tsx b/app/mobile/src/app/(tabs)/_layout.tsx
index a39cf81..1be1520 100644
--- a/app/mobile/src/app/(tabs)/_layout.tsx
+++ b/app/mobile/src/app/(tabs)/_layout.tsx
@@ -1,10 +1,39 @@
import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
+import * as Network from 'expo-network';
import { useApi } from '../../session';
import { colors } from '../../theme';
+// OfflineBanner pings /health every 15s; expo-network gives a fast local
+// signal, the ping catches "device online but instance down".
+function OfflineBanner() {
+ const api = useApi();
+ const net = useQuery({
+ queryKey: ['connectivity', 'net'],
+ queryFn: async () => (await Network.getNetworkStateAsync()).isConnected !== false,
+ refetchInterval: 15_000,
+ });
+ const ping = useQuery({
+ queryKey: ['connectivity', 'ping'],
+ queryFn: () => api.ping(),
+ refetchInterval: 15_000,
+ retry: 0,
+ networkMode: 'always',
+ });
+ if (net.data !== false && ping.data !== false && !ping.isError) return null;
+ return (
+
+
+
+ {net.data === false ? 'No connection' : 'Instance unreachable'} — showing last-known data
+
+
+ );
+}
+
export default function TabsLayout() {
const api = useApi();
const notifs = useQuery({
@@ -15,7 +44,9 @@ export default function TabsLayout() {
const unread = notifs.data?.unread ?? 0;
return (
-
+
+
-
+
+
);
}
+
+const styles = StyleSheet.create({
+ banner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ paddingVertical: 6,
+ backgroundColor: colors.surface,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.warn,
+ },
+ bannerText: { color: colors.textDim, fontSize: 11 },
+});
diff --git a/app/mobile/src/app/_layout.tsx b/app/mobile/src/app/_layout.tsx
index 279ebac..6a2d670 100644
--- a/app/mobile/src/app/_layout.tsx
+++ b/app/mobile/src/app/_layout.tsx
@@ -1,16 +1,28 @@
import React, { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { QueryClient } from '@tanstack/react-query';
+import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
+import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { StatusBar } from 'expo-status-bar';
import { SessionProvider, useSession } from '../session';
import { colors } from '../theme';
const queryClient = new QueryClient({
defaultOptions: {
- queries: { staleTime: 15_000, retry: 1 },
+ queries: { staleTime: 15_000, retry: 1, networkMode: 'offlineFirst' },
},
});
+// High-churn keys never hit disk — a stale notification count or log
+// tail is worse than none.
+const NEVER_PERSIST = new Set(['notifications', 'notification-channels', 'logs', 'connectivity']);
+
+const persister = createAsyncStoragePersister({
+ storage: AsyncStorage,
+ key: 'containr-query-cache-v1',
+});
+
function AuthGate() {
const { ready, api } = useSession();
const segments = useSegments();
@@ -39,6 +51,7 @@ function AuthGate() {
+
@@ -48,11 +61,21 @@ function AuthGate() {
export default function RootLayout() {
return (
-
+
+ q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
+ },
+ }}
+ >
-
+
);
}
diff --git a/app/mobile/src/app/service/[id].tsx b/app/mobile/src/app/service/[id].tsx
index 705a441..3257dc8 100644
--- a/app/mobile/src/app/service/[id].tsx
+++ b/app/mobile/src/app/service/[id].tsx
@@ -157,6 +157,11 @@ export default function ServiceDetail() {
subtitle={s.domain ?? 'Hostnames routed to this service'}
onPress={() => router.push(`/service/domains?id=${s.id}`)}
/>
+ router.push(`/service/cron?id=${s.id}`)}
+ />
();
+ const api = useApi();
+ const qc = useQueryClient();
+ const [historyFor, setHistoryFor] = useState(null);
+
+ const jobs = useQuery({
+ queryKey: ['service-cron', id],
+ queryFn: () => api.cronJobs(id!),
+ enabled: !!id,
+ refetchInterval: 15_000,
+ });
+ const executions = useQuery({
+ queryKey: ['cron-executions', historyFor],
+ queryFn: () => api.cronExecutions(historyFor!),
+ enabled: !!historyFor,
+ });
+
+ const trigger = useMutation({
+ mutationFn: (jobId: string) => api.triggerCronJob(jobId),
+ onSuccess: (_d, jobId) => {
+ qc.invalidateQueries({ queryKey: ['service-cron', id] });
+ setHistoryFor(jobId);
+ setTimeout(
+ () => qc.invalidateQueries({ queryKey: ['cron-executions', jobId] }),
+ 2000,
+ );
+ },
+ onError: (e) =>
+ Alert.alert('Trigger failed', e instanceof Error ? e.message : String(e)),
+ });
+
+ if (jobs.isLoading) return ;
+ if (jobs.error) return ;
+
+ const list = jobs.data ?? [];
+
+ return (
+ jobs.refetch()}
+ tintColor={colors.accent}
+ />
+ }
+ >
+ {list.length === 0 ? : null}
+ {list.map((job) => (
+
+
+
+ {job.name}
+
+ {job.schedule}
+ {job.timezone ? ` · ${job.timezone}` : ''}
+ {job.enabled === false ? ' · disabled' : ''}
+
+
+ {job.command}
+
+
+ {job.last_run_at ? `last ${relTime(job.last_run_at)}` : 'never run'}
+ {job.next_run_at ? ` · next ${relTime(job.next_run_at)}` : ''}
+
+
+ {job.last_status ? : null}
+
+
+ trigger.mutate(job.id)}
+ />
+ setHistoryFor(historyFor === job.id ? null : job.id)}
+ />
+
+ {historyFor === job.id ? (
+
+ {executions.isLoading ? (
+
+ ) : (executions.data ?? []).length === 0 ? (
+ No executions yet
+ ) : (
+ (executions.data ?? []).slice(0, 10).map((ex) => (
+
+
+ {relTime(ex.started_at)}
+ {ex.error ? (
+
+ {ex.error}
+
+ ) : ex.output ? (
+
+ {ex.output}
+
+ ) : null}
+
+
+
+ ))
+ )}
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: colors.bg },
+ jobHead: { flexDirection: 'row', alignItems: 'flex-start' },
+ jobName: { color: colors.text, fontSize: 15, fontWeight: '600' },
+ meta: { color: colors.textDim, fontSize: 11, marginTop: 3 },
+ cmd: { color: colors.textDim, fontSize: 12, fontFamily: 'monospace', marginTop: 4 },
+ jobActions: { flexDirection: 'row', gap: 8, marginTop: 10 },
+ history: { marginTop: 10, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 8 },
+ execRow: { flexDirection: 'row', alignItems: 'flex-start', paddingVertical: 6 },
+ execTime: { color: colors.text, fontSize: 12 },
+ execErr: { color: colors.err, fontSize: 11, marginTop: 2 },
+ execOut: { color: colors.textDim, fontSize: 11, marginTop: 2 },
+});