mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(mobile): env editing, domains, scaling, notifications (#61)
Companion app was read-mostly — quick fixes still needed a workstation. - service/variables: full env editor — add/edit/delete rows, secret toggle, masked-secret preservation (untouched secrets resubmit the "********" placeholder so stored ciphertext survives the PUT). - service/domains: list with default/TLS status, add, remove, set-default. - service/settings: replicas stepper (0–16), container port, scale-to- zero toggle + idle minutes — saves via PUT /services/:id. - (tabs)/notifications: alerts tab with unread badge on the tab bar, mark-read per item and mark-all. - Service detail gains a Manage section linking all three screens. State initializes at mount inside a child component rather than an effect — post-save refetches no longer clobber in-flight edits, and the react-hooks/set-state-in-effect lint stays clean. Verified: tsc --noEmit, expo lint, expo export (android bundle, 1378 modules — router graph compiles). Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
3cc84553f4
commit
604319ee06
8 files changed
+758
No files matched your search
@@ -68,6 +68,36 @@ export interface Template {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Variable {
|
||||||
|
id?: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
is_secret: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceDomain {
|
||||||
|
id: string;
|
||||||
|
domain: string;
|
||||||
|
is_default: boolean;
|
||||||
|
cert_type?: string;
|
||||||
|
cert_status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppNotification {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
resource_id?: string;
|
||||||
|
read_at?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masked value the API returns for secret variables; echoing it back on
|
||||||
|
// update preserves the stored ciphertext.
|
||||||
|
export const MASKED_SECRET = '********';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
@@ -145,6 +175,69 @@ export class Api {
|
|||||||
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
|
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serviceVariables(id: string) {
|
||||||
|
return this.req<{ variables: Variable[] }>('GET', `/services/${id}/variables`).then(
|
||||||
|
(r) => r.variables ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVariables(id: string, variables: { key: string; value: string; is_secret: boolean }[]) {
|
||||||
|
return this.req<unknown>('PUT', `/services/${id}/variables`, { variables });
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceDomains(id: string) {
|
||||||
|
return this.req<{ domains: ServiceDomain[] }>('GET', `/services/${id}/domains`).then(
|
||||||
|
(r) => r.domains ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addDomain(id: string, domain: string) {
|
||||||
|
return this.req<unknown>('POST', `/services/${id}/domains`, { domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDomain(id: string, domainId: string) {
|
||||||
|
return this.req<unknown>('DELETE', `/services/${id}/domains/${domainId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaultDomain(id: string, domainId: string) {
|
||||||
|
return this.req<unknown>('POST', `/services/${id}/domains/${domainId}/default`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateService(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
replicas: number;
|
||||||
|
port: number;
|
||||||
|
domain: string;
|
||||||
|
restart_policy: string;
|
||||||
|
healthcheck_path: string;
|
||||||
|
cpu: string;
|
||||||
|
memory: string;
|
||||||
|
sleep_enabled: boolean;
|
||||||
|
sleep_idle_minutes: number;
|
||||||
|
maintenance_mode: boolean;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return this.req<unknown>('PUT', `/services/${id}`, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications() {
|
||||||
|
return this.req<{ notifications: AppNotification[]; unread: number }>(
|
||||||
|
'GET',
|
||||||
|
'/notifications?limit=50',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
markNotificationRead(id: string) {
|
||||||
|
return this.req<unknown>('POST', `/notifications/${id}/read`);
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllNotificationsRead() {
|
||||||
|
return this.req<unknown>('POST', '/notifications/read-all');
|
||||||
|
}
|
||||||
|
|
||||||
serviceDeployments(id: string) {
|
serviceDeployments(id: string) {
|
||||||
return this.req<{ deployments: Deployment[] }>(
|
return this.req<{ deployments: Deployment[] }>(
|
||||||
'GET',
|
'GET',
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
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 { useApi } from '../../session';
|
||||||
import { colors } from '../../theme';
|
import { colors } from '../../theme';
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
|
const api = useApi();
|
||||||
|
const notifs = useQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: () => api.notifications(),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
const unread = notifs.data?.unread ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -41,6 +51,16 @@ export default function TabsLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="notifications"
|
||||||
|
options={{
|
||||||
|
title: 'Alerts',
|
||||||
|
tabBarBadge: unread > 0 ? unread : undefined,
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="notifications-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { 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 } from '../../components';
|
||||||
|
|
||||||
|
const KIND_COLOR: Record<string, string> = {
|
||||||
|
deployment_failed: colors.err,
|
||||||
|
deployment: colors.accent,
|
||||||
|
alert: colors.warn,
|
||||||
|
scale: colors.info,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Notifications() {
|
||||||
|
const api = useApi();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const notifs = useQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: () => api.notifications(),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markAll = useMutation({
|
||||||
|
mutationFn: () => api.markAllNotificationsRead(),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
});
|
||||||
|
const markRead = useMutation({
|
||||||
|
mutationFn: (id: string) => api.markNotificationRead(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notifs.isLoading) return <Loading />;
|
||||||
|
if (notifs.error) return <ErrorBox error={notifs.error} />;
|
||||||
|
|
||||||
|
const items = notifs.data?.notifications ?? [];
|
||||||
|
const unread = notifs.data?.unread ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={notifs.isRefetching}
|
||||||
|
onRefresh={() => notifs.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListHeaderComponent={
|
||||||
|
unread > 0 ? (
|
||||||
|
<ActionButton
|
||||||
|
label={`Mark all read (${unread})`}
|
||||||
|
disabled={markAll.isPending}
|
||||||
|
onPress={() => markAll.mutate()}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={<Empty text="No notifications" />}
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(n) => n.id}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const isUnread = !item.read_at;
|
||||||
|
const dot = KIND_COLOR[item.kind] ?? colors.accent;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={isUnread ? { borderColor: colors.accent } : undefined}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: isUnread ? dot : colors.border }]} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.title, !isUnread && { color: colors.textDim }]}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{item.body ? <Text style={styles.body}>{item.body}</Text> : null}
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{item.kind}
|
||||||
|
{item.created_at ? ` · ${relTime(item.created_at)}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isUnread ? (
|
||||||
|
<Text
|
||||||
|
style={styles.markRead}
|
||||||
|
onPress={() => markRead.mutate(item.id)}
|
||||||
|
>
|
||||||
|
Mark read
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'flex-start' },
|
||||||
|
dot: { width: 8, height: 8, borderRadius: 4, marginTop: 5, marginRight: 10 },
|
||||||
|
title: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
|
body: { color: colors.textDim, fontSize: 13, marginTop: 4, lineHeight: 18 },
|
||||||
|
meta: { color: colors.textDim, fontSize: 11, marginTop: 6 },
|
||||||
|
markRead: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 8 },
|
||||||
|
});
|
||||||
@@ -37,6 +37,9 @@ function AuthGate() {
|
|||||||
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
|
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
|
||||||
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
|
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
|
||||||
<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/domains" options={{ title: 'Domains' }} />
|
||||||
|
<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 }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Empty,
|
Empty,
|
||||||
ErrorBox,
|
ErrorBox,
|
||||||
Loading,
|
Loading,
|
||||||
|
Row,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
} from '../../components';
|
} from '../../components';
|
||||||
@@ -142,6 +143,28 @@ export default function ServiceDetail() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<SectionTitle>Manage</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<Row
|
||||||
|
title="Variables"
|
||||||
|
subtitle="Environment and secrets"
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/service/variables?id=${s.id}&name=${encodeURIComponent(s.name)}`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
title="Domains"
|
||||||
|
subtitle={s.domain ?? 'Hostnames routed to this service'}
|
||||||
|
onPress={() => router.push(`/service/domains?id=${s.id}`)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
title="Scale & sleep"
|
||||||
|
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
|
||||||
|
(s.sleep_enabled ? ` · sleeps after ${s.sleep_idle_minutes}m idle` : '')}
|
||||||
|
onPress={() => router.push(`/service/settings?id=${s.id}`)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{s.sleep_enabled ? (
|
{s.sleep_enabled ? (
|
||||||
<Text style={styles.hint}>
|
<Text style={styles.hint}>
|
||||||
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
|
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
|
||||||
|
|
||||||
|
export default function ServiceDomains() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [newDomain, setNewDomain] = useState('');
|
||||||
|
|
||||||
|
const domains = useQuery({
|
||||||
|
queryKey: ['domains', id],
|
||||||
|
queryFn: () => api.serviceDomains(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['domains', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['service', id] });
|
||||||
|
};
|
||||||
|
const onErr = (e: unknown) =>
|
||||||
|
Alert.alert('Failed', e instanceof Error ? e.message : String(e));
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: (d: string) => api.addDomain(id!, d),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewDomain('');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (domainId: string) => api.deleteDomain(id!, domainId),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const makeDefault = useMutation({
|
||||||
|
mutationFn: (domainId: string) => api.setDefaultDomain(id!, domainId),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (domains.isLoading) return <Loading />;
|
||||||
|
if (domains.error) return <ErrorBox error={domains.error} />;
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const d = newDomain.trim().toLowerCase();
|
||||||
|
if (d) add.mutate(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Hostnames routed to this service. The default domain is the one shown on cards and used
|
||||||
|
for generated links.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{domains.data?.length === 0 ? <Empty text="No domains configured" /> : null}
|
||||||
|
{(domains.data ?? []).map((d) => (
|
||||||
|
<Card key={d.id}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.domain}>{d.domain}</Text>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{d.is_default ? 'default · ' : ''}
|
||||||
|
{d.cert_status ? `tls: ${d.cert_status}` : 'tls pending'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!d.is_default ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => makeDefault.mutate(d.id)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.iconBtn}
|
||||||
|
>
|
||||||
|
<Ionicons name="star-outline" size={20} color={colors.accent} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<Ionicons name="star" size={20} color={colors.warn} style={styles.iconBtn} />
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Remove domain', `Detach ${d.domain} from this service?`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Remove', style: 'destructive', onPress: () => del.mutate(d.id) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.iconBtn}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.err} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={styles.addRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={newDomain}
|
||||||
|
onChangeText={setNewDomain}
|
||||||
|
placeholder="app.example.com"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
onSubmitEditing={submit}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label={add.isPending ? '…' : 'Add'}
|
||||||
|
kind="primary"
|
||||||
|
disabled={add.isPending || newDomain.trim() === ''}
|
||||||
|
onPress={submit}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
hint: { color: colors.textDim, fontSize: 12, marginBottom: 12, lineHeight: 17 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
domain: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
|
meta: { color: colors.textDim, fontSize: 12, marginTop: 3 },
|
||||||
|
iconBtn: { padding: 6, marginLeft: 10 },
|
||||||
|
addRow: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
color: colors.text,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
marginRight: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { Service } from '../../api';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { ActionButton, Card, ErrorBox, Loading, SectionTitle } from '../../components';
|
||||||
|
|
||||||
|
export default function ServiceSettings() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const service = useQuery({
|
||||||
|
queryKey: ['service', id],
|
||||||
|
queryFn: () => api.service(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (service.isLoading) return <Loading />;
|
||||||
|
if (service.error || !service.data) {
|
||||||
|
return <ErrorBox error={service.error ?? new Error('Not found')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form mounts once data exists; its state initializes from the fetch.
|
||||||
|
return <Form serviceId={id!} initial={service.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Form({ serviceId, initial }: { serviceId: string; initial: Service }) {
|
||||||
|
const api = useApi();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [replicas, setReplicas] = useState(initial.replicas ?? 1);
|
||||||
|
const [port, setPort] = useState(String(initial.port ?? 0));
|
||||||
|
const [sleepEnabled, setSleepEnabled] = useState(!!initial.sleep_enabled);
|
||||||
|
const [sleepMinutes, setSleepMinutes] = useState(
|
||||||
|
String(initial.sleep_idle_minutes || 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.updateService(serviceId, {
|
||||||
|
replicas,
|
||||||
|
port: parseInt(port, 10) || 0,
|
||||||
|
sleep_enabled: sleepEnabled,
|
||||||
|
sleep_idle_minutes: Math.min(1440, Math.max(1, parseInt(sleepMinutes, 10) || 30)),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['services'] });
|
||||||
|
Alert.alert('Saved', 'Changes apply on the next deploy.');
|
||||||
|
},
|
||||||
|
onError: (e) => Alert.alert('Save failed', e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clampReplicas = (n: number) => Math.min(16, Math.max(0, n));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<SectionTitle>Scaling</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.label}>Replicas</Text>
|
||||||
|
<Text style={styles.sub}>Containers run per deploy; 0 parks the service.</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.stepBtn}
|
||||||
|
onPress={() => setReplicas((r) => clampReplicas(r - 1))}
|
||||||
|
disabled={replicas <= 0}
|
||||||
|
>
|
||||||
|
<Text style={styles.stepText}>−</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.stepValue}>{replicas}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.stepBtn}
|
||||||
|
onPress={() => setReplicas((r) => clampReplicas(r + 1))}
|
||||||
|
disabled={replicas >= 16}
|
||||||
|
>
|
||||||
|
<Text style={styles.stepText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Container port</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={port}
|
||||||
|
onChangeText={setPort}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="0 = none"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SectionTitle>Scale to zero</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.label}>Sleep when idle</Text>
|
||||||
|
<Text style={styles.sub}>Scales to zero after idle; incoming traffic wakes it.</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={sleepEnabled}
|
||||||
|
onValueChange={setSleepEnabled}
|
||||||
|
trackColor={{ false: colors.border, true: colors.accentDim }}
|
||||||
|
thumbColor={sleepEnabled ? colors.accent : colors.textDim}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{sleepEnabled ? (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Idle minutes before sleep (1–1440)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={sleepMinutes}
|
||||||
|
onChangeText={setSleepMinutes}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="30"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
label={save.isPending ? 'Saving…' : 'Save settings'}
|
||||||
|
kind="primary"
|
||||||
|
disabled={save.isPending}
|
||||||
|
onPress={() => save.mutate()}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
label: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
|
sub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
|
||||||
|
stepRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
stepBtn: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
stepText: { color: colors.text, fontSize: 20, fontWeight: '700' },
|
||||||
|
stepValue: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
minWidth: 36,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
switchRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
field: { marginTop: 12 },
|
||||||
|
input: {
|
||||||
|
color: colors.text,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { MASKED_SECRET, Variable } from '../../api';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
is_secret: boolean;
|
||||||
|
// Secret untouched by the user — submits the masked placeholder so the
|
||||||
|
// server keeps the stored ciphertext.
|
||||||
|
keepSecret: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceVariables() {
|
||||||
|
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const vars = useQuery({
|
||||||
|
queryKey: ['variables', id],
|
||||||
|
queryFn: () => api.serviceVariables(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vars.isLoading) return <Loading />;
|
||||||
|
if (vars.error) return <ErrorBox error={vars.error} />;
|
||||||
|
if (!vars.data) return <ErrorBox error={new Error('Not found')} />;
|
||||||
|
|
||||||
|
// Editor mounts once data exists; its state initializes from the fetch.
|
||||||
|
return <Editor serviceId={id!} name={name} initial={vars.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor({
|
||||||
|
serviceId,
|
||||||
|
name,
|
||||||
|
initial,
|
||||||
|
}: {
|
||||||
|
serviceId: string;
|
||||||
|
name?: string;
|
||||||
|
initial: Variable[];
|
||||||
|
}) {
|
||||||
|
const api = useApi();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [rows, setRows] = useState<Row[]>(() =>
|
||||||
|
initial.map((v) => ({
|
||||||
|
key: v.key,
|
||||||
|
value: v.is_secret ? '' : v.value,
|
||||||
|
is_secret: v.is_secret,
|
||||||
|
keepSecret: v.is_secret,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const clean = rows
|
||||||
|
.filter((r) => r.key.trim() !== '')
|
||||||
|
.map((r) => ({
|
||||||
|
key: r.key.trim(),
|
||||||
|
value: r.keepSecret ? MASKED_SECRET : r.value,
|
||||||
|
is_secret: r.is_secret,
|
||||||
|
}));
|
||||||
|
return api.updateVariables(serviceId, clean);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['variables', serviceId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['env-check', serviceId] });
|
||||||
|
Alert.alert('Saved', 'Variables apply on the next deploy/restart.');
|
||||||
|
},
|
||||||
|
onError: (e) => Alert.alert('Save failed', e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const set = (i: number, patch: Partial<Row>) =>
|
||||||
|
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.root}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Variables for {name ?? 'this service'}. Secret values are stored encrypted — leave a
|
||||||
|
secret blank to keep it, type to replace.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{rows.length === 0 ? <Empty text="No variables yet" /> : null}
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<View style={styles.rowHead}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.keyInput}
|
||||||
|
value={r.key}
|
||||||
|
onChangeText={(t) => set(i, { key: t.toUpperCase().replace(/\s/g, '_') })}
|
||||||
|
placeholder="KEY"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
set(i, r.is_secret ? { is_secret: false, keepSecret: false } : { is_secret: true })
|
||||||
|
}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.iconBtn}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={r.is_secret ? 'lock-closed' : 'lock-open-outline'}
|
||||||
|
size={18}
|
||||||
|
color={r.is_secret ? colors.warn : colors.textDim}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setRows((rs) => rs.filter((_, j) => j !== i))}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.iconBtn}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.err} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
style={styles.valueInput}
|
||||||
|
value={r.value}
|
||||||
|
onChangeText={(t) => set(i, { value: t, keepSecret: false })}
|
||||||
|
placeholder={r.keepSecret ? '•••••••• (unchanged)' : 'value'}
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
secureTextEntry={r.is_secret && !r.keepSecret}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
multiline={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
label="+ Add variable"
|
||||||
|
onPress={() =>
|
||||||
|
setRows((rs) => [...rs, { key: '', value: '', is_secret: false, keepSecret: false }])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label={save.isPending ? 'Saving…' : 'Save variables'}
|
||||||
|
kind="primary"
|
||||||
|
disabled={save.isPending}
|
||||||
|
onPress={() => save.mutate()}
|
||||||
|
/>
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
hint: { color: colors.textDim, fontSize: 12, marginBottom: 12, lineHeight: 17 },
|
||||||
|
rowHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
|
||||||
|
keyInput: {
|
||||||
|
flex: 1,
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||||
|
},
|
||||||
|
valueInput: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
iconBtn: { padding: 6, marginLeft: 6 },
|
||||||
|
});
|
||||||
Reference in new issue
Block a user