feat(notifications): ntfy/Gotify push channels with live delivery (#69)

Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.

- POST/GET/DELETE /notifications/channels + POST .../:id/test which
  pushes a real probe so misconfiguration surfaces before alerts depend
  on it (502 DELIVERY_FAILED on endpoint errors).
- ntfy: POST to endpoint (topic in path), Title/Tags headers, optional
  Bearer token. Gotify: POST {endpoint}/message?token=...
- Delivery is best-effort by design — a dead relay never breaks the
  producing flow; one shared 5s-timeout client.
- CLI `notifications channels list|add|test|remove`, MCP tools,
  OpenAPI paths + NotificationChannel schema.
- Web settings "Push notifications" section and a Push channels card on
  the mobile notifications tab (add/test/remove).

Verified live: channel CRUD, test-ping hit a recording endpoint with
correct headers/body, and a real deployment fanned out "Deployment
succeeded" through the channel end-to-end.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-10 01:39:09 +02:00
1 parent 60145f7b21
commit 9243d020cc
19 files changed
+1261 -6

No files matched your search

+35
View File
@@ -94,6 +94,14 @@ export interface AppNotification {
created_at?: string;
}
export interface NotificationChannel {
id: string;
kind: 'ntfy' | 'gotify';
endpoint: string;
enabled: boolean;
created_at?: string;
}
// Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext.
export const MASKED_SECRET = '********';
@@ -238,6 +246,33 @@ export class Api {
return this.req<unknown>('POST', '/notifications/read-all');
}
notificationChannels() {
return this.req<{ channels: NotificationChannel[] }>(
'GET',
'/notifications/channels',
).then((r) => r.channels ?? []);
}
addNotificationChannel(input: {
kind: 'ntfy' | 'gotify';
endpoint: string;
token?: string;
}) {
return this.req<{ channel: NotificationChannel }>(
'POST',
'/notifications/channels',
input,
);
}
deleteNotificationChannel(id: string) {
return this.req<unknown>('DELETE', `/notifications/channels/${id}`);
}
testNotificationChannel(id: string) {
return this.req<unknown>('POST', `/notifications/channels/${id}/test`, {});
}
serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>(
'GET',
+129 -3
View File
@@ -1,10 +1,10 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import React, { useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, TextInput, 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';
import { ActionButton, Card, Empty, ErrorBox, Loading, SectionTitle } from '../../components';
const KIND_COLOR: Record<string, string> = {
deployment_failed: colors.err,
@@ -59,6 +59,7 @@ export default function Notifications() {
) : null
}
ListEmptyComponent={<Empty text="No notifications" />}
ListFooterComponent={<PushChannelsCard />}
data={items}
keyExtractor={(n) => n.id}
renderItem={({ item }) => {
@@ -96,6 +97,122 @@ export default function Notifications() {
);
}
function PushChannelsCard() {
const api = useApi();
const qc = useQueryClient();
const channels = useQuery({
queryKey: ['notification-channels'],
queryFn: () => api.notificationChannels(),
});
const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy');
const [endpoint, setEndpoint] = useState('');
const [token, setToken] = useState('');
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ['notification-channels'] });
const add = useMutation({
mutationFn: () =>
api.addNotificationChannel({
kind,
endpoint: endpoint.trim(),
token: token.trim() || undefined,
}),
onSuccess: () => {
setEndpoint('');
setToken('');
setAdding(false);
invalidate();
},
});
const remove = useMutation({
mutationFn: (id: string) => api.deleteNotificationChannel(id),
onSuccess: invalidate,
});
const test = useMutation({
mutationFn: (id: string) => api.testNotificationChannel(id),
});
const list = channels.data ?? [];
return (
<View style={{ marginTop: 24 }}>
<SectionTitle>Push channels</SectionTitle>
<Card>
<Text style={styles.hint}>
Relay alerts to a self-hosted ntfy or Gotify server — your phone gets pushes via the ntfy app, no account required here.
</Text>
{list.map((ch) => (
<View key={ch.id} style={styles.channelRow}>
<View style={{ flex: 1 }}>
<Text style={styles.channelKind}>{ch.kind.toUpperCase()}</Text>
<Text style={styles.channelEndpoint} numberOfLines={1}>
{ch.endpoint}
</Text>
</View>
<Text
style={styles.channelAction}
onPress={() => test.mutate(ch.id)}
>
{test.isPending ? '…' : 'Test'}
</Text>
<Text
style={[styles.channelAction, { color: colors.err }]}
onPress={() => remove.mutate(ch.id)}
>
Remove
</Text>
</View>
))}
{adding ? (
<View style={{ marginTop: 8 }}>
<View style={styles.kindRow}>
{(['ntfy', 'gotify'] as const).map((k) => (
<Text
key={k}
style={[styles.kindChip, kind === k && styles.kindChipActive]}
onPress={() => setKind(k)}
>
{k}
</Text>
))}
</View>
<TextInput
style={styles.input}
value={endpoint}
onChangeText={setEndpoint}
placeholder={kind === 'ntfy' ? 'https://ntfy.example.com/my-alerts' : 'https://gotify.example.com'}
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TextInput
style={styles.input}
value={token}
onChangeText={setToken}
placeholder={kind === 'ntfy' ? 'Access token (optional)' : 'App token'}
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
{add.error ? <ErrorBox error={add.error} /> : null}
<ActionButton
label={add.isPending ? 'Adding…' : 'Add channel'}
disabled={add.isPending || !endpoint.trim()}
onPress={() => add.mutate()}
/>
</View>
) : (
<Text style={styles.channelAction} onPress={() => setAdding(true)}>
+ Add channel
</Text>
)}
</Card>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
row: { flexDirection: 'row', alignItems: 'flex-start' },
@@ -104,4 +221,13 @@ const styles = StyleSheet.create({
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 },
hint: { color: colors.textDim, fontSize: 12, lineHeight: 17, marginBottom: 8 },
channelRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border },
channelKind: { color: colors.text, fontSize: 12, fontWeight: '700' },
channelEndpoint: { color: colors.textDim, fontSize: 12, marginTop: 2 },
channelAction: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 12 },
kindRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
kindChip: { color: colors.textDim, fontSize: 13, paddingVertical: 4, paddingHorizontal: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border },
kindChipActive: { color: colors.accent, borderColor: colors.accent },
input: { color: colors.text, fontSize: 14, borderWidth: 1, borderColor: colors.border, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, marginBottom: 8 },
});