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 }, +});