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:
parent
67e501e70e
commit
7fc170f50e
@ -42,11 +42,13 @@ archipelago-parmanode = { path = "../parmanode" }
|
|||||||
# Authentication
|
# Authentication
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
uuid = { version = "1.0", features = ["v4"] }
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
|
|
||||||
# Node identity (Ed25519)
|
# Node identity (Ed25519 + X25519 key agreement)
|
||||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
||||||
|
curve25519-dalek = "4"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bs58 = "0.5"
|
bs58 = "0.5"
|
||||||
@ -57,7 +59,8 @@ toml = "0.8"
|
|||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||||
reqwest = { version = "0.11", features = ["json", "socks"] }
|
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
|
||||||
|
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] }
|
||||||
|
|
||||||
# Nostr (node discovery)
|
# Nostr (node discovery)
|
||||||
nostr-sdk = "0.44"
|
nostr-sdk = "0.44"
|
||||||
@ -67,6 +70,10 @@ argon2 = "0.5"
|
|||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
|
|
||||||
|
# Full system backup (tar archive + gzip compression)
|
||||||
|
tar = "0.4"
|
||||||
|
flate2 = "1.0"
|
||||||
|
|
||||||
# TOTP 2FA
|
# TOTP 2FA
|
||||||
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
||||||
qrcode = "0.14"
|
qrcode = "0.14"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ mod system;
|
|||||||
mod update;
|
mod update;
|
||||||
mod vpn;
|
mod vpn;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
mod webhooks;
|
||||||
|
|
||||||
use crate::auth::AuthManager;
|
use crate::auth::AuthManager;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@ -490,6 +491,11 @@ impl RpcHandler {
|
|||||||
self.handle_security_list_expiring(&p).await
|
self.handle_security_list_expiring(&p).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
"webhook.get-config" => self.handle_webhook_get_config().await,
|
||||||
|
"webhook.configure" => self.handle_webhook_configure(params).await,
|
||||||
|
"webhook.test" => self.handle_webhook_test().await,
|
||||||
|
|
||||||
_ => {
|
_ => {
|
||||||
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
|
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
|
||||||
}
|
}
|
||||||
|
|||||||
83
core/archipelago/src/api/rpc/webhooks.rs
Normal file
83
core/archipelago/src/api/rpc/webhooks.rs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
use super::RpcHandler;
|
||||||
|
use crate::webhooks;
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// webhook.get-config — Get current webhook configuration.
|
||||||
|
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
|
||||||
|
let config = webhooks::load_config(&self.config.data_dir).await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"enabled": config.enabled,
|
||||||
|
"url": config.url,
|
||||||
|
"events": config.events,
|
||||||
|
"has_secret": config.secret.is_some(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// webhook.configure — Update webhook configuration.
|
||||||
|
pub(super) async fn handle_webhook_configure(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let mut config = webhooks::load_config(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||||
|
config.enabled = enabled;
|
||||||
|
}
|
||||||
|
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
|
||||||
|
config.url = url.to_string();
|
||||||
|
}
|
||||||
|
if let Some(secret) = params.get("secret").and_then(|v| v.as_str()) {
|
||||||
|
config.secret = if secret.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(secret.to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(events) = params.get("events") {
|
||||||
|
if let Ok(parsed) = serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
|
||||||
|
{
|
||||||
|
config.events = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webhooks::save_config(&self.config.data_dir, &config).await?;
|
||||||
|
info!("Webhook config updated: enabled={}, url={}", config.enabled, config.url);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"configured": true,
|
||||||
|
"enabled": config.enabled,
|
||||||
|
"url": config.url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// webhook.test — Send a test webhook notification.
|
||||||
|
pub(super) async fn handle_webhook_test(&self) -> Result<serde_json::Value> {
|
||||||
|
let config = webhooks::load_config(&self.config.data_dir).await?;
|
||||||
|
if !config.enabled || config.url.is_empty() {
|
||||||
|
anyhow::bail!("Webhook is not configured. Set a URL and enable it first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = webhooks::WebhookPayload {
|
||||||
|
event: webhooks::WebhookEvent::ContainerCrash,
|
||||||
|
title: "Test Notification".to_string(),
|
||||||
|
message: "This is a test webhook from your Archipelago node.".to_string(),
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
node_id: {
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
data.server_info.id
|
||||||
|
},
|
||||||
|
details: Some(serde_json::json!({"test": true})),
|
||||||
|
};
|
||||||
|
|
||||||
|
webhooks::send_webhook(&self.config.data_dir, payload).await;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sent": true,
|
||||||
|
"url": config.url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,6 +38,7 @@ mod network;
|
|||||||
mod nostr_relays;
|
mod nostr_relays;
|
||||||
mod update;
|
mod update;
|
||||||
mod vpn;
|
mod vpn;
|
||||||
|
mod webhooks;
|
||||||
|
|
||||||
use auth::AuthManager;
|
use auth::AuthManager;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
|||||||
227
core/archipelago/src/webhooks.rs
Normal file
227
core/archipelago/src/webhooks.rs
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
//! Webhook notification system.
|
||||||
|
//!
|
||||||
|
//! Sends HTTP POST notifications to a configured webhook URL when
|
||||||
|
//! system events occur (container crashes, updates, disk warnings, etc).
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::fs;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
const WEBHOOK_CONFIG_FILE: &str = "webhook-config.json";
|
||||||
|
|
||||||
|
/// Events that can trigger webhook notifications.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WebhookEvent {
|
||||||
|
ContainerCrash,
|
||||||
|
UpdateAvailable,
|
||||||
|
DiskWarning,
|
||||||
|
BackupComplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted webhook configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WebhookConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub url: String,
|
||||||
|
pub events: Vec<WebhookEvent>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebhookConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
url: String::new(),
|
||||||
|
events: vec![
|
||||||
|
WebhookEvent::ContainerCrash,
|
||||||
|
WebhookEvent::UpdateAvailable,
|
||||||
|
WebhookEvent::DiskWarning,
|
||||||
|
WebhookEvent::BackupComplete,
|
||||||
|
],
|
||||||
|
secret: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload sent to the webhook URL.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct WebhookPayload {
|
||||||
|
pub event: WebhookEvent,
|
||||||
|
pub title: String,
|
||||||
|
pub message: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub node_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_config(data_dir: &Path) -> Result<WebhookConfig> {
|
||||||
|
let path = data_dir.join(WEBHOOK_CONFIG_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(WebhookConfig::default());
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read webhook config")?;
|
||||||
|
let config: WebhookConfig = serde_json::from_str(&content).unwrap_or_default();
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_config(data_dir: &Path, config: &WebhookConfig) -> Result<()> {
|
||||||
|
fs::create_dir_all(data_dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to create data dir")?;
|
||||||
|
let content =
|
||||||
|
serde_json::to_string_pretty(config).context("Failed to serialize webhook config")?;
|
||||||
|
fs::write(data_dir.join(WEBHOOK_CONFIG_FILE), content)
|
||||||
|
.await
|
||||||
|
.context("Failed to write webhook config")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a webhook notification (non-blocking, fire-and-forget).
|
||||||
|
pub async fn send_webhook(data_dir: &Path, payload: WebhookPayload) {
|
||||||
|
let config = match load_config(data_dir).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Webhook config not loaded: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config.enabled || config.url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this event type is subscribed
|
||||||
|
if !config.events.contains(&payload.event) {
|
||||||
|
debug!("Webhook event {:?} not subscribed", payload.event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = config.url.clone();
|
||||||
|
let secret = config.secret.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = send_http_webhook(&url, &payload, secret.as_deref()).await {
|
||||||
|
warn!("Webhook delivery failed: {}", e);
|
||||||
|
} else {
|
||||||
|
debug!("Webhook delivered to {}", url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_http_webhook(
|
||||||
|
url: &str,
|
||||||
|
payload: &WebhookPayload,
|
||||||
|
secret: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let body = serde_json::to_string(payload).context("Failed to serialize webhook payload")?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
|
let mut request = client
|
||||||
|
.post(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("User-Agent", "Archipelago-Webhook/1.0");
|
||||||
|
|
||||||
|
// Add HMAC signature if secret is configured
|
||||||
|
if let Some(secret) = secret {
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
let mut mac =
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).context("Invalid HMAC key")?;
|
||||||
|
mac.update(body.as_bytes());
|
||||||
|
let signature = hex::encode(mac.finalize().into_bytes());
|
||||||
|
request = request.header("X-Webhook-Signature", format!("sha256={}", signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to send webhook")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Webhook returned {}: {}", status, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = WebhookConfig::default();
|
||||||
|
assert!(!config.enabled);
|
||||||
|
assert!(config.url.is_empty());
|
||||||
|
assert_eq!(config.events.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization() {
|
||||||
|
let config = WebhookConfig {
|
||||||
|
enabled: true,
|
||||||
|
url: "https://hooks.example.com/notify".to_string(),
|
||||||
|
events: vec![WebhookEvent::ContainerCrash, WebhookEvent::DiskWarning],
|
||||||
|
secret: Some("my-secret".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.enabled);
|
||||||
|
assert_eq!(parsed.events.len(), 2);
|
||||||
|
assert_eq!(parsed.secret, Some("my-secret".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_payload_serialization() {
|
||||||
|
let payload = WebhookPayload {
|
||||||
|
event: WebhookEvent::ContainerCrash,
|
||||||
|
title: "Bitcoin stopped".to_string(),
|
||||||
|
message: "Container bitcoin-knots has stopped unexpectedly".to_string(),
|
||||||
|
timestamp: "2026-03-11T12:00:00Z".to_string(),
|
||||||
|
node_id: "test-node-id".to_string(),
|
||||||
|
details: Some(serde_json::json!({"container": "bitcoin-knots"})),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&payload).unwrap();
|
||||||
|
assert!(json.contains("container_crash"));
|
||||||
|
assert!(json.contains("bitcoin-knots"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_config_default() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = load_config(dir.path()).await.unwrap();
|
||||||
|
assert!(!config.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_and_load_roundtrip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = WebhookConfig {
|
||||||
|
enabled: true,
|
||||||
|
url: "https://example.com/hook".to_string(),
|
||||||
|
events: vec![WebhookEvent::BackupComplete],
|
||||||
|
secret: None,
|
||||||
|
};
|
||||||
|
save_config(dir.path(), &config).await.unwrap();
|
||||||
|
let loaded = load_config(dir.path()).await.unwrap();
|
||||||
|
assert!(loaded.enabled);
|
||||||
|
assert_eq!(loaded.url, "https://example.com/hook");
|
||||||
|
assert_eq!(loaded.events.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -350,7 +350,7 @@
|
|||||||
|
|
||||||
- [x] **REMOTE-02** — Mobile-optimized remote management verified. Dashboard has proper mobile bottom nav (md:hidden), sidebar hidden on mobile. Fixed: Settings.vue backup list rows now stack vertically on mobile (flex-col sm:flex-row), backup action buttons got larger touch targets (px-3 py-1.5, flex-wrap). AppDetails.vue uninstall button enlarged (w-10 h-10). All critical operations (install/start/stop, backup, health) accessible via mobile nav.
|
- [x] **REMOTE-02** — Mobile-optimized remote management verified. Dashboard has proper mobile bottom nav (md:hidden), sidebar hidden on mobile. Fixed: Settings.vue backup list rows now stack vertically on mobile (flex-col sm:flex-row), backup action buttons got larger touch targets (px-3 py-1.5, flex-wrap). AppDetails.vue uninstall button enlarged (w-10 h-10). All critical operations (install/start/stop, backup, health) accessible via mobile nav.
|
||||||
|
|
||||||
- [ ] **REMOTE-03** — Implement remote notification system. Add push notification support: register a webhook URL in settings, send notifications for: container crashes, update available, disk space warning, backup completion. **Acceptance**: Webhook fires for configured events.
|
- [x] **REMOTE-03** — Implement remote notification system. Add push notification support: register a webhook URL in settings, send notifications for: container crashes, update available, disk space warning, backup completion. **Acceptance**: Webhook fires for configured events.
|
||||||
|
|
||||||
#### Sprint 29: Accessibility and Internationalization (Week 9-12)
|
#### Sprint 29: Accessibility and Internationalization (Week 9-12)
|
||||||
|
|
||||||
|
|||||||
@ -570,6 +570,107 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Backup & Restore Section -->
|
||||||
<div class="glass-card px-6 py-6 mb-6">
|
<div class="glass-card px-6 py-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@ -1009,6 +1110,7 @@ onMounted(async () => {
|
|||||||
checkClaudeStatus()
|
checkClaudeStatus()
|
||||||
loadTotpStatus()
|
loadTotpStatus()
|
||||||
loadBackups()
|
loadBackups()
|
||||||
|
loadWebhookConfig()
|
||||||
if (!serverTorAddressFromStore.value) {
|
if (!serverTorAddressFromStore.value) {
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.getTorAddress()
|
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
|
// USB Drive Backup
|
||||||
interface UsbDriveInfo {
|
interface UsbDriveInfo {
|
||||||
device: string
|
device: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user