From 604319ee062282676ce9b3f5599880d6a8d148ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:33:06 +0200 Subject: [PATCH] feat(mobile): env editing, domains, scaling, notifications (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/mobile/src/api.ts | 93 ++++++++++ app/mobile/src/app/(tabs)/_layout.tsx | 20 +++ app/mobile/src/app/(tabs)/notifications.tsx | 107 +++++++++++ app/mobile/src/app/_layout.tsx | 3 + app/mobile/src/app/service/[id].tsx | 23 +++ app/mobile/src/app/service/domains.tsx | 151 ++++++++++++++++ app/mobile/src/app/service/settings.tsx | 174 ++++++++++++++++++ app/mobile/src/app/service/variables.tsx | 187 ++++++++++++++++++++ 8 files changed, 758 insertions(+) create mode 100644 app/mobile/src/app/(tabs)/notifications.tsx create mode 100644 app/mobile/src/app/service/domains.tsx create mode 100644 app/mobile/src/app/service/settings.tsx create mode 100644 app/mobile/src/app/service/variables.tsx diff --git a/app/mobile/src/api.ts b/app/mobile/src/api.ts index 9a73209..809d3ec 100644 --- a/app/mobile/src/api.ts +++ b/app/mobile/src/api.ts @@ -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('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('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('POST', `/services/${id}/domains`, { domain }); + } + + deleteDomain(id: string, domainId: string) { + return this.req('DELETE', `/services/${id}/domains/${domainId}`); + } + + setDefaultDomain(id: string, domainId: string) { + return this.req('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('PUT', `/services/${id}`, patch); + } + + notifications() { + return this.req<{ notifications: AppNotification[]; unread: number }>( + 'GET', + '/notifications?limit=50', + ); + } + + markNotificationRead(id: string) { + return this.req('POST', `/notifications/${id}/read`); + } + + markAllNotificationsRead() { + return this.req('POST', '/notifications/read-all'); + } + serviceDeployments(id: string) { return this.req<{ deployments: Deployment[] }>( 'GET', diff --git a/app/mobile/src/app/(tabs)/_layout.tsx b/app/mobile/src/app/(tabs)/_layout.tsx index 6a117d0..a39cf81 100644 --- a/app/mobile/src/app/(tabs)/_layout.tsx +++ b/app/mobile/src/app/(tabs)/_layout.tsx @@ -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 ( + 0 ? unread : undefined, + tabBarIcon: ({ color, size }) => ( + + ), + }} + /> = { + 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 ; + if (notifs.error) return ; + + const items = notifs.data?.notifications ?? []; + const unread = notifs.data?.unread ?? 0; + + return ( + notifs.refetch()} + tintColor={colors.accent} + /> + } + ListHeaderComponent={ + unread > 0 ? ( + markAll.mutate()} + /> + ) : null + } + ListEmptyComponent={} + data={items} + keyExtractor={(n) => n.id} + renderItem={({ item }) => { + const isUnread = !item.read_at; + const dot = KIND_COLOR[item.kind] ?? colors.accent; + return ( + + + + + + {item.title} + + {item.body ? {item.body} : null} + + {item.kind} + {item.created_at ? ` · ${relTime(item.created_at)}` : ''} + + + {isUnread ? ( + markRead.mutate(item.id)} + > + Mark read + + ) : null} + + + ); + }} + /> + ); +} + +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 }, +}); diff --git a/app/mobile/src/app/_layout.tsx b/app/mobile/src/app/_layout.tsx index 53ab67f..279ebac 100644 --- a/app/mobile/src/app/_layout.tsx +++ b/app/mobile/src/app/_layout.tsx @@ -37,6 +37,9 @@ function AuthGate() { + + + diff --git a/app/mobile/src/app/service/[id].tsx b/app/mobile/src/app/service/[id].tsx index 87b07f5..705a441 100644 --- a/app/mobile/src/app/service/[id].tsx +++ b/app/mobile/src/app/service/[id].tsx @@ -11,6 +11,7 @@ import { Empty, ErrorBox, Loading, + Row, SectionTitle, StatusBadge, } from '../../components'; @@ -142,6 +143,28 @@ export default function ServiceDetail() { /> + Manage + + + router.push(`/service/variables?id=${s.id}&name=${encodeURIComponent(s.name)}`) + } + /> + router.push(`/service/domains?id=${s.id}`)} + /> + router.push(`/service/settings?id=${s.id}`)} + /> + + {s.sleep_enabled ? ( Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic. diff --git a/app/mobile/src/app/service/domains.tsx b/app/mobile/src/app/service/domains.tsx new file mode 100644 index 0000000..d0d0dc1 --- /dev/null +++ b/app/mobile/src/app/service/domains.tsx @@ -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 ; + if (domains.error) return ; + + const submit = () => { + const d = newDomain.trim().toLowerCase(); + if (d) add.mutate(d); + }; + + return ( + + + Hostnames routed to this service. The default domain is the one shown on cards and used + for generated links. + + + {domains.data?.length === 0 ? : null} + {(domains.data ?? []).map((d) => ( + + + + {d.domain} + + {d.is_default ? 'default · ' : ''} + {d.cert_status ? `tls: ${d.cert_status}` : 'tls pending'} + + + {!d.is_default ? ( + makeDefault.mutate(d.id)} + hitSlop={8} + style={styles.iconBtn} + > + + + ) : ( + + )} + + 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} + > + + + + + ))} + + + + + + + ); +} + +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, + }, +}); diff --git a/app/mobile/src/app/service/settings.tsx b/app/mobile/src/app/service/settings.tsx new file mode 100644 index 0000000..0cc7f84 --- /dev/null +++ b/app/mobile/src/app/service/settings.tsx @@ -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 ; + if (service.error || !service.data) { + return ; + } + + // Form mounts once data exists; its state initializes from the fetch. + return
; +} + +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 ( + + Scaling + + + + Replicas + Containers run per deploy; 0 parks the service. + + setReplicas((r) => clampReplicas(r - 1))} + disabled={replicas <= 0} + > + − + + {replicas} + setReplicas((r) => clampReplicas(r + 1))} + disabled={replicas >= 16} + > + + + + + + Container port + + + + + Scale to zero + + + + Sleep when idle + Scales to zero after idle; incoming traffic wakes it. + + + + {sleepEnabled ? ( + + Idle minutes before sleep (1–1440) + + + ) : null} + + + save.mutate()} + /> + + ); +} + +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, + }, +}); diff --git a/app/mobile/src/app/service/variables.tsx b/app/mobile/src/app/service/variables.tsx new file mode 100644 index 0000000..82273e1 --- /dev/null +++ b/app/mobile/src/app/service/variables.tsx @@ -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 ; + if (vars.error) return ; + if (!vars.data) return ; + + // Editor mounts once data exists; its state initializes from the fetch. + return ; +} + +function Editor({ + serviceId, + name, + initial, +}: { + serviceId: string; + name?: string; + initial: Variable[]; +}) { + const api = useApi(); + const qc = useQueryClient(); + const [rows, setRows] = useState(() => + 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) => + setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r))); + + return ( + + + + Variables for {name ?? 'this service'}. Secret values are stored encrypted — leave a + secret blank to keep it, type to replace. + + + {rows.length === 0 ? : null} + {rows.map((r, i) => ( + + + set(i, { key: t.toUpperCase().replace(/\s/g, '_') })} + placeholder="KEY" + placeholderTextColor={colors.textDim} + autoCapitalize="characters" + autoCorrect={false} + /> + + set(i, r.is_secret ? { is_secret: false, keepSecret: false } : { is_secret: true }) + } + hitSlop={8} + style={styles.iconBtn} + > + + + setRows((rs) => rs.filter((_, j) => j !== i))} + hitSlop={8} + style={styles.iconBtn} + > + + + + 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} + /> + + ))} + + + setRows((rs) => [...rs, { key: '', value: '', is_secret: false, keepSecret: false }]) + } + /> + save.mutate()} + /> + + + + ); +} + +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 }, +});