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
+91
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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) {
|
||||
return this.req<{ deployments: Deployment[] }>(
|
||||
'GET',
|
||||
|
||||
@@ -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 (
|
||||
<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() {
|
||||
const api = useApi();
|
||||
const notifs = useQuery({
|
||||
@@ -15,7 +44,9 @@ export default function TabsLayout() {
|
||||
const unread = notifs.data?.unread ?? 0;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
<View style={{ flex: 1 }}>
|
||||
<OfflineBanner />
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
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 },
|
||||
});
|
||||
@@ -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() {
|
||||
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
|
||||
<Stack.Screen name="service/variables" options={{ title: 'Variables' }} />
|
||||
<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.Protected>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
@@ -48,11 +61,21 @@ function AuthGate() {
|
||||
|
||||
export default function RootLayout() {
|
||||
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>
|
||||
<StatusBar style="light" />
|
||||
<AuthGate />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,11 @@ export default function ServiceDetail() {
|
||||
subtitle={s.domain ?? 'Hostnames routed to this service'}
|
||||
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
|
||||
title="Scale & sleep"
|
||||
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
Reference in new issue
Block a user