mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +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;
|
||||
}
|
||||
|
||||
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 {
|
||||
constructor(
|
||||
public status: number,
|
||||
@@ -145,6 +175,69 @@ export class Api {
|
||||
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) {
|
||||
return this.req<{ deployments: Deployment[] }>(
|
||||
'GET',
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
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 (
|
||||
<Tabs
|
||||
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
|
||||
name="settings"
|
||||
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="service/[id]" options={{ title: 'Service' }} />
|
||||
<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.Screen name="login" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Empty,
|
||||
ErrorBox,
|
||||
Loading,
|
||||
Row,
|
||||
SectionTitle,
|
||||
StatusBadge,
|
||||
} from '../../components';
|
||||
@@ -142,6 +143,28 @@ export default function ServiceDetail() {
|
||||
/>
|
||||
</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 ? (
|
||||
<Text style={styles.hint}>
|
||||
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