feat(mobile): Containr companion app — Expo/RN thin client

Phone app v1 (Phase I): PAT auth against any instance, secure-store
credentials, dark UI matching the design system.

- Overview: service status counts, per-project health dots, deploy feed
- Services/Projects: status badges, drill-down detail
- Service detail: start/stop/restart/redeploy/sleep/wake, env-doctor
  issues, deployment history + rollback
- Logs: 300-line tail, 5s poll, stderr highlighted
- Databases: status, start/stop/restart, backup-now
- Settings: server info, disconnect

CI: mobile job (tsc + expo lint) gated on a dedicated app/mobile paths
filter so non-mobile PRs skip it entirely.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-09 05:08:48 +02:00
1 parent 1054679ca2
commit 925199633b
31 files changed
+13064 -62

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
export interface Project {
id: string;
name: string;
description?: string;
created_at?: string;
}
export interface Service {
id: string;
project_id: string;
name: string;
type: string;
status: string;
image?: string;
environment?: string;
domain?: string;
public_url?: string;
port?: number;
replicas?: number;
cpu?: string;
memory?: string;
sleep_enabled?: boolean;
sleep_idle_minutes?: number;
created_at?: string;
updated_at?: string;
}
export interface Deployment {
id: string;
service_id: string;
service_name?: string;
project_name?: string;
status: string;
image_name?: string;
created_at?: string;
completed_at?: string | null;
}
export interface Database {
id: string;
name: string;
type: string;
status: string;
version?: string;
plan?: string;
connection_url?: string;
created_at?: string;
}
export interface EnvCheck {
ok: boolean;
unresolved: string[];
empty: string[];
unreadable: string[];
}
export interface LogEntry {
timestamp: string;
message: string;
stream: string;
}
export interface Template {
id: string;
name: string;
description?: string;
category?: string;
icon?: string;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export class Api {
constructor(
private baseUrl: string,
private token: string,
) {}
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const j = (await res.json()) as { error?: string };
if (j.error) msg = j.error;
} catch {
// non-JSON error body
}
throw new ApiError(res.status, msg);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
ping() {
return fetch(`${this.baseUrl}/health`).then((r) => r.ok);
}
projects() {
return this.req<{ projects: Project[] }>('GET', '/projects?limit=100').then(
(r) => r.projects ?? [],
);
}
projectServices(projectId: string) {
return this.req<{ services: Service[] }>(
'GET',
`/projects/${projectId}/services?limit=200`,
).then((r) => r.services ?? []);
}
service(id: string) {
return this.req<{ service: Service }>('GET', `/services/${id}`).then((r) => r.service);
}
serviceAction(id: string, action: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') {
return this.req<{ status?: string; message?: string }>(
'POST',
`/services/${id}/${action}`,
);
}
serviceLogs(id: string, tail = 300) {
return this.req<{ logs: LogEntry[]; message?: string }>(
'GET',
`/services/${id}/logs?tail=${tail}`,
).then((r) => r.logs ?? []);
}
envCheck(id: string) {
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
}
serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>(
'GET',
`/services/${id}/deployments?limit=20`,
).then((r) => r.deployments ?? []);
}
recentDeployments() {
return this.req<{ deployments: Deployment[] }>(
'GET',
'/deployments?limit=20',
).then((r) => r.deployments ?? []);
}
deploymentAction(id: string, action: 'rollback' | 'cancel') {
return this.req<unknown>('POST', `/deployments/${id}/${action}`);
}
databases() {
return this.req<{ databases: Database[] }>('GET', '/databases').then(
(r) => r.databases ?? [],
);
}
databaseBackup(id: string) {
return this.req<unknown>('POST', `/databases/${id}/backup`);
}
databaseAction(id: string, action: 'start' | 'stop' | 'restart') {
return this.req<unknown>('POST', `/databases/${id}/action`, { action });
}
templates() {
return this.req<{ templates: Template[] }>('GET', '/templates?limit=100').then(
(r) => r.templates ?? [],
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../theme';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTintColor: colors.text,
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textDim,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Overview',
tabBarIcon: ({ color, size }) => (
<Ionicons name="grid-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="services"
options={{
title: 'Services',
tabBarIcon: ({ color, size }) => (
<Ionicons name="cube-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="databases"
options={{
title: 'Databases',
tabBarIcon: ({ color, size }) => (
<Ionicons name="server-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
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 Databases() {
const api = useApi();
const qc = useQueryClient();
const dbs = useQuery({ queryKey: ['databases'], queryFn: () => api.databases() });
const action = useMutation({
mutationFn: ({ id, act }: { id: string; act: 'start' | 'stop' | 'restart' }) =>
api.databaseAction(id, act),
onSuccess: () => qc.invalidateQueries({ queryKey: ['databases'] }),
onError: (e) => Alert.alert('Action failed', e instanceof Error ? e.message : String(e)),
});
const backup = useMutation({
mutationFn: (id: string) => api.databaseBackup(id),
onSuccess: () => Alert.alert('Backup started', 'Snapshot queued for this database.'),
onError: (e) => Alert.alert('Backup failed', e instanceof Error ? e.message : String(e)),
});
if (dbs.isLoading) return <Loading />;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
data={dbs.data ?? []}
keyExtractor={(d) => d.id}
refreshControl={
<RefreshControl
refreshing={dbs.isRefetching}
onRefresh={() => dbs.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
dbs.error ? <ErrorBox error={dbs.error} /> : <Empty text="No databases" />
}
renderItem={({ item }) => (
<Card>
<View style={styles.head}>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.meta}>
{item.type}
{item.version ? ` ${item.version}` : ''} · created {relTime(item.created_at)}
</Text>
</View>
<StatusBadge status={item.status} />
</View>
<View style={styles.actions}>
{item.status === 'running' ? (
<ActionButton
label="Backup now"
kind="primary"
disabled={backup.isPending}
onPress={() => backup.mutate(item.id)}
/>
) : (
<ActionButton
label="Start"
kind="primary"
disabled={action.isPending}
onPress={() => action.mutate({ id: item.id, act: 'start' })}
/>
)}
{item.status === 'running' ? (
<>
<ActionButton
label="Restart"
disabled={action.isPending}
onPress={() => action.mutate({ id: item.id, act: 'restart' })}
/>
<ActionButton
label="Stop"
kind="danger"
disabled={action.isPending}
onPress={() => action.mutate({ id: item.id, act: 'stop' })}
/>
</>
) : null}
</View>
</Card>
)}
/>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
name: { color: colors.text, fontSize: 16, fontWeight: '600' },
meta: { color: colors.textDim, fontSize: 12, marginTop: 2 },
actions: { flexDirection: 'row', flexWrap: 'wrap' },
});
+122
View File
@@ -0,0 +1,122 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import { Card, Empty, ErrorBox, Row, SectionTitle, StatusBadge, StatusDot } from '../../components';
export default function Overview() {
const api = useApi();
const router = useRouter();
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
const deployments = useQuery({
queryKey: ['deployments'],
queryFn: () => api.recentDeployments(),
});
const servicesQueries = useQueries({
queries: (projects.data ?? []).map((p) => ({
queryKey: ['services', p.id],
queryFn: () => api.projectServices(p.id),
})),
});
const allServices = servicesQueries.flatMap((q) => q.data ?? []);
const counts: Record<string, number> = {};
for (const s of allServices) counts[s.status] = (counts[s.status] ?? 0) + 1;
const refetch = () => {
projects.refetch();
deployments.refetch();
servicesQueries.forEach((q) => q.refetch());
};
const refreshing = projects.isRefetching || deployments.isRefetching;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor={colors.accent} />
}
ListHeaderComponent={
<>
{projects.error ? <ErrorBox error={projects.error} /> : null}
<View style={styles.statRow}>
<Stat label="Running" value={counts.running ?? 0} color={colors.ok} />
<Stat label="Sleeping" value={counts.sleeping ?? 0} color={colors.info} />
<Stat label="Failed" value={counts.failed ?? 0} color={colors.err} />
<Stat label="Stopped" value={counts.stopped ?? 0} color={colors.textDim} />
</View>
<SectionTitle>Projects</SectionTitle>
{projects.data?.length === 0 ? <Empty text="No projects yet" /> : null}
</>
}
data={projects.data ?? []}
keyExtractor={(p) => p.id}
renderItem={({ item }) => {
const idx = (projects.data ?? []).findIndex((p) => p.id === item.id);
const svcs = servicesQueries[idx]?.data ?? [];
return (
<Card>
<Row
title={item.name}
subtitle={`${svcs.length} service${svcs.length === 1 ? '' : 's'}`}
onPress={() => router.push(`/project/${item.id}?name=${encodeURIComponent(item.name)}`)}
right={
<View style={styles.dots}>
{svcs.slice(0, 6).map((s) => (
<StatusDot key={s.id} status={s.status} />
))}
</View>
}
/>
</Card>
);
}}
ListFooterComponent={
<>
<SectionTitle>Recent deployments</SectionTitle>
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
{(deployments.data ?? []).slice(0, 10).map((d) => (
<Row
key={d.id}
title={`${d.service_name ?? d.service_id}`}
subtitle={`${d.project_name ?? ''} · ${relTime(d.created_at)}`}
right={<StatusBadge status={d.status} />}
onPress={() => router.push(`/service/${d.service_id}`)}
/>
))}
</>
}
/>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View style={[styles.stat, { borderColor: color }]}>
<Text style={[styles.statValue, { color }]}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
statRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
stat: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: 10,
borderWidth: 1,
paddingVertical: 12,
alignItems: 'center',
},
statValue: { fontSize: 20, fontWeight: '800' },
statLabel: { color: colors.textDim, fontSize: 11, marginTop: 2 },
dots: { flexDirection: 'row', gap: 4 },
});
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
export default function Services() {
const api = useApi();
const router = useRouter();
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
const servicesQueries = useQueries({
queries: (projects.data ?? []).map((p) => ({
queryKey: ['services', p.id],
queryFn: () => api.projectServices(p.id),
})),
});
const rows = servicesQueries.flatMap((q, i) =>
(q.data ?? []).map((s) => ({ ...s, _project: projects.data?.[i]?.name ?? '' })),
);
if (projects.isLoading) return <Loading />;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
data={rows}
keyExtractor={(s) => s.id}
refreshControl={
<RefreshControl
refreshing={servicesQueries.some((q) => q.isRefetching)}
onRefresh={() => servicesQueries.forEach((q) => q.refetch())}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
projects.error ? <ErrorBox error={projects.error} /> : <Empty text="No services" />
}
renderItem={({ item }) => (
<Row
title={item.name}
subtitle={`${item._project} · ${item.type}${item.domain ? ` · ${item.domain}` : ''}`}
right={<StatusBadge status={item.status} />}
onPress={() => router.push(`/service/${item.id}`)}
/>
)}
/>
);
}
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { Alert, ScrollView, StyleSheet, Text } from 'react-native';
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import { useSession } from '../../session';
import { colors } from '../../theme';
import { ActionButton, Card, SectionTitle } from '../../components';
export default function Settings() {
const { serverUrl, signOut } = useSession();
const router = useRouter();
return (
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
<SectionTitle>Connection</SectionTitle>
<Card>
<Text style={styles.label}>Server</Text>
<Text style={styles.value}>{serverUrl}</Text>
</Card>
<SectionTitle>App</SectionTitle>
<Card>
<Text style={styles.label}>Version</Text>
<Text style={styles.value}>
{Constants.expoConfig?.version ?? 'dev'} · Expo SDK{' '}
{Constants.expoConfig?.sdkVersion ?? '?'}
</Text>
</Card>
<ActionButton
label="Disconnect"
kind="danger"
onPress={() =>
Alert.alert('Disconnect', 'Remove the stored server and token?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disconnect',
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/login');
},
},
])
}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
label: { color: colors.textDim, fontSize: 12, marginBottom: 4 },
value: { color: colors.text, fontSize: 15 },
});
+52
View File
@@ -0,0 +1,52 @@
import React, { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
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 },
},
});
function AuthGate() {
const { ready, api } = useSession();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (!ready) return;
const inLogin = segments[0] === 'login';
if (!api && !inLogin) router.replace('/login');
else if (api && inLogin) router.replace('/');
}, [ready, api, segments, router]);
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
</Stack>
);
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<StatusBar style="light" />
<AuthGate />
</SessionProvider>
</QueryClientProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from 'expo-router';
export default function Index() {
return <Redirect href="/(tabs)" />;
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
} from 'react-native';
import { useSession, normalizeServerUrl } from '../session';
import { colors } from '../theme';
export default function Login() {
const { signIn } = useSession();
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = async () => {
setBusy(true);
setError(null);
try {
const normalized = normalizeServerUrl(url);
const res = await fetch(`${normalized}/api/v1/projects?limit=1`, {
headers: { Authorization: `Bearer ${token.trim()}` },
});
if (res.status === 401 || res.status === 403) {
throw new Error('Token rejected. Use a personal access token (cnp_…).');
}
if (!res.ok) throw new Error(`Server responded ${res.status}`);
await signIn(normalized, token);
} catch (e) {
setError(e instanceof Error ? e.message : 'Connection failed');
} finally {
setBusy(false);
}
};
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.logo}>Containr</Text>
<Text style={styles.sub}>Self-hosted platform console</Text>
<Text style={styles.label}>Server URL</Text>
<TextInput
style={styles.input}
placeholder="https://containr.example.com"
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
value={url}
onChangeText={setUrl}
/>
<Text style={styles.label}>Personal access token</Text>
<TextInput
style={styles.input}
placeholder="cnp_…"
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
value={token}
onChangeText={setToken}
/>
{error ? <Text style={styles.err}>{error}</Text> : null}
<TouchableOpacity
style={[styles.btn, (!url.trim() || !token.trim() || busy) && { opacity: 0.5 }]}
onPress={connect}
disabled={!url.trim() || !token.trim() || busy}
>
{busy ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.btnText}>Connect</Text>
)}
</TouchableOpacity>
<Text style={styles.hint}>
Create a token in the web UI under Settings → Access Tokens.
</Text>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg, padding: 24, justifyContent: 'center' },
logo: { color: colors.text, fontSize: 34, fontWeight: '800', letterSpacing: -0.5 },
sub: { color: colors.textDim, fontSize: 14, marginBottom: 32, marginTop: 4 },
label: {
color: colors.textDim,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 6,
},
input: {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
color: colors.text,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
marginBottom: 16,
},
err: { color: colors.err, marginBottom: 12 },
btn: {
backgroundColor: colors.accent,
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
},
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
hint: { color: colors.textDim, fontSize: 12, marginTop: 20, textAlign: 'center' },
});
+57
View File
@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
export default function ProjectDetail() {
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
const api = useApi();
const router = useRouter();
const nav = useNavigation();
useEffect(() => {
if (name) nav.setOptions({ title: name });
}, [name, nav]);
const services = useQuery({
queryKey: ['services', id],
queryFn: () => api.projectServices(id!),
enabled: !!id,
});
if (services.isLoading) return <Loading />;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
data={services.data ?? []}
keyExtractor={(s) => s.id}
refreshControl={
<RefreshControl
refreshing={services.isRefetching}
onRefresh={() => services.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
services.error ? <ErrorBox error={services.error} /> : <Empty text="No services" />
}
renderItem={({ item }) => (
<Row
title={item.name}
subtitle={`${item.type} · ${item.environment ?? 'production'}${
item.domain ? ` · ${item.domain}` : ''
}`}
right={<StatusBadge status={item.status} />}
onPress={() => router.push(`/service/${item.id}`)}
/>
)}
/>
);
}
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
+191
View File
@@ -0,0 +1,191 @@
import React from 'react';
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useLocalSearchParams, useRouter } 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,
SectionTitle,
StatusBadge,
} from '../../components';
export default function ServiceDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const router = useRouter();
const qc = useQueryClient();
const service = useQuery({
queryKey: ['service', id],
queryFn: () => api.service(id!),
enabled: !!id,
refetchInterval: 10_000,
});
const envCheck = useQuery({
queryKey: ['env-check', id],
queryFn: () => api.envCheck(id!),
enabled: !!id,
retry: false,
});
const deployments = useQuery({
queryKey: ['service-deployments', id],
queryFn: () => api.serviceDeployments(id!),
enabled: !!id,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['service', id] });
qc.invalidateQueries({ queryKey: ['services'] });
qc.invalidateQueries({ queryKey: ['service-deployments', id] });
};
const onErr = (e: unknown) =>
Alert.alert('Action failed', e instanceof Error ? e.message : String(e));
const action = useMutation({
mutationFn: (act: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') =>
api.serviceAction(id!, act),
onSuccess: invalidate,
onError: onErr,
});
const rollback = useMutation({
mutationFn: (depId: string) => api.deploymentAction(depId, 'rollback'),
onSuccess: invalidate,
onError: onErr,
});
const s = service.data;
if (service.isLoading) return <Loading />;
if (!s) return <ErrorBox error={service.error ?? new Error('Not found')} />;
const busy = action.isPending;
const canStart = s.status === 'stopped' || s.status === 'failed';
const canStop = s.status === 'running';
const canWake = s.status === 'sleeping';
const lastDeploy = deployments.data?.[0];
return (
<ScrollView
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={service.isRefetching}
onRefresh={() => service.refetch()}
tintColor={colors.accent}
/>
}
>
<View style={styles.head}>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{s.name}</Text>
<Text style={styles.meta}>
{s.type} · {s.environment ?? 'production'}
{s.image ? `\n${s.image}` : ''}
</Text>
</View>
<StatusBadge status={s.status} />
</View>
{envCheck.data && !envCheck.data.ok ? (
<Card style={{ borderColor: colors.warn }}>
<Text style={styles.warnTitle}>Environment issues</Text>
{envCheck.data.unresolved.map((v) => (
<Text key={v} style={styles.warnItem}>
Unresolved reference: {v}
</Text>
))}
{envCheck.data.empty.map((v) => (
<Text key={v} style={styles.warnItem}>
Empty value: {v}
</Text>
))}
{envCheck.data.unreadable.map((v) => (
<Text key={v} style={styles.warnItem}>
Unreadable secret (rotate): {v}
</Text>
))}
</Card>
) : null}
<SectionTitle>Actions</SectionTitle>
<View style={styles.actions}>
{canStart ? (
<ActionButton label="Start" kind="primary" disabled={busy} onPress={() => action.mutate('start')} />
) : null}
{canStop ? (
<ActionButton label="Stop" kind="danger" disabled={busy} onPress={() => action.mutate('stop')} />
) : null}
{canWake ? (
<ActionButton label="Wake" kind="primary" disabled={busy} onPress={() => action.mutate('wake')} />
) : null}
{s.status === 'running' ? (
<>
<ActionButton label="Restart" disabled={busy} onPress={() => action.mutate('restart')} />
<ActionButton label="Redeploy" disabled={busy} onPress={() => action.mutate('redeploy')} />
{s.sleep_enabled ? (
<ActionButton label="Sleep" disabled={busy} onPress={() => action.mutate('sleep')} />
) : null}
</>
) : null}
</View>
<View style={styles.actions}>
<ActionButton
label="View logs"
onPress={() => router.push(`/service/logs?id=${s.id}&name=${encodeURIComponent(s.name)}`)}
/>
</View>
{s.sleep_enabled ? (
<Text style={styles.hint}>
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
</Text>
) : null}
<SectionTitle>Deployments</SectionTitle>
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
{(deployments.data ?? []).slice(0, 8).map((d) => (
<Card key={d.id}>
<View style={styles.depRow}>
<View style={{ flex: 1 }}>
<Text style={styles.depTitle}>{d.image_name ?? d.id.slice(0, 8)}</Text>
<Text style={styles.meta}>{relTime(d.created_at)}</Text>
</View>
<StatusBadge status={d.status} />
</View>
{d.id !== lastDeploy?.id && d.status === 'success' ? (
<ActionButton
label="Rollback to this"
disabled={rollback.isPending}
onPress={() =>
Alert.alert('Rollback', 'Roll back to this deployment?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Rollback', onPress: () => rollback.mutate(d.id) },
])
}
/>
) : null}
</Card>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
name: { color: colors.text, fontSize: 24, fontWeight: '800' },
meta: { color: colors.textDim, fontSize: 12, marginTop: 4 },
warnTitle: { color: colors.warn, fontWeight: '700', marginBottom: 6 },
warnItem: { color: colors.text, fontSize: 13, marginTop: 2 },
actions: { flexDirection: 'row', flexWrap: 'wrap' },
hint: { color: colors.textDim, fontSize: 12, marginTop: 4 },
depRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
depTitle: { color: colors.text, fontSize: 14, fontWeight: '600' },
});
+71
View File
@@ -0,0 +1,71 @@
import React, { useEffect } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text } from 'react-native';
import { useLocalSearchParams, useNavigation } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { Empty, ErrorBox, Loading } from '../../components';
export default function ServiceLogs() {
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
const api = useApi();
const nav = useNavigation();
useEffect(() => {
if (name) nav.setOptions({ title: `${name} logs` });
}, [name, nav]);
const logs = useQuery({
queryKey: ['logs', id],
queryFn: () => api.serviceLogs(id!, 300),
enabled: !!id,
refetchInterval: 5000,
});
if (logs.isLoading) return <Loading />;
const entries = logs.data ?? [];
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 12 }}
data={[...entries].reverse()}
inverted
keyExtractor={(_, i) => String(i)}
refreshControl={
<RefreshControl
refreshing={logs.isRefetching}
onRefresh={() => logs.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
logs.error ? (
<ErrorBox error={logs.error} />
) : (
<Empty text="No log output yet" />
)
}
renderItem={({ item }) => (
<Text
style={[
styles.line,
item.stream === 'stderr' && { color: colors.err },
]}
>
{item.message}
</Text>
)}
/>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
line: {
color: colors.text,
fontFamily: 'monospace',
fontSize: 11,
paddingVertical: 1,
},
});
+150
View File
@@ -0,0 +1,150 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { colors, statusColor } from './theme';
export function StatusDot({ status }: { status: string }) {
return (
<View
style={[styles.dot, { backgroundColor: statusColor[status] ?? colors.textDim }]}
/>
);
}
export function StatusBadge({ status }: { status: string }) {
const c = statusColor[status] ?? colors.textDim;
return (
<View style={[styles.badge, { borderColor: c }]}>
<View style={[styles.dot, { backgroundColor: c }]} />
<Text style={[styles.badgeText, { color: c }]}>{status}</Text>
</View>
);
}
export function Card({ children, style }: { children: React.ReactNode; style?: object }) {
return <View style={[styles.card, style]}>{children}</View>;
}
export function SectionTitle({ children }: { children: React.ReactNode }) {
return <Text style={styles.section}>{children}</Text>;
}
export function Empty({ text }: { text: string }) {
return <Text style={styles.empty}>{text}</Text>;
}
export function ErrorBox({ error }: { error: unknown }) {
const msg = error instanceof Error ? error.message : String(error);
return <Text style={styles.err}>{msg}</Text>;
}
export function ActionButton({
label,
onPress,
kind = 'default',
disabled,
}: {
label: string;
onPress: () => void;
kind?: 'default' | 'primary' | 'danger';
disabled?: boolean;
}) {
const bg =
kind === 'primary' ? colors.accent : kind === 'danger' ? colors.err : colors.surfaceAlt;
return (
<TouchableOpacity
style={[styles.btn, { backgroundColor: bg, opacity: disabled ? 0.4 : 1 }]}
onPress={onPress}
disabled={disabled}
>
<Text style={styles.btnText}>{label}</Text>
</TouchableOpacity>
);
}
export function Row({
title,
subtitle,
right,
onPress,
}: {
title: string;
subtitle?: string;
right?: React.ReactNode;
onPress?: () => void;
}) {
return (
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress}>
<View style={{ flex: 1, marginRight: 8 }}>
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
{subtitle ? (
<Text style={styles.rowSub} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
{right}
</TouchableOpacity>
);
}
export function Loading() {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} size="large" />
</View>
);
}
const styles = StyleSheet.create({
dot: { width: 8, height: 8, borderRadius: 4 },
badge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 3,
},
badgeText: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' },
card: {
backgroundColor: colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
padding: 14,
marginBottom: 10,
},
section: {
color: colors.textDim,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
marginTop: 14,
marginBottom: 8,
},
empty: { color: colors.textDim, fontSize: 14, paddingVertical: 12 },
err: { color: colors.err, fontSize: 13, paddingVertical: 8 },
btn: {
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 14,
alignItems: 'center',
marginRight: 8,
marginBottom: 8,
},
btnText: { color: colors.text, fontWeight: '600', fontSize: 13 },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
rowSub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});
+83
View File
@@ -0,0 +1,83 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Api } from './api';
const KEY_URL = 'containr.server_url';
const KEY_TOKEN = 'containr.token';
interface Session {
ready: boolean;
api: Api | null;
serverUrl: string | null;
signIn: (serverUrl: string, token: string) => Promise<void>;
signOut: () => Promise<void>;
}
const Ctx = createContext<Session>({
ready: false,
api: null,
serverUrl: null,
signIn: async () => {},
signOut: async () => {},
});
export function normalizeServerUrl(raw: string): string {
let u = raw.trim();
if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
return u.replace(/\/+$/, '');
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [serverUrl, setServerUrl] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const [u, t] = await Promise.all([
SecureStore.getItemAsync(KEY_URL),
SecureStore.getItemAsync(KEY_TOKEN),
]);
setServerUrl(u);
setToken(t);
} finally {
setReady(true);
}
})();
}, []);
const value = useMemo<Session>(
() => ({
ready,
serverUrl,
api: serverUrl && token ? new Api(serverUrl, token) : null,
signIn: async (url, tok) => {
const normalized = normalizeServerUrl(url);
await SecureStore.setItemAsync(KEY_URL, normalized);
await SecureStore.setItemAsync(KEY_TOKEN, tok.trim());
setServerUrl(normalized);
setToken(tok.trim());
},
signOut: async () => {
await SecureStore.deleteItemAsync(KEY_URL);
await SecureStore.deleteItemAsync(KEY_TOKEN);
setServerUrl(null);
setToken(null);
},
}),
[ready, serverUrl, token],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSession() {
return useContext(Ctx);
}
export function useApi(): Api {
const { api } = useSession();
if (!api) throw new Error('not signed in');
return api;
}
+30
View File
@@ -0,0 +1,30 @@
export const colors = {
bg: '#0B0F14',
surface: '#131A22',
surfaceAlt: '#1A232E',
border: '#26313D',
text: '#E8EEF4',
textDim: '#8A97A5',
accent: '#4F8FF7',
accentDim: '#2A4A7F',
ok: '#3FB68B',
warn: '#E0A93E',
err: '#E05C5C',
info: '#4FB7D9',
};
export const statusColor: Record<string, string> = {
running: colors.ok,
deployed: colors.ok,
success: colors.ok,
building: colors.warn,
deploying: colors.warn,
pending: colors.warn,
waking: colors.warn,
sleeping: colors.info,
stopped: colors.textDim,
queued: colors.textDim,
failed: colors.err,
error: colors.err,
cancelled: colors.textDim,
};
+14
View File
@@ -0,0 +1,14 @@
export function relTime(iso?: string | null): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '—';
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}