feat: add webhook notification system with Settings UI (REMOTE-03)
Webhook module with HTTP delivery, HMAC-SHA256 signing, and event filtering. RPC handlers for get-config, configure, and test endpoints. Settings page gains webhook configuration section with URL, secret, event toggles, and test button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -570,6 +570,107 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Notifications Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Webhook Notifications</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Get push notifications for critical events via webhook</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="toggleWebhookEnabled"
|
||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||
:class="webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
|
||||
:title="webhookConfig.enabled ? 'Disable webhooks' : 'Enable webhooks'"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="webhookConfig.enabled ? 'translate-x-5' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Webhook URL -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Webhook URL</label>
|
||||
<input
|
||||
v-model="webhookConfig.url"
|
||||
type="url"
|
||||
placeholder="https://example.com/webhook"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Secret (optional) -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Secret (optional, for HMAC-SHA256 signing)</label>
|
||||
<input
|
||||
v-model="webhookConfig.secret"
|
||||
type="password"
|
||||
placeholder="Shared secret for payload signing"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Event Types -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-2">Events to notify</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="evt in webhookEventTypes"
|
||||
:key="evt.id"
|
||||
@click="toggleWebhookEvent(evt.id)"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border transition-colors text-left"
|
||||
:class="webhookConfig.events.includes(evt.id)
|
||||
? 'bg-orange-500/10 border-orange-500/30'
|
||||
: 'bg-white/5 border-white/10 hover:border-white/20'"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors"
|
||||
:class="webhookConfig.events.includes(evt.id)
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-white/30'"
|
||||
>
|
||||
<svg v-if="webhookConfig.events.includes(evt.id)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-white/90 font-medium">{{ evt.label }}</p>
|
||||
<p class="text-xs text-white/50">{{ evt.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-2 pt-2">
|
||||
<button
|
||||
@click="saveWebhookConfig"
|
||||
:disabled="savingWebhook"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
||||
>
|
||||
{{ savingWebhook ? 'Saving...' : 'Save Configuration' }}
|
||||
</button>
|
||||
<button
|
||||
@click="testWebhook"
|
||||
:disabled="testingWebhook || !webhookConfig.url"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{{ testingWebhook ? 'Sending...' : 'Send Test' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook status message -->
|
||||
<div v-if="webhookStatusMsg" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'">
|
||||
{{ webhookStatusMsg }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -1009,6 +1110,7 @@ onMounted(async () => {
|
||||
checkClaudeStatus()
|
||||
loadTotpStatus()
|
||||
loadBackups()
|
||||
loadWebhookConfig()
|
||||
if (!serverTorAddressFromStore.value) {
|
||||
try {
|
||||
const res = await rpcClient.getTorAddress()
|
||||
@@ -1141,6 +1243,98 @@ async function deleteBackup(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook Notifications
|
||||
interface WebhookConfigData {
|
||||
enabled: boolean
|
||||
url: string
|
||||
secret: string
|
||||
events: string[]
|
||||
}
|
||||
const webhookConfig = ref<WebhookConfigData>({
|
||||
enabled: false,
|
||||
url: '',
|
||||
secret: '',
|
||||
events: [],
|
||||
})
|
||||
const savingWebhook = ref(false)
|
||||
const testingWebhook = ref(false)
|
||||
const webhookStatusMsg = ref('')
|
||||
const webhookStatusType = ref<'success' | 'error'>('success')
|
||||
|
||||
const webhookEventTypes = [
|
||||
{ id: 'container_crash', label: 'Container Crash', description: 'A running container stops unexpectedly' },
|
||||
{ id: 'update_available', label: 'Update Available', description: 'A new system or app update is ready' },
|
||||
{ id: 'disk_warning', label: 'Disk Space Warning', description: 'Disk usage exceeds warning threshold' },
|
||||
{ id: 'backup_complete', label: 'Backup Complete', description: 'A scheduled or manual backup finishes' },
|
||||
]
|
||||
|
||||
function showWebhookStatus(msg: string, type: 'success' | 'error') {
|
||||
webhookStatusMsg.value = msg
|
||||
webhookStatusType.value = type
|
||||
setTimeout(() => { webhookStatusMsg.value = '' }, 5000)
|
||||
}
|
||||
|
||||
function toggleWebhookEvent(id: string) {
|
||||
const idx = webhookConfig.value.events.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
webhookConfig.value.events.splice(idx, 1)
|
||||
} else {
|
||||
webhookConfig.value.events.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWebhookEnabled() {
|
||||
webhookConfig.value.enabled = !webhookConfig.value.enabled
|
||||
}
|
||||
|
||||
async function loadWebhookConfig() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ enabled: boolean; url: string; events: string[]; has_secret: boolean }>({ method: 'webhook.get-config' })
|
||||
webhookConfig.value.enabled = res.enabled
|
||||
webhookConfig.value.url = res.url
|
||||
webhookConfig.value.events = res.events || []
|
||||
// Don't overwrite secret — server doesn't return it
|
||||
} catch {
|
||||
// Webhook system may not be available
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebhookConfig() {
|
||||
savingWebhook.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'webhook.configure',
|
||||
params: {
|
||||
enabled: webhookConfig.value.enabled,
|
||||
url: webhookConfig.value.url,
|
||||
secret: webhookConfig.value.secret || null,
|
||||
events: webhookConfig.value.events,
|
||||
},
|
||||
})
|
||||
showWebhookStatus('Webhook configuration saved', 'success')
|
||||
} catch {
|
||||
showWebhookStatus('Failed to save webhook configuration', 'error')
|
||||
} finally {
|
||||
savingWebhook.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhook() {
|
||||
testingWebhook.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ sent: boolean; url: string }>({ method: 'webhook.test' })
|
||||
if (res.sent) {
|
||||
showWebhookStatus('Test webhook sent successfully', 'success')
|
||||
} else {
|
||||
showWebhookStatus('Test failed: webhook not sent', 'error')
|
||||
}
|
||||
} catch {
|
||||
showWebhookStatus('Failed to send test webhook', 'error')
|
||||
} finally {
|
||||
testingWebhook.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// USB Drive Backup
|
||||
interface UsbDriveInfo {
|
||||
device: string
|
||||
|
||||
Reference in New Issue
Block a user