Compare commits

..
1 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 26f495ac8e 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>
2026-10-10 01:55:34 +02:00
13 changed files with 510 additions and 22 deletions

No files matched your search

+9 -5
View File
@@ -507,15 +507,19 @@ and wired refs, one click or one CLI call.
deployment history, env-doctor issues surfaced on the service view. deployment history, env-doctor issues surfaced on the service view.
- [x] **env var edit + domain management + replica scaling** (PR #61 — - [x] **env var edit + domain management + replica scaling** (PR #61 —
mobile v1.5: variables editor, domains screen, scale + sleep 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` - [x] **Push notifications** — per-user `notification_channels`
(ntfy + Gotify): every `insertUserNotification` fans out best-effort (ntfy + Gotify): every `insertUserNotification` fans out best-effort
to enabled channels; CRUD + live test-ping via web settings, CLI to enabled channels; CRUD + live test-ping via web settings, CLI
`notifications channels`, and MCP. FCM/APNs remain optional. `notifications channels`, and MCP. FCM/APNs remain optional.
- [ ] **Offline resilience** — cached last-known state + unreachable - [x] **Offline resilience** — AsyncStorage-persisted query cache
banner. (7d, high-churn keys excluded) + banner when the instance is
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when unreachable (`/health` ping + expo-network).
the instance is unreachable (cached last-known state, clear banner). - [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) ## 10. Carried-over debt (ROADMAP.md items still open)
+54 -8
View File
@@ -31,7 +31,9 @@
"@tabler/icons-react": "^3.37.1", "@tabler/icons-react": "^3.37.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/query-sync-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-persist-client": "^5.104.1",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@@ -3441,22 +3443,49 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.90.20", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/query-persist-client-core": {
"version": "5.90.21", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.1.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "integrity": "sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==",
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "funding": {
"type": "github", "type": "github",
@@ -3466,6 +3495,23 @@
"react": "^18 || ^19" "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": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+2
View File
@@ -42,7 +42,9 @@
"@tabler/icons-react": "^3.37.1", "@tabler/icons-react": "^3.37.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/query-sync-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-persist-client": "^5.104.1",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@@ -30,10 +30,12 @@ import {
User, User,
Users, Users,
Webhook, Webhook,
WifiOff,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getCurrentUserProfile, getCurrentUserProfile,
getUpgradeStatus, getUpgradeStatus,
pingServer,
listNotifications, listNotifications,
markAllNotificationsRead, markAllNotificationsRead,
markNotificationRead, markNotificationRead,
@@ -105,6 +107,42 @@ function getInitialSidebar(): boolean {
return readStorage('containr.sidebar.expanded') !== '0'; 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() { export function PlatformShell() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -557,6 +595,7 @@ export function PlatformShell() {
</nav> </nav>
</header> </header>
<ConnectivityBanner />
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<Outlet /> <Outlet />
</main> </main>
+9
View File
@@ -490,6 +490,15 @@ export function getApiBaseUrl(): string {
return API_BASE; 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 { export function getAgentPublicBaseUrl(): string {
return `${API_ROOT}/api/agents`; return `${API_ROOT}/api/agents`;
} }
+36 -3
View File
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; 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 { BrowserRouter } from 'react-router-dom';
import App from './app/App'; import App from './app/App';
import { ToastProvider } from './shared/components'; import { ToastProvider } from './shared/components';
@@ -12,18 +14,49 @@ const queryClient = new QueryClient({
staleTime: 30_000, staleTime: 30_000,
retry: 1, retry: 1,
refetchOnWindowFocus: false, 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <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> <BrowserRouter>
<ToastProvider> <ToastProvider>
<App /> <App />
</ToastProvider> </ToastProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </PersistQueryClientProvider>
</StrictMode>, </StrictMode>,
); );
+91
View File
@@ -9,11 +9,15 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@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": "^5.104.1",
"@tanstack/react-query-persist-client": "^5.104.1",
"expo": "~57.0.27", "expo": "~57.0.27",
"expo-constants": "~57.0.21", "expo-constants": "~57.0.21",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-linking": "~57.0.12", "expo-linking": "~57.0.12",
"expo-network": "~57.0.2",
"expo-router": "~57.0.25", "expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4", "expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1", "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": { "node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", "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==", "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT" "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": { "node_modules/@tanstack/query-core": {
"version": "5.104.1", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
@@ -2479,6 +2509,19 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@tanstack/react-query": {
"version": "5.104.1", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
@@ -2495,6 +2538,23 @@
"react": "^18 || ^19" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.4", "version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
@@ -5416,6 +5476,16 @@
"react-native": "*" "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": { "node_modules/expo-router": {
"version": "57.0.25", "version": "57.0.25",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz",
@@ -7084,6 +7154,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "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==", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT" "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": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+4
View File
@@ -4,11 +4,15 @@
"main": "expo-router/entry", "main": "expo-router/entry",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@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": "^5.104.1",
"@tanstack/react-query-persist-client": "^5.104.1",
"expo": "~57.0.27", "expo": "~57.0.27",
"expo-constants": "~57.0.21", "expo-constants": "~57.0.21",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-linking": "~57.0.12", "expo-linking": "~57.0.12",
"expo-network": "~57.0.2",
"expo-router": "~57.0.25", "expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4", "expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
+42
View File
@@ -102,6 +102,30 @@ export interface NotificationChannel {
created_at?: string; 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 // Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext. // update preserves the stored ciphertext.
export const MASKED_SECRET = '********'; export const MASKED_SECRET = '********';
@@ -273,6 +297,24 @@ export class Api {
return this.req<unknown>('POST', `/notifications/channels/${id}/test`, {}); return this.req<unknown>('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<unknown>('POST', `/cron-jobs/${jobId}/trigger`, {});
}
serviceDeployments(id: string) { serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>( return this.req<{ deployments: Deployment[] }>(
'GET', 'GET',
+48 -2
View File
@@ -1,10 +1,39 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import * as Network from 'expo-network';
import { useApi } from '../../session'; import { useApi } from '../../session';
import { colors } from '../../theme'; 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 (
<View style={styles.banner}>
<Ionicons name="cloud-offline-outline" size={13} color={colors.warn} />
<Text style={styles.bannerText}>
{net.data === false ? 'No connection' : 'Instance unreachable'} — showing last-known data
</Text>
</View>
);
}
export default function TabsLayout() { export default function TabsLayout() {
const api = useApi(); const api = useApi();
const notifs = useQuery({ const notifs = useQuery({
@@ -15,7 +44,9 @@ export default function TabsLayout() {
const unread = notifs.data?.unread ?? 0; const unread = notifs.data?.unread ?? 0;
return ( return (
<Tabs <View style={{ flex: 1 }}>
<OfflineBanner />
<Tabs
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: colors.bg }, headerStyle: { backgroundColor: colors.bg },
headerTintColor: colors.text, headerTintColor: colors.text,
@@ -70,6 +101,21 @@ export default function TabsLayout() {
), ),
}} }}
/> />
</Tabs> </Tabs>
</View>
); );
} }
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 },
});
+27 -4
View File
@@ -1,16 +1,28 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router'; 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 { StatusBar } from 'expo-status-bar';
import { SessionProvider, useSession } from '../session'; import { SessionProvider, useSession } from '../session';
import { colors } from '../theme'; import { colors } from '../theme';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { 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() { function AuthGate() {
const { ready, api } = useSession(); const { ready, api } = useSession();
const segments = useSegments(); const segments = useSegments();
@@ -39,6 +51,7 @@ function AuthGate() {
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} /> <Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
<Stack.Screen name="service/variables" options={{ title: 'Variables' }} /> <Stack.Screen name="service/variables" options={{ title: 'Variables' }} />
<Stack.Screen name="service/domains" options={{ title: 'Domains' }} /> <Stack.Screen name="service/domains" options={{ title: 'Domains' }} />
<Stack.Screen name="service/cron" options={{ title: 'Cron jobs' }} />
<Stack.Screen name="service/settings" options={{ title: 'Service settings' }} /> <Stack.Screen name="service/settings" options={{ title: 'Service settings' }} />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
@@ -48,11 +61,21 @@ function AuthGate() {
export default function RootLayout() { export default function RootLayout() {
return ( return (
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 7 * 24 * 60 * 60 * 1000,
dehydrateOptions: {
shouldDehydrateQuery: (q) =>
q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
},
}}
>
<SessionProvider> <SessionProvider>
<StatusBar style="light" /> <StatusBar style="light" />
<AuthGate /> <AuthGate />
</SessionProvider> </SessionProvider>
</QueryClientProvider> </PersistQueryClientProvider>
); );
} }
+5
View File
@@ -157,6 +157,11 @@ export default function ServiceDetail() {
subtitle={s.domain ?? 'Hostnames routed to this service'} subtitle={s.domain ?? 'Hostnames routed to this service'}
onPress={() => router.push(`/service/domains?id=${s.id}`)} onPress={() => router.push(`/service/domains?id=${s.id}`)}
/> />
<Row
title="Cron jobs"
subtitle="Schedules, run now, history"
onPress={() => router.push(`/service/cron?id=${s.id}`)}
/>
<Row <Row
title="Scale & sleep" title="Scale & sleep"
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` + subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
+144
View File
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import {
ActionButton,
Card,
Empty,
ErrorBox,
Loading,
StatusBadge,
} from '../../components';
export default function ServiceCron() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const qc = useQueryClient();
const [historyFor, setHistoryFor] = useState<string | null>(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 <Loading />;
if (jobs.error) return <ErrorBox error={jobs.error} />;
const list = jobs.data ?? [];
return (
<ScrollView
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={jobs.isRefetching}
onRefresh={() => jobs.refetch()}
tintColor={colors.accent}
/>
}
>
{list.length === 0 ? <Empty text="No cron jobs on this service" /> : null}
{list.map((job) => (
<Card key={job.id}>
<View style={styles.jobHead}>
<View style={{ flex: 1 }}>
<Text style={styles.jobName}>{job.name}</Text>
<Text style={styles.meta}>
{job.schedule}
{job.timezone ? ` · ${job.timezone}` : ''}
{job.enabled === false ? ' · disabled' : ''}
</Text>
<Text style={styles.cmd} numberOfLines={1}>
{job.command}
</Text>
<Text style={styles.meta}>
{job.last_run_at ? `last ${relTime(job.last_run_at)}` : 'never run'}
{job.next_run_at ? ` · next ${relTime(job.next_run_at)}` : ''}
</Text>
</View>
{job.last_status ? <StatusBadge status={job.last_status} /> : null}
</View>
<View style={styles.jobActions}>
<ActionButton
label="Run now"
kind="primary"
disabled={trigger.isPending}
onPress={() => trigger.mutate(job.id)}
/>
<ActionButton
label={historyFor === job.id ? 'Hide history' : 'History'}
onPress={() => setHistoryFor(historyFor === job.id ? null : job.id)}
/>
</View>
{historyFor === job.id ? (
<View style={styles.history}>
{executions.isLoading ? (
<Loading />
) : (executions.data ?? []).length === 0 ? (
<Text style={styles.meta}>No executions yet</Text>
) : (
(executions.data ?? []).slice(0, 10).map((ex) => (
<View key={ex.id} style={styles.execRow}>
<View style={{ flex: 1 }}>
<Text style={styles.execTime}>{relTime(ex.started_at)}</Text>
{ex.error ? (
<Text style={styles.execErr} numberOfLines={2}>
{ex.error}
</Text>
) : ex.output ? (
<Text style={styles.execOut} numberOfLines={2}>
{ex.output}
</Text>
) : null}
</View>
<StatusBadge status={ex.status ?? 'unknown'} />
</View>
))
)}
</View>
) : null}
</Card>
))}
</ScrollView>
);
}
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 },
});